@spooky-sync/core 0.0.1-canary.321 → 0.0.1-canary.323
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/dist/index.d.ts +21 -7
- package/dist/index.js +158 -15
- package/dist/live.d.ts +8 -0
- package/dist/live.js +9 -3
- package/dist/pure.d.ts +8 -0
- package/dist/pure.js +1 -1
- package/dist/types.d.ts +10 -0
- package/dist/worker-edges.js +11 -1
- package/package.json +3 -3
- package/src/boot/boot.saga.test.ts +30 -7
- package/src/boot/boot.saga.ts +10 -6
- package/src/client/services.test.ts +17 -3
- package/src/client/services.ts +25 -9
- package/src/kernel/effects.ts +8 -4
- package/src/kernel/interpreter.ts +3 -3
- package/src/live/index.test.ts +14 -3
- package/src/live/index.ts +23 -3
- package/src/query/anon-query-isolation.integration.test.ts +161 -2
- package/src/query/register.saga.ts +7 -1
- package/src/query/sql.test.ts +6 -0
- package/src/query/sql.ts +8 -0
- package/src/services/database/cache-engine.ts +10 -0
- package/src/services/database/sqlite-cache-engine.ts +26 -0
- package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
- package/src/services/database/sqlite-column-reset.ts +91 -0
- package/src/services/stream-processor/index.ts +24 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +36 -0
- package/src/state/client-state.ts +8 -0
- package/src/state/reducers.ts +4 -1
- package/src/testing/adapters.ts +5 -1
- package/src/utils/index.ts +11 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
|
3
|
+
import { COLUMNS_ROW_ID, columnFingerprints, planColumnReset } from './sqlite-column-reset';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A body landed under an older schema lacks the columns that schema did not
|
|
7
|
+
* know, at the server's version. Without a reset the next build never fetches
|
|
8
|
+
* it again (WhitePawn's puzzles stuck on "Preparing" for want of `layout`).
|
|
9
|
+
* These run the planned statements against a REAL in-memory SQLite, the same
|
|
10
|
+
* build the worker loads.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
let db: any;
|
|
14
|
+
|
|
15
|
+
const rows = (sql: string, bind?: unknown[]) =>
|
|
16
|
+
db.exec({ sql, bind, rowMode: 'object', returnValue: 'resultRows' }) as Record<string, unknown>[];
|
|
17
|
+
|
|
18
|
+
const versions = (table: string) =>
|
|
19
|
+
Object.fromEntries(
|
|
20
|
+
rows(`SELECT id, json_extract(data, '$._00_rv') AS rv FROM "${table}"`).map((r) => [r.id, r.rv])
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const storedSets = () => {
|
|
24
|
+
const r = rows(`SELECT data FROM "_00_schema" WHERE id = ?`, [COLUMNS_ROW_ID]);
|
|
25
|
+
return r[0] ? (JSON.parse(r[0].data as string) as Record<string, string>) : null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const existing = () => new Set(rows(`SELECT name FROM sqlite_master WHERE type = 'table'`).map((r) => r.name as string));
|
|
29
|
+
|
|
30
|
+
function run(current: Record<string, string>, keep: string[] = []) {
|
|
31
|
+
const plan = planColumnReset(storedSets(), current, existing(), keep);
|
|
32
|
+
db.exec({ sql: 'BEGIN' });
|
|
33
|
+
for (const s of plan.statements) db.exec({ sql: s.sql, bind: s.bind });
|
|
34
|
+
db.exec({ sql: 'COMMIT' });
|
|
35
|
+
return plan;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
beforeEach(async () => {
|
|
39
|
+
const sqlite3: any = await sqlite3InitModule();
|
|
40
|
+
db = new sqlite3.oo1.DB(':memory:', 'c');
|
|
41
|
+
for (const t of ['_00_schema', 'puzzle', 'game']) {
|
|
42
|
+
db.exec({ sql: `CREATE TABLE "${t}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)` });
|
|
43
|
+
}
|
|
44
|
+
const put = (t: string, id: string, body: object) =>
|
|
45
|
+
db.exec({ sql: `INSERT INTO "${t}"(id, data) VALUES(?, ?)`, bind: [id, JSON.stringify(body)] });
|
|
46
|
+
// Landed by the old build: no `layout`, at the server's version 6.
|
|
47
|
+
put('puzzle', 'puzzle:a', { title: 'A', _00_rv: 6 });
|
|
48
|
+
put('puzzle', 'puzzle:b', { title: 'B', _00_rv: 6 });
|
|
49
|
+
put('game', 'game:1', { white: 'x', _00_rv: 3 });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => db?.close());
|
|
53
|
+
|
|
54
|
+
const OLD = { puzzle: 'title', game: 'white' };
|
|
55
|
+
const NEW = { puzzle: 'layout,title', game: 'white' };
|
|
56
|
+
|
|
57
|
+
describe('column reset on the SQLite cache', () => {
|
|
58
|
+
it('fingerprints are the sorted declared columns', () => {
|
|
59
|
+
expect(columnFingerprints([{ name: 'puzzle', columns: { title: {}, layout: {} } }])).toEqual({
|
|
60
|
+
puzzle: 'layout,title',
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('first boot with no recorded sets resets every cached table once', () => {
|
|
65
|
+
const plan = run(NEW);
|
|
66
|
+
expect(plan.changed.sort()).toEqual(['game', 'puzzle']);
|
|
67
|
+
expect(versions('puzzle')).toEqual({ 'puzzle:a': 0, 'puzzle:b': 0 });
|
|
68
|
+
expect(versions('game')).toEqual({ 'game:1': 0 });
|
|
69
|
+
expect(storedSets()).toEqual(NEW);
|
|
70
|
+
// Bodies stay: they keep rendering until the refetch lands.
|
|
71
|
+
expect(rows(`SELECT data FROM puzzle WHERE id = 'puzzle:a'`)[0].data).toContain('"title":"A"');
|
|
72
|
+
|
|
73
|
+
// The next boot of the same build changes nothing.
|
|
74
|
+
const again = run(NEW);
|
|
75
|
+
expect(again.statements).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('a new column resets only that table', () => {
|
|
79
|
+
run(OLD);
|
|
80
|
+
db.exec({ sql: `UPDATE puzzle SET data = json_set(data, '$._00_rv', 6)` });
|
|
81
|
+
db.exec({ sql: `UPDATE game SET data = json_set(data, '$._00_rv', 3)` });
|
|
82
|
+
|
|
83
|
+
const plan = run(NEW);
|
|
84
|
+
expect(plan.changed).toEqual(['puzzle']);
|
|
85
|
+
expect(versions('puzzle')).toEqual({ 'puzzle:a': 0, 'puzzle:b': 0 });
|
|
86
|
+
expect(versions('game')).toEqual({ 'game:1': 3 });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('keeps the version of a row with an unsettled local write', () => {
|
|
90
|
+
const plan = run(NEW, ['puzzle:b', 'game:9']);
|
|
91
|
+
expect(plan.changed).toContain('puzzle');
|
|
92
|
+
expect(versions('puzzle')).toEqual({ 'puzzle:a': 0, 'puzzle:b': 6 });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('records a table the store has no rows for without touching it', () => {
|
|
96
|
+
const plan = run({ ...NEW, puzzle_chapter: 'puzzle,sort,title' });
|
|
97
|
+
expect(plan.changed).not.toContain('puzzle_chapter');
|
|
98
|
+
expect(storedSets()).toEqual({ ...NEW, puzzle_chapter: 'puzzle,sort,title' });
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cached bodies outlive a schema change on the SQLite engine, and that loses
|
|
3
|
+
* columns for good.
|
|
4
|
+
*
|
|
5
|
+
* A body is landed through `cleanRecord`, which keeps only the columns the
|
|
6
|
+
* running app's schema declares, and is stored at the server's `_00_rv`. The
|
|
7
|
+
* engine is schemaless (no migrator, nothing is recreated when the schema
|
|
8
|
+
* changes), so a tab on an older build that fetched a row after the server
|
|
9
|
+
* added and backfilled a column stored it WITHOUT that column at the new
|
|
10
|
+
* version. The next build knows the column, but its local version equals the
|
|
11
|
+
* server's, so the fetch plan never asks for the row again and the column
|
|
12
|
+
* stays missing until something else bumps the row. (WhitePawn, 2026-10-11:
|
|
13
|
+
* every puzzle read as unconverted because `layout` never arrived.)
|
|
14
|
+
*
|
|
15
|
+
* So the store remembers each table's column set (`_00_schema`, row
|
|
16
|
+
* `columns`). On boot, before the prime reads the versions, a table whose set
|
|
17
|
+
* changed, or every table when no set was recorded yet, gets its cached
|
|
18
|
+
* `_00_rv` reset to 0. The bodies stay and keep rendering; any view that names
|
|
19
|
+
* one of those rows sees it as stale and fetches it again, whole. Rows with an
|
|
20
|
+
* unsettled local write keep their version: their echo is a newer body anyway.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** `_00_schema` row holding the recorded column sets. */
|
|
24
|
+
export const COLUMNS_ROW_ID = 'columns';
|
|
25
|
+
|
|
26
|
+
export interface ColumnResetPlan {
|
|
27
|
+
/** Tables with cached rows whose versions are reset. */
|
|
28
|
+
changed: string[];
|
|
29
|
+
/** One transaction: the resets, then the new column sets. Empty when nothing changed. */
|
|
30
|
+
statements: { sql: string; bind?: unknown[] }[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The column set of every table, as `cleanRecord` sees it: the sorted declared
|
|
35
|
+
* column names. Plain names rather than a hash, so a stored set says what it is.
|
|
36
|
+
*/
|
|
37
|
+
export function columnFingerprints(
|
|
38
|
+
tables: readonly { name: string; columns: Record<string, unknown> }[]
|
|
39
|
+
): Record<string, string> {
|
|
40
|
+
const out: Record<string, string> = {};
|
|
41
|
+
for (const t of tables) out[t.name] = Object.keys(t.columns).sort().join(',');
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sameSets = (a: Record<string, string> | null, b: Record<string, string>): boolean => {
|
|
46
|
+
if (!a) return false;
|
|
47
|
+
const ka = Object.keys(a);
|
|
48
|
+
return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What to run for the column sets `current`, given the `stored` ones (null when
|
|
53
|
+
* none were recorded), the tables that exist in the store, and the encoded ids
|
|
54
|
+
* (`table:id`) with an unsettled local write.
|
|
55
|
+
*/
|
|
56
|
+
export function planColumnReset(
|
|
57
|
+
stored: Record<string, string> | null,
|
|
58
|
+
current: Record<string, string>,
|
|
59
|
+
existingTables: ReadonlySet<string>,
|
|
60
|
+
keep: Iterable<string>
|
|
61
|
+
): ColumnResetPlan {
|
|
62
|
+
if (sameSets(stored, current)) return { changed: [], statements: [] };
|
|
63
|
+
|
|
64
|
+
const keepByTable = new Map<string, string[]>();
|
|
65
|
+
for (const id of keep) {
|
|
66
|
+
const table = id.slice(0, id.indexOf(':'));
|
|
67
|
+
if (!keepByTable.has(table)) keepByTable.set(table, []);
|
|
68
|
+
keepByTable.get(table)!.push(id);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const changed: string[] = [];
|
|
72
|
+
const statements: ColumnResetPlan['statements'] = [];
|
|
73
|
+
for (const [table, set] of Object.entries(current)) {
|
|
74
|
+
if (stored && stored[table] === set) continue;
|
|
75
|
+
if (!existingTables.has(table)) continue;
|
|
76
|
+
changed.push(table);
|
|
77
|
+
const quoted = `"${table.replace(/"/g, '""')}"`;
|
|
78
|
+
const reset = `UPDATE ${quoted} SET data = json_set(data, '$._00_rv', 0) WHERE json_extract(data, '$._00_rv') != 0`;
|
|
79
|
+
const kept = keepByTable.get(table);
|
|
80
|
+
statements.push(
|
|
81
|
+
kept
|
|
82
|
+
? { sql: `${reset} AND id NOT IN (SELECT value FROM json_each(?))`, bind: [JSON.stringify(kept)] }
|
|
83
|
+
: { sql: reset }
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
statements.push({
|
|
87
|
+
sql: `INSERT OR REPLACE INTO "_00_schema"(id, data) VALUES (?, ?)`,
|
|
88
|
+
bind: [COLUMNS_ROW_ID, JSON.stringify(current)],
|
|
89
|
+
});
|
|
90
|
+
return { changed, statements };
|
|
91
|
+
}
|
|
@@ -87,6 +87,10 @@ export interface CircuitPrimeContext {
|
|
|
87
87
|
/** Encoded ids with an unsettled local mutation (their `_00_rv` was bumped
|
|
88
88
|
* locally and may exceed the server's next version). */
|
|
89
89
|
pendingIds: Set<string>;
|
|
90
|
+
/** Each table's client column set (`columnFingerprints`). An engine that
|
|
91
|
+
* keeps bodies across schema changes resets the versions of a table whose
|
|
92
|
+
* set changed before they are read, so its rows are fetched again whole. */
|
|
93
|
+
columnFingerprints?: Record<string, string>;
|
|
90
94
|
/** Receives every `(id, rv)` the prime put into the circuit, per table, so
|
|
91
95
|
* the sync layer can skip re-downloading bodies it already has. */
|
|
92
96
|
onVersions?: (table: string, entries: [string, number][]) => void;
|
|
@@ -507,6 +511,26 @@ export class StreamProcessorService {
|
|
|
507
511
|
this.processor === processor && generation === this.stateGeneration && epoch === this.db.epoch;
|
|
508
512
|
const t0 = performance.now();
|
|
509
513
|
|
|
514
|
+
// Before anything reads a version: a table whose columns changed since its
|
|
515
|
+
// bodies were cached reads as stale from here on (see sqlite-column-reset).
|
|
516
|
+
if (ctx.columnFingerprints && typeof this.db.resetChangedTables === 'function') {
|
|
517
|
+
try {
|
|
518
|
+
const reset = await this.db.resetChangedTables(ctx.columnFingerprints, ctx.pendingIds);
|
|
519
|
+
if (!alive()) return;
|
|
520
|
+
if (reset.length > 0) {
|
|
521
|
+
this.logger.info(
|
|
522
|
+
{ tables: reset, Category: 'sp00ky-client::StreamProcessorService::primeFromLocal' },
|
|
523
|
+
'Columns changed since these rows were cached; they will be fetched again'
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
} catch (e) {
|
|
527
|
+
this.logger.warn(
|
|
528
|
+
{ error: e, Category: 'sp00ky-client::StreamProcessorService::primeFromLocal' },
|
|
529
|
+
'Column change check failed; cached versions kept'
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
510
534
|
let restored = false;
|
|
511
535
|
if (
|
|
512
536
|
this.persistCircuit &&
|
|
@@ -106,6 +106,42 @@ describe('StreamProcessorService.primeFromLocal', () => {
|
|
|
106
106
|
await expect(service.whenPrimed()).resolves.toBeUndefined();
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
+
it('resets tables whose columns changed BEFORE reading the versions it reports', async () => {
|
|
110
|
+
const { service, db } = makeService();
|
|
111
|
+
const order: string[] = [];
|
|
112
|
+
// The engine zeroes `thing`'s cached versions (a column was added).
|
|
113
|
+
const reset = vi.fn(async () => {
|
|
114
|
+
order.push('reset');
|
|
115
|
+
db.scanVersions.mockImplementation(async (tables: string[]) => {
|
|
116
|
+
order.push('scan');
|
|
117
|
+
return Object.fromEntries(tables.map((t) => [t, t === 'thing' ? [['thing:a', 0], ['thing:b', 0]] : [['other:x', 5]]]));
|
|
118
|
+
});
|
|
119
|
+
return ['thing'];
|
|
120
|
+
});
|
|
121
|
+
(db as any).resetChangedTables = reset;
|
|
122
|
+
await service.init();
|
|
123
|
+
const c = { ...ctx(), pendingIds: new Set(['thing:b']), columnFingerprints: { thing: 'layout,title', other: 'title' } };
|
|
124
|
+
await service.primeFromLocal(c);
|
|
125
|
+
expect(reset).toHaveBeenCalledWith({ thing: 'layout,title', other: 'title' }, new Set(['thing:b']));
|
|
126
|
+
expect(order).toEqual(['reset', 'scan']);
|
|
127
|
+
// Reported as stale, so the fetch plan pulls the bodies again.
|
|
128
|
+
expect(c.onVersions).toHaveBeenCalledWith('thing', [['thing:a', 0]]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('keeps priming when the column check fails', async () => {
|
|
132
|
+
const { service, db } = makeService();
|
|
133
|
+
(db as any).resetChangedTables = vi.fn(async () => {
|
|
134
|
+
throw new Error('worker gone');
|
|
135
|
+
});
|
|
136
|
+
await service.init();
|
|
137
|
+
const c = { ...ctx(), columnFingerprints: { thing: 'title' } };
|
|
138
|
+
await service.primeFromLocal(c);
|
|
139
|
+
expect(c.onVersions).toHaveBeenCalledWith('thing', [
|
|
140
|
+
['thing:a', 1],
|
|
141
|
+
['thing:b', 2],
|
|
142
|
+
]);
|
|
143
|
+
});
|
|
144
|
+
|
|
109
145
|
it('with a snapshot, restores it under the registered views and only fetches what reconcile asks for', async () => {
|
|
110
146
|
const { service, db, received } = makeService({ snapshot: { bytes: new Uint8Array([7]), meta: goodMeta } });
|
|
111
147
|
await service.init();
|
|
@@ -114,6 +114,13 @@ export interface ClientState {
|
|
|
114
114
|
readonly userId: string | null;
|
|
115
115
|
/** The principal the current session salt was minted for. */
|
|
116
116
|
readonly saltUserId: string | null;
|
|
117
|
+
/**
|
|
118
|
+
* This page's secret, sent with every registration (`$config.owner`). An
|
|
119
|
+
* anonymous view answers only to the secret it was registered with, so it
|
|
120
|
+
* lives as long as the page's views: a reconnect keeps it, and a reload
|
|
121
|
+
* that keeps the salt keeps it too.
|
|
122
|
+
*/
|
|
123
|
+
readonly ownerSecret: string | null;
|
|
117
124
|
/** Latest bucket-switch target; an older switch that wakes up to a newer target skips. */
|
|
118
125
|
readonly pendingBucket: string | null;
|
|
119
126
|
readonly tabId: string;
|
|
@@ -167,6 +174,7 @@ export function emptyState(init: { tabId: string }): ClientState {
|
|
|
167
174
|
sessionId: null,
|
|
168
175
|
userId: null,
|
|
169
176
|
saltUserId: null,
|
|
177
|
+
ownerSecret: null,
|
|
170
178
|
pendingBucket: null,
|
|
171
179
|
tabId: init.tabId,
|
|
172
180
|
tabRole: 'solo',
|
package/src/state/reducers.ts
CHANGED
|
@@ -444,7 +444,10 @@ export const setFailedCount =
|
|
|
444
444
|
export const setIdentity =
|
|
445
445
|
(
|
|
446
446
|
patch: Partial<
|
|
447
|
-
Pick<
|
|
447
|
+
Pick<
|
|
448
|
+
ClientState,
|
|
449
|
+
'sessionId' | 'userId' | 'saltUserId' | 'ownerSecret' | 'pendingBucket' | 'bucketId' | 'tabRole' | 'localReady' | 'primed'
|
|
450
|
+
>
|
|
448
451
|
>
|
|
449
452
|
): Reducer =>
|
|
450
453
|
(s) => ({ ...s, ...patch });
|
package/src/testing/adapters.ts
CHANGED
|
@@ -78,7 +78,11 @@ export function fakeAdapters(over: {
|
|
|
78
78
|
clear: (key) => void timers.pending.delete(key),
|
|
79
79
|
},
|
|
80
80
|
clock: { now: () => now },
|
|
81
|
-
ids: {
|
|
81
|
+
ids: {
|
|
82
|
+
mutation: () => `_00_pending_mutations:${String(++ids).padStart(13, '0')}_0001_tab`,
|
|
83
|
+
salt: () => `salt-${++ids}`,
|
|
84
|
+
owner: () => `owner-${++ids}`,
|
|
85
|
+
},
|
|
82
86
|
hash: sha256Hex,
|
|
83
87
|
services,
|
|
84
88
|
};
|
package/src/utils/index.ts
CHANGED
|
@@ -51,6 +51,17 @@ export function generateId(): string {
|
|
|
51
51
|
return Uuid.v4().toString().replace(/-/g, '');
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* 32 random bytes, hex: the secret a page proves it owns its anonymous views
|
|
56
|
+
* with (`$config.owner` on `fn::query::register`). `getRandomValues` rather
|
|
57
|
+
* than `randomUUID`, which a page served over plain http does not have.
|
|
58
|
+
*/
|
|
59
|
+
export function mintOwnerSecret(): string {
|
|
60
|
+
const bytes = new Uint8Array(32);
|
|
61
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
62
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
export function generateNewTableId<S extends SchemaStructure, T extends TableNames<S>>(
|
|
55
66
|
tableName: T
|
|
56
67
|
): RecordId {
|