2026-05-09 18:24:44 +08:00
// Snapshot writer integration test (spec §8.2.1).
//
// The applied_plugin_snapshots table has exactly one writer: the
// `snapshots.ts` module. This test exercises createSnapshot →
// linkSnapshotToRun → markSnapshotStale and asserts:
// - Inserted rows expose the AppliedPluginSnapshot shape.
// - Linking to a run pins expires_at = NULL.
// - Marking stale flips status to 'stale'.
import { afterEach , beforeEach , describe , expect , it } from 'vitest' ;
import { mkdtemp , rm } from 'node:fs/promises' ;
import os from 'node:os' ;
import path from 'node:path' ;
import Database from 'better-sqlite3' ;
import { migratePlugins } from '../src/plugins/persistence.js' ;
import {
createSnapshot ,
getSnapshot ,
2026-08-24 08:53:36 +00:00
InvalidAppliedStrategySnapshotError ,
2026-05-09 18:24:44 +08:00
linkSnapshotToRun ,
2026-05-29 11:20:47 +08:00
linkSnapshotToProject ,
2026-05-09 18:24:44 +08:00
markSnapshotStale ,
2026-05-29 11:20:47 +08:00
restoreProjectSnapshotLink ,
2026-08-24 08:53:36 +00:00
rowToSnapshot ,
2026-05-09 18:24:44 +08:00
} from '../src/plugins/snapshots.js' ;
2026-08-24 08:53:36 +00:00
import { strategyPackageHashFromDigests } from '@open-design/plugin-runtime' ;
2026-05-09 18:24:44 +08:00
let db : Database.Database ;
let tmpDir : string ;
beforeEach ( async () => {
tmpDir = await mkdtemp ( path . join ( os . tmpdir (), 'od-snapshot-' ));
db = new Database ( path . join ( tmpDir , 'test.sqlite' ));
// Minimal projects/conversations tables so the FK ON DELETE clauses
// don't blow up the migration.
db . exec ( `
CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT);
CREATE TABLE conversations (id TEXT PRIMARY KEY, project_id TEXT, title TEXT);
` );
migratePlugins ( db );
});
afterEach ( async () => {
db . close ();
await rm ( tmpDir , { recursive : true , force : true });
});
const baseInput = ( extra : Partial < Parameters < typeof createSnapshot > [ 1 ] > = {}) => ({
projectId : 'project-1' ,
conversationId : null ,
runId : null ,
pluginId : 'sample-plugin' ,
pluginVersion : '1.0.0' ,
pluginTitle : 'Sample Plugin' ,
pluginDescription : 'Fixture' ,
manifestSourceDigest : 'digest-1' ,
sourceMarketplaceId : null ,
pinnedRef : null ,
taskKind : 'new-generation' as const ,
inputs : { topic : 'design' },
resolvedContext : { items : [] },
pipeline : undefined ,
genuiSurfaces : [],
capabilitiesGranted : [ 'prompt:inject' ],
capabilitiesRequired : [ 'prompt:inject' ],
assetsStaged : [],
connectorsRequired : [],
connectorsResolved : [],
mcpServers : [],
query : 'Make a deck' ,
... extra ,
});
2026-08-24 08:53:36 +00:00
function strategyBinding() {
const assetDigests = [
{ path : './SKILL.md' , sha256 : 'a' . repeat ( 64 ) },
{ path : './assets/task-profiles/prototype.md' , sha256 : 'b' . repeat ( 64 ) },
];
return {
schema : 'open-design.applied-strategy/v2' as const ,
id : 'od-next-strategy' as const ,
version : '2.0.0' ,
packageHash : strategyPackageHashFromDigests ( assetDigests ),
assetDigests ,
selectedTaskProfile : {
taskType : 'prototype' as const ,
version : '2.0.0' ,
path : './assets/task-profiles/prototype.md' ,
sha256 : 'b' . repeat ( 64 ),
},
taskProfileVersions : [ '2.0.0' ],
promptRecipe : 'od-next-plan-build-v2' as const ,
};
}
function strategySnapshotInput (
extra : Partial < Parameters < typeof createSnapshot > [ 1 ] > = {},
) {
return baseInput ({
pluginId : 'od-next-strategy' ,
pluginVersion : '2.0.0' ,
strategy : strategyBinding (),
... extra ,
});
}
2026-05-09 18:24:44 +08:00
describe ( 'snapshots writer' , () => {
2026-08-24 08:53:36 +00:00
it ( 'adds strategy_json as a nullable reader-first migration column' , () => {
const columns = db . prepare ( 'PRAGMA table_info(applied_plugin_snapshots)' ). all () as Array < {
name : string ;
notnull : number ;
dflt_value : unknown ;
} > ;
expect ( columns . find (( column ) => column . name === 'strategy_json' )). toEqual (
expect . objectContaining ({ notnull : 0 , dflt_value : null }),
);
});
it ( 'migrates an existing snapshot row additively before enabling the writer' , () => {
const legacy = new Database ( ':memory:' );
try {
legacy . exec ( `
CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT);
CREATE TABLE conversations (id TEXT PRIMARY KEY, project_id TEXT, title TEXT);
CREATE TABLE applied_plugin_snapshots (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
run_id TEXT,
plugin_id TEXT NOT NULL,
plugin_spec_version TEXT NOT NULL DEFAULT '1.0.0',
plugin_version TEXT NOT NULL
);
INSERT INTO applied_plugin_snapshots (
id, project_id, run_id, plugin_id, plugin_version
) VALUES ('legacy-snapshot', 'legacy-project', NULL, 'legacy-plugin', '1.0.0');
` );
migratePlugins ( legacy );
const row = legacy . prepare (
'SELECT * FROM applied_plugin_snapshots WHERE id = ?' ,
). get ( 'legacy-snapshot' ) as Record < string , unknown >;
expect ( row [ 'strategy_json' ]). toBeNull ();
expect ( rowToSnapshot ( row ). strategy ). toBeUndefined ();
} finally {
legacy . close ();
}
});
2026-05-09 18:24:44 +08:00
it ( 'createSnapshot inserts a row and round-trips via getSnapshot' , () => {
db . prepare ( 'INSERT INTO projects (id, name) VALUES (?, ?)' ). run ( 'project-1' , 'Project 1' );
2026-05-14 07:25:05 +08:00
const snap = createSnapshot ( db , baseInput ({
sourceMarketplaceId : 'official' ,
sourceMarketplaceEntryName : 'open-design/sample-plugin' ,
sourceMarketplaceEntryVersion : '1.0.0' ,
marketplaceTrust : 'official' ,
resolvedSource : 'github:open-design/plugins@abc123/sample-plugin' ,
resolvedRef : 'abc123' ,
archiveIntegrity : 'sha512-fixture' ,
}));
2026-05-09 18:24:44 +08:00
expect ( snap . snapshotId ). toMatch ( /^[0-9a-f-]{36}$/ );
const fetched = getSnapshot ( db , snap . snapshotId );
expect ( fetched ). not . toBeNull ();
expect ( fetched ! . pluginId ). toBe ( 'sample-plugin' );
expect ( fetched ! . manifestSourceDigest ). toBe ( 'digest-1' );
2026-05-14 07:25:05 +08:00
expect ( fetched ! . sourceMarketplaceId ). toBe ( 'official' );
expect ( fetched ! . sourceMarketplaceEntryName ). toBe ( 'open-design/sample-plugin' );
expect ( fetched ! . sourceMarketplaceEntryVersion ). toBe ( '1.0.0' );
expect ( fetched ! . marketplaceTrust ). toBe ( 'official' );
expect ( fetched ! . resolvedSource ). toBe ( 'github:open-design/plugins@abc123/sample-plugin' );
expect ( fetched ! . resolvedRef ). toBe ( 'abc123' );
expect ( fetched ! . archiveIntegrity ). toBe ( 'sha512-fixture' );
2026-05-09 18:24:44 +08:00
expect ( fetched ! . status ). toBe ( 'fresh' );
});
2026-06-09 09:49:35 +08:00
it ( 'persists apply-time craft requirements on the immutable snapshot' , () => {
db . prepare ( 'INSERT INTO projects (id, name) VALUES (?, ?)' ). run ( 'project-1' , 'Project 1' );
const snap = createSnapshot ( db , baseInput ({
craftRequires : [ 'typography' , 'color' , 'anti-ai-slop' ],
}));
const fetched = getSnapshot ( db , snap . snapshotId );
expect ( fetched ? . craftRequires ). toEqual ([ 'typography' , 'color' , 'anti-ai-slop' ]);
});
2026-08-24 08:53:36 +00:00
it ( 'round-trips a complete strategy binding and writes SQL NULL for ordinary plugins' , () => {
db . prepare ( 'INSERT INTO projects (id, name) VALUES (?, ?)' ). run ( 'project-1' , 'Project 1' );
const strategy = createSnapshot ( db , strategySnapshotInput ());
const ordinary = createSnapshot ( db , baseInput ({ pluginId : 'ordinary-plugin' }));
expect ( getSnapshot ( db , strategy . snapshotId ) ? . strategy ). toEqual ( strategyBinding ());
expect ( getSnapshot ( db , ordinary . snapshotId ) ? . strategy ). toBeUndefined ();
const rows = db . prepare (
'SELECT id, strategy_json AS strategyJson FROM applied_plugin_snapshots WHERE id IN (?, ?)' ,
). all ( strategy . snapshotId , ordinary . snapshotId ) as Array < {
id : string ;
strategyJson : string | null ;
} > ;
expect ( rows . find (( row ) => row . id === strategy . snapshotId ) ? . strategyJson ). toBeTypeOf ( 'string' );
expect ( rows . find (( row ) => row . id === ordinary . snapshotId ) ? . strategyJson ). toBeNull ();
});
it ( 'reads a legacy row with no strategy column and preserves null compatibility' , () => {
db . prepare ( 'INSERT INTO projects (id, name) VALUES (?, ?)' ). run ( 'project-1' , 'Project 1' );
const snapshot = createSnapshot ( db , baseInput ());
const row = db . prepare ( 'SELECT * FROM applied_plugin_snapshots WHERE id = ?' )
. get ( snapshot . snapshotId ) as Record < string , unknown >;
delete row [ 'strategy_json' ];
expect ( rowToSnapshot ( row ). strategy ). toBeUndefined ();
expect ( getSnapshot ( db , snapshot . snapshotId ) ? . strategy ). toBeUndefined ();
});
it . each ([ '{invalid' , '' ])(
'fails closed for invalid strategy JSON %j' ,
( invalidJson ) => {
db . prepare ( 'INSERT INTO projects (id, name) VALUES (?, ?)' ). run ( 'project-1' , 'Project 1' );
const snapshot = createSnapshot ( db , strategySnapshotInput ());
const update = db . prepare (
'UPDATE applied_plugin_snapshots SET strategy_json = ? WHERE id = ?' ,
). run ( invalidJson , snapshot . snapshotId );
expect ( update . changes ). toBe ( 1 );
expect (() => getSnapshot ( db , snapshot . snapshotId )). toThrow (
InvalidAppliedStrategySnapshotError ,
);
},
);
it ( 'fails closed for a strategy package hash mismatch' , () => {
db . prepare ( 'INSERT INTO projects (id, name) VALUES (?, ?)' ). run ( 'project-1' , 'Project 1' );
const snapshot = createSnapshot ( db , strategySnapshotInput ());
const mismatched = { ... strategyBinding (), packageHash : 'f' . repeat ( 64 ) };
db . prepare ( 'UPDATE applied_plugin_snapshots SET strategy_json = ? WHERE id = ?' )
. run ( JSON . stringify ( mismatched ), snapshot . snapshotId );
expect (() => getSnapshot ( db , snapshot . snapshotId )). toThrow ( /does not match/i );
});
it ( 'rejects an invalid binding before the single writer inserts a row' , () => {
db . prepare ( 'INSERT INTO projects (id, name) VALUES (?, ?)' ). run ( 'project-1' , 'Project 1' );
const invalid = { ... strategyBinding (), packageHash : 'f' . repeat ( 64 ) };
const before = db . prepare ( 'SELECT COUNT(*) AS count FROM applied_plugin_snapshots' )
. get () as { count : number };
expect (() => createSnapshot ( db , strategySnapshotInput ({ strategy : invalid }))). toThrow (
/does not match/i ,
);
const after = db . prepare ( 'SELECT COUNT(*) AS count FROM applied_plugin_snapshots' )
. get () as { count : number };
expect ( after . count ). toBe ( before . count );
});
it . each ([
[ 'plugin id' , { pluginId : 'ordinary-plugin' }],
[ 'plugin version' , { pluginVersion : '9.9.9' }],
])( 'rejects a strategy whose %s does not match before inserting a row' , ( _label , mismatch ) => {
db . prepare ( 'INSERT INTO projects (id, name) VALUES (?, ?)' ). run ( 'project-1' , 'Project 1' );
const before = db . prepare ( 'SELECT COUNT(*) AS count FROM applied_plugin_snapshots' )
. get () as { count : number };
expect (() => createSnapshot ( db , strategySnapshotInput ( mismatch ))). toThrow (
/snapshot plugin identity/i ,
);
const after = db . prepare ( 'SELECT COUNT(*) AS count FROM applied_plugin_snapshots' )
. get () as { count : number };
expect ( after . count ). toBe ( before . count );
});
it . each ([
[ 'plugin id' , 'plugin_id' , 'ordinary-plugin' ],
[ 'plugin version' , 'plugin_version' , '9.9.9' ],
])( 'fails closed when a persisted strategy row tampers with its %s' , ( _label , column , value ) => {
db . prepare ( 'INSERT INTO projects (id, name) VALUES (?, ?)' ). run ( 'project-1' , 'Project 1' );
const snapshot = createSnapshot ( db , strategySnapshotInput ());
const update = db . prepare (
`UPDATE applied_plugin_snapshots SET ${ column } = ? WHERE id = ?` ,
). run ( value , snapshot . snapshotId );
expect ( update . changes ). toBe ( 1 );
expect (() => getSnapshot ( db , snapshot . snapshotId )). toThrow (
/snapshot plugin identity/i ,
);
});
2026-05-09 18:24:44 +08:00
it ( 'linkSnapshotToRun pins expires_at to NULL' , () => {
db . prepare ( 'INSERT INTO projects (id, name) VALUES (?, ?)' ). run ( 'project-1' , 'Project 1' );
const snap = createSnapshot ( db , baseInput ());
const beforeRow = db . prepare ( 'SELECT expires_at FROM applied_plugin_snapshots WHERE id = ?' ). get ( snap . snapshotId ) as { expires_at : number | null };
// Either null (TTL=0) or a numeric expiry — we care that linking nulls it.
void beforeRow ;
linkSnapshotToRun ( db , snap . snapshotId , 'run-1' );
const after = db . prepare ( 'SELECT run_id, expires_at FROM applied_plugin_snapshots WHERE id = ?' ). get ( snap . snapshotId ) as { run_id : string ; expires_at : number | null };
expect ( after . run_id ). toBe ( 'run-1' );
expect ( after . expires_at ). toBeNull ();
});
2026-05-29 11:20:47 +08:00
it ( 'restoreProjectSnapshotLink makes an unlinked discarded snapshot expirable again' , () => {
db . prepare ( 'INSERT INTO projects (id, name) VALUES (?, ?)' ). run ( 'project-1' , 'Project 1' );
const previous = createSnapshot ( db , baseInput ({ query : 'Previous {{topic}}' }));
linkSnapshotToProject ( db , previous . snapshotId , 'project-1' );
const discarded = createSnapshot ( db , baseInput ({ query : 'Discarded {{topic}}' }));
linkSnapshotToProject ( db , discarded . snapshotId , 'project-1' );
restoreProjectSnapshotLink (
db ,
'project-1' ,
discarded . snapshotId ,
previous . snapshotId ,
'run-that-was-never-linked' ,
);
const project = db . prepare (
`SELECT applied_plugin_snapshot_id AS appliedPluginSnapshotId
FROM projects
WHERE id = ?` ,
). get ( 'project-1' ) as { appliedPluginSnapshotId : string | null };
const discardedRow = db . prepare (
`SELECT run_id AS runId, expires_at AS expiresAt
FROM applied_plugin_snapshots
WHERE id = ?` ,
). get ( discarded . snapshotId ) as { runId : string | null ; expiresAt : number | null };
expect ( project . appliedPluginSnapshotId ). toBe ( previous . snapshotId );
expect ( discardedRow . runId ). toBeNull ();
expect ( discardedRow . expiresAt ). not . toBeNull ();
});
2026-05-09 18:24:44 +08:00
it ( 'markSnapshotStale flips status' , () => {
db . prepare ( 'INSERT INTO projects (id, name) VALUES (?, ?)' ). run ( 'project-1' , 'Project 1' );
const snap = createSnapshot ( db , baseInput ());
markSnapshotStale ( db , snap . snapshotId );
const fetched = getSnapshot ( db , snap . snapshotId );
expect ( fetched ! . status ). toBe ( 'stale' );
});
});