This commit is contained in:
@@ -49,6 +49,7 @@ export class GuardViolationError extends Error {
|
||||
export interface GuardUpdate {
|
||||
guardName: string;
|
||||
key: string;
|
||||
codeRev: string;
|
||||
newState: any;
|
||||
lastEventId: number;
|
||||
}
|
||||
|
||||
@@ -253,3 +253,90 @@ test('kind wildcard *.__start__ matches meta.__start__ and coding.__start__', as
|
||||
kinds: ['meta.__start__', 'coding.__start__'],
|
||||
});
|
||||
});
|
||||
|
||||
// ── code_rev dimension tests ──────────────────────────────────
|
||||
|
||||
test('same guard + key, different code_rev have independent state', async () => {
|
||||
last = mkScoped();
|
||||
const { db, store } = last;
|
||||
|
||||
registerGuard(db, {
|
||||
name: 'counter',
|
||||
initial_value: { n: 0 },
|
||||
sources: [
|
||||
{
|
||||
kind: 'tick',
|
||||
check: 'true',
|
||||
transition: '{ "n": state.n + 1 }',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Append with code_rev 'v1'
|
||||
await store.appendEvent({ kind: 'tick', key: 'k', occurredAt: 1, codeRev: 'v1' });
|
||||
await store.appendEvent({ kind: 'tick', key: 'k', occurredAt: 2, codeRev: 'v1' });
|
||||
|
||||
// Append with code_rev 'v2'
|
||||
await store.appendEvent({ kind: 'tick', key: 'k', occurredAt: 3, codeRev: 'v2' });
|
||||
|
||||
// v1 should have n=2, v2 should have n=1
|
||||
expect(getGuardState(db, 'counter', 'k', 'v1')).toEqual({ n: 2 });
|
||||
expect(getGuardState(db, 'counter', 'k', 'v2')).toEqual({ n: 1 });
|
||||
});
|
||||
|
||||
test('code_rev switch starts from initial_value', async () => {
|
||||
last = mkScoped();
|
||||
const { db, store } = last;
|
||||
|
||||
registerGuard(db, {
|
||||
name: 'phase',
|
||||
initial_value: { phase: 'idle' },
|
||||
sources: [
|
||||
{
|
||||
kind: 'task.created',
|
||||
check: 'state.phase = "idle"',
|
||||
transition: '{ "phase": "open" }',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await store.appendEvent({ kind: 'task.created', key: 'x', occurredAt: 1, codeRev: 'rev-a' });
|
||||
expect(getGuardState(db, 'phase', 'x', 'rev-a')).toEqual({ phase: 'open' });
|
||||
|
||||
// New code_rev starts fresh from initial_value
|
||||
await store.appendEvent({ kind: 'task.created', key: 'x', occurredAt: 2, codeRev: 'rev-b' });
|
||||
expect(getGuardState(db, 'phase', 'x', 'rev-b')).toEqual({ phase: 'open' });
|
||||
|
||||
// rev-a state unchanged
|
||||
expect(getGuardState(db, 'phase', 'x', 'rev-a')).toEqual({ phase: 'open' });
|
||||
});
|
||||
|
||||
test('rollback to old code_rev resumes existing state', async () => {
|
||||
last = mkScoped();
|
||||
const { db, store } = last;
|
||||
|
||||
registerGuard(db, {
|
||||
name: 'counter',
|
||||
initial_value: { n: 0 },
|
||||
sources: [
|
||||
{
|
||||
kind: 'tick',
|
||||
check: 'true',
|
||||
transition: '{ "n": state.n + 1 }',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// v1: two ticks
|
||||
await store.appendEvent({ kind: 'tick', key: 'k', occurredAt: 1, codeRev: 'v1' });
|
||||
await store.appendEvent({ kind: 'tick', key: 'k', occurredAt: 2, codeRev: 'v1' });
|
||||
expect(getGuardState(db, 'counter', 'k', 'v1')).toEqual({ n: 2 });
|
||||
|
||||
// Switch to v2
|
||||
await store.appendEvent({ kind: 'tick', key: 'k', occurredAt: 3, codeRev: 'v2' });
|
||||
expect(getGuardState(db, 'counter', 'k', 'v2')).toEqual({ n: 1 });
|
||||
|
||||
// Rollback to v1 — resumes from n=2
|
||||
await store.appendEvent({ kind: 'tick', key: 'k', occurredAt: 4, codeRev: 'v1' });
|
||||
expect(getGuardState(db, 'counter', 'k', 'v1')).toEqual({ n: 3 });
|
||||
});
|
||||
|
||||
@@ -34,10 +34,11 @@ CREATE TABLE IF NOT EXISTS guard_defs (
|
||||
CREATE TABLE IF NOT EXISTS guard_states (
|
||||
guard_name TEXT NOT NULL,
|
||||
key TEXT NOT NULL DEFAULT '',
|
||||
code_rev TEXT NOT NULL DEFAULT '',
|
||||
value TEXT NOT NULL,
|
||||
last_event_id INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (guard_name, key)
|
||||
PRIMARY KEY (guard_name, key, code_rev)
|
||||
);
|
||||
`;
|
||||
|
||||
@@ -69,14 +70,14 @@ const selectGuardDefsStmt = (db: Database) =>
|
||||
const selectGuardStateStmt = (db: Database) =>
|
||||
db.prepare(`
|
||||
SELECT value, last_event_id FROM guard_states
|
||||
WHERE guard_name = ? AND key = ?
|
||||
WHERE guard_name = ? AND key = ? AND code_rev = ?
|
||||
`);
|
||||
|
||||
const upsertGuardStateStmt = (db: Database) =>
|
||||
db.prepare(`
|
||||
INSERT INTO guard_states (guard_name, key, value, last_event_id, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(guard_name, key) DO UPDATE SET
|
||||
INSERT INTO guard_states (guard_name, key, code_rev, value, last_event_id, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(guard_name, key, code_rev) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
last_event_id = excluded.last_event_id,
|
||||
updated_at = excluded.updated_at
|
||||
@@ -106,8 +107,9 @@ function loadGuardRow(
|
||||
db: Database,
|
||||
guardName: string,
|
||||
key: string,
|
||||
codeRev: string = '',
|
||||
): { value: any; lastEventId: number } | null {
|
||||
const row = selectGuardStateStmt(db).get(guardName, key) as {
|
||||
const row = selectGuardStateStmt(db).get(guardName, key, codeRev) as {
|
||||
value: string;
|
||||
last_event_id: number;
|
||||
} | null;
|
||||
@@ -141,8 +143,9 @@ export function getGuardState(
|
||||
db: Database,
|
||||
guardName: string,
|
||||
key: string,
|
||||
codeRev: string = '',
|
||||
): any {
|
||||
const row = loadGuardRow(db, guardName, key);
|
||||
const row = loadGuardRow(db, guardName, key, codeRev);
|
||||
if (!row) {
|
||||
const defs = listGuardDefs(db);
|
||||
const def = defs.find((d) => d.name === guardName);
|
||||
@@ -158,11 +161,17 @@ export function getGuardState(
|
||||
export async function checkGuards(
|
||||
db: Database,
|
||||
event: { kind: string; key?: string; meta?: string; occurredAt: number },
|
||||
codeRev: string = '',
|
||||
): Promise<{ updates: GuardUpdate[] }> {
|
||||
const defs = listGuardDefs(db);
|
||||
return checkGuardsCore(defs, event, (guardName, key) =>
|
||||
loadGuardRow(db, guardName, key),
|
||||
const result = await checkGuardsCore(defs, event, (guardName, key) =>
|
||||
loadGuardRow(db, guardName, key, codeRev),
|
||||
);
|
||||
// Stamp codeRev onto each update
|
||||
for (const u of result.updates) {
|
||||
u.codeRev = codeRev;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,6 +184,7 @@ export function applyGuardUpdates(db: Database, updates: GuardUpdate[]): void {
|
||||
stmt.run(
|
||||
u.guardName,
|
||||
u.key,
|
||||
u.codeRev ?? '',
|
||||
JSON.stringify(u.newState),
|
||||
u.lastEventId,
|
||||
now,
|
||||
|
||||
@@ -228,7 +228,7 @@ async function appendOneWithGuards(
|
||||
const nested = db.inTransaction;
|
||||
if (!nested) db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const { updates } = await checkGuards(db, event);
|
||||
const { updates } = await checkGuards(db, event, event.codeRev ?? '');
|
||||
const written = insert(event);
|
||||
applyGuardUpdates(
|
||||
db,
|
||||
@@ -253,7 +253,7 @@ async function appendManyWithGuards(
|
||||
try {
|
||||
const results: EventRecord[] = [];
|
||||
for (const event of events) {
|
||||
const { updates } = await checkGuards(db, event);
|
||||
const { updates } = await checkGuards(db, event, event.codeRev ?? '');
|
||||
const written = insert(event);
|
||||
applyGuardUpdates(
|
||||
db,
|
||||
|
||||
Reference in New Issue
Block a user