@spooky-sync/core 0.0.1-canary.13 → 0.0.1-canary.130
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +56 -0
- package/dist/index.d.ts +1171 -372
- package/dist/index.js +5716 -1278
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +107 -0
- package/dist/types.d.ts +746 -0
- package/package.json +39 -8
- package/skills/sp00ky-core/SKILL.md +258 -0
- package/skills/sp00ky-core/references/auth.md +98 -0
- package/skills/sp00ky-core/references/config.md +76 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +9 -2
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +77 -32
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +288 -0
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +357 -0
- package/src/modules/data/data.hydration.test.ts +150 -0
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.recurring.test.ts +137 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/index.ts +1228 -127
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +154 -0
- package/src/modules/devtools/index.ts +191 -30
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/feature-flag/index.test.ts +120 -0
- package/src/modules/feature-flag/index.ts +209 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +101 -37
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.ts +12 -5
- package/src/modules/sync/queue/queue-up.ts +29 -14
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.ts +73 -7
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.ts +1017 -62
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +182 -17
- package/src/otel/index.ts +127 -0
- package/src/services/database/cache-engine.ts +143 -0
- package/src/services/database/database.ts +11 -11
- package/src/services/database/engine-factory.ts +32 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +6 -0
- package/src/services/database/local-migrator.ts +28 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +452 -66
- package/src/services/database/plan-render.test.ts +133 -0
- package/src/services/database/plan-render.ts +100 -0
- package/src/services/database/relation-resolver.test.ts +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +13 -13
- package/src/services/database/sqlite-cache-engine.test.ts +85 -0
- package/src/services/database/sqlite-cache-engine.ts +699 -0
- package/src/services/database/sqlite-worker.ts +116 -0
- package/src/services/database/surql-translate.ts +291 -0
- package/src/services/database/surreal-cache-engine.ts +122 -0
- package/src/services/logger/index.ts +6 -101
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +41 -0
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +295 -38
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +18 -2
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +185 -0
- package/src/sp00ky.ts +1016 -0
- package/src/types.ts +258 -15
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +35 -13
- package/src/utils/parser.ts +3 -2
- package/src/utils/surql.ts +24 -15
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +77 -1
- package/src/spooky.ts +0 -392
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { QueryPlan, RelationPlan, WhereNode } from '@spooky-sync/query-builder';
|
|
2
|
+
import type { SealedQuery } from '../../utils/surql';
|
|
3
|
+
import type { DatabaseEventSystem } from './events/index';
|
|
4
|
+
import type { Sp00kyConfig } from '../../types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A materialized row. Keys are field names; values are already decoded to the
|
|
8
|
+
* client's runtime shapes (RecordId stays a RecordId, bytes a Uint8Array, …) so
|
|
9
|
+
* every backend hands `DataModule` the same shape SurrealDB does today.
|
|
10
|
+
*/
|
|
11
|
+
export type Row = Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
/** A record identifier — a `RecordId` or its stable string form (`table:id`). */
|
|
14
|
+
export type Id = unknown;
|
|
15
|
+
|
|
16
|
+
/** How an order clause is expressed everywhere in the engine layer. */
|
|
17
|
+
export type OrderBy = [field: string, direction: 'asc' | 'desc'][];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Batched relation fetch: "give me every row of `table` whose `matchField` is
|
|
21
|
+
* one of `keys`, filtered by `where`, ordered by `orderBy`". This is the single
|
|
22
|
+
* primitive relation decomposition (§3) leans on — implemented as
|
|
23
|
+
* `SELECT … WHERE <matchField> IN (…)` on SQLite, `SELECT … FROM $keys` /
|
|
24
|
+
* `WHERE <matchField> IN $keys` on SurrealDB, or an index scan elsewhere. Order
|
|
25
|
+
* here is a hint; the resolver re-applies order+limit PER PARENT after grouping.
|
|
26
|
+
*/
|
|
27
|
+
export interface RelationFetch {
|
|
28
|
+
table: string;
|
|
29
|
+
matchField: string;
|
|
30
|
+
keys: Id[];
|
|
31
|
+
where?: WhereNode[];
|
|
32
|
+
orderBy?: OrderBy;
|
|
33
|
+
select?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The read side of an engine, minus relation resolution — the surface a
|
|
38
|
+
* {@link RelationResolver} needs. Kept separate so the resolver can be unit
|
|
39
|
+
* tested against an in-memory fake without a full engine.
|
|
40
|
+
*/
|
|
41
|
+
export interface RowFetcher {
|
|
42
|
+
/** Batched fan-out fetch. See {@link RelationFetch}. */
|
|
43
|
+
fetchRelation(req: RelationFetch): Promise<Row[]>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A transaction handle — the same verbs as the engine, but atomic. */
|
|
47
|
+
export interface EngineTx {
|
|
48
|
+
upsert(table: string, id: Id, data: Row, mode: 'replace' | 'merge'): Promise<void>;
|
|
49
|
+
patch(table: string, id: Id, patches: unknown[]): Promise<void>;
|
|
50
|
+
delete(table: string, id: Id): Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A pluggable local cache backend. SurrealDB (the default) and SQLite both
|
|
55
|
+
* implement this; the rest of the client talks verbs, never SurrealQL.
|
|
56
|
+
*
|
|
57
|
+
* Reactivity is NOT part of this contract: the local cache is passive. The SSP
|
|
58
|
+
* (remote) drives change; `DataModule` writes rows here and re-reads them. The
|
|
59
|
+
* `epoch` field preserves the existing bucket-switch fencing (see
|
|
60
|
+
* `LocalDatabaseService.epoch`): an async chain captures it at start and its
|
|
61
|
+
* write is dropped if the epoch moved (a bucket switch) in between.
|
|
62
|
+
*/
|
|
63
|
+
export interface LocalCacheEngine extends RowFetcher {
|
|
64
|
+
/** Monotonic store generation; bumped on every bucket switch. */
|
|
65
|
+
readonly epoch: number;
|
|
66
|
+
|
|
67
|
+
connect(bucketId: string): Promise<void>;
|
|
68
|
+
switchBucket(bucketId: string): Promise<void>;
|
|
69
|
+
close(): Promise<void>;
|
|
70
|
+
|
|
71
|
+
/** Run `fn` inside a single atomic transaction. */
|
|
72
|
+
transaction<T>(fn: (tx: EngineTx) => Promise<T>): Promise<T>;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Materialize a query, including its `.related()` tree (via §3
|
|
76
|
+
* decomposition). Params bind `where` `paramRef`s and any windowing id-set.
|
|
77
|
+
*/
|
|
78
|
+
select(plan: QueryPlan, params?: Record<string, unknown>): Promise<Row[]>;
|
|
79
|
+
|
|
80
|
+
/** Fetch rows by primary id, preserving `ids` order; missing ids are skipped. */
|
|
81
|
+
selectByIds(table: string, ids: Id[], opts?: { select?: string[]; orderBy?: OrderBy }): Promise<Row[]>;
|
|
82
|
+
|
|
83
|
+
/** Single-record read by primary id, or `null`. */
|
|
84
|
+
getById(table: string, id: Id): Promise<Row | null>;
|
|
85
|
+
|
|
86
|
+
upsert(table: string, id: Id, data: Row, mode: 'replace' | 'merge'): Promise<void>;
|
|
87
|
+
patch(table: string, id: Id, patches: unknown[]): Promise<void>;
|
|
88
|
+
delete(table: string, id: Id): Promise<void>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The full surface the client's `this.local` field depends on: the
|
|
93
|
+
* engine-neutral {@link LocalCacheEngine} verbs PLUS the legacy
|
|
94
|
+
* SurrealQL/lifecycle methods the not-yet-migrated call sites still use.
|
|
95
|
+
* `SurrealCacheEngine` (subclass of `LocalDatabaseService`) and
|
|
96
|
+
* `SqliteCacheEngine` (via a SurrealQL-vocabulary shim) both satisfy this, so
|
|
97
|
+
* either can back `this.local`.
|
|
98
|
+
*
|
|
99
|
+
* `getClient()` returns the underlying SurrealDB `Surreal` handle where one
|
|
100
|
+
* exists (SurrealDB backend); backends without one (SQLite) throw — it is only
|
|
101
|
+
* used by advanced/DevTools paths, never on the hot path.
|
|
102
|
+
*/
|
|
103
|
+
export interface LocalStore extends LocalCacheEngine {
|
|
104
|
+
/**
|
|
105
|
+
* Whether this engine needs SurrealQL schema provisioning (`DEFINE TABLE`,
|
|
106
|
+
* `DEFINE FIELD`, …) run against it at init / bucket switch. SurrealDB → true;
|
|
107
|
+
* schemaless engines (SQLite creates tables lazily) → false, so the client
|
|
108
|
+
* skips the `LocalMigrator` entirely for them.
|
|
109
|
+
*/
|
|
110
|
+
readonly usesSurqlSchema: boolean;
|
|
111
|
+
query<T extends unknown[]>(
|
|
112
|
+
query: string,
|
|
113
|
+
vars?: Record<string, unknown>,
|
|
114
|
+
opts?: { epoch?: number }
|
|
115
|
+
): Promise<T>;
|
|
116
|
+
execute<T>(query: SealedQuery<T>, vars?: Record<string, unknown>, opts?: { epoch?: number }): Promise<T>;
|
|
117
|
+
queryUngated<T extends unknown[]>(query: string, vars?: Record<string, unknown>): Promise<T>;
|
|
118
|
+
switchStore(bucketId: string): Promise<void>;
|
|
119
|
+
beginSwitch(): () => void;
|
|
120
|
+
getEvents(): DatabaseEventSystem;
|
|
121
|
+
getClient(): unknown;
|
|
122
|
+
getConfig(): Sp00kyConfig<any>['database'];
|
|
123
|
+
readonly currentBucketId: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Selected local cache backend. Mirrors the `persistenceClient` config pattern. */
|
|
127
|
+
export type LocalEngineChoice = 'surrealdb' | 'sqlite' | LocalStore;
|
|
128
|
+
|
|
129
|
+
/** Thrown when relation decomposition nests past {@link MAX_RELATION_DEPTH} —
|
|
130
|
+
* a guard against a cyclic schema producing unbounded fan-out. */
|
|
131
|
+
export class RelationCycleError extends Error {
|
|
132
|
+
constructor(path: string[]) {
|
|
133
|
+
super(`Relation nesting exceeded safe depth; possible cyclic schema: ${path.join(' -> ')}`);
|
|
134
|
+
this.name = 'RelationCycleError';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Defensive ceiling on relation nesting depth. A finite plan tree never hits
|
|
139
|
+
* this in practice; it exists so a malformed/cyclic plan fails loudly instead
|
|
140
|
+
* of running away. */
|
|
141
|
+
export const MAX_RELATION_DEPTH = 12;
|
|
142
|
+
|
|
143
|
+
export type { QueryPlan, RelationPlan, WhereNode };
|
|
@@ -1,11 +1,9 @@
|
|
|
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 './events/index';
|
|
8
|
-
import { SealedQuery } from '../../utils/surql';
|
|
5
|
+
DatabaseEventTypes} from './events/index';
|
|
6
|
+
import type { SealedQuery } from '../../utils/surql';
|
|
9
7
|
|
|
10
8
|
export abstract class AbstractDatabaseService {
|
|
11
9
|
protected client: Surreal;
|
|
@@ -43,11 +41,12 @@ export abstract class AbstractDatabaseService {
|
|
|
43
41
|
async query<T extends unknown[]>(query: string, vars?: Record<string, unknown>): Promise<T> {
|
|
44
42
|
return new Promise((resolve, reject) => {
|
|
45
43
|
this.queryQueue = this.queryQueue
|
|
44
|
+
// oxlint-disable-next-line promise/always-return
|
|
46
45
|
.then(async () => {
|
|
47
46
|
const startTime = performance.now();
|
|
48
47
|
try {
|
|
49
48
|
this.logger.debug(
|
|
50
|
-
{ query, vars, Category: '
|
|
49
|
+
{ query, vars, Category: 'sp00ky-client::Database::query' },
|
|
51
50
|
'Executing query'
|
|
52
51
|
);
|
|
53
52
|
const pending = this.client.query(query, vars);
|
|
@@ -67,7 +66,7 @@ export abstract class AbstractDatabaseService {
|
|
|
67
66
|
|
|
68
67
|
resolve(result);
|
|
69
68
|
this.logger.trace(
|
|
70
|
-
{ query, result, Category: '
|
|
69
|
+
{ query, result, Category: 'sp00ky-client::Database::query' },
|
|
71
70
|
'Query executed successfully'
|
|
72
71
|
);
|
|
73
72
|
} catch (err) {
|
|
@@ -84,9 +83,10 @@ export abstract class AbstractDatabaseService {
|
|
|
84
83
|
});
|
|
85
84
|
|
|
86
85
|
this.logger.error(
|
|
87
|
-
{ query, vars, err, Category: '
|
|
86
|
+
{ query, vars, err, Category: 'sp00ky-client::Database::query' },
|
|
88
87
|
'Query execution failed'
|
|
89
88
|
);
|
|
89
|
+
// oxlint-disable-next-line no-multiple-resolved -- resolve/reject are in try/catch, mutually exclusive
|
|
90
90
|
reject(err);
|
|
91
91
|
}
|
|
92
92
|
})
|
|
@@ -102,7 +102,7 @@ export abstract class AbstractDatabaseService {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
async close(): Promise<void> {
|
|
105
|
-
this.logger.info({ Category: '
|
|
105
|
+
this.logger.info({ Category: 'sp00ky-client::Database::close' }, 'Closing database connection');
|
|
106
106
|
await this.client.close();
|
|
107
107
|
}
|
|
108
108
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
): LocalStore {
|
|
21
|
+
if (choice === undefined || choice === 'surrealdb') {
|
|
22
|
+
return new SurrealCacheEngine(config, logger);
|
|
23
|
+
}
|
|
24
|
+
if (choice === 'sqlite') {
|
|
25
|
+
// Mirror the SurrealDB engine's `store` semantics: 'memory' → in-memory
|
|
26
|
+
// SQLite (transient), 'indexeddb' → OPFS-backed (durable).
|
|
27
|
+
const useOpfs = (config.store ?? 'memory') !== 'memory';
|
|
28
|
+
return new SqliteCacheEngine(config, logger, { useOpfs });
|
|
29
|
+
}
|
|
30
|
+
// Custom engine instance — trust it to satisfy LocalStore.
|
|
31
|
+
return choice as unknown as LocalStore;
|
|
32
|
+
}
|
|
@@ -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',
|
|
@@ -3,3 +3,9 @@ export * from './local';
|
|
|
3
3
|
export * from './remote';
|
|
4
4
|
export * from './local-migrator';
|
|
5
5
|
export * from './events/index';
|
|
6
|
+
export * from './cache-engine';
|
|
7
|
+
export * from './surreal-cache-engine';
|
|
8
|
+
export * from './sqlite-cache-engine';
|
|
9
|
+
export * from './relation-resolver';
|
|
10
|
+
export * from './plan-render';
|
|
11
|
+
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,11 @@ 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_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
|
|
48
|
+
DEFINE TABLE IF NOT EXISTS _00_preload SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
|
|
49
|
+
DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
|
|
50
|
+
DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
|
|
50
51
|
`;
|
|
51
52
|
const fullSchema = schemaSurql + '\n' + systemSchema;
|
|
52
53
|
|
|
@@ -60,7 +61,7 @@ export class LocalMigrator {
|
|
|
60
61
|
// SKIP INDEXES: WASM engine hangs on DEFINE INDEX (confirmed)
|
|
61
62
|
if (cleanStatement.toUpperCase().startsWith('DEFINE INDEX')) {
|
|
62
63
|
this.logger.warn(
|
|
63
|
-
{ Category: '
|
|
64
|
+
{ Category: 'sp00ky-client::LocalMigrator::provision' },
|
|
64
65
|
`[Provisioning] Skipping index definition (WASM hang avoidance): ${cleanStatement.substring(0, 50)}...`
|
|
65
66
|
);
|
|
66
67
|
continue;
|
|
@@ -68,17 +69,17 @@ export class LocalMigrator {
|
|
|
68
69
|
|
|
69
70
|
try {
|
|
70
71
|
this.logger.info(
|
|
71
|
-
{ Category: '
|
|
72
|
+
{ Category: 'sp00ky-client::LocalMigrator::provision' },
|
|
72
73
|
`[Provisioning] (${i + 1}/${statements.length}) Executing: ${statement.substring(0, 50)}...`
|
|
73
74
|
);
|
|
74
|
-
await this.localDb.
|
|
75
|
+
await this.localDb.queryUngated(statement);
|
|
75
76
|
this.logger.info(
|
|
76
|
-
{ Category: '
|
|
77
|
+
{ Category: 'sp00ky-client::LocalMigrator::provision' },
|
|
77
78
|
`[Provisioning] (${i + 1}/${statements.length}) Done`
|
|
78
79
|
);
|
|
79
80
|
} catch (e) {
|
|
80
81
|
this.logger.error(
|
|
81
|
-
{ Category: '
|
|
82
|
+
{ Category: 'sp00ky-client::LocalMigrator::provision' },
|
|
82
83
|
`[Provisioning] (${i + 1}/${statements.length}) Error executing statement: ${statement}`
|
|
83
84
|
);
|
|
84
85
|
throw e;
|
|
@@ -90,32 +91,32 @@ export class LocalMigrator {
|
|
|
90
91
|
|
|
91
92
|
private async isSchemaUpToDate(hash: string): Promise<boolean> {
|
|
92
93
|
try {
|
|
93
|
-
const [lastSchemaRecord] = await this.localDb.
|
|
94
|
-
`SELECT hash, created_at FROM ONLY
|
|
94
|
+
const [lastSchemaRecord] = await this.localDb.queryUngated<any>(
|
|
95
|
+
`SELECT hash, created_at FROM ONLY _00_schema ORDER BY created_at DESC LIMIT 1;`
|
|
95
96
|
);
|
|
96
97
|
return lastSchemaRecord?.hash === hash;
|
|
97
|
-
} catch (
|
|
98
|
+
} catch (_error) {
|
|
98
99
|
return false;
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
private async recreateDatabase(database: string) {
|
|
103
104
|
try {
|
|
104
|
-
await this.localDb.
|
|
105
|
-
} catch (
|
|
105
|
+
await this.localDb.queryUngated(`DEFINE DATABASE _00_temp;`);
|
|
106
|
+
} catch (_e) {
|
|
106
107
|
// Ignore if exists
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
try {
|
|
110
|
-
await this.localDb.
|
|
111
|
-
USE DB
|
|
111
|
+
await this.localDb.queryUngated(`
|
|
112
|
+
USE DB _00_temp;
|
|
112
113
|
REMOVE DATABASE ${database};
|
|
113
114
|
`);
|
|
114
|
-
} catch (
|
|
115
|
+
} catch (_e) {
|
|
115
116
|
// Ignore error if database doesn't exist
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
await this.localDb.
|
|
119
|
+
await this.localDb.queryUngated(`
|
|
119
120
|
DEFINE DATABASE ${database};
|
|
120
121
|
USE DB ${database};
|
|
121
122
|
`);
|
|
@@ -195,8 +196,8 @@ export class LocalMigrator {
|
|
|
195
196
|
}
|
|
196
197
|
|
|
197
198
|
private async createHashRecord(hash: string) {
|
|
198
|
-
await this.localDb.
|
|
199
|
-
`UPSERT
|
|
199
|
+
await this.localDb.queryUngated(
|
|
200
|
+
`UPSERT _00_schema SET hash = $hash, created_at = time::now() WHERE hash = $hash;`,
|
|
200
201
|
{ hash }
|
|
201
202
|
);
|
|
202
203
|
}
|
|
@@ -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
|
+
});
|