@spooky-sync/core 0.0.1-canary.27 → 0.0.1-canary.270
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 +78 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2279 -81
- package/dist/index.js +13490 -2585
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +331 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +939 -46
- package/package.json +16 -8
- package/scripts/check-broker-bundle.mjs +33 -0
- package/skills/{spooky-core → sp00ky-core}/SKILL.md +30 -12
- package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
- package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
- package/src/boot/auth-flip.saga.test.ts +82 -0
- package/src/boot/auth-flip.saga.ts +62 -0
- package/src/boot/boot.saga.test.ts +151 -0
- package/src/boot/boot.saga.ts +128 -0
- package/src/boot/bucket-switch.saga.test.ts +187 -0
- package/src/boot/bucket-switch.saga.ts +131 -0
- package/src/boot/preload.saga.test.ts +51 -0
- package/src/boot/preload.saga.ts +31 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +117 -0
- package/src/client/bucket-handle.ts +227 -0
- package/src/client/router.test.ts +56 -0
- package/src/client/router.ts +96 -0
- package/src/client/runtime.test.ts +192 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.ts +386 -0
- package/src/client/sp00ky-client.test.ts +264 -0
- package/src/client/sp00ky-client.ts +528 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +37 -2
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +61 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +161 -0
- package/src/kernel/events.ts +56 -0
- package/src/kernel/interpreter.test.ts +116 -0
- package/src/kernel/interpreter.ts +148 -0
- package/src/kernel/saga.test.ts +89 -0
- package/src/kernel/saga.ts +73 -0
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +209 -0
- package/src/modules/auth/auth.local-first.test.ts +141 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.test.ts +355 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +344 -27
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +529 -47
- package/src/modules/devtools/notify-throttle.test.ts +154 -0
- package/src/modules/devtools/state-shape.test.ts +149 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +251 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/mutation/mutation-id.test.ts +37 -0
- package/src/mutation/mutation-id.ts +35 -0
- package/src/mutation/push.saga.test.ts +285 -0
- package/src/mutation/push.saga.ts +215 -0
- package/src/mutation/rows.test.ts +201 -0
- package/src/mutation/rows.ts +377 -0
- package/src/mutation/tray.saga.test.ts +63 -0
- package/src/mutation/tray.saga.ts +48 -0
- package/src/mutation/write.saga.test.ts +163 -0
- package/src/mutation/write.saga.ts +166 -0
- package/src/otel/index.ts +13 -10
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +56 -0
- package/src/query/fetch.saga.test.ts +156 -0
- package/src/query/fetch.saga.ts +115 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/query/id-set-plan.test.ts +132 -0
- package/src/query/lifecycle.saga.test.ts +122 -0
- package/src/query/lifecycle.saga.ts +101 -0
- package/src/query/materialize.saga.test.ts +82 -0
- package/src/query/materialize.saga.ts +75 -0
- package/src/query/materialize.test.ts +50 -0
- package/src/query/materialize.ts +44 -0
- package/src/query/membership.saga.test.ts +322 -0
- package/src/query/membership.saga.ts +262 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +171 -0
- package/src/query/register.saga.test.ts +293 -0
- package/src/query/register.saga.ts +226 -0
- package/src/query/render-set.test.ts +36 -0
- package/src/query/render-set.ts +60 -0
- package/src/query/sql.test.ts +62 -0
- package/src/query/sql.ts +79 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +195 -0
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +199 -0
- package/src/services/database/connection-supervisor.test.ts +318 -0
- package/src/services/database/connection-supervisor.ts +422 -0
- package/src/services/database/database.query-timeout.test.ts +140 -0
- package/src/services/database/database.responses.test.ts +43 -0
- package/src/services/database/database.ts +145 -62
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/local-migrator.ts +30 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +491 -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 +467 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +222 -15
- package/src/services/database/sqlite-cache-engine.test.ts +617 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1366 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +104 -0
- package/src/services/database/sqlite-plan-sql.ts +153 -0
- package/src/services/database/sqlite-projection.test.ts +121 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +416 -0
- package/src/services/database/surreal-cache-engine.ts +161 -0
- package/src/services/logger/index.ts +3 -2
- 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 +796 -84
- 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 +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +59 -3
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +327 -0
- package/src/services/tabs/coordinator.test.ts +365 -0
- package/src/services/tabs/coordinator.ts +633 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +262 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.ts +5 -0
- package/src/state/client-state.ts +191 -0
- package/src/state/lifecycle.test.ts +118 -0
- package/src/state/lifecycle.ts +86 -0
- package/src/state/reducers.test.ts +283 -0
- package/src/state/reducers.ts +488 -0
- package/src/state/selectors.test.ts +149 -0
- package/src/state/selectors.ts +184 -0
- package/src/sync/connection.saga.test.ts +86 -0
- package/src/sync/connection.saga.ts +85 -0
- package/src/sync/live.saga.test.ts +178 -0
- package/src/sync/live.saga.ts +107 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/sync/policy.test.ts +659 -0
- package/src/sync/policy.ts +502 -0
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +74 -0
- package/src/sync/tabs.saga.ts +93 -0
- package/src/testing/adapters.ts +133 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +34 -0
- package/src/testing/run-pure.test.ts +92 -0
- package/src/testing/run-pure.ts +128 -0
- package/src/testing/services.ts +17 -0
- package/src/types.ts +603 -14
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/error-classification.test.ts +54 -0
- package/src/utils/error-classification.ts +25 -0
- package/src/utils/index.ts +79 -13
- package/src/utils/parser.test.ts +49 -120
- package/src/utils/parser.ts +32 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/utils.test.ts +2 -1
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- package/vitest.config.ts +48 -0
- package/src/modules/cache/index.ts +0 -241
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -879
- package/src/modules/sync/engine.ts +0 -173
- package/src/modules/sync/events/index.ts +0 -77
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.ts +0 -89
- package/src/modules/sync/queue/queue-up.ts +0 -223
- package/src/modules/sync/scheduler.ts +0 -84
- package/src/modules/sync/sync.ts +0 -421
- package/src/modules/sync/utils.test.ts +0 -311
- package/src/modules/sync/utils.ts +0 -167
- package/src/spooky.ts +0 -395
package/src/utils/surql.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MutationEventType } from '../types';
|
|
1
|
+
import type { MutationEventType } from '../types';
|
|
2
2
|
|
|
3
3
|
// ==================== TYPES ====================
|
|
4
4
|
|
|
@@ -34,6 +34,7 @@ export interface SurqlHelper {
|
|
|
34
34
|
keyDataVars: ({ key: string; variable: string } | { statement: string } | string)[]
|
|
35
35
|
): string;
|
|
36
36
|
upsert(idVar: string, dataVar: string): string;
|
|
37
|
+
upsertMerge(idVar: string, dataVar: string): string;
|
|
37
38
|
updateMerge(idVar: string, dataVar: string): string;
|
|
38
39
|
updateSet(
|
|
39
40
|
idVar: string,
|
|
@@ -90,17 +91,9 @@ export const surql: SurqlHelper = {
|
|
|
90
91
|
returnValues: ({ field: string; alias: string } | string)[]
|
|
91
92
|
) {
|
|
92
93
|
return `SELECT ${returnValues
|
|
93
|
-
.map((
|
|
94
|
-
typeof returnValues === 'string'
|
|
95
|
-
? returnValues
|
|
96
|
-
: `${returnValues.field} as ${returnValues.alias}`
|
|
97
|
-
)
|
|
94
|
+
.map((rv) => (typeof rv === 'string' ? rv : `${rv.field} as ${rv.alias}`))
|
|
98
95
|
.join(',')} FROM ${table} WHERE ${whereVar
|
|
99
|
-
.map((
|
|
100
|
-
typeof whereVar === 'string'
|
|
101
|
-
? `${whereVar} = $${whereVar}`
|
|
102
|
-
: `${whereVar.field} = $${whereVar.variable}`
|
|
103
|
-
)
|
|
96
|
+
.map((wv) => (typeof wv === 'string' ? `${wv} = $${wv}` : `${wv.field} = $${wv.variable}`))
|
|
104
97
|
.join(' AND ')}`;
|
|
105
98
|
},
|
|
106
99
|
|
|
@@ -127,6 +120,14 @@ export const surql: SurqlHelper = {
|
|
|
127
120
|
return `UPSERT ONLY $${idVar} REPLACE $${dataVar}`;
|
|
128
121
|
},
|
|
129
122
|
|
|
123
|
+
// Use this for sync-down ingestion: the remote payload omits local-only
|
|
124
|
+
// fields injected by the CLI's local-schema setup (`_00_crdt`,
|
|
125
|
+
// `_00_cursor`), and REPLACE would wipe them on every round-trip and
|
|
126
|
+
// break offline reload of CRDT state.
|
|
127
|
+
upsertMerge(idVar: string, dataVar: string) {
|
|
128
|
+
return `UPSERT ONLY $${idVar} MERGE $${dataVar}`;
|
|
129
|
+
},
|
|
130
|
+
|
|
130
131
|
updateMerge(idVar: string, dataVar: string) {
|
|
131
132
|
return `UPDATE ONLY $${idVar} MERGE $${dataVar}`;
|
|
132
133
|
},
|
|
@@ -136,12 +137,12 @@ export const surql: SurqlHelper = {
|
|
|
136
137
|
keyDataVar: ({ key: string; variable: string } | { statement: string } | string)[]
|
|
137
138
|
) {
|
|
138
139
|
return `UPDATE $${idVar} SET ${keyDataVar
|
|
139
|
-
.map((
|
|
140
|
-
typeof
|
|
141
|
-
? `${
|
|
142
|
-
: 'statement' in
|
|
143
|
-
?
|
|
144
|
-
: `${
|
|
140
|
+
.map((kdv) =>
|
|
141
|
+
typeof kdv === 'string'
|
|
142
|
+
? `${kdv} = $${kdv}`
|
|
143
|
+
: 'statement' in kdv
|
|
144
|
+
? kdv.statement
|
|
145
|
+
: `${kdv.key} = $${kdv.variable}`
|
|
145
146
|
)
|
|
146
147
|
.join(', ')}`;
|
|
147
148
|
},
|
|
@@ -163,7 +164,18 @@ export const surql: SurqlHelper = {
|
|
|
163
164
|
) {
|
|
164
165
|
switch (t) {
|
|
165
166
|
case 'create':
|
|
166
|
-
|
|
167
|
+
// `data` is REQUIRED here, not decorative. The outbox row is the only
|
|
168
|
+
// copy of a pending create once the in-memory UpEvent is gone (reload,
|
|
169
|
+
// or a shared-tabs follower whose row is replayed by the leader). This
|
|
170
|
+
// used to drop `dataVar` on the floor, so every replayed create arrived
|
|
171
|
+
// with `data: undefined` and `processUpEvent` threw on
|
|
172
|
+
// `Object.keys(event.data)` before it ever reached the network: the
|
|
173
|
+
// create was unsendable AND it sat at the head of the queue blocking
|
|
174
|
+
// every later mutation. The payload was simply never persisted, so
|
|
175
|
+
// those creates were unrecoverable.
|
|
176
|
+
return dataVar
|
|
177
|
+
? `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}, data = $${dataVar}`
|
|
178
|
+
: `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}`;
|
|
167
179
|
case 'update': {
|
|
168
180
|
let stmt = `CREATE ONLY $${mutationIdVar} SET mutationType = 'update', recordId = $${recordIdVar}, data = $${dataVar}`;
|
|
169
181
|
if (beforeRecordVar) {
|
package/src/utils/utils.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
generateId,
|
|
10
10
|
parseDuration,
|
|
11
11
|
} from './index';
|
|
12
|
+
import type { QueryTimeToLive } from '../types';
|
|
12
13
|
|
|
13
14
|
describe('compareRecordIds', () => {
|
|
14
15
|
it('returns true for equal strings', () => {
|
|
@@ -123,7 +124,7 @@ describe('generateId', () => {
|
|
|
123
124
|
|
|
124
125
|
describe('parseDuration', () => {
|
|
125
126
|
it('parses seconds', () => {
|
|
126
|
-
expect(parseDuration('30s')).toBe(30000);
|
|
127
|
+
expect(parseDuration('30s' as QueryTimeToLive)).toBe(30000);
|
|
127
128
|
});
|
|
128
129
|
|
|
129
130
|
it('parses minutes', () => {
|
package/tsdown.config.ts
CHANGED
|
@@ -1,9 +1,94 @@
|
|
|
1
1
|
import { defineConfig } from 'tsdown';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
|
|
6
|
+
// Resolve real package versions at build time so the DevTools can report the
|
|
7
|
+
// actual bundled frontend versions (used to detect frontend/backend drift).
|
|
8
|
+
const corePkg = require('./package.json');
|
|
9
|
+
const coreVersion: string = corePkg.version;
|
|
10
|
+
// ssp-wasm has no `exports` map; resolve its package.json directly, falling
|
|
11
|
+
// back to the relative workspace path if module resolution can't find it.
|
|
12
|
+
let wasmVersion: string;
|
|
13
|
+
try {
|
|
14
|
+
wasmVersion = require('@spooky-sync/ssp-wasm/package.json').version;
|
|
15
|
+
} catch {
|
|
16
|
+
wasmVersion = require('../ssp-wasm/package.json').version;
|
|
17
|
+
}
|
|
18
|
+
// Report the SurrealDB WASM ENGINE version (`@surrealdb/wasm`) — the engine the
|
|
19
|
+
// in-browser local DB actually runs — NOT the `surrealdb` JS client library
|
|
20
|
+
// version. The WASM engine is the one that must stay compatible with the server
|
|
21
|
+
// engine, so it's the meaningful number to compare in DevTools. Prefer the
|
|
22
|
+
// actually-installed version; fall back to our pinned range if its `exports`
|
|
23
|
+
// map blocks importing its package.json.
|
|
24
|
+
let surrealVersion: string;
|
|
25
|
+
try {
|
|
26
|
+
surrealVersion = require('@surrealdb/wasm/package.json').version;
|
|
27
|
+
} catch {
|
|
28
|
+
surrealVersion = String(corePkg.dependencies?.['@surrealdb/wasm'] ?? 'unknown').replace(
|
|
29
|
+
/^[\^~]/,
|
|
30
|
+
''
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// tsdown 0.12.x forwards a top-level `define` to rolldown's inputOptions, which
|
|
35
|
+
// rejects it — so we do the substitution ourselves with a tiny transform
|
|
36
|
+
// plugin. Only our own modules reference the `__SP00KY_*__` identifiers, and
|
|
37
|
+
// the early `includes` check skips everything else.
|
|
38
|
+
const replacements: Record<string, string> = {
|
|
39
|
+
__SP00KY_CORE_VERSION__: JSON.stringify(coreVersion),
|
|
40
|
+
__SP00KY_WASM_VERSION__: JSON.stringify(wasmVersion),
|
|
41
|
+
__SP00KY_SURREAL_VERSION__: JSON.stringify(surrealVersion),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const versionDefinePlugin = {
|
|
45
|
+
name: 'sp00ky-version-define',
|
|
46
|
+
transform(code: string) {
|
|
47
|
+
if (!code.includes('__SP00KY_')) return null;
|
|
48
|
+
let out = code;
|
|
49
|
+
for (const [token, value] of Object.entries(replacements)) {
|
|
50
|
+
out = out.split(token).join(value);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Worker URLs are written as `./<name>.ts` in source so Vite's src-bundling
|
|
57
|
+
// consumers (the example app aliases core to `src`) resolve them. In the
|
|
58
|
+
// published build each worker is emitted at `dist/<name>.js`, so rewrite the
|
|
59
|
+
// references to `.js` here — otherwise the flat `dist/index.js` carries
|
|
60
|
+
// dangling `.ts` URLs that a consumer's bundler can't resolve.
|
|
61
|
+
const workerUrlPlugin = {
|
|
62
|
+
name: 'sp00ky-worker-url',
|
|
63
|
+
transform(code: string) {
|
|
64
|
+
if (!code.includes('sqlite-worker.ts') && !code.includes('tabs-broker-worker.ts')) return null;
|
|
65
|
+
return code
|
|
66
|
+
.split('sqlite-worker.ts')
|
|
67
|
+
.join('sqlite-worker.js')
|
|
68
|
+
.split('tabs-broker-worker.ts')
|
|
69
|
+
.join('tabs-broker-worker.js');
|
|
70
|
+
},
|
|
71
|
+
};
|
|
2
72
|
|
|
3
73
|
export default defineConfig({
|
|
4
|
-
|
|
74
|
+
// `sqlite-worker` is emitted at `dist/sqlite-worker.js` (top level, NOT under
|
|
75
|
+
// services/database) so the `new URL('./sqlite-worker.js', import.meta.url)`
|
|
76
|
+
// in `SqliteCacheEngine` — which gets bundled into the flat `dist/index.js` —
|
|
77
|
+
// resolves correctly. It (and `@sqlite.org/sqlite-wasm`) load lazily in the
|
|
78
|
+
// Worker; never pulled into the main bundle unless `localEngine: 'sqlite'`.
|
|
79
|
+
entry: {
|
|
80
|
+
index: 'src/index.ts',
|
|
81
|
+
'otel/index': 'src/otel/index.ts',
|
|
82
|
+
'sqlite-worker': 'src/services/database/sqlite-worker.ts',
|
|
83
|
+
// The shared-tabs broker MUST stay a self-contained emit (no chunk
|
|
84
|
+
// imports): bundlers cannot trace `new SharedWorker(url)` graphs, so a
|
|
85
|
+
// relative chunk import would 404 in consumers. The source keeps zero
|
|
86
|
+
// runtime imports and scripts/check-broker-bundle.mjs enforces it.
|
|
87
|
+
'tabs-broker-worker': 'src/services/tabs/tabs-broker-worker.ts',
|
|
88
|
+
},
|
|
5
89
|
format: ['esm'],
|
|
6
90
|
dts: true,
|
|
7
91
|
clean: true,
|
|
8
92
|
hash: false,
|
|
93
|
+
plugins: [versionDefinePlugin, workerUrlPlugin],
|
|
9
94
|
});
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
// Thresholds start at the numbers the suite hits today and are ratcheted per
|
|
4
|
+
// directory as the saga core lands (see the plan's commit 10). Exclusions:
|
|
5
|
+
// *.fixture.ts / src/testing/** test-only harness code
|
|
6
|
+
// sqlite-worker.ts runs inside a Worker (sqlite-wasm + OPFS +
|
|
7
|
+
// postMessage globals); covered by the sqlite
|
|
8
|
+
// integration tests and the build check
|
|
9
|
+
// client/services.ts constructs the real adapters (SurrealDB
|
|
10
|
+
// WASM store, socket, SharedWorker broker,
|
|
11
|
+
// OPFS blob cache); exercised by the example
|
|
12
|
+
// app and the WhitePawn A/B, not by vitest
|
|
13
|
+
export default defineConfig({
|
|
14
|
+
test: {
|
|
15
|
+
environment: 'node',
|
|
16
|
+
include: ['src/**/*.test.ts'],
|
|
17
|
+
coverage: {
|
|
18
|
+
provider: 'v8',
|
|
19
|
+
all: true,
|
|
20
|
+
reporter: ['text-summary', 'lcov'],
|
|
21
|
+
include: ['src/**/*.ts'],
|
|
22
|
+
exclude: [
|
|
23
|
+
'src/**/*.test.ts',
|
|
24
|
+
'src/**/*.d.ts',
|
|
25
|
+
'src/**/*.fixture.ts',
|
|
26
|
+
'src/testing/**',
|
|
27
|
+
'src/services/database/sqlite-worker.ts',
|
|
28
|
+
'src/client/services.ts',
|
|
29
|
+
],
|
|
30
|
+
thresholds: {
|
|
31
|
+
// Global numbers are held back by the legacy adapters (devtools,
|
|
32
|
+
// sqlite engine, tabs broker, blobs, crdt); the saga core below is 100%.
|
|
33
|
+
lines: 75,
|
|
34
|
+
functions: 82,
|
|
35
|
+
branches: 87,
|
|
36
|
+
statements: 75,
|
|
37
|
+
'src/kernel/**': { lines: 100, functions: 100, branches: 100, statements: 100 },
|
|
38
|
+
'src/state/**': { lines: 100, functions: 100, branches: 100, statements: 100 },
|
|
39
|
+
'src/query/**': { lines: 100, functions: 100, branches: 100, statements: 100 },
|
|
40
|
+
'src/mutation/**': { lines: 100, functions: 100, branches: 100, statements: 100 },
|
|
41
|
+
'src/sync/**': { lines: 100, functions: 100, branches: 100, statements: 100 },
|
|
42
|
+
'src/boot/**': { lines: 100, functions: 100, branches: 100, statements: 100 },
|
|
43
|
+
// The facade's shared-tabs host callbacks need a SharedWorker broker to fire.
|
|
44
|
+
'src/client/**': { lines: 93, functions: 92, branches: 89, statements: 93 },
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
});
|
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
import { LocalDatabaseService } from '../../services/database/index';
|
|
2
|
-
import {
|
|
3
|
-
StreamProcessorService,
|
|
4
|
-
StreamUpdate,
|
|
5
|
-
StreamUpdateReceiver,
|
|
6
|
-
} from '../../services/stream-processor/index';
|
|
7
|
-
import { Logger } from '../../services/logger/index';
|
|
8
|
-
import { parseRecordIdString, encodeRecordId, surql } from '../../utils/index';
|
|
9
|
-
import { CacheRecord, QueryConfig } from './types';
|
|
10
|
-
import { RecordVersionArray } from '../../types';
|
|
11
|
-
|
|
12
|
-
export * from './types';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* CacheModule - Centralized storage and DBSP ingestion
|
|
16
|
-
*
|
|
17
|
-
* Single responsibility: Handle all local storage operations and DBSP ingestion.
|
|
18
|
-
* This module acts as the bridge between data operations and persistence.
|
|
19
|
-
*/
|
|
20
|
-
export class CacheModule implements StreamUpdateReceiver {
|
|
21
|
-
private logger: Logger;
|
|
22
|
-
private streamUpdateCallback: (update: StreamUpdate) => void;
|
|
23
|
-
private versionLookups: Record<string, number> = {};
|
|
24
|
-
|
|
25
|
-
constructor(
|
|
26
|
-
private local: LocalDatabaseService,
|
|
27
|
-
private streamProcessor: StreamProcessorService,
|
|
28
|
-
streamUpdateCallback: (update: StreamUpdate) => void,
|
|
29
|
-
logger: Logger
|
|
30
|
-
) {
|
|
31
|
-
this.logger = logger.child({ service: 'CacheModule' });
|
|
32
|
-
this.streamUpdateCallback = streamUpdateCallback;
|
|
33
|
-
// Register as receiver for DBSP stream updates
|
|
34
|
-
this.streamProcessor.addReceiver(this);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Implements StreamUpdateReceiver interface
|
|
39
|
-
* Called directly by StreamProcessor when views change
|
|
40
|
-
*/
|
|
41
|
-
onStreamUpdate(update: StreamUpdate): void {
|
|
42
|
-
this.logger.debug(
|
|
43
|
-
{
|
|
44
|
-
queryHash: update.queryHash,
|
|
45
|
-
arrayLength: update.localArray?.length,
|
|
46
|
-
Category: 'spooky-client::CacheModule::onStreamUpdate',
|
|
47
|
-
},
|
|
48
|
-
'Stream update received'
|
|
49
|
-
);
|
|
50
|
-
this.streamUpdateCallback(update);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
public lookup(recordId: string): number {
|
|
54
|
-
return this.versionLookups[recordId] ?? 0;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Save a single record to local DB and ingest into DBSP
|
|
59
|
-
* Used by mutations (create/update)
|
|
60
|
-
*/
|
|
61
|
-
async save(cacheRecord: CacheRecord, skipDbInsert: boolean = false): Promise<void> {
|
|
62
|
-
return this.saveBatch([cacheRecord], skipDbInsert);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Save multiple records in a batch
|
|
67
|
-
* More efficient than calling save() multiple times
|
|
68
|
-
* Used by sync operations
|
|
69
|
-
*/
|
|
70
|
-
async saveBatch(records: CacheRecord[], skipDbInsert: boolean = false): Promise<void> {
|
|
71
|
-
if (records.length === 0) return;
|
|
72
|
-
|
|
73
|
-
this.logger.debug(
|
|
74
|
-
{
|
|
75
|
-
count: records.length,
|
|
76
|
-
Category: 'spooky-client::CacheModule::saveBatch',
|
|
77
|
-
},
|
|
78
|
-
'Saving record batch'
|
|
79
|
-
);
|
|
80
|
-
|
|
81
|
-
try {
|
|
82
|
-
const populatedRecords = records.map((record) => {
|
|
83
|
-
if (!record.version) throw new Error('Record version is required');
|
|
84
|
-
return {
|
|
85
|
-
...record,
|
|
86
|
-
record: {
|
|
87
|
-
...record.record,
|
|
88
|
-
spooky_rv: record.version,
|
|
89
|
-
},
|
|
90
|
-
};
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
if (!skipDbInsert) {
|
|
94
|
-
const query = surql.seal<void>(
|
|
95
|
-
surql.tx(
|
|
96
|
-
populatedRecords.map((_, i) => {
|
|
97
|
-
return surql.upsert(`id${i}`, `content${i}`);
|
|
98
|
-
})
|
|
99
|
-
)
|
|
100
|
-
);
|
|
101
|
-
|
|
102
|
-
const params = populatedRecords.reduce(
|
|
103
|
-
(acc, record, i) => {
|
|
104
|
-
const { id, ...content } = record.record;
|
|
105
|
-
return {
|
|
106
|
-
...acc,
|
|
107
|
-
[`id${i}`]: id,
|
|
108
|
-
[`content${i}`]: content,
|
|
109
|
-
};
|
|
110
|
-
},
|
|
111
|
-
{} as Record<string, any>
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
await this.local.execute(query, params);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// 2. Batch ingest into DBSP (use populatedRecords which has spooky_rv set)
|
|
118
|
-
for (const record of populatedRecords) {
|
|
119
|
-
const recordId = encodeRecordId(record.record.id);
|
|
120
|
-
this.versionLookups[recordId] = record.version;
|
|
121
|
-
this.streamProcessor.ingest(record.table, record.op, recordId, record.record);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
this.logger.debug(
|
|
125
|
-
{ count: records.length, Category: 'spooky-client::CacheModule::saveBatch' },
|
|
126
|
-
'Batch saved successfully'
|
|
127
|
-
);
|
|
128
|
-
} catch (err) {
|
|
129
|
-
this.logger.error(
|
|
130
|
-
{ err, count: records.length, Category: 'spooky-client::CacheModule::saveBatch' },
|
|
131
|
-
'Failed to save batch'
|
|
132
|
-
);
|
|
133
|
-
throw err;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Delete a record from local DB and ingest deletion into DBSP
|
|
139
|
-
*/
|
|
140
|
-
async delete(table: string, id: string, skipDbDelete: boolean = false, recordData: Record<string, any> = {}): Promise<void> {
|
|
141
|
-
this.logger.debug(
|
|
142
|
-
{ table, id, Category: 'spooky-client::CacheModule::delete' },
|
|
143
|
-
'Deleting record'
|
|
144
|
-
);
|
|
145
|
-
|
|
146
|
-
try {
|
|
147
|
-
// 1. Delete from local database
|
|
148
|
-
if (!skipDbDelete) {
|
|
149
|
-
await this.local.query('DELETE $id', { id: parseRecordIdString(id) });
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// 2. Ingest deletion into DBSP (pass record data so predicates can be matched)
|
|
153
|
-
delete this.versionLookups[id];
|
|
154
|
-
this.streamProcessor.ingest(table, 'DELETE', id, recordData);
|
|
155
|
-
|
|
156
|
-
this.logger.debug(
|
|
157
|
-
{ table, id, Category: 'spooky-client::CacheModule::delete' },
|
|
158
|
-
'Record deleted successfully'
|
|
159
|
-
);
|
|
160
|
-
} catch (err) {
|
|
161
|
-
this.logger.error(
|
|
162
|
-
{ err, table, id, Category: 'spooky-client::CacheModule::delete' },
|
|
163
|
-
'Failed to delete record'
|
|
164
|
-
);
|
|
165
|
-
throw err;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Register a query with DBSP to create a materialized view
|
|
171
|
-
* Returns the initial result array
|
|
172
|
-
*/
|
|
173
|
-
registerQuery(config: QueryConfig): { localArray: RecordVersionArray } {
|
|
174
|
-
this.logger.debug(
|
|
175
|
-
{
|
|
176
|
-
queryHash: config.queryHash,
|
|
177
|
-
surql: config.surql,
|
|
178
|
-
Category: 'spooky-client::CacheModule::registerQuery',
|
|
179
|
-
},
|
|
180
|
-
'Registering query'
|
|
181
|
-
);
|
|
182
|
-
|
|
183
|
-
try {
|
|
184
|
-
const update = this.streamProcessor.registerQueryPlan({
|
|
185
|
-
queryHash: config.queryHash,
|
|
186
|
-
surql: config.surql,
|
|
187
|
-
params: config.params,
|
|
188
|
-
ttl: config.ttl,
|
|
189
|
-
lastActiveAt: config.lastActiveAt,
|
|
190
|
-
localArray: [],
|
|
191
|
-
remoteArray: [],
|
|
192
|
-
meta: {
|
|
193
|
-
tableName: '',
|
|
194
|
-
},
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
if (!update) {
|
|
198
|
-
throw new Error('Failed to register query with DBSP');
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
this.logger.debug(
|
|
202
|
-
{
|
|
203
|
-
queryHash: config.queryHash,
|
|
204
|
-
arrayLength: update.localArray?.length,
|
|
205
|
-
Category: 'spooky-client::CacheModule::registerQuery',
|
|
206
|
-
},
|
|
207
|
-
'Query registered successfully'
|
|
208
|
-
);
|
|
209
|
-
|
|
210
|
-
return { localArray: update.localArray };
|
|
211
|
-
} catch (err) {
|
|
212
|
-
this.logger.error(
|
|
213
|
-
{ err, queryHash: config.queryHash, Category: 'spooky-client::CacheModule::registerQuery' },
|
|
214
|
-
'Failed to register query'
|
|
215
|
-
);
|
|
216
|
-
throw err;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Unregister a query from DBSP
|
|
222
|
-
*/
|
|
223
|
-
unregisterQuery(queryHash: string): void {
|
|
224
|
-
this.logger.debug(
|
|
225
|
-
{ queryHash, Category: 'spooky-client::CacheModule::unregisterQuery' },
|
|
226
|
-
'Unregistering query'
|
|
227
|
-
);
|
|
228
|
-
try {
|
|
229
|
-
this.streamProcessor.unregisterQueryPlan(queryHash);
|
|
230
|
-
this.logger.debug(
|
|
231
|
-
{ queryHash, Category: 'spooky-client::CacheModule::unregisterQuery' },
|
|
232
|
-
'Query unregistered successfully'
|
|
233
|
-
);
|
|
234
|
-
} catch (err) {
|
|
235
|
-
this.logger.error(
|
|
236
|
-
{ err, queryHash, Category: 'spooky-client::CacheModule::unregisterQuery' },
|
|
237
|
-
'Failed to unregister query'
|
|
238
|
-
);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { RecordId, Duration } from 'surrealdb';
|
|
2
|
-
import { QueryTimeToLive, RecordVersionArray } from '../../types';
|
|
3
|
-
|
|
4
|
-
export type RecordWithId = Record<string, any> & { id: RecordId<string> };
|
|
5
|
-
|
|
6
|
-
export interface QueryConfig {
|
|
7
|
-
queryHash: string;
|
|
8
|
-
surql: string;
|
|
9
|
-
params: Record<string, any>;
|
|
10
|
-
ttl: QueryTimeToLive | Duration;
|
|
11
|
-
lastActiveAt: Date;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface CacheRecord {
|
|
15
|
-
table: string;
|
|
16
|
-
op: 'CREATE' | 'UPDATE' | 'DELETE';
|
|
17
|
-
record: RecordWithId;
|
|
18
|
-
version: number;
|
|
19
|
-
}
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { parseUpdateOptions } from './index';
|
|
3
|
-
|
|
4
|
-
describe('parseUpdateOptions', () => {
|
|
5
|
-
it('returns empty options when no debounce', () => {
|
|
6
|
-
const result = parseUpdateOptions('user:1', { name: 'Alice' });
|
|
7
|
-
expect(result).toEqual({});
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
it('returns empty options when options is undefined', () => {
|
|
11
|
-
const result = parseUpdateOptions('user:1', { name: 'Alice' }, undefined);
|
|
12
|
-
expect(result).toEqual({});
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it('returns empty options when debounced is false', () => {
|
|
16
|
-
const result = parseUpdateOptions('user:1', { name: 'Alice' }, { debounced: false });
|
|
17
|
-
expect(result).toEqual({});
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('debounced: true uses default delay (200) and key = id', () => {
|
|
21
|
-
const result = parseUpdateOptions('user:1', { name: 'Alice' }, { debounced: true });
|
|
22
|
-
expect(result).toEqual({
|
|
23
|
-
debounced: {
|
|
24
|
-
delay: 200,
|
|
25
|
-
key: 'user:1',
|
|
26
|
-
},
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('supports custom delay', () => {
|
|
31
|
-
const result = parseUpdateOptions('user:1', { name: 'Alice' }, {
|
|
32
|
-
debounced: { delay: 500 },
|
|
33
|
-
});
|
|
34
|
-
expect(result.debounced?.delay).toBe(500);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('supports custom key type recordId', () => {
|
|
38
|
-
const result = parseUpdateOptions('user:1', { name: 'Alice', age: 30 }, {
|
|
39
|
-
debounced: { key: 'recordId' },
|
|
40
|
-
});
|
|
41
|
-
expect(result.debounced?.key).toBe('user:1');
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('key: recordId_x_fields generates composite key from id + sorted field names', () => {
|
|
45
|
-
const result = parseUpdateOptions(
|
|
46
|
-
'user:1',
|
|
47
|
-
{ name: 'Alice', age: 30, email: 'a@b.com' },
|
|
48
|
-
{ debounced: { key: 'recordId_x_fields' } }
|
|
49
|
-
);
|
|
50
|
-
expect(result.debounced?.key).toBe('user:1::age#email#name');
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('uses defaults when debounced is object with no key or delay', () => {
|
|
54
|
-
const result = parseUpdateOptions('user:1', { name: 'Alice' }, { debounced: {} });
|
|
55
|
-
expect(result.debounced?.delay).toBe(200);
|
|
56
|
-
expect(result.debounced?.key).toBe('user:1');
|
|
57
|
-
});
|
|
58
|
-
});
|