@spooky-sync/core 0.0.1-canary.25 → 0.0.1-canary.255
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 +2187 -80
- package/dist/index.js +12653 -2581
- 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 +787 -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 +158 -0
- package/src/boot/bucket-switch.saga.ts +115 -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 +94 -0
- package/src/client/runtime.test.ts +192 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.ts +382 -0
- package/src/client/sp00ky-client.test.ts +264 -0
- package/src/client/sp00ky-client.ts +499 -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 +54 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +159 -0
- package/src/kernel/events.ts +55 -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 +2 -1
- package/src/modules/auth/index.ts +138 -25
- 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 +459 -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 +246 -0
- package/src/query/membership.saga.ts +208 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +158 -0
- package/src/query/register.saga.test.ts +285 -0
- package/src/query/register.saga.ts +216 -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 +193 -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 +484 -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 +1349 -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 +184 -0
- package/src/state/lifecycle.test.ts +118 -0
- package/src/state/lifecycle.ts +86 -0
- package/src/state/reducers.test.ts +280 -0
- package/src/state/reducers.ts +476 -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 +61 -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 +591 -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 -864
- package/src/modules/sync/engine.ts +0 -166
- 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
|
@@ -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,74 +50,142 @@ 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
|
+
* Hook run right before a statement is sent, inside its slot. The remote
|
|
91
|
+
* service uses it to hold statements while a connect is in flight, so a
|
|
92
|
+
* query never runs on a socket that is open but has not had its namespace
|
|
93
|
+
* and token applied yet. The default does nothing.
|
|
94
|
+
*/
|
|
95
|
+
protected async beforeQuery(): Promise<void> {}
|
|
39
96
|
|
|
40
97
|
/**
|
|
41
|
-
* Execute a
|
|
98
|
+
* Execute a statement under {@link maxConcurrentQueries}.
|
|
99
|
+
*
|
|
100
|
+
* A statement that never settles would hold its slot forever, and with a
|
|
101
|
+
* limit of 1 that blocks every later statement behind it. {@link
|
|
102
|
+
* queryTimeoutMs} bounds each one: on expiry this promise rejects and the
|
|
103
|
+
* slot is released, even though the underlying RPC is still parked in the
|
|
104
|
+
* SDK's pending map.
|
|
42
105
|
*/
|
|
43
106
|
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: 'spooky-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: 'spooky-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
|
-
});
|
|
107
|
+
return this.runStatement<T>(query, vars, () => this.client.query(query, vars) as unknown as Promise<T>);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Like {@link query} but never rejects on a statement error: every
|
|
112
|
+
* statement answers on its own as `{ status: 'OK', result }` or
|
|
113
|
+
* `{ status: 'ERR', error }`. This is what lets a batched outbox push judge
|
|
114
|
+
* each mutation separately.
|
|
115
|
+
*/
|
|
116
|
+
async queryResponses(
|
|
117
|
+
query: string,
|
|
118
|
+
vars?: Record<string, unknown>
|
|
119
|
+
): Promise<Array<{ status: 'OK'; result: unknown } | { status: 'ERR'; error: string }>> {
|
|
120
|
+
return this.runStatement(query, vars, async () => {
|
|
121
|
+
const responses = await this.client.query(query, vars).responses();
|
|
122
|
+
return responses.map((r) =>
|
|
123
|
+
r.success
|
|
124
|
+
? { status: 'OK' as const, result: r.result }
|
|
125
|
+
: { status: 'ERR' as const, error: r.error instanceof Error ? r.error.message : String(r.error) }
|
|
126
|
+
);
|
|
96
127
|
});
|
|
97
128
|
}
|
|
98
129
|
|
|
130
|
+
private async runStatement<T>(query: string, vars: Record<string, unknown> | undefined, dispatch: () => Promise<T>): Promise<T> {
|
|
131
|
+
await this.acquireQuerySlot();
|
|
132
|
+
try {
|
|
133
|
+
await this.beforeQuery();
|
|
134
|
+
const startTime = performance.now();
|
|
135
|
+
try {
|
|
136
|
+
this.logger.debug(
|
|
137
|
+
{ query, vars, Category: 'sp00ky-client::Database::query' },
|
|
138
|
+
'Executing query'
|
|
139
|
+
);
|
|
140
|
+
// "timed out" in the message is load-bearing: `classifySyncError`
|
|
141
|
+
// keys off it to classify this as `network` so the sync queues
|
|
142
|
+
// retry rather than rolling the mutation back.
|
|
143
|
+
const result = await withTimeout(dispatch(), this.queryTimeoutMs, () => this.timeoutError(query));
|
|
144
|
+
const duration = performance.now() - startTime;
|
|
145
|
+
|
|
146
|
+
this.events.emit(this.eventType, {
|
|
147
|
+
query,
|
|
148
|
+
vars,
|
|
149
|
+
duration,
|
|
150
|
+
success: true,
|
|
151
|
+
timestamp: Date.now(),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
this.logger.trace(
|
|
155
|
+
{ query, result, Category: 'sp00ky-client::Database::query' },
|
|
156
|
+
'Query executed successfully'
|
|
157
|
+
);
|
|
158
|
+
return result;
|
|
159
|
+
} catch (err) {
|
|
160
|
+
const duration = performance.now() - startTime;
|
|
161
|
+
|
|
162
|
+
this.events.emit(this.eventType, {
|
|
163
|
+
query,
|
|
164
|
+
vars,
|
|
165
|
+
duration,
|
|
166
|
+
success: false,
|
|
167
|
+
error: err instanceof Error ? err.message : String(err),
|
|
168
|
+
timestamp: Date.now(),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
this.logger.error(
|
|
172
|
+
{ query, vars, err, Category: 'sp00ky-client::Database::query' },
|
|
173
|
+
'Query execution failed'
|
|
174
|
+
);
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
} finally {
|
|
178
|
+
this.releaseQuerySlot();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
99
182
|
async execute<T>(query: SealedQuery<T>, vars?: Record<string, unknown>): Promise<T> {
|
|
100
183
|
const raw = await this.query<unknown[]>(query.sql, vars);
|
|
101
184
|
return query.extract(raw);
|
|
102
185
|
}
|
|
103
186
|
|
|
104
187
|
async close(): Promise<void> {
|
|
105
|
-
this.logger.info({ Category: '
|
|
188
|
+
this.logger.info({ Category: 'sp00ky-client::Database::close' }, 'Closing database connection');
|
|
106
189
|
await this.client.close();
|
|
107
190
|
}
|
|
108
191
|
}
|
|
@@ -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';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import type { Logger} from '../logger/index';
|
|
2
|
+
import { createLogger } from '../logger/index';
|
|
3
|
+
import type { LocalStore } from './cache-engine';
|
|
4
4
|
|
|
5
5
|
export interface SchemaRecord {
|
|
6
6
|
hash: string;
|
|
@@ -19,12 +19,12 @@ export class LocalMigrator {
|
|
|
19
19
|
private logger: Logger;
|
|
20
20
|
|
|
21
21
|
constructor(
|
|
22
|
-
private localDb:
|
|
22
|
+
private localDb: LocalStore,
|
|
23
23
|
logger: Logger
|
|
24
24
|
) {
|
|
25
25
|
this.logger = logger.child({ service: 'LocalMigrator' });
|
|
26
|
-
logger?.child({ service: 'LocalMigrator' }) ??
|
|
27
|
-
createLogger('info').child({ service: 'LocalMigrator' });
|
|
26
|
+
void (logger?.child({ service: 'LocalMigrator' }) ??
|
|
27
|
+
createLogger('info').child({ service: 'LocalMigrator' }));
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
async provision(schemaSurql: string): Promise<void> {
|
|
@@ -34,7 +34,7 @@ export class LocalMigrator {
|
|
|
34
34
|
|
|
35
35
|
if (await this.isSchemaUpToDate(hash)) {
|
|
36
36
|
this.logger.info(
|
|
37
|
-
{ Category: '
|
|
37
|
+
{ Category: 'sp00ky-client::LocalMigrator::provision' },
|
|
38
38
|
'[Provisioning] Schema is up to date, skipping migration'
|
|
39
39
|
);
|
|
40
40
|
return;
|
|
@@ -43,10 +43,13 @@ export class LocalMigrator {
|
|
|
43
43
|
await this.recreateDatabase(database);
|
|
44
44
|
|
|
45
45
|
const systemSchema = `
|
|
46
|
-
DEFINE TABLE IF NOT EXISTS
|
|
47
|
-
DEFINE TABLE IF NOT EXISTS
|
|
48
|
-
DEFINE TABLE IF NOT EXISTS
|
|
49
|
-
DEFINE TABLE IF NOT EXISTS
|
|
46
|
+
DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
|
|
47
|
+
DEFINE TABLE IF NOT EXISTS _00_view SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
|
|
48
|
+
DEFINE TABLE IF NOT EXISTS _00_window SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
|
|
49
|
+
DEFINE TABLE IF NOT EXISTS _00_failed_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
|
|
50
|
+
DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
|
|
51
|
+
DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
|
|
52
|
+
DEFINE TABLE IF NOT EXISTS _00_blob SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
|
|
50
53
|
`;
|
|
51
54
|
const fullSchema = schemaSurql + '\n' + systemSchema;
|
|
52
55
|
|
|
@@ -60,7 +63,7 @@ export class LocalMigrator {
|
|
|
60
63
|
// SKIP INDEXES: WASM engine hangs on DEFINE INDEX (confirmed)
|
|
61
64
|
if (cleanStatement.toUpperCase().startsWith('DEFINE INDEX')) {
|
|
62
65
|
this.logger.warn(
|
|
63
|
-
{ Category: '
|
|
66
|
+
{ Category: 'sp00ky-client::LocalMigrator::provision' },
|
|
64
67
|
`[Provisioning] Skipping index definition (WASM hang avoidance): ${cleanStatement.substring(0, 50)}...`
|
|
65
68
|
);
|
|
66
69
|
continue;
|
|
@@ -68,17 +71,17 @@ export class LocalMigrator {
|
|
|
68
71
|
|
|
69
72
|
try {
|
|
70
73
|
this.logger.info(
|
|
71
|
-
{ Category: '
|
|
74
|
+
{ Category: 'sp00ky-client::LocalMigrator::provision' },
|
|
72
75
|
`[Provisioning] (${i + 1}/${statements.length}) Executing: ${statement.substring(0, 50)}...`
|
|
73
76
|
);
|
|
74
|
-
await this.localDb.
|
|
77
|
+
await this.localDb.queryUngated(statement);
|
|
75
78
|
this.logger.info(
|
|
76
|
-
{ Category: '
|
|
79
|
+
{ Category: 'sp00ky-client::LocalMigrator::provision' },
|
|
77
80
|
`[Provisioning] (${i + 1}/${statements.length}) Done`
|
|
78
81
|
);
|
|
79
82
|
} catch (e) {
|
|
80
83
|
this.logger.error(
|
|
81
|
-
{ Category: '
|
|
84
|
+
{ Category: 'sp00ky-client::LocalMigrator::provision' },
|
|
82
85
|
`[Provisioning] (${i + 1}/${statements.length}) Error executing statement: ${statement}`
|
|
83
86
|
);
|
|
84
87
|
throw e;
|
|
@@ -90,32 +93,32 @@ export class LocalMigrator {
|
|
|
90
93
|
|
|
91
94
|
private async isSchemaUpToDate(hash: string): Promise<boolean> {
|
|
92
95
|
try {
|
|
93
|
-
const [lastSchemaRecord] = await this.localDb.
|
|
94
|
-
`SELECT hash, created_at FROM ONLY
|
|
96
|
+
const [lastSchemaRecord] = await this.localDb.queryUngated<any>(
|
|
97
|
+
`SELECT hash, created_at FROM ONLY _00_schema ORDER BY created_at DESC LIMIT 1;`
|
|
95
98
|
);
|
|
96
99
|
return lastSchemaRecord?.hash === hash;
|
|
97
|
-
} catch (
|
|
100
|
+
} catch (_error) {
|
|
98
101
|
return false;
|
|
99
102
|
}
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
private async recreateDatabase(database: string) {
|
|
103
106
|
try {
|
|
104
|
-
await this.localDb.
|
|
105
|
-
} catch (
|
|
107
|
+
await this.localDb.queryUngated(`DEFINE DATABASE _00_temp;`);
|
|
108
|
+
} catch (_e) {
|
|
106
109
|
// Ignore if exists
|
|
107
110
|
}
|
|
108
111
|
|
|
109
112
|
try {
|
|
110
|
-
await this.localDb.
|
|
111
|
-
USE DB
|
|
113
|
+
await this.localDb.queryUngated(`
|
|
114
|
+
USE DB _00_temp;
|
|
112
115
|
REMOVE DATABASE ${database};
|
|
113
116
|
`);
|
|
114
|
-
} catch (
|
|
117
|
+
} catch (_e) {
|
|
115
118
|
// Ignore error if database doesn't exist
|
|
116
119
|
}
|
|
117
120
|
|
|
118
|
-
await this.localDb.
|
|
121
|
+
await this.localDb.queryUngated(`
|
|
119
122
|
DEFINE DATABASE ${database};
|
|
120
123
|
USE DB ${database};
|
|
121
124
|
`);
|
|
@@ -195,8 +198,8 @@ export class LocalMigrator {
|
|
|
195
198
|
}
|
|
196
199
|
|
|
197
200
|
private async createHashRecord(hash: string) {
|
|
198
|
-
await this.localDb.
|
|
199
|
-
`UPSERT
|
|
201
|
+
await this.localDb.queryUngated(
|
|
202
|
+
`UPSERT _00_schema SET hash = $hash, created_at = time::now() WHERE hash = $hash;`,
|
|
200
203
|
{ hash }
|
|
201
204
|
);
|
|
202
205
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { isLocalStoreOpenError } from './local';
|
|
3
|
+
|
|
4
|
+
// `LocalDatabaseService.connect` recovers (drops the store + reconnects, falling
|
|
5
|
+
// back to in-memory) only when the failure is the SurrealDB-WASM IndexedDB
|
|
6
|
+
// open/transaction error — NOT for unrelated errors. This pins the message match
|
|
7
|
+
// against the real error the engine throws so a recovery path doesn't silently
|
|
8
|
+
// stop triggering (or start swallowing unrelated failures).
|
|
9
|
+
describe('isLocalStoreOpenError', () => {
|
|
10
|
+
it('matches the real SurrealDB-WASM IndexedDB key-value store error', () => {
|
|
11
|
+
const real = new Error(
|
|
12
|
+
'There was a problem with the key-value store: There was a problem with a ' +
|
|
13
|
+
'transaction: An IndexedDB error occured: idb error'
|
|
14
|
+
);
|
|
15
|
+
expect(isLocalStoreOpenError(real)).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('matches on the individual signals (indexeddb / idb error / key-value store)', () => {
|
|
19
|
+
expect(isLocalStoreOpenError(new Error('IndexedDB is not available'))).toBe(true);
|
|
20
|
+
expect(isLocalStoreOpenError(new Error('idb error'))).toBe(true);
|
|
21
|
+
expect(isLocalStoreOpenError(new Error('problem with the key-value store'))).toBe(true);
|
|
22
|
+
// Non-Error inputs are stringified.
|
|
23
|
+
expect(isLocalStoreOpenError('An IndexedDB error occured')).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('does NOT match unrelated errors (so we never clear the store spuriously)', () => {
|
|
27
|
+
expect(isLocalStoreOpenError(new Error('WebSocket connection refused'))).toBe(false);
|
|
28
|
+
expect(isLocalStoreOpenError(new Error('Parse error: unexpected token'))).toBe(false);
|
|
29
|
+
expect(isLocalStoreOpenError(new Error('permission denied'))).toBe(false);
|
|
30
|
+
expect(isLocalStoreOpenError(null)).toBe(false);
|
|
31
|
+
expect(isLocalStoreOpenError(undefined)).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Per-user local buckets: URL/name derivation and the SCOPE of the tier-2
|
|
36
|
+
// corruption drop. The drop must only ever match the failing bucket's store —
|
|
37
|
+
// a substring wipe would take every user's cache AND their un-pushed mutation
|
|
38
|
+
// outboxes down with one corrupt store.
|
|
39
|
+
import { bucketStoreName, bucketStoreUrl, matchesBucketStore } from './local';
|
|
40
|
+
|
|
41
|
+
describe('bucket store naming', () => {
|
|
42
|
+
it('derives the store url/name from the bucket id', () => {
|
|
43
|
+
expect(bucketStoreUrl('abc')).toBe('indxdb://sp00ky-abc');
|
|
44
|
+
expect(bucketStoreName('abc')).toBe('sp00ky-abc');
|
|
45
|
+
expect(bucketStoreUrl('anon')).toBe('indxdb://sp00ky-anon');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('matchesBucketStore', () => {
|
|
50
|
+
it('matches the exact store and derived names', () => {
|
|
51
|
+
expect(matchesBucketStore('sp00ky-abc', 'sp00ky-abc')).toBe(true);
|
|
52
|
+
expect(matchesBucketStore('sp00ky-abc-wal', 'sp00ky-abc')).toBe(true);
|
|
53
|
+
expect(matchesBucketStore('surrealdb/sp00ky-abc', 'sp00ky-abc')).toBe(true);
|
|
54
|
+
expect(matchesBucketStore('SP00KY-ABC', 'sp00ky-abc')).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("never matches another bucket's store", () => {
|
|
58
|
+
expect(matchesBucketStore('sp00ky-abcdef', 'sp00ky-abc')).toBe(false);
|
|
59
|
+
expect(matchesBucketStore('sp00ky-anon', 'sp00ky-abc')).toBe(false);
|
|
60
|
+
expect(matchesBucketStore('sp00ky', 'sp00ky-abc')).toBe(false);
|
|
61
|
+
// The legacy shared store is not any bucket's store.
|
|
62
|
+
expect(matchesBucketStore('sp00ky', 'sp00ky-anon')).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|