feat: 내구성 있는 read model 변화 저널 기반을 추가

This commit is contained in:
2026-08-16 18:01:22 +00:00
parent aa51f1b7d9
commit 86733b99b7
16 changed files with 653 additions and 4 deletions
+1
View File
@@ -6,3 +6,4 @@ export type { GatewayPrismaClient } from './gatewayPrisma.js';
export * from './db.js';
export * from './redis.js';
export * from './turnEngineDb.js';
export * from './readModelChangeJournal.js';
@@ -0,0 +1,123 @@
import {
isReadModelDomain,
normalizeReadModelRevisionKeys,
READ_MODEL_OUTBOX_PAYLOAD_VERSION,
type CommittedReadModelInvalidation,
type ReadModelRevisionKey,
} from '@sammo-ts/common';
import { GamePrisma } from './gamePrisma.js';
interface ReadModelJournalWriteRow {
domain: string;
entityId: number;
revision: bigint;
outboxId: bigint;
}
export interface ReadModelJournalWriteResult {
invalidation: CommittedReadModelInvalidation;
/** Delivery identity only. It must not be used as a projection revision. */
outboxId: bigint;
}
const assertWriteRows = (
keys: readonly ReadModelRevisionKey[],
rows: readonly ReadModelJournalWriteRow[]
): ReadModelJournalWriteResult => {
if (rows.length !== keys.length) {
throw new Error(`Read-model journal wrote ${rows.length} revisions for ${keys.length} keys.`);
}
const first = rows[0];
if (!first) {
throw new Error('Read-model journal write did not return an outbox ID.');
}
const outboxId = BigInt(first.outboxId);
const revisions = rows.map((row, index) => {
const key = keys[index];
if (!key || !isReadModelDomain(row.domain) || row.domain !== key.domain || row.entityId !== key.entityId) {
throw new Error('Read-model journal write returned revisions in an unexpected key order.');
}
if (BigInt(row.outboxId) !== outboxId) {
throw new Error('Read-model journal write returned more than one outbox row.');
}
return {
domain: row.domain,
entityId: row.entityId,
revision: BigInt(row.revision),
};
});
return {
invalidation: { revisions },
outboxId,
};
};
/**
* Atomically increments every normalized revision key and stores exactly one
* compact outbox payload. The caller must pass the existing Prisma transaction
* that owns the domain mutation; this function never opens or commits one.
*/
export const writeReadModelChangeJournal = async (
transaction: GamePrisma.TransactionClient,
candidates: Iterable<ReadModelRevisionKey>
): Promise<ReadModelJournalWriteResult | null> => {
const keys = normalizeReadModelRevisionKeys(candidates);
if (keys.length === 0) {
return null;
}
const requestedRows = keys.map(({ domain, entityId }) => GamePrisma.sql`(${domain}::text, ${entityId}::integer)`);
const rows = await transaction.$queryRaw<ReadModelJournalWriteRow[]>(GamePrisma.sql`
WITH requested("domain", "entity_id") AS (
VALUES ${GamePrisma.join(requestedRows)}
),
bumped AS (
INSERT INTO "read_model_revision" (
"domain",
"entity_id",
"revision",
"updated_at"
)
SELECT
requested."domain",
requested."entity_id",
1,
CURRENT_TIMESTAMP
FROM requested
ORDER BY requested."domain", requested."entity_id"
ON CONFLICT ("domain", "entity_id") DO UPDATE
SET
"revision" = "read_model_revision"."revision" + 1,
"updated_at" = CURRENT_TIMESTAMP
RETURNING "domain", "entity_id", "revision"
),
outbox_payload AS (
SELECT jsonb_build_object(
'version', ${READ_MODEL_OUTBOX_PAYLOAD_VERSION}::integer,
'changes', jsonb_agg(
jsonb_build_array("domain", "entity_id", "revision"::text)
ORDER BY "domain", "entity_id"
)
) AS "payload"
FROM bumped
),
inserted_outbox AS (
INSERT INTO "read_model_outbox" ("payload")
SELECT "payload" FROM outbox_payload
RETURNING "id"
)
SELECT
bumped."domain",
bumped."entity_id" AS "entityId",
bumped."revision",
inserted_outbox."id" AS "outboxId"
FROM bumped
CROSS JOIN inserted_outbox
ORDER BY bumped."domain", bumped."entity_id"
`);
return assertWriteRows(keys, rows);
};