@spooky-sync/core 0.0.1-canary.33 → 0.0.1-canary.331
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 +84 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2080 -142
- package/dist/index.js +16009 -2480
- package/dist/live.d.ts +149 -0
- package/dist/live.js +739 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +373 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +392 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/sw.d.ts +207 -0
- package/dist/sw.js +393 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +477 -0
- package/dist/types.d.ts +1857 -38
- package/dist/worker-edges.js +480 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +48 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +28 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +173 -0
- package/skills/sp00ky-core/SKILL.md +32 -7
- package/skills/sp00ky-core/references/config.md +7 -1
- package/src/boot/auth-flip.saga.test.ts +135 -0
- package/src/boot/auth-flip.saga.ts +77 -0
- package/src/boot/boot.saga.test.ts +203 -0
- package/src/boot/boot.saga.ts +146 -0
- package/src/boot/bucket-switch.saga.test.ts +187 -0
- package/src/boot/bucket-switch.saga.ts +133 -0
- package/src/boot/preload.saga.test.ts +80 -0
- package/src/boot/preload.saga.ts +40 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +195 -0
- package/src/client/bucket-handle.ts +352 -0
- package/src/client/router.test.ts +58 -0
- package/src/client/router.ts +100 -0
- package/src/client/runtime.test.ts +195 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.test.ts +74 -0
- package/src/client/services.ts +434 -0
- package/src/client/sp00ky-client.test.ts +341 -0
- package/src/client/sp00ky-client.ts +625 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +69 -1
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +80 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +167 -0
- package/src/kernel/events.ts +57 -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/live/index.test.ts +512 -0
- package/src/live/index.ts +1059 -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 +228 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.integration.test.ts +205 -0
- package/src/modules/auth/impersonation.test.ts +377 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +476 -22
- package/src/modules/crdt/acceptance.ts +75 -0
- package/src/modules/crdt/bytes.ts +50 -0
- package/src/modules/crdt/crdt-field.ts +125 -0
- package/src/modules/crdt/crdt.test.ts +469 -0
- package/src/modules/crdt/hash.ts +103 -0
- package/src/modules/crdt/index.ts +757 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/crdt/mirror.ts +37 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +746 -95
- package/src/modules/devtools/mutations.test.ts +92 -0
- package/src/modules/devtools/mutations.ts +160 -0
- package/src/modules/devtools/notify-throttle.test.ts +166 -0
- package/src/modules/devtools/state-shape.test.ts +263 -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 +298 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +93 -0
- package/src/modules/ref-tables.ts +91 -0
- package/src/modules/web-push/index.test.ts +824 -0
- package/src/modules/web-push/index.ts +1027 -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/pending-writes.integration.test.ts +175 -0
- package/src/mutation/push.saga.test.ts +301 -0
- package/src/mutation/push.saga.ts +225 -0
- package/src/mutation/rows.test.ts +231 -0
- package/src/mutation/rows.ts +427 -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 +316 -0
- package/src/mutation/write.saga.ts +268 -0
- package/src/otel/index.ts +10 -7
- package/src/pure.ts +66 -0
- package/src/push/types.ts +174 -0
- package/src/query/anon-query-isolation.integration.test.ts +527 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +65 -0
- package/src/query/fetch.saga.test.ts +199 -0
- package/src/query/fetch.saga.ts +120 -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 +274 -0
- package/src/query/lifecycle.saga.ts +186 -0
- package/src/query/list-ref-live.integration.test.ts +203 -0
- package/src/query/materialize.saga.test.ts +115 -0
- package/src/query/materialize.saga.ts +84 -0
- package/src/query/materialize.test.ts +58 -0
- package/src/query/materialize.ts +57 -0
- package/src/query/membership.saga.test.ts +407 -0
- package/src/query/membership.saga.ts +282 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +201 -0
- package/src/query/register.saga.test.ts +311 -0
- package/src/query/register.saga.ts +235 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/sql.test.ts +118 -0
- package/src/query/sql.ts +157 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +208 -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 +209 -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 +156 -61
- 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/landed-body-clear.integration.test.ts +236 -0
- package/src/services/database/local-migrator.integration.test.ts +110 -0
- package/src/services/database/local-migrator.ts +61 -16
- package/src/services/database/local.test.ts +150 -0
- package/src/services/database/local.ts +508 -63
- 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 +484 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +285 -6
- package/src/services/database/sqlite-cache-engine.test.ts +628 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1401 -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/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 +164 -0
- package/src/services/database/sqlite-plan-sql.ts +196 -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 +261 -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 +417 -0
- package/src/services/database/surreal-cache-engine.ts +163 -0
- package/src/services/logger/index.test.ts +132 -0
- package/src/services/logger/index.ts +189 -7
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +10 -3
- package/src/services/persistence/surrealdb.ts +4 -4
- package/src/services/stream-processor/index.ts +806 -67
- 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 +234 -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 +58 -2
- package/src/services/tabs/broker-client.ts +284 -0
- package/src/services/tabs/broker.test.ts +381 -0
- package/src/services/tabs/coordinator.test.ts +386 -0
- package/src/services/tabs/coordinator.ts +646 -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 +652 -0
- package/src/sp00ky.ts +5 -395
- package/src/state/client-state.ts +208 -0
- package/src/state/lifecycle.test.ts +141 -0
- package/src/state/lifecycle.ts +99 -0
- package/src/state/reducers.test.ts +306 -0
- package/src/state/reducers.ts +531 -0
- package/src/state/selectors.test.ts +188 -0
- package/src/state/selectors.ts +258 -0
- package/src/sw/index.test.ts +582 -0
- package/src/sw/index.ts +607 -0
- package/src/sw/store.ts +96 -0
- package/src/sync/connection.saga.test.ts +97 -0
- package/src/sync/connection.saga.ts +89 -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 +663 -0
- package/src/sync/policy.ts +515 -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 +78 -0
- package/src/sync/tabs.saga.ts +96 -0
- package/src/testing/adapters.ts +137 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +63 -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 +633 -10
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/edges.ts +39 -0
- package/src/utils/error-classification.test.ts +54 -0
- package/src/utils/error-classification.ts +25 -0
- package/src/utils/index.ts +89 -12
- package/src/utils/parser.test.ts +67 -102
- package/src/utils/parser.ts +64 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/sha256.ts +15 -0
- package/src/utils/surql.ts +29 -18
- package/src/utils/utils.test.ts +3 -2
- package/tsdown.config.ts +117 -3
- 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
|
@@ -1,16 +1,31 @@
|
|
|
1
|
-
import { Surreal, SurrealTransaction } from 'surrealdb';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import type { Surreal, SurrealTransaction } from 'surrealdb';
|
|
2
|
+
import type { Logger } from '../logger/index';
|
|
3
|
+
import type {
|
|
4
4
|
DatabaseEventSystem,
|
|
5
|
-
DatabaseEventTypes
|
|
6
|
-
|
|
7
|
-
} from '
|
|
8
|
-
import { SealedQuery } from '../../utils/surql';
|
|
5
|
+
DatabaseEventTypes} from './events/index';
|
|
6
|
+
import type { SealedQuery } from '../../utils/surql';
|
|
7
|
+
import { withTimeout } from '../../utils/index';
|
|
9
8
|
|
|
10
9
|
export abstract class AbstractDatabaseService {
|
|
11
10
|
protected client: Surreal;
|
|
12
11
|
protected logger: Logger;
|
|
13
12
|
protected events: DatabaseEventSystem;
|
|
13
|
+
/**
|
|
14
|
+
* Per-query deadline in ms; `0` disables. The remote service sets it from
|
|
15
|
+
* `queryTimeoutMs` (see `RemoteDatabaseService`), the local one from
|
|
16
|
+
* `localOpTimeoutMs` (see `LocalDatabaseService`): a local query can be
|
|
17
|
+
* legitimately slow, but it must never be endless - every query waits on the
|
|
18
|
+
* previous link of {@link query}'s chain, and one that never settled wedged
|
|
19
|
+
* every later local op behind it.
|
|
20
|
+
*/
|
|
21
|
+
protected queryTimeoutMs = 0;
|
|
22
|
+
|
|
23
|
+
/** The error a deadline expiry rejects with; the local service substitutes
|
|
24
|
+
* its typed `LocalOpTimeoutError`. "timed out" in the message is
|
|
25
|
+
* load-bearing either way: `classifySyncError` keys off it. */
|
|
26
|
+
protected timeoutError(_query: string): Error {
|
|
27
|
+
return new Error(`Remote query timed out after ${this.queryTimeoutMs}ms`);
|
|
28
|
+
}
|
|
14
29
|
protected abstract eventType:
|
|
15
30
|
| typeof DatabaseEventTypes.LocalQuery
|
|
16
31
|
| typeof DatabaseEventTypes.RemoteQuery;
|
|
@@ -35,67 +50,147 @@ export abstract class AbstractDatabaseService {
|
|
|
35
50
|
return this.client.beginTransaction();
|
|
36
51
|
}
|
|
37
52
|
|
|
38
|
-
|
|
53
|
+
/**
|
|
54
|
+
* How many statements may be in flight at once.
|
|
55
|
+
*
|
|
56
|
+
* `1` serializes every statement behind the previous one. The local engines
|
|
57
|
+
* need that: overlapping ops trip the WASM / SQLite transaction layer. The
|
|
58
|
+
* remote service raises it (`RemoteDatabaseService`): a SurrealDB socket
|
|
59
|
+
* multiplexes requests by id, and serializing them capped the client at one
|
|
60
|
+
* statement per round trip (~5/s at 170ms RTT), so the `_00_list_ref` poll
|
|
61
|
+
* alone could hold every registration behind it and one slow one-shot read
|
|
62
|
+
* stalled the whole client. The scheduler's `MAX_CONCURRENT_DOWN` only means
|
|
63
|
+
* something when this is above 1.
|
|
64
|
+
*/
|
|
65
|
+
protected maxConcurrentQueries = 1;
|
|
66
|
+
private inFlightQueries = 0;
|
|
67
|
+
private queryWaiters: Array<() => void> = [];
|
|
68
|
+
|
|
69
|
+
/** FIFO slot acquisition, so `maxConcurrentQueries = 1` keeps strict order. */
|
|
70
|
+
private acquireQuerySlot(): Promise<void> {
|
|
71
|
+
if (this.inFlightQueries < this.maxConcurrentQueries) {
|
|
72
|
+
this.inFlightQueries++;
|
|
73
|
+
return Promise.resolve();
|
|
74
|
+
}
|
|
75
|
+
return new Promise<void>((resolve) => {
|
|
76
|
+
this.queryWaiters.push(() => {
|
|
77
|
+
this.inFlightQueries++;
|
|
78
|
+
resolve();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private releaseQuerySlot(): void {
|
|
84
|
+
this.inFlightQueries--;
|
|
85
|
+
const next = this.queryWaiters.shift();
|
|
86
|
+
if (next) next();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve once every statement issued before this call has settled: take
|
|
91
|
+
* every slot (FIFO, so earlier statements go first), then hand them back.
|
|
92
|
+
* Bounded by {@link queryTimeoutMs}, like the statements it waits on.
|
|
93
|
+
*/
|
|
94
|
+
protected async drainStatements(): Promise<void> {
|
|
95
|
+
for (let i = 0; i < this.maxConcurrentQueries; i++) await this.acquireQuerySlot();
|
|
96
|
+
for (let i = 0; i < this.maxConcurrentQueries; i++) this.releaseQuerySlot();
|
|
97
|
+
}
|
|
39
98
|
|
|
40
99
|
/**
|
|
41
|
-
*
|
|
100
|
+
* Hook run right before a statement is sent, inside its slot. The remote
|
|
101
|
+
* service uses it to hold statements while a connect is in flight, so a
|
|
102
|
+
* query never runs on a socket that is open but has not had its namespace
|
|
103
|
+
* and token applied yet. The default does nothing.
|
|
104
|
+
*/
|
|
105
|
+
protected async beforeQuery(): Promise<void> {}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Execute a statement under {@link maxConcurrentQueries}.
|
|
109
|
+
*
|
|
110
|
+
* A statement that never settles would hold its slot forever, and with a
|
|
111
|
+
* limit of 1 that blocks every later statement behind it. {@link
|
|
112
|
+
* queryTimeoutMs} bounds each one: on expiry this promise rejects and the
|
|
113
|
+
* slot is released, even though the underlying RPC is still parked in the
|
|
114
|
+
* SDK's pending map.
|
|
42
115
|
*/
|
|
43
116
|
async query<T extends unknown[]>(query: string, vars?: Record<string, unknown>): Promise<T> {
|
|
44
|
-
return
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
success: true,
|
|
65
|
-
timestamp: Date.now(),
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
resolve(result);
|
|
69
|
-
this.logger.trace(
|
|
70
|
-
{ query, result, Category: 'sp00ky-client::Database::query' },
|
|
71
|
-
'Query executed successfully'
|
|
72
|
-
);
|
|
73
|
-
} catch (err) {
|
|
74
|
-
const duration = performance.now() - startTime;
|
|
75
|
-
|
|
76
|
-
// Emit query event with error
|
|
77
|
-
this.events.emit(this.eventType, {
|
|
78
|
-
query,
|
|
79
|
-
vars,
|
|
80
|
-
duration,
|
|
81
|
-
success: false,
|
|
82
|
-
error: err instanceof Error ? err.message : String(err),
|
|
83
|
-
timestamp: Date.now(),
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
this.logger.error(
|
|
87
|
-
{ query, vars, err, Category: 'sp00ky-client::Database::query' },
|
|
88
|
-
'Query execution failed'
|
|
89
|
-
);
|
|
90
|
-
reject(err);
|
|
91
|
-
}
|
|
92
|
-
})
|
|
93
|
-
.catch(() => {
|
|
94
|
-
// Ignore queue errors to keep the chain alive; the specific promise was rejected above.
|
|
95
|
-
});
|
|
117
|
+
return this.runStatement<T>(query, vars, () => this.client.query(query, vars) as unknown as Promise<T>);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Like {@link query} but never rejects on a statement error: every
|
|
122
|
+
* statement answers on its own as `{ status: 'OK', result }` or
|
|
123
|
+
* `{ status: 'ERR', error }`. This is what lets a batched outbox push judge
|
|
124
|
+
* each mutation separately.
|
|
125
|
+
*/
|
|
126
|
+
async queryResponses(
|
|
127
|
+
query: string,
|
|
128
|
+
vars?: Record<string, unknown>
|
|
129
|
+
): Promise<Array<{ status: 'OK'; result: unknown } | { status: 'ERR'; error: string }>> {
|
|
130
|
+
return this.runStatement(query, vars, async () => {
|
|
131
|
+
const responses = await this.client.query(query, vars).responses();
|
|
132
|
+
return responses.map((r) =>
|
|
133
|
+
r.success
|
|
134
|
+
? { status: 'OK' as const, result: r.result }
|
|
135
|
+
: { status: 'ERR' as const, error: r.error instanceof Error ? r.error.message : String(r.error) }
|
|
136
|
+
);
|
|
96
137
|
});
|
|
97
138
|
}
|
|
98
139
|
|
|
140
|
+
private async runStatement<T>(query: string, vars: Record<string, unknown> | undefined, dispatch: () => Promise<T>): Promise<T> {
|
|
141
|
+
await this.acquireQuerySlot();
|
|
142
|
+
try {
|
|
143
|
+
await this.beforeQuery();
|
|
144
|
+
const startTime = performance.now();
|
|
145
|
+
try {
|
|
146
|
+
// Trace: the finished statement is logged at debug by the client,
|
|
147
|
+
// from the query event below. This line is for a statement that hangs.
|
|
148
|
+
this.logger.trace(
|
|
149
|
+
{ query, vars, Category: 'sp00ky-client::Database::query' },
|
|
150
|
+
'Executing query'
|
|
151
|
+
);
|
|
152
|
+
// "timed out" in the message is load-bearing: `classifySyncError`
|
|
153
|
+
// keys off it to classify this as `network` so the sync queues
|
|
154
|
+
// retry rather than rolling the mutation back.
|
|
155
|
+
const result = await withTimeout(dispatch(), this.queryTimeoutMs, () => this.timeoutError(query));
|
|
156
|
+
const duration = performance.now() - startTime;
|
|
157
|
+
|
|
158
|
+
this.events.emit(this.eventType, {
|
|
159
|
+
query,
|
|
160
|
+
vars,
|
|
161
|
+
duration,
|
|
162
|
+
success: true,
|
|
163
|
+
timestamp: Date.now(),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
this.logger.trace(
|
|
167
|
+
{ query, result, Category: 'sp00ky-client::Database::query' },
|
|
168
|
+
'Query executed successfully'
|
|
169
|
+
);
|
|
170
|
+
return result;
|
|
171
|
+
} catch (err) {
|
|
172
|
+
const duration = performance.now() - startTime;
|
|
173
|
+
|
|
174
|
+
this.events.emit(this.eventType, {
|
|
175
|
+
query,
|
|
176
|
+
vars,
|
|
177
|
+
duration,
|
|
178
|
+
success: false,
|
|
179
|
+
error: err instanceof Error ? err.message : String(err),
|
|
180
|
+
timestamp: Date.now(),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
this.logger.error(
|
|
184
|
+
{ query, vars, err, Category: 'sp00ky-client::Database::query' },
|
|
185
|
+
'Query execution failed'
|
|
186
|
+
);
|
|
187
|
+
throw err;
|
|
188
|
+
}
|
|
189
|
+
} finally {
|
|
190
|
+
this.releaseQuerySlot();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
99
194
|
async execute<T>(query: SealedQuery<T>, vars?: Record<string, unknown>): Promise<T> {
|
|
100
195
|
const raw = await this.query<unknown[]>(query.sql, vars);
|
|
101
196
|
return query.extract(raw);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Sp00kyConfig } from '../../types';
|
|
2
|
+
import type { Logger } from '../logger/index';
|
|
3
|
+
import type { LocalEngineChoice, LocalStore } from './cache-engine';
|
|
4
|
+
import { SurrealCacheEngine } from './surreal-cache-engine';
|
|
5
|
+
import { SqliteCacheEngine } from './sqlite-cache-engine';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Build the local cache engine for the given `localEngine` config choice.
|
|
9
|
+
*
|
|
10
|
+
* - `'surrealdb'` / unset → {@link SurrealCacheEngine} (subclass of
|
|
11
|
+
* `LocalDatabaseService`; the historical behavior, verbatim).
|
|
12
|
+
* - `'sqlite'` → {@link SqliteCacheEngine} (SQLite-WASM Worker + OPFS). Backs
|
|
13
|
+
* `this.local` through the SurrealQL-vocabulary shim + verb surface.
|
|
14
|
+
* - a custom object → used as-is (must satisfy {@link LocalStore}).
|
|
15
|
+
*/
|
|
16
|
+
export function createLocalEngine(
|
|
17
|
+
choice: LocalEngineChoice | undefined,
|
|
18
|
+
config: Sp00kyConfig<any>['database'],
|
|
19
|
+
logger: Logger,
|
|
20
|
+
opts: { shared?: boolean } = {}
|
|
21
|
+
): LocalStore {
|
|
22
|
+
if (choice === undefined || choice === 'surrealdb') {
|
|
23
|
+
return new SurrealCacheEngine(config, logger);
|
|
24
|
+
}
|
|
25
|
+
if (choice === 'sqlite') {
|
|
26
|
+
// Mirror the SurrealDB engine's `store` semantics: 'memory' → in-memory
|
|
27
|
+
// SQLite (transient), 'indexeddb' → OPFS-backed (durable).
|
|
28
|
+
const useOpfs = (config.store ?? 'memory') !== 'memory';
|
|
29
|
+
return new SqliteCacheEngine(config, logger, { useOpfs, shared: opts.shared });
|
|
30
|
+
}
|
|
31
|
+
// Custom engine instance — trust it to satisfy LocalStore.
|
|
32
|
+
return choice as unknown as LocalStore;
|
|
33
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A local-store operation that did not answer within its deadline.
|
|
3
|
+
*
|
|
4
|
+
* The local write path (`db.create` / `db.update` / `db.delete`, every local
|
|
5
|
+
* query behind them) used to have no deadline anywhere: the SQLite worker
|
|
6
|
+
* transport parks a call until the worker replies, the surrealdb engine's
|
|
7
|
+
* query chain waits on the previous link, and `withRetry` retries without a
|
|
8
|
+
* clock. One op that never settled (a worker starved behind a long select, a
|
|
9
|
+
* lock verification awaiting `navigator.locks.query()` forever) left the
|
|
10
|
+
* caller's promise pending for the tab's lifetime - a chat composer that never
|
|
11
|
+
* re-enabled, a call that never got past "Connecting".
|
|
12
|
+
*
|
|
13
|
+
* The message says "timed out" on purpose: `classifySyncError` keys off it and
|
|
14
|
+
* treats the failure as transient (re-queue), never as an application error
|
|
15
|
+
* that rolls the mutation back. `retryable: false` keeps `withRetry` from
|
|
16
|
+
* spinning on it: the op is still running in the engine, retrying queues a
|
|
17
|
+
* second copy behind it.
|
|
18
|
+
*/
|
|
19
|
+
export class LocalOpTimeoutError extends Error {
|
|
20
|
+
override readonly name = 'LocalOpTimeoutError';
|
|
21
|
+
readonly retryable = false;
|
|
22
|
+
readonly op: string;
|
|
23
|
+
readonly timeoutMs: number;
|
|
24
|
+
|
|
25
|
+
constructor(op: string, timeoutMs: number) {
|
|
26
|
+
super(`Local database operation timed out after ${timeoutMs}ms (${op})`);
|
|
27
|
+
this.op = op;
|
|
28
|
+
this.timeoutMs = timeoutMs;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Default deadline for one local-store operation. Generous: a cold 4k-row
|
|
33
|
+
* select on a throttled tab is seconds, not tens of seconds. */
|
|
34
|
+
export const DEFAULT_LOCAL_OP_TIMEOUT_MS = 30_000;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { EventDefinition, EventSystem } from '../../../events/index';
|
|
2
|
+
import { createEventSystem } from '../../../events/index';
|
|
2
3
|
|
|
3
4
|
export const DatabaseEventTypes = {
|
|
4
5
|
LocalQuery: 'DATABASE_LOCAL_QUERY',
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
export * from './database';
|
|
2
2
|
export * from './local';
|
|
3
3
|
export * from './remote';
|
|
4
|
+
export * from './connection-supervisor';
|
|
4
5
|
export * from './local-migrator';
|
|
5
6
|
export * from './events/index';
|
|
7
|
+
export * from './cache-engine';
|
|
8
|
+
export * from './surreal-cache-engine';
|
|
9
|
+
export * from './sqlite-cache-engine';
|
|
10
|
+
export * from './relation-resolver';
|
|
11
|
+
export * from './plan-render';
|
|
12
|
+
export { createLocalEngine } from './engine-factory';
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
|
5
|
+
import { RecordId, Surreal } from 'surrealdb';
|
|
6
|
+
import { landChunk } from '../../query/fetch.saga';
|
|
7
|
+
import { defaultEnv } from '../../query/env';
|
|
8
|
+
import { runPure } from '../../testing/run-pure';
|
|
9
|
+
import { buildState } from '../../testing/build';
|
|
10
|
+
import type { Vars } from '../../kernel/effects';
|
|
11
|
+
import type { SealedQuery } from '../../utils/surql';
|
|
12
|
+
import { SqliteCacheEngine } from './sqlite-cache-engine';
|
|
13
|
+
import { stubTransport } from './sqlite-transport.fixture';
|
|
14
|
+
import type { Row } from './cache-engine';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Integration: a field cleared on the server (`UPDATE broadcast:x UNSET club`)
|
|
18
|
+
* is ABSENT from the body the server returns, and the body is landed with
|
|
19
|
+
* MERGE, which keeps every key it does not name. These land real bodies
|
|
20
|
+
* through `landChunk` and run the exact transaction it emits on BOTH local
|
|
21
|
+
* engines: a real in-memory SQLite (the worker's @sqlite.org/sqlite-wasm
|
|
22
|
+
* build) behind the SqliteCacheEngine, and a real SurrealDB-WASM `mem://`
|
|
23
|
+
* store provisioned with a typed schema, as the local migrator does.
|
|
24
|
+
*
|
|
25
|
+
* On each: the cleared optional field is gone, local `_00_*` bookkeeping
|
|
26
|
+
* survives (still a MERGE, not a REPLACE), and a required field the body
|
|
27
|
+
* lacks is kept rather than cleared.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const env = defaultEnv({
|
|
31
|
+
tables: [
|
|
32
|
+
{
|
|
33
|
+
name: 'broadcast',
|
|
34
|
+
columns: {
|
|
35
|
+
title: { type: 'string', optional: false },
|
|
36
|
+
secret: { type: 'string', optional: false },
|
|
37
|
+
club: { type: 'string', recordId: true, optional: true },
|
|
38
|
+
note: { type: 'string', optional: true },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
} as any);
|
|
43
|
+
|
|
44
|
+
type Exec = (query: SealedQuery<unknown>, vars: Vars) => Promise<unknown>;
|
|
45
|
+
|
|
46
|
+
/** Land `rows` at `version` through the real saga, executing its write with `exec`. */
|
|
47
|
+
async function land(
|
|
48
|
+
exec: Exec,
|
|
49
|
+
rows: Array<Record<string, unknown>>,
|
|
50
|
+
version: number
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
const ids = rows.map((r) => String(r.id));
|
|
53
|
+
const out = await runPure(
|
|
54
|
+
landChunk(env, ids, rows, new Map(ids.map((id) => [id, version] as const)), buildState(), 0),
|
|
55
|
+
{
|
|
56
|
+
handlers: {
|
|
57
|
+
'local.execute': (e: any) => exec(e.query, e.vars),
|
|
58
|
+
'ssp.ingest': () => undefined,
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
);
|
|
62
|
+
expect(out.emitted).not.toContainEqual(expect.objectContaining({ message: 'body write failed' }));
|
|
63
|
+
expect(out.result).toBe(true);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const x = new RecordId('broadcast', 'x');
|
|
67
|
+
const fresh = new RecordId('broadcast', 'fresh');
|
|
68
|
+
const club = new RecordId('club', 'c1');
|
|
69
|
+
|
|
70
|
+
const noop = () => {};
|
|
71
|
+
|
|
72
|
+
// ==================== SQLite ====================
|
|
73
|
+
|
|
74
|
+
describe('landed body clears an absent optional field: SQLite engine', () => {
|
|
75
|
+
let db: any;
|
|
76
|
+
|
|
77
|
+
function realEngine(): SqliteCacheEngine {
|
|
78
|
+
const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
79
|
+
logger.child = () => logger;
|
|
80
|
+
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, logger);
|
|
81
|
+
stubTransport(engine, (type, payload: any) => {
|
|
82
|
+
switch (type) {
|
|
83
|
+
case 'open':
|
|
84
|
+
return { persisted: true };
|
|
85
|
+
case 'exec':
|
|
86
|
+
return {
|
|
87
|
+
rows: db.exec({
|
|
88
|
+
sql: payload.sql,
|
|
89
|
+
bind: payload.bind,
|
|
90
|
+
rowMode: 'object',
|
|
91
|
+
returnValue: 'resultRows',
|
|
92
|
+
}),
|
|
93
|
+
};
|
|
94
|
+
case 'run':
|
|
95
|
+
db.exec({ sql: payload.sql, bind: payload.bind });
|
|
96
|
+
return {};
|
|
97
|
+
case 'batch':
|
|
98
|
+
for (const stmt of payload as { sql: string; bind?: unknown[] }[])
|
|
99
|
+
db.exec({ sql: stmt.sql, bind: stmt.bind });
|
|
100
|
+
return {};
|
|
101
|
+
default:
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
return engine;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
beforeAll(async () => {
|
|
109
|
+
const sqlite3: any = await sqlite3InitModule();
|
|
110
|
+
db = new sqlite3.oo1.DB(':memory:', 'c');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterAll(() => db?.close());
|
|
114
|
+
|
|
115
|
+
it('removes the cleared key on the batched fast path, keeps _00_* and required fields', async () => {
|
|
116
|
+
const engine = realEngine();
|
|
117
|
+
await engine.connect('anon');
|
|
118
|
+
const exec: Exec = (q, v) => engine.execute(q, v);
|
|
119
|
+
|
|
120
|
+
await land(exec, [{ id: x, title: 't1', secret: 's', club, note: 'n' }], 1);
|
|
121
|
+
await engine.upsert('broadcast', x, { _00_local: 'keep' }, 'merge');
|
|
122
|
+
const before = (await engine.getById('broadcast', x)) as Row;
|
|
123
|
+
expect(before.club).toBeDefined();
|
|
124
|
+
|
|
125
|
+
// The server unset `club`; `secret` is hidden from this reader (required,
|
|
126
|
+
// so its absence is not a clear).
|
|
127
|
+
await land(exec, [{ id: x, title: 't2', note: 'n' }], 2);
|
|
128
|
+
const after = (await engine.getById('broadcast', x)) as Row;
|
|
129
|
+
expect('club' in after).toBe(false);
|
|
130
|
+
expect(after).toMatchObject({
|
|
131
|
+
id: 'broadcast:x',
|
|
132
|
+
title: 't2',
|
|
133
|
+
note: 'n',
|
|
134
|
+
secret: 's',
|
|
135
|
+
_00_local: 'keep',
|
|
136
|
+
_00_rv: 2,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// A fresh row stores no placeholder for the cleared key.
|
|
140
|
+
await land(exec, [{ id: fresh, title: 'f', secret: 's' }], 1);
|
|
141
|
+
const inserted = (await engine.getById('broadcast', fresh)) as Row;
|
|
142
|
+
expect(Object.keys(inserted).toSorted()).toEqual(['_00_rv', 'id', 'secret', 'title']);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('removes the cleared key on the single-statement merge path too', async () => {
|
|
146
|
+
const engine = realEngine();
|
|
147
|
+
await engine.connect('anon');
|
|
148
|
+
|
|
149
|
+
const y = new RecordId('broadcast', 'y');
|
|
150
|
+
await engine.query('UPSERT ONLY $id MERGE $c', {
|
|
151
|
+
id: y,
|
|
152
|
+
c: { club, note: 'n2', _00_local: 'keep' },
|
|
153
|
+
});
|
|
154
|
+
expect((await engine.getById('broadcast', y))?.club).toBeDefined();
|
|
155
|
+
await engine.query('UPSERT ONLY $id MERGE $c', { id: y, c: { club: undefined, note: 'n3' } });
|
|
156
|
+
const row = (await engine.getById('broadcast', y)) as Row;
|
|
157
|
+
expect('club' in row).toBe(false);
|
|
158
|
+
expect(row.note).toBe('n3');
|
|
159
|
+
expect(row._00_local).toBe('keep');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ==================== SurrealDB-WASM ====================
|
|
164
|
+
|
|
165
|
+
describe('landed body clears an absent optional field: SurrealDB-WASM engine', () => {
|
|
166
|
+
let db: Surreal;
|
|
167
|
+
|
|
168
|
+
beforeAll(async () => {
|
|
169
|
+
// The wasm engine loads its binary with `fetch(new URL(..., import.meta.url))`,
|
|
170
|
+
// and Node's fetch has no `file:` scheme: serve that one read from disk.
|
|
171
|
+
const realFetch = globalThis.fetch;
|
|
172
|
+
vi.stubGlobal('fetch', async (input: unknown, init?: RequestInit) => {
|
|
173
|
+
const url = input instanceof URL ? input : new URL(String(input));
|
|
174
|
+
if (url.protocol === 'file:') return new Response(await readFile(fileURLToPath(url)));
|
|
175
|
+
return realFetch(input as RequestInfo, init);
|
|
176
|
+
});
|
|
177
|
+
const { createWasmEngines } = await import('@surrealdb/wasm');
|
|
178
|
+
db = new Surreal({ engines: createWasmEngines() });
|
|
179
|
+
await db.connect('mem://');
|
|
180
|
+
await db.use({ namespace: 'n', database: 'd' });
|
|
181
|
+
await db.query(`
|
|
182
|
+
DEFINE TABLE club SCHEMAFULL;
|
|
183
|
+
DEFINE TABLE broadcast SCHEMAFULL;
|
|
184
|
+
DEFINE FIELD title ON broadcast TYPE string;
|
|
185
|
+
DEFINE FIELD secret ON broadcast TYPE string;
|
|
186
|
+
DEFINE FIELD club ON broadcast TYPE option<record<club>>;
|
|
187
|
+
DEFINE FIELD note ON broadcast TYPE option<string>;
|
|
188
|
+
DEFINE FIELD _00_rv ON broadcast TYPE option<int>;
|
|
189
|
+
DEFINE FIELD _00_local ON broadcast TYPE option<string>;
|
|
190
|
+
`);
|
|
191
|
+
}, 60_000);
|
|
192
|
+
|
|
193
|
+
afterAll(async () => {
|
|
194
|
+
await db?.close();
|
|
195
|
+
vi.unstubAllGlobals();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const exec: Exec = (q, v) => db.query(q.sql, v) as unknown as Promise<unknown>;
|
|
199
|
+
const read = async (id: RecordId<string>): Promise<Record<string, unknown>> => {
|
|
200
|
+
const [row] = (await db.query('SELECT * FROM ONLY $id', { id })) as [Record<string, unknown>];
|
|
201
|
+
return row;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
it('the undefined clear reaches SurrealDB as NONE and the MERGE removes the field', async () => {
|
|
205
|
+
await land(exec, [{ id: x, title: 't1', secret: 's', club, note: 'n' }], 1);
|
|
206
|
+
await db.query("UPDATE $id SET _00_local = 'keep'", { id: x });
|
|
207
|
+
expect(String((await read(x)).club)).toBe('club:c1');
|
|
208
|
+
|
|
209
|
+
// Would fail the whole transaction if the clear were NULL (a coercion error
|
|
210
|
+
// on `option<record<club>>`) or named the required `secret` as NONE.
|
|
211
|
+
await land(exec, [{ id: x, title: 't2', note: 'n' }], 2);
|
|
212
|
+
const after = await read(x);
|
|
213
|
+
expect('club' in after).toBe(false);
|
|
214
|
+
expect(after).toMatchObject({
|
|
215
|
+
title: 't2',
|
|
216
|
+
note: 'n',
|
|
217
|
+
secret: 's',
|
|
218
|
+
_00_local: 'keep',
|
|
219
|
+
_00_rv: 2,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await land(exec, [{ id: fresh, title: 'f', secret: 's' }], 1);
|
|
223
|
+
expect(Object.keys(await read(fresh)).toSorted()).toEqual(['_00_rv', 'id', 'secret', 'title']);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('why the clear is NONE on optional columns only: NULL and a required NONE both fail', async () => {
|
|
227
|
+
const z = new RecordId('broadcast', 'z');
|
|
228
|
+
await db.query('CREATE $id CONTENT $c', { id: z, c: { title: 't', secret: 's', club } });
|
|
229
|
+
await expect(
|
|
230
|
+
db.query('UPSERT ONLY $id MERGE $c', { id: z, c: { club: null } })
|
|
231
|
+
).rejects.toThrow(/club/);
|
|
232
|
+
await expect(
|
|
233
|
+
db.query('UPSERT ONLY $id MERGE $c', { id: z, c: { secret: undefined } })
|
|
234
|
+
).rejects.toThrow(/secret/);
|
|
235
|
+
});
|
|
236
|
+
});
|