@spooky-sync/core 0.0.1-canary.326 → 0.0.1-canary.328
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 +5 -3
- package/dist/index.d.ts +12 -2
- package/dist/index.js +461 -344
- package/dist/types.d.ts +10 -1
- package/package.json +5 -4
- package/scripts/check-worker-entries.mjs +1 -0
- package/skills/sp00ky-core/SKILL.md +1 -1
- package/skills/sp00ky-core/references/config.md +1 -1
- package/src/client/sp00ky-client.test.ts +35 -0
- package/src/client/sp00ky-client.ts +8 -2
- package/src/modules/auth/auth.local-first.test.ts +14 -0
- package/src/modules/auth/index.ts +3 -3
- package/src/modules/crdt/crdt.test.ts +49 -1
- package/src/modules/crdt/index.ts +35 -13
- package/src/query/id-set-plan.test.ts +2 -2
- package/src/query/lifecycle.saga.ts +28 -1
- package/src/query/materialize.test.ts +1 -1
- package/src/query/membership.saga.test.ts +57 -0
- package/src/query/membership.saga.ts +4 -0
- package/src/query/window-query.test.ts +6 -6
- package/src/query/window-query.ts +14 -1
- package/src/services/database/database.ts +10 -0
- package/src/services/database/local.test.ts +86 -0
- package/src/services/database/local.ts +22 -1
- package/src/services/database/sqlite-cache-engine.test.ts +11 -0
- package/src/services/database/surql-translate.ts +4 -3
- package/src/services/database/surreal-cache-engine.ts +3 -1
- package/src/state/selectors.ts +24 -0
- package/src/testing/fake-services.ts +16 -1
|
@@ -62,3 +62,89 @@ describe('matchesBucketStore', () => {
|
|
|
62
62
|
expect(matchesBucketStore('sp00ky', 'sp00ky-anon')).toBe(false);
|
|
63
63
|
});
|
|
64
64
|
});
|
|
65
|
+
|
|
66
|
+
// A bucket switch that lands while the boot open is still in flight (a reload
|
|
67
|
+
// of a signed-in tab, with IndexedDB slow to open) must wait for it. Run
|
|
68
|
+
// concurrently, the switch closed the client `connect` was opening and both
|
|
69
|
+
// provisioned a store at once, so boot never finished.
|
|
70
|
+
import { vi } from 'vitest';
|
|
71
|
+
import { Surreal } from 'surrealdb';
|
|
72
|
+
import { LocalDatabaseService } from './local';
|
|
73
|
+
|
|
74
|
+
vi.mock('@surrealdb/wasm', () => ({ createWasmWorkerEngines: () => ({}) }));
|
|
75
|
+
|
|
76
|
+
describe('LocalDatabaseService open and switch', () => {
|
|
77
|
+
it('a switch waits for the in-flight connect instead of closing the client it opens', async () => {
|
|
78
|
+
const opens: Array<{ url: string; done: () => void }> = [];
|
|
79
|
+
const closed: string[] = [];
|
|
80
|
+
const urlOf = new WeakMap<object, string>();
|
|
81
|
+
vi.spyOn(Surreal.prototype, 'connect').mockImplementation(function (this: Surreal, url: string | URL) {
|
|
82
|
+
urlOf.set(this, String(url));
|
|
83
|
+
return new Promise<true>((resolve) => opens.push({ url: String(url), done: () => resolve(true) }));
|
|
84
|
+
});
|
|
85
|
+
vi.spyOn(Surreal.prototype, 'use').mockResolvedValue({});
|
|
86
|
+
vi.spyOn(Surreal.prototype, 'close').mockImplementation(async function (this: Surreal) {
|
|
87
|
+
closed.push(urlOf.get(this) ?? 'placeholder');
|
|
88
|
+
return true as const;
|
|
89
|
+
});
|
|
90
|
+
const logger: any = { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
91
|
+
logger.child = () => logger;
|
|
92
|
+
const local = new LocalDatabaseService({ namespace: 'n', database: 'd', store: 'indexeddb' } as any, logger);
|
|
93
|
+
|
|
94
|
+
const booting = local.connect('u1');
|
|
95
|
+
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
96
|
+
const switching = local.switchStore('anon');
|
|
97
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
98
|
+
expect(opens.map((o) => o.url)).toEqual(['indxdb://sp00ky-u1']);
|
|
99
|
+
expect(closed).toEqual([]);
|
|
100
|
+
|
|
101
|
+
opens[0].done();
|
|
102
|
+
await booting;
|
|
103
|
+
await vi.waitFor(() => expect(opens).toHaveLength(2));
|
|
104
|
+
expect(opens[1].url).toBe('indxdb://sp00ky-anon');
|
|
105
|
+
opens[1].done();
|
|
106
|
+
await switching;
|
|
107
|
+
expect(closed).toEqual(['indxdb://sp00ky-u1']);
|
|
108
|
+
expect(local.currentBucketId).toBe('anon');
|
|
109
|
+
vi.restoreAllMocks();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// The engine streams a statement's result from its worker; closing the
|
|
113
|
+
// client under it left the statement pending until its 30 s deadline, and
|
|
114
|
+
// every later local statement queued behind it (sign-in hung on "Loading").
|
|
115
|
+
it.each(['memory', 'indexeddb'] as const)('%s: a switch lets a statement in flight finish before it closes the client', async (store) => {
|
|
116
|
+
let answer!: (rows: unknown[]) => void;
|
|
117
|
+
const events: string[] = [];
|
|
118
|
+
vi.spyOn(Surreal.prototype, 'connect').mockResolvedValue(true);
|
|
119
|
+
vi.spyOn(Surreal.prototype, 'use').mockResolvedValue({});
|
|
120
|
+
vi.spyOn(Surreal.prototype, 'query').mockImplementation((() =>
|
|
121
|
+
new Promise((r) => {
|
|
122
|
+
answer = (rows) => {
|
|
123
|
+
events.push('answered');
|
|
124
|
+
r(rows);
|
|
125
|
+
};
|
|
126
|
+
})) as any);
|
|
127
|
+
vi.spyOn(Surreal.prototype, 'close').mockImplementation(async () => {
|
|
128
|
+
events.push('close');
|
|
129
|
+
return true as const;
|
|
130
|
+
});
|
|
131
|
+
const logger: any = { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
132
|
+
logger.child = () => logger;
|
|
133
|
+
const local = new LocalDatabaseService({ namespace: 'n', database: 'd', store } as any, logger);
|
|
134
|
+
await local.connect('anon');
|
|
135
|
+
|
|
136
|
+
const reading = local.query('SELECT * FROM thing');
|
|
137
|
+
await vi.waitFor(() => expect(answer).toBeDefined());
|
|
138
|
+
const release = local.beginSwitch();
|
|
139
|
+
const switching = local.switchStore('u1');
|
|
140
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
141
|
+
expect(events).toEqual([]);
|
|
142
|
+
|
|
143
|
+
answer([[]]);
|
|
144
|
+
await reading;
|
|
145
|
+
await switching;
|
|
146
|
+
release();
|
|
147
|
+
expect(events).toEqual(['answered', 'close']);
|
|
148
|
+
vi.restoreAllMocks();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -104,6 +104,8 @@ export class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
104
104
|
private gate: Promise<void> = Promise.resolve();
|
|
105
105
|
/** The incoming client while a switch is in flight (for unload cleanup). */
|
|
106
106
|
private pendingSwitchClient: Surreal | null = null;
|
|
107
|
+
/** The open `connect()` started, until it settles. */
|
|
108
|
+
private opening: Promise<void> | null = null;
|
|
107
109
|
|
|
108
110
|
constructor(config: Sp00kyConfig<any>['database'], logger: Logger) {
|
|
109
111
|
const events = createDatabaseEventSystem();
|
|
@@ -176,6 +178,16 @@ export class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
176
178
|
}
|
|
177
179
|
|
|
178
180
|
async connect(bucketId: string = ANON_USER_ID): Promise<void> {
|
|
181
|
+
const opening = this.openBucket(bucketId);
|
|
182
|
+
this.opening = opening;
|
|
183
|
+
try {
|
|
184
|
+
await opening;
|
|
185
|
+
} finally {
|
|
186
|
+
if (this.opening === opening) this.opening = null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private async openBucket(bucketId: string): Promise<void> {
|
|
179
191
|
const { namespace, database } = this.getConfig();
|
|
180
192
|
const store = this.getConfig().store ?? 'memory';
|
|
181
193
|
this.bucketId = bucketId;
|
|
@@ -201,9 +213,16 @@ export class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
201
213
|
* async chains can detect they're stale.
|
|
202
214
|
*
|
|
203
215
|
* Callers own the drain/rebind choreography (close the gate, quiesce sync +
|
|
204
|
-
* timers BEFORE calling this; re-provision + rebind AFTER).
|
|
216
|
+
* timers BEFORE calling this; re-provision + rebind AFTER). Statements that
|
|
217
|
+
* passed the gate before it closed are let finish before the old client
|
|
218
|
+
* closes: the engine streams results from its worker, and a client closed
|
|
219
|
+
* under a statement leaves that statement pending until its deadline, with
|
|
220
|
+
* every later local statement queued behind it.
|
|
205
221
|
*/
|
|
206
222
|
async switchStore(bucketId: string): Promise<void> {
|
|
223
|
+
// Never overlap the boot open: the switch would close the client `connect`
|
|
224
|
+
// is still opening, and both would provision a store at once.
|
|
225
|
+
if (this.opening) await this.opening.catch(() => {});
|
|
207
226
|
if (bucketId === this.bucketId) return;
|
|
208
227
|
const { namespace, database } = this.getConfig();
|
|
209
228
|
const store = this.getConfig().store ?? 'memory';
|
|
@@ -212,6 +231,7 @@ export class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
212
231
|
if (store === 'memory') {
|
|
213
232
|
// mem:// has no per-user persistence; close + reopen the same client
|
|
214
233
|
// yields a fresh empty store, which is exactly the reset we want.
|
|
234
|
+
await this.drainStatements();
|
|
215
235
|
try {
|
|
216
236
|
await this.client.close();
|
|
217
237
|
} catch {
|
|
@@ -241,6 +261,7 @@ export class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
241
261
|
this.pendingSwitchClient = null;
|
|
242
262
|
}
|
|
243
263
|
|
|
264
|
+
await this.drainStatements();
|
|
244
265
|
const old = this.client;
|
|
245
266
|
this.client = next;
|
|
246
267
|
this.bucketId = bucketId;
|
|
@@ -556,6 +556,17 @@ describe('create() tx result shaping (fast path parity)', () => {
|
|
|
556
556
|
expect(outboxRow.mutationType).toBe('create');
|
|
557
557
|
expect(outboxRow.data).toEqual(payload);
|
|
558
558
|
});
|
|
559
|
+
|
|
560
|
+
// A raw query with a field list materializes from its id-set as
|
|
561
|
+
// `FROM (SELECT * FROM $__win)` (the wasm SurrealDB throws on a field list
|
|
562
|
+
// over bare record ids). This engine reads both shapes the same way.
|
|
563
|
+
it('reads a field list over a wrapped id-set like the bare one', () => {
|
|
564
|
+
const ids = ['_00_user_feature:a', '_00_user_feature:b'];
|
|
565
|
+
const bare = translateSurql('SELECT key, variant FROM $__win', { __win: ids }).ops[0];
|
|
566
|
+
const wrapped = translateSurql('SELECT key, variant FROM (SELECT * FROM $__win)', { __win: ids }).ops[0];
|
|
567
|
+
expect(wrapped).toEqual(bare);
|
|
568
|
+
expect(wrapped).toMatchObject({ kind: 'selectByIds', ids });
|
|
569
|
+
});
|
|
559
570
|
});
|
|
560
571
|
|
|
561
572
|
|
|
@@ -193,13 +193,14 @@ function translateStatement(stmt: string, vars: Record<string, unknown>): SqlOp
|
|
|
193
193
|
return { kind: 'getById', id: idOperand(m[3], vars), select: projFields(m[2], !!m[1]), value };
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
// SELECT [VALUE] <proj> FROM $arrayParam
|
|
197
|
-
|
|
196
|
+
// SELECT [VALUE] <proj> FROM $arrayParam, or FROM (SELECT * FROM $arrayParam)
|
|
197
|
+
// (the id-set read's shape for a field list, see `idSetSource`).
|
|
198
|
+
m = /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+(?:\$(\w+)|\(\s*SELECT\s+\*\s+FROM\s+\$(\w+)\s*\))$/i.exec(head);
|
|
198
199
|
if (m) {
|
|
199
200
|
const value = m[1] ? m[2].trim() : undefined;
|
|
200
201
|
return {
|
|
201
202
|
kind: 'selectByIds',
|
|
202
|
-
ids: (vars[m[3]] as unknown[]) ?? [],
|
|
203
|
+
ids: (vars[m[3] ?? m[4]] as unknown[]) ?? [],
|
|
203
204
|
select: projFields(m[2], !!m[1]),
|
|
204
205
|
value,
|
|
205
206
|
limit,
|
|
@@ -69,7 +69,9 @@ export class SurrealCacheEngine extends LocalDatabaseService implements LocalCac
|
|
|
69
69
|
if (ids.length === 0) return [];
|
|
70
70
|
const projection =
|
|
71
71
|
opts?.select && opts.select.length > 0 ? ['id', ...opts.select].join(', ') : '*';
|
|
72
|
-
|
|
72
|
+
// As documents: over bare record ids the wasm engine throws on a field
|
|
73
|
+
// list and ignores ORDER BY (see `idSetSource`).
|
|
74
|
+
let sql = `SELECT ${projection} FROM (SELECT * FROM $__ids)`;
|
|
73
75
|
if (opts?.orderBy && opts.orderBy.length > 0) {
|
|
74
76
|
sql += ` ORDER BY ${opts.orderBy.map(([f, d]) => `${f} ${d}`).join(', ')}`;
|
|
75
77
|
}
|
package/src/state/selectors.ts
CHANGED
|
@@ -79,6 +79,30 @@ export function retained(s: ClientState): (id: string) => boolean {
|
|
|
79
79
|
return (id) => id.startsWith('_00_') || known.has(id) || held.has(id);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Ids something live still needs: a mounted query's membership or subquery
|
|
84
|
+
* children, or a local write not yet settled. Unlike `retained`, durable view
|
|
85
|
+
* rows of queries nobody runs right now do not count: a row the server just
|
|
86
|
+
* took out of a live query must stop showing even if an old view named it.
|
|
87
|
+
*/
|
|
88
|
+
export function activelyHeld(s: ClientState): ReadonlySet<string> {
|
|
89
|
+
const held = new Set<string>();
|
|
90
|
+
for (const e of s.queries.values()) {
|
|
91
|
+
for (const [id] of e.remoteArray) held.add(id);
|
|
92
|
+
for (const [id] of e.subqueryRemoteArray) held.add(id);
|
|
93
|
+
}
|
|
94
|
+
for (const item of s.outbox) held.add(item.recordId);
|
|
95
|
+
for (const write of s.pendingWrites.values()) held.add(write.recordId);
|
|
96
|
+
return held;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Ids of `before` that `after` no longer names. */
|
|
100
|
+
export function departedIds(before: RecordVersionArray, after: RecordVersionArray): string[] {
|
|
101
|
+
if (before.length === 0) return [];
|
|
102
|
+
const kept = new Set(after.map(([id]) => id));
|
|
103
|
+
return before.filter(([id]) => !kept.has(id)).map(([id]) => id);
|
|
104
|
+
}
|
|
105
|
+
|
|
82
106
|
export const pendingDeleteIds = (s: ClientState): ReadonlySet<string> =>
|
|
83
107
|
new Set(s.outbox.filter((i) => i.type === 'delete').map((i) => i.recordId));
|
|
84
108
|
|
|
@@ -38,7 +38,22 @@ export function fakeServiceBundle<S extends SchemaStructure>(over: Partial<Servi
|
|
|
38
38
|
streamProcessor: { addReceiver: (r: unknown) => void receivers.push(r), checkpoint: async () => undefined, dispose: noop } as any,
|
|
39
39
|
migrator: {} as any,
|
|
40
40
|
crdt: { attach: noop, setSessionId: noop, closeAll: noop, dispose: noop, open: async () => 'field', close: async () => {} } as any,
|
|
41
|
-
|
|
41
|
+
// Mirrors `AuthService.subscribe`: the current (signed-out) identity at
|
|
42
|
+
// once unless the subscriber opts out, then every change.
|
|
43
|
+
auth: {
|
|
44
|
+
subscribe: (cb: (u: string | null) => void, opts: { immediate?: boolean } = {}) => {
|
|
45
|
+
if (opts.immediate !== false) cb(null);
|
|
46
|
+
authListeners.push(cb);
|
|
47
|
+
return noop;
|
|
48
|
+
},
|
|
49
|
+
subscribeImpersonation: () => noop,
|
|
50
|
+
onBeforeSignOut: () => noop,
|
|
51
|
+
currentUser: null,
|
|
52
|
+
isAuthenticated: false,
|
|
53
|
+
token: null,
|
|
54
|
+
impersonation: null,
|
|
55
|
+
eventSystem: { subscribe: noop },
|
|
56
|
+
} as any,
|
|
42
57
|
tabs: null,
|
|
43
58
|
tabsUnsupportedReason: 'test',
|
|
44
59
|
tabId: 'tab-test',
|