@spooky-sync/core 0.0.1-canary.15 → 0.0.1-canary.151
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 +56 -0
- package/dist/index.d.ts +1192 -372
- package/dist/index.js +5772 -1270
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/sqlite-plan-sql.js +194 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +202 -0
- package/dist/types.d.ts +755 -0
- package/package.json +40 -8
- package/skills/sp00ky-core/SKILL.md +258 -0
- package/skills/sp00ky-core/references/auth.md +98 -0
- package/skills/sp00ky-core/references/config.md +76 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +16 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +77 -32
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +361 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/data/data.hydration.test.ts +142 -0
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/index.ts +1134 -128
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +154 -0
- package/src/modules/devtools/index.ts +191 -30
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/feature-flag/index.test.ts +120 -0
- package/src/modules/feature-flag/index.ts +209 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +101 -37
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.ts +12 -5
- package/src/modules/sync/queue/queue-up.ts +29 -14
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.ts +73 -7
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.ts +1017 -62
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +182 -17
- package/src/otel/index.ts +127 -0
- package/src/services/database/cache-engine.ts +143 -0
- package/src/services/database/database.ts +11 -11
- package/src/services/database/engine-factory.ts +32 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +6 -0
- package/src/services/database/local-migrator.ts +28 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +478 -67
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +13 -13
- package/src/services/database/sqlite-cache-engine.test.ts +215 -0
- package/src/services/database/sqlite-cache-engine.ts +829 -0
- package/src/services/database/sqlite-plan-sql.test.ts +104 -0
- package/src/services/database/sqlite-plan-sql.ts +106 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +279 -0
- package/src/services/database/sqlite-select.ts +113 -0
- package/src/services/database/sqlite-worker.ts +160 -0
- package/src/services/database/surql-translate.ts +291 -0
- package/src/services/database/surreal-cache-engine.ts +139 -0
- package/src/services/logger/index.ts +6 -101
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +11 -4
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +295 -38
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +18 -2
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.ts +1000 -0
- package/src/types.ts +267 -15
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +35 -13
- package/src/utils/parser.ts +3 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +24 -15
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +77 -1
- package/src/spooky.ts +0 -395
|
@@ -1,20 +1,20 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Diagnostic} from 'surrealdb';
|
|
1
3
|
import {
|
|
2
4
|
applyDiagnostics,
|
|
3
5
|
createRemoteEngines,
|
|
4
|
-
Diagnostic,
|
|
5
6
|
Surreal,
|
|
6
|
-
SurrealTransaction,
|
|
7
7
|
} from 'surrealdb';
|
|
8
|
-
import {
|
|
9
|
-
import { Logger } from '../logger/index';
|
|
8
|
+
import type { Sp00kyConfig } from '../../types';
|
|
9
|
+
import type { Logger } from '../logger/index';
|
|
10
10
|
import { AbstractDatabaseService } from './database';
|
|
11
11
|
import { createDatabaseEventSystem, DatabaseEventTypes } from './events/index';
|
|
12
12
|
|
|
13
13
|
export class RemoteDatabaseService extends AbstractDatabaseService {
|
|
14
|
-
private config:
|
|
14
|
+
private config: Sp00kyConfig<any>['database'];
|
|
15
15
|
protected eventType = DatabaseEventTypes.RemoteQuery;
|
|
16
16
|
|
|
17
|
-
constructor(config:
|
|
17
|
+
constructor(config: Sp00kyConfig<any>['database'], logger: Logger) {
|
|
18
18
|
const events = createDatabaseEventSystem();
|
|
19
19
|
super(
|
|
20
20
|
new Surreal({
|
|
@@ -29,7 +29,7 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
|
|
|
29
29
|
type,
|
|
30
30
|
phase,
|
|
31
31
|
service: 'surrealdb:remote',
|
|
32
|
-
Category: '
|
|
32
|
+
Category: 'sp00ky-client::RemoteDatabaseService::diagnostics',
|
|
33
33
|
},
|
|
34
34
|
`Remote SurrealDB diagnostics captured ${type}:${phase}`
|
|
35
35
|
);
|
|
@@ -43,7 +43,7 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
|
|
|
43
43
|
this.config = config;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
getConfig():
|
|
46
|
+
getConfig(): Sp00kyConfig<any>['database'] {
|
|
47
47
|
return this.config;
|
|
48
48
|
}
|
|
49
49
|
|
|
@@ -55,7 +55,7 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
|
|
|
55
55
|
endpoint,
|
|
56
56
|
namespace,
|
|
57
57
|
database,
|
|
58
|
-
Category: '
|
|
58
|
+
Category: 'sp00ky-client::RemoteDatabaseService::connect',
|
|
59
59
|
},
|
|
60
60
|
'Connecting to remote database'
|
|
61
61
|
);
|
|
@@ -68,25 +68,25 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
|
|
|
68
68
|
|
|
69
69
|
if (token) {
|
|
70
70
|
this.logger.debug(
|
|
71
|
-
{ Category: '
|
|
71
|
+
{ Category: 'sp00ky-client::RemoteDatabaseService::connect' },
|
|
72
72
|
'Authenticating with token'
|
|
73
73
|
);
|
|
74
74
|
await this.client.authenticate(token);
|
|
75
75
|
}
|
|
76
76
|
this.logger.info(
|
|
77
|
-
{ Category: '
|
|
77
|
+
{ Category: 'sp00ky-client::RemoteDatabaseService::connect' },
|
|
78
78
|
'Connected to remote database'
|
|
79
79
|
);
|
|
80
80
|
} catch (err) {
|
|
81
81
|
this.logger.error(
|
|
82
|
-
{ err, Category: '
|
|
82
|
+
{ err, Category: 'sp00ky-client::RemoteDatabaseService::connect' },
|
|
83
83
|
'Failed to connect to remote database'
|
|
84
84
|
);
|
|
85
85
|
throw err;
|
|
86
86
|
}
|
|
87
87
|
} else {
|
|
88
88
|
this.logger.warn(
|
|
89
|
-
{ Category: '
|
|
89
|
+
{ Category: 'sp00ky-client::RemoteDatabaseService::connect' },
|
|
90
90
|
'No endpoint configured for remote database'
|
|
91
91
|
);
|
|
92
92
|
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { pureWriteOpResult, SqliteCacheEngine } from './sqlite-cache-engine';
|
|
4
|
+
import { translateSurql } from './surql-translate';
|
|
5
|
+
import type { SqlOp } from './surql-translate';
|
|
6
|
+
import { surql } from '../../utils/surql';
|
|
7
|
+
|
|
8
|
+
function makeLogger(): any {
|
|
9
|
+
const noop = () => {};
|
|
10
|
+
const l: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
11
|
+
l.child = () => l;
|
|
12
|
+
return l;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A fake Worker that models the real SQLite worker's open/closed lifecycle:
|
|
17
|
+
* `open` opens the DB, `close` closes it, and `exec`/`run` REJECT with
|
|
18
|
+
* "sqlite: DB not open" when the DB isn't currently open — exactly the failure
|
|
19
|
+
* the fix targets. Records every message `type` (across worker generations) so
|
|
20
|
+
* tests can also inspect dispatch order.
|
|
21
|
+
*/
|
|
22
|
+
class FakeWorker {
|
|
23
|
+
onmessage: ((ev: any) => void) | null = null;
|
|
24
|
+
onerror: any = null;
|
|
25
|
+
onmessageerror: any = null;
|
|
26
|
+
private dbOpen = false;
|
|
27
|
+
constructor(private log: string[]) {}
|
|
28
|
+
postMessage(msg: any) {
|
|
29
|
+
this.log.push(msg.type);
|
|
30
|
+
Promise.resolve().then(() => {
|
|
31
|
+
let ok = true;
|
|
32
|
+
let error: string | undefined;
|
|
33
|
+
let rest: Record<string, unknown> = {};
|
|
34
|
+
if (msg.type === 'open') {
|
|
35
|
+
this.dbOpen = true;
|
|
36
|
+
rest = { persisted: true };
|
|
37
|
+
} else if (msg.type === 'close') {
|
|
38
|
+
this.dbOpen = false;
|
|
39
|
+
} else if (msg.type === 'exec') {
|
|
40
|
+
if (!this.dbOpen) { ok = false; error = 'sqlite: DB not open'; }
|
|
41
|
+
else rest = { rows: [] };
|
|
42
|
+
} else if (msg.type === 'select') {
|
|
43
|
+
if (!this.dbOpen) { ok = false; error = 'sqlite: DB not open'; }
|
|
44
|
+
else rest = { rows: [], relationFetches: 0 };
|
|
45
|
+
} else if (msg.type === 'run' || msg.type === 'batch') {
|
|
46
|
+
if (!this.dbOpen) { ok = false; error = 'sqlite: DB not open'; }
|
|
47
|
+
}
|
|
48
|
+
this.onmessage?.({ data: { id: msg.id, ok, error, ...rest } });
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
terminate() {}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Regression: switchBucket must run close → reopen as a single serialized
|
|
55
|
+
// opQueue entry. Otherwise a read/write enqueued during an auth/bucket change
|
|
56
|
+
// dispatches to the just-closed worker → "sqlite: DB not open" (the crash on
|
|
57
|
+
// sign-in). The invariant: no `exec` may appear between a `close` and the next
|
|
58
|
+
// `open` in the message stream.
|
|
59
|
+
describe('SqliteCacheEngine.switchBucket serialization', () => {
|
|
60
|
+
it('never dispatches an op to a closed DB during a bucket switch', async () => {
|
|
61
|
+
const log: string[] = [];
|
|
62
|
+
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
|
|
63
|
+
// Replicate the real spawnWorker's onmessage wiring (resolve/reject pending).
|
|
64
|
+
(engine as any).spawnWorker = () => {
|
|
65
|
+
const w = new FakeWorker(log);
|
|
66
|
+
w.onmessage = (ev: any) => {
|
|
67
|
+
const { id, ok, error, ...rest } = ev.data ?? {};
|
|
68
|
+
const p = (engine as any).pending.get(id);
|
|
69
|
+
if (!p) return;
|
|
70
|
+
(engine as any).pending.delete(id);
|
|
71
|
+
if (ok) p.resolve(rest);
|
|
72
|
+
else p.reject(new Error(error));
|
|
73
|
+
};
|
|
74
|
+
return w as unknown as Worker;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
await engine.connect('anon');
|
|
78
|
+
expect(log).toContain('open');
|
|
79
|
+
|
|
80
|
+
// Fire a switch and a concurrent read (exactly what the sign-in query
|
|
81
|
+
// re-registration does). With the old non-atomic switch the read's `exec`
|
|
82
|
+
// dispatched to the closed/terminated worker and REJECTED ("sqlite: DB not
|
|
83
|
+
// open" / "not connected") — the uncaught crash. The atomic switch makes the
|
|
84
|
+
// read wait for the reopen and resolve against the new bucket.
|
|
85
|
+
const switching = engine.switchBucket('user:abc');
|
|
86
|
+
const reading = engine.getById('_00_query', 'h1');
|
|
87
|
+
await expect(Promise.all([switching, reading])).resolves.toBeDefined();
|
|
88
|
+
await expect(reading).resolves.toBeNull(); // missing row → null, not a throw
|
|
89
|
+
|
|
90
|
+
// And the close→reopen ran with no op wedged between them.
|
|
91
|
+
const closeIdx = log.indexOf('close');
|
|
92
|
+
const openAfter = log.indexOf('open', closeIdx + 1);
|
|
93
|
+
expect(openAfter).toBeGreaterThan(closeIdx);
|
|
94
|
+
expect(log.slice(closeIdx + 1, openAfter)).not.toContain('exec');
|
|
95
|
+
expect(engine.currentBucketId).toBe('user:abc');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Regression: DEFINE is a noop on this engine, so the `_00_*` internal tables
|
|
100
|
+
// the migrator DEFINEs are never physically created — a fresh bucket that READS
|
|
101
|
+
// one before any write (the sync layer selects `_00_query` at startup) threw
|
|
102
|
+
// "no such table: _00_query" and wedged the client on "Loading database". The
|
|
103
|
+
// fix seeds them inside `open`; assert the open message carries them.
|
|
104
|
+
describe('SqliteCacheEngine system-table seeding', () => {
|
|
105
|
+
it('passes the _00_* system tables to the worker open (fresh-bucket safe)', async () => {
|
|
106
|
+
const msgs: any[] = [];
|
|
107
|
+
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
|
|
108
|
+
(engine as any).spawnWorker = () => {
|
|
109
|
+
const w: any = { onmessage: null, onerror: null, onmessageerror: null, terminate() {} };
|
|
110
|
+
w.postMessage = (msg: any) => {
|
|
111
|
+
msgs.push(msg);
|
|
112
|
+
Promise.resolve().then(() => {
|
|
113
|
+
const rest = msg.type === 'open' ? { persisted: true } : {};
|
|
114
|
+
w.onmessage?.({ data: { id: msg.id, ok: true, ...rest } });
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
// Wire pending resolution like the real spawnWorker.
|
|
118
|
+
const inner = w.onmessage;
|
|
119
|
+
w.onmessage = (ev: any) => {
|
|
120
|
+
const { id, ok, error, ...rest } = ev.data ?? {};
|
|
121
|
+
const p = (engine as any).pending.get(id);
|
|
122
|
+
if (!p) return;
|
|
123
|
+
(engine as any).pending.delete(id);
|
|
124
|
+
if (ok) p.resolve(rest);
|
|
125
|
+
else p.reject(new Error(error));
|
|
126
|
+
void inner;
|
|
127
|
+
};
|
|
128
|
+
return w as unknown as Worker;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
await engine.connect('user:fresh');
|
|
132
|
+
const open = msgs.find((m) => m.type === 'open');
|
|
133
|
+
expect(open?.payload?.systemTables).toContain('_00_query');
|
|
134
|
+
expect(open?.payload?.systemTables).toContain('_00_pending_mutations');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// `pureWriteOpResult` is the single source of truth for what a pure-write op
|
|
139
|
+
// contributes to a query's per-statement results. The batched fast path in
|
|
140
|
+
// `query()` and the per-op `execOp` path BOTH route through it, so a caller that
|
|
141
|
+
// reads a statement's output (e.g. `create()` reads `resultIndex:0` for the new
|
|
142
|
+
// row + its id) sees the same shape either way.
|
|
143
|
+
describe('pureWriteOpResult', () => {
|
|
144
|
+
it('echoes the written row (with id) for an upsert — no read-back', () => {
|
|
145
|
+
const op: SqlOp = {
|
|
146
|
+
kind: 'upsert',
|
|
147
|
+
id: 'connection:CONN_abc',
|
|
148
|
+
data: { provider: 'chesscom', username: 'hikaru' },
|
|
149
|
+
mode: 'replace',
|
|
150
|
+
};
|
|
151
|
+
expect(pureWriteOpResult(op)).toEqual({
|
|
152
|
+
provider: 'chesscom',
|
|
153
|
+
username: 'hikaru',
|
|
154
|
+
id: 'connection:CONN_abc',
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('stringifies a RecordId id via stableKey', () => {
|
|
159
|
+
const op: SqlOp = {
|
|
160
|
+
kind: 'upsert',
|
|
161
|
+
id: new RecordId('connection', 'CONN_abc'),
|
|
162
|
+
data: { provider: 'lichess' },
|
|
163
|
+
mode: 'replace',
|
|
164
|
+
};
|
|
165
|
+
expect(pureWriteOpResult(op)).toEqual({ provider: 'lichess', id: 'connection:CONN_abc' });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('yields [] for delete / deleteAll and null for noop', () => {
|
|
169
|
+
expect(pureWriteOpResult({ kind: 'delete', id: 'game:1' })).toEqual([]);
|
|
170
|
+
expect(pureWriteOpResult({ kind: 'deleteAll', table: 'game' })).toEqual([]);
|
|
171
|
+
expect(pureWriteOpResult({ kind: 'noop' })).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Regression: a single `create()` compiles to an all-upsert transaction
|
|
176
|
+
// (createSet for the row + createMutation for the pending-mutation log) and
|
|
177
|
+
// extracts `resultIndex:0` for the created row. The SQLite fast path must return
|
|
178
|
+
// that row (with its id) at that index — returning `[]` there dropped the id and
|
|
179
|
+
// crashed the reconcile in `encodeRecordId` ("reading 'table'").
|
|
180
|
+
describe('create() tx result shaping (fast path parity)', () => {
|
|
181
|
+
it('resultIndex:0 carries the created row with its id', () => {
|
|
182
|
+
const rid = new RecordId('connection', 'CONN_abc');
|
|
183
|
+
const mid = new RecordId('_00_pending_mutations', '1');
|
|
184
|
+
const vars = {
|
|
185
|
+
id: rid,
|
|
186
|
+
mid,
|
|
187
|
+
data_provider: 'chesscom',
|
|
188
|
+
data_username: 'hikaru',
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Same statement pair DataModule.create emits.
|
|
192
|
+
const sealed = surql.seal(
|
|
193
|
+
surql.tx([
|
|
194
|
+
surql.createSet('id', [
|
|
195
|
+
{ key: 'provider', variable: 'data_provider' },
|
|
196
|
+
{ key: 'username', variable: 'data_username' },
|
|
197
|
+
]),
|
|
198
|
+
surql.createMutation('create', 'mid', 'id', 'data'),
|
|
199
|
+
]),
|
|
200
|
+
{ resultIndex: 0 }
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const { transaction, ops } = translateSurql(sealed.sql, vars);
|
|
204
|
+
expect(transaction).toBe(true);
|
|
205
|
+
// Both statements are upserts → the engine takes the all-write fast path.
|
|
206
|
+
expect(ops.every((o) => o.kind === 'upsert')).toBe(true);
|
|
207
|
+
|
|
208
|
+
// Fast-path shaping: [null (BEGIN), ...one result per statement].
|
|
209
|
+
const shaped = [null, ...ops.map(pureWriteOpResult)];
|
|
210
|
+
const created = sealed.extract(shaped) as unknown as { id: unknown; provider: string };
|
|
211
|
+
|
|
212
|
+
expect(created.id).toBe('connection:CONN_abc');
|
|
213
|
+
expect(created.provider).toBe('chesscom');
|
|
214
|
+
});
|
|
215
|
+
});
|