Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 149 additions & 40 deletions src/data/lobsterEncounters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import {
lobsterEncounters,
type LobsterEncounter
} from "../db/schema.js"
import { commitProjection } from "../quilt/commit.js"
import {
projectLobsterEncounter,
projectLobsterPublication,
projectLobsterResponse,
type EncounterProjectionInput
} from "../quilt/projection.js"
import {
actionCooldownExpiries,
readActionCooldowns,
Expand Down Expand Up @@ -87,6 +94,60 @@ const toJson = (value: unknown) => {
return serialized
}

// Dual-write the outcome into the quilt kernel's WAL. Failure is logged,
// never thrown — the projection is a mirror (P2 posture, same as P1).
// The negative ledger lives here: cooldown refusals get first-class rows.
const projectEncounterSafely = async (
database: LobsterDatabase,
input: CreateLobsterEncounterInput,
outcome: CreateLobsterEncounterResult,
ts: string
): Promise<void> => {
try {
const attempt = {
guildId: input.guildId,
channelId: input.channelId,
actorId: input.actorId,
targetId: input.targetId
}
const projection: EncounterProjectionInput =
outcome.kind === "cooldown"
? {
resultKind: "cooldown",
interactionId: input.interactionId,
attempt,
refusedBy: outcome.cooldowns.map((cooldown) => cooldown.kind),
remaining: outcome.cooldowns,
ts
}
: outcome.kind === "created"
? {
resultKind: "created",
interactionId: input.interactionId,
attempt,
encounter: {
id: outcome.encounter.id,
speciesDisplayName: outcome.encounter.speciesDisplayName,
publicationStatus: outcome.encounter.publicationStatus
},
ts
}
: {
resultKind: outcome.kind,
interactionId: input.interactionId,
attempt,
ts
}
await commitProjection(
database.$client,
{ mutationId: input.interactionId, ts },
(kernel) => projectLobsterEncounter(kernel, projection)
)
} catch (error) {
console.warn("quilt wal encounter projection failed", error)
}
}

export const getLobsterEncounter = async (
id: number,
database: LobsterDatabase = getPrimaryDb()
Expand Down Expand Up @@ -334,42 +395,45 @@ export const createLobsterEncounter = async (
])

const existingId = Number(results[0]?.results[0]?.id)
let outcome: CreateLobsterEncounterResult
if (Number.isInteger(existingId) && existingId > 0) {
const encounter = await getLobsterEncounter(existingId, database)
if (!encounter) {
throw new Error(`Lobster encounter ${existingId} disappeared during retry`)
}
return existingResult(encounter)
}

const createdId = Number(results[3]?.results[0]?.id)
if (Number.isInteger(createdId) && createdId > 0) {
const encounter = await getLobsterEncounter(createdId, database)
if (!encounter) {
throw new Error(`Lobster encounter ${createdId} disappeared after creation`)
outcome = existingResult(encounter)
} else {
const createdId = Number(results[3]?.results[0]?.id)
if (Number.isInteger(createdId) && createdId > 0) {
const encounter = await getLobsterEncounter(createdId, database)
if (!encounter) {
throw new Error(`Lobster encounter ${createdId} disappeared after creation`)
}
outcome = { kind: "created", encounter }
} else {
const concurrentEncounter = await getLobsterEncounterByInteractionId(
input.interactionId,
database
)
if (concurrentEncounter) {
outcome = existingResult(concurrentEncounter)
} else {
const cooldowns = readActionCooldowns([
{ kind: "actor", expiresAt: results[4]?.results[0]?.actor_expires_at },
{ kind: "target", expiresAt: results[5]?.results[0]?.target_expires_at },
{ kind: "channel", expiresAt: results[6]?.results[0]?.channel_expires_at }
], referenceDate)
if (cooldowns.length === 0) {
throw new Error(
"Lobster encounter was neither created nor blocked by a cooldown"
)
}
outcome = { kind: "cooldown", cooldowns }
}
}
return { kind: "created", encounter }
}

const concurrentEncounter = await getLobsterEncounterByInteractionId(
input.interactionId,
database
)
if (concurrentEncounter) {
return existingResult(concurrentEncounter)
}

const cooldowns = readActionCooldowns([
{ kind: "actor", expiresAt: results[4]?.results[0]?.actor_expires_at },
{ kind: "target", expiresAt: results[5]?.results[0]?.target_expires_at },
{ kind: "channel", expiresAt: results[6]?.results[0]?.channel_expires_at }
], referenceDate)
if (cooldowns.length === 0) {
throw new Error(
"Lobster encounter was neither created nor blocked by a cooldown"
)
}
return { kind: "cooldown", cooldowns }
await projectEncounterSafely(database, input, outcome, timestamp)
return outcome
}

export const bindLobsterMessage = async (
Expand Down Expand Up @@ -434,15 +498,30 @@ export const bindLobsterMessage = async (
) {
return { kind: "conflict", encounter }
}
return {
kind:
previous.message_id === messageId
? "already_bound"
: results[1]?.results[0]
? "bound"
: "conflict",
encounter
const kind =
previous.message_id === messageId
? "already_bound"
: results[1]?.results[0]
? "bound"
: "conflict"
if (kind === "bound") {
try {
await commitProjection(
database.$client,
{ mutationId: `bind:${encounterId}:${messageId}`, ts: timestamp },
(kernel) =>
projectLobsterPublication(kernel, {
encounterId,
kind: "bound",
messageId,
ts: timestamp
})
)
} catch (error) {
console.warn("quilt wal bind projection failed", error)
}
}
return { kind, encounter }
}

export const markLobsterPublicationFailed = async (
Expand Down Expand Up @@ -489,6 +568,21 @@ export const markLobsterPublicationFailed = async (
return { kind: "not_found" }
}
if (results[0]?.results[0]) {
try {
await commitProjection(
database.$client,
{ mutationId: `pubfail:${encounter.interactionId}`, ts: timestamp },
(kernel) =>
projectLobsterPublication(kernel, {
encounterId,
kind: "publication_failed",
failure,
ts: timestamp
})
)
} catch (error) {
console.warn("quilt wal publication projection failed", error)
}
return { kind: "marked_failed", encounter }
}
return {
Expand Down Expand Up @@ -578,8 +672,23 @@ export const recordLobsterResponse = async (
if (!authorized) {
return { kind: "unauthorized", encounter }
}
return {
kind: updateResult?.results[0] ? "recorded" : "already_recorded",
encounter
const kind = updateResult?.results[0] ? "recorded" : "already_recorded"
if (kind === "recorded") {
try {
await commitProjection(
database.$client,
{ mutationId: `response:${encounter.interactionId}`, ts: timestamp },
(kernel) =>
projectLobsterResponse(kernel, {
encounterId: input.encounterId,
responseType: input.responseType,
responderId: input.responderId,
ts: timestamp
})
)
} catch (error) {
console.warn("quilt wal response projection failed", error)
}
}
return { kind, encounter }
}
62 changes: 50 additions & 12 deletions src/quilt/commit.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
// commit.ts — dual-write one vote transition into the quilt WAL.
// commit.ts — dual-write state transitions into the quilt WAL.
import { QuiltKernel, type QuiltEvent } from "./reference-kernel.mjs"
import {
buildWalRows,
projectNominationVote,
type VoteProjectionInput,
type WalRow
} from "./projection.js"

type WalClient = {
export type WalClient = {
batch<T = Record<string, unknown>>(
statements: unknown[]
): Promise<Array<{ results: T[] }>>
Expand All @@ -18,19 +16,24 @@ type WalClient = {

export type ChainTip = { tip: number; prev: string | null }

// Project a vote into kernel ops and append the hash-chained WAL rows in
// ONE batch. Returns the number of rows committed. Throws only on D1
// failure — callers in the vote path catch and log (dual-write posture).
export const commitNominationVoteProjection = async (
export type CommitContext = {
mutationId: string
ts: string
}

// Generic projector: run `project` against a fresh kernel, drain the
// events, append hash-chained WAL rows in ONE batch. Returns rows written.
export const commitProjection = async (
client: WalClient,
input: VoteProjectionInput
context: CommitContext,
project: (kernel: QuiltKernel) => void
): Promise<number> => {
const kernel = new QuiltKernel()
const events: QuiltEvent[] = []
const unsubscribe = kernel.subscribe((event) => {
events.push(event)
})
projectNominationVote(kernel, input)
project(kernel)
unsubscribe()

const [tipResult] = await client.batch<ChainTip>([
Expand All @@ -42,8 +45,8 @@ export const commitNominationVoteProjection = async (
])
const tipRow = tipResult?.results?.[0] ?? { tip: 0, prev: null }
const rows = buildWalRows(events, {
mutationId: input.mutationId,
ts: input.ts,
mutationId: context.mutationId,
ts: context.ts,
tip: Number(tipRow.tip ?? 0),
prevHash: tipRow.prev ?? null
})
Expand All @@ -70,3 +73,38 @@ export const commitNominationVoteProjection = async (
)
return rows.length
}

// P1 entry point — kept for src/data/nominations.ts.
export const commitNominationVoteProjection = (
client: WalClient,
input: {
nominationId: number
reviewerId: string
choice: "approve" | "decline"
resultKind:
| "recorded"
| "switched"
| "granting"
| "declined"
| "expired"
status: string
totals: { approvals: number; declines: number }
completedAt: string | null
mutationId: string
ts: string
}
): Promise<number> =>
commitProjection(client, { mutationId: input.mutationId, ts: input.ts }, (kernel) => {
const base = `nomination.${input.nominationId}`
// the nomination itself is a cell — link endpoints must exist (L1 law)
kernel.bind(base, { id: input.nominationId, kind: "nomination" })
kernel.bind(`${base}.mutation`, input.mutationId, { ts: input.ts })
kernel.bind(`${base}.status`, input.status)
kernel.bind(`${base}.totals`, input.totals)
kernel.bind(`${base}.completedAt`, input.completedAt)
if (input.resultKind !== "expired") {
const voteCell = `${base}.vote.${input.reviewerId}`
kernel.bind(voteCell, input.choice, { ts: input.ts })
kernel.link(voteCell, base, "cast")
}
})
Loading