feat: 내구성 있는 read model 변화 저널 기반을 추가
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { ChangeJournal } from '@sammo-ts/common';
|
||||
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '../src/gamePrisma.js';
|
||||
import { writeReadModelChangeJournal } from '../src/readModelChangeJournal.js';
|
||||
|
||||
const databaseUrl = process.env.READ_MODEL_JOURNAL_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
|
||||
integration('read-model change journal PostgreSQL boundary', () => {
|
||||
let disconnect: (() => Promise<void>) | undefined;
|
||||
let prisma: GamePrismaClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!databaseUrl) {
|
||||
throw new Error('READ_MODEL_JOURNAL_DATABASE_URL is required.');
|
||||
}
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl });
|
||||
prisma = connector.prisma;
|
||||
disconnect = connector.disconnect;
|
||||
await connector.connect();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await disconnect?.();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.$executeRaw`TRUNCATE TABLE "read_model_outbox", "read_model_revision" RESTART IDENTITY`;
|
||||
});
|
||||
|
||||
it('commits normalized revisions and one compact outbox payload', async () => {
|
||||
const journal = new ChangeJournal().mark('map.world').mark('general.content', 7).mark('map.world');
|
||||
|
||||
const receipt = await prisma.$transaction((transaction) =>
|
||||
writeReadModelChangeJournal(transaction, journal.snapshot())
|
||||
);
|
||||
const outboxes = await prisma.readModelOutbox.findMany();
|
||||
|
||||
expect(receipt).toEqual({
|
||||
invalidation: {
|
||||
revisions: [
|
||||
{ domain: 'general.content', entityId: 7, revision: 1n },
|
||||
{ domain: 'map.world', entityId: 0, revision: 1n },
|
||||
],
|
||||
},
|
||||
outboxId: 1n,
|
||||
});
|
||||
expect(outboxes).toHaveLength(1);
|
||||
expect(outboxes[0]?.payload).toEqual({
|
||||
version: 1,
|
||||
changes: [
|
||||
['general.content', 7, '1'],
|
||||
['map.world', 0, '1'],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls revision and outbox changes back with the owner transaction', async () => {
|
||||
await expect(
|
||||
prisma.$transaction(async (transaction) => {
|
||||
await writeReadModelChangeJournal(transaction, [{ domain: 'nation.content', entityId: 2 }]);
|
||||
throw new Error('rollback fixture');
|
||||
})
|
||||
).rejects.toThrow('rollback fixture');
|
||||
|
||||
await expect(prisma.readModelRevision.count()).resolves.toBe(0);
|
||||
await expect(prisma.readModelOutbox.count()).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it('does not lose increments from concurrent writers of the same key', async () => {
|
||||
const writerCount = 32;
|
||||
await Promise.all(
|
||||
Array.from({ length: writerCount }, () =>
|
||||
prisma.$transaction((transaction) =>
|
||||
writeReadModelChangeJournal(transaction, [{ domain: 'world.content', entityId: 0 }])
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const revision = await prisma.readModelRevision.findUniqueOrThrow({
|
||||
where: { domain_entityId: { domain: 'world.content', entityId: 0 } },
|
||||
});
|
||||
expect(revision.revision).toBe(BigInt(writerCount));
|
||||
await expect(prisma.readModelOutbox.count()).resolves.toBe(writerCount);
|
||||
});
|
||||
|
||||
it('keeps revision-first coverage disabled by default', async () => {
|
||||
const meta = await prisma.readModelRevisionMeta.findUniqueOrThrow({ where: { id: 1 } });
|
||||
expect(meta.coverageVersion).toBe(0);
|
||||
});
|
||||
|
||||
it('uses the Prisma-declared outbox dispatch index name', async () => {
|
||||
const indexes = await prisma.$queryRaw<Array<{ indexname: string }>>`
|
||||
SELECT "indexname"
|
||||
FROM "pg_indexes"
|
||||
WHERE "schemaname" = current_schema()
|
||||
AND "tablename" = 'read_model_outbox'
|
||||
`;
|
||||
expect(indexes.map(({ indexname }) => indexname)).toContain(
|
||||
'read_model_outbox_delivered_at_available_at_id_idx'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects negative revision, entity, retry, and coverage state at the database boundary', async () => {
|
||||
await expect(
|
||||
prisma.readModelRevision.create({
|
||||
data: { domain: 'general.content', entityId: -1 },
|
||||
})
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
prisma.readModelRevision.create({
|
||||
data: { domain: 'general.content', entityId: 1, revision: -1 },
|
||||
})
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
prisma.readModelOutbox.create({
|
||||
data: { payload: {}, attempts: -1 },
|
||||
})
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
prisma.readModelRevisionMeta.update({
|
||||
where: { id: 1 },
|
||||
data: { coverageVersion: -1 },
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GamePrisma } from '../src/gamePrisma.js';
|
||||
import { writeReadModelChangeJournal } from '../src/readModelChangeJournal.js';
|
||||
|
||||
const createTransaction = (rows: readonly object[]) => {
|
||||
const queryRaw = vi.fn().mockResolvedValue(rows);
|
||||
return {
|
||||
transaction: { $queryRaw: queryRaw } as unknown as GamePrisma.TransactionClient,
|
||||
queryRaw,
|
||||
};
|
||||
};
|
||||
|
||||
describe('writeReadModelChangeJournal', () => {
|
||||
it('dedupes keys, writes one statement, and returns committed revisions', async () => {
|
||||
const { transaction, queryRaw } = createTransaction([
|
||||
{ domain: 'general.content', entityId: 3, revision: 8n, outboxId: 41n },
|
||||
{ domain: 'map.world', entityId: 0, revision: 2n, outboxId: 41n },
|
||||
]);
|
||||
|
||||
const result = await writeReadModelChangeJournal(transaction, [
|
||||
{ domain: 'map.world', entityId: 0 },
|
||||
{ domain: 'general.content', entityId: 3 },
|
||||
{ domain: 'map.world', entityId: 0 },
|
||||
]);
|
||||
|
||||
expect(queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({
|
||||
invalidation: {
|
||||
revisions: [
|
||||
{ domain: 'general.content', entityId: 3, revision: 8n },
|
||||
{ domain: 'map.world', entityId: 0, revision: 2n },
|
||||
],
|
||||
},
|
||||
outboxId: 41n,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not touch the transaction for an empty journal', async () => {
|
||||
const { transaction, queryRaw } = createTransaction([]);
|
||||
|
||||
await expect(writeReadModelChangeJournal(transaction, [])).resolves.toBeNull();
|
||||
expect(queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a malformed database receipt instead of publishing partial state', async () => {
|
||||
const { transaction } = createTransaction([
|
||||
{ domain: 'general.content', entityId: 4, revision: 1n, outboxId: 1n },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
writeReadModelChangeJournal(transaction, [{ domain: 'general.content', entityId: 3 }])
|
||||
).rejects.toThrow('unexpected key order');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user