@spooky-sync/core 0.0.1-canary.16 → 0.0.1-canary.161

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.
Files changed (123) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +1406 -364
  3. package/dist/index.js +7773 -1391
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/sqlite-open.js +276 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +420 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +434 -0
  11. package/dist/types.d.ts +874 -0
  12. package/package.json +20 -7
  13. package/scripts/check-broker-bundle.mjs +33 -0
  14. package/skills/sp00ky-core/SKILL.md +258 -0
  15. package/skills/sp00ky-core/references/auth.md +98 -0
  16. package/skills/sp00ky-core/references/config.md +76 -0
  17. package/src/build-globals.d.ts +12 -0
  18. package/src/events/events.test.ts +2 -1
  19. package/src/events/index.ts +3 -0
  20. package/src/index.ts +16 -2
  21. package/src/modules/app-release/index.test.ts +125 -0
  22. package/src/modules/app-release/index.ts +201 -0
  23. package/src/modules/auth/events/index.ts +2 -1
  24. package/src/modules/auth/index.ts +59 -20
  25. package/src/modules/cache/index.ts +112 -32
  26. package/src/modules/cache/types.ts +2 -2
  27. package/src/modules/crdt/crdt-field.ts +294 -0
  28. package/src/modules/crdt/crdt-hydration.test.ts +206 -0
  29. package/src/modules/crdt/index.ts +361 -0
  30. package/src/modules/crdt/loro-loader.ts +25 -0
  31. package/src/modules/data/data.hydration.test.ts +142 -0
  32. package/src/modules/data/data.rebind.test.ts +147 -0
  33. package/src/modules/data/data.run.test.ts +113 -0
  34. package/src/modules/data/data.status.test.ts +249 -0
  35. package/src/modules/data/index.ts +1151 -129
  36. package/src/modules/data/mutation-id.test.ts +25 -0
  37. package/src/modules/data/mutation-id.ts +35 -0
  38. package/src/modules/data/window-query.test.ts +52 -0
  39. package/src/modules/data/window-query.ts +154 -0
  40. package/src/modules/devtools/index.ts +325 -37
  41. package/src/modules/devtools/notify-throttle.test.ts +149 -0
  42. package/src/modules/devtools/storage-info.test.ts +79 -0
  43. package/src/modules/devtools/storage-info.ts +143 -0
  44. package/src/modules/devtools/versions.test.ts +74 -0
  45. package/src/modules/devtools/versions.ts +81 -0
  46. package/src/modules/feature-flag/index.test.ts +120 -0
  47. package/src/modules/feature-flag/index.ts +209 -0
  48. package/src/modules/ref-tables.test.ts +91 -0
  49. package/src/modules/ref-tables.ts +88 -0
  50. package/src/modules/sync/engine.ts +101 -37
  51. package/src/modules/sync/events/index.ts +9 -2
  52. package/src/modules/sync/queue/queue-down.ts +12 -5
  53. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  54. package/src/modules/sync/queue/queue-up.ts +216 -56
  55. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  56. package/src/modules/sync/scheduler.ts +77 -9
  57. package/src/modules/sync/sync.health.test.ts +149 -0
  58. package/src/modules/sync/sync.subquery.test.ts +82 -0
  59. package/src/modules/sync/sync.ts +1333 -92
  60. package/src/modules/sync/utils.test.ts +269 -2
  61. package/src/modules/sync/utils.ts +182 -17
  62. package/src/otel/index.ts +127 -0
  63. package/src/services/database/cache-engine.ts +160 -0
  64. package/src/services/database/database.ts +11 -11
  65. package/src/services/database/engine-factory.ts +33 -0
  66. package/src/services/database/events/index.ts +2 -1
  67. package/src/services/database/index.ts +6 -0
  68. package/src/services/database/local-migrator.ts +28 -27
  69. package/src/services/database/local.test.ts +64 -0
  70. package/src/services/database/local.ts +478 -67
  71. package/src/services/database/plan-render.test.ts +159 -0
  72. package/src/services/database/plan-render.ts +108 -0
  73. package/src/services/database/relation-resolver.test.ts +413 -0
  74. package/src/services/database/relation-resolver.ts +0 -0
  75. package/src/services/database/remote.ts +13 -13
  76. package/src/services/database/sqlite-cache-engine.test.ts +464 -0
  77. package/src/services/database/sqlite-cache-engine.ts +1154 -0
  78. package/src/services/database/sqlite-open.test.ts +150 -0
  79. package/src/services/database/sqlite-open.ts +164 -0
  80. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  81. package/src/services/database/sqlite-plan-sql.ts +106 -0
  82. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  83. package/src/services/database/sqlite-select.test.ts +246 -0
  84. package/src/services/database/sqlite-select.ts +113 -0
  85. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  86. package/src/services/database/sqlite-transport.ts +219 -0
  87. package/src/services/database/sqlite-worker.ts +437 -0
  88. package/src/services/database/surql-translate.ts +291 -0
  89. package/src/services/database/surreal-cache-engine.ts +141 -0
  90. package/src/services/logger/index.ts +6 -109
  91. package/src/services/persistence/localstorage.ts +2 -2
  92. package/src/services/persistence/resilient.ts +11 -4
  93. package/src/services/persistence/surrealdb.ts +10 -10
  94. package/src/services/stream-processor/index.ts +444 -52
  95. package/src/services/stream-processor/permissions.test.ts +47 -0
  96. package/src/services/stream-processor/permissions.ts +53 -0
  97. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  98. package/src/services/stream-processor/stream-processor.reset.test.ts +216 -0
  99. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  100. package/src/services/stream-processor/wasm-types.ts +23 -2
  101. package/src/services/tabs/broker-client.ts +283 -0
  102. package/src/services/tabs/broker.test.ts +278 -0
  103. package/src/services/tabs/coordinator.test.ts +192 -0
  104. package/src/services/tabs/coordinator.ts +567 -0
  105. package/src/services/tabs/fake-ports.fixture.ts +70 -0
  106. package/src/services/tabs/leader-locks.ts +75 -0
  107. package/src/services/tabs/protocol.ts +239 -0
  108. package/src/services/tabs/support.ts +36 -0
  109. package/src/services/tabs/tabs-broker-worker.ts +581 -0
  110. package/src/sp00ky.auth-order.test.ts +92 -0
  111. package/src/sp00ky.init-query.test.ts +183 -0
  112. package/src/sp00ky.ts +1225 -0
  113. package/src/types.ts +342 -15
  114. package/src/utils/error-classification.test.ts +44 -0
  115. package/src/utils/error-classification.ts +7 -0
  116. package/src/utils/index.ts +35 -13
  117. package/src/utils/parser.ts +3 -2
  118. package/src/utils/semver.test.ts +32 -0
  119. package/src/utils/semver.ts +30 -0
  120. package/src/utils/surql.ts +30 -18
  121. package/src/utils/withRetry.test.ts +1 -1
  122. package/tsdown.config.ts +86 -1
  123. package/src/spooky.ts +0 -395
@@ -0,0 +1,160 @@
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, StorageHealth } from '../../types';
5
+ import type { EngineStorageDiagnostics } from '../../modules/devtools/storage-info';
6
+
7
+ /**
8
+ * A materialized row. Keys are field names; values are already decoded to the
9
+ * client's runtime shapes (RecordId stays a RecordId, bytes a Uint8Array, …) so
10
+ * every backend hands `DataModule` the same shape SurrealDB does today.
11
+ */
12
+ export type Row = Record<string, unknown>;
13
+
14
+ /** A record identifier — a `RecordId` or its stable string form (`table:id`). */
15
+ export type Id = unknown;
16
+
17
+ /** How an order clause is expressed everywhere in the engine layer. */
18
+ export type OrderBy = [field: string, direction: 'asc' | 'desc'][];
19
+
20
+ /**
21
+ * Batched relation fetch: "give me every row of `table` whose `matchField` is
22
+ * one of `keys`, filtered by `where`, ordered by `orderBy`". This is the single
23
+ * primitive relation decomposition (§3) leans on — implemented as
24
+ * `SELECT … WHERE <matchField> IN (…)` on SQLite, `SELECT … FROM $keys` /
25
+ * `WHERE <matchField> IN $keys` on SurrealDB, or an index scan elsewhere. Order
26
+ * here is a hint; the resolver re-applies order+limit PER PARENT after grouping.
27
+ */
28
+ export interface RelationFetch {
29
+ table: string;
30
+ matchField: string;
31
+ keys: Id[];
32
+ where?: WhereNode[];
33
+ orderBy?: OrderBy;
34
+ select?: string[];
35
+ }
36
+
37
+ /**
38
+ * The read side of an engine, minus relation resolution — the surface a
39
+ * {@link RelationResolver} needs. Kept separate so the resolver can be unit
40
+ * tested against an in-memory fake without a full engine.
41
+ */
42
+ export interface RowFetcher {
43
+ /** Batched fan-out fetch. See {@link RelationFetch}. */
44
+ fetchRelation(req: RelationFetch): Promise<Row[]>;
45
+ }
46
+
47
+ /** A transaction handle — the same verbs as the engine, but atomic. */
48
+ export interface EngineTx {
49
+ upsert(table: string, id: Id, data: Row, mode: 'replace' | 'merge'): Promise<void>;
50
+ patch(table: string, id: Id, patches: unknown[]): Promise<void>;
51
+ delete(table: string, id: Id): Promise<void>;
52
+ }
53
+
54
+ /**
55
+ * A pluggable local cache backend. SurrealDB (the default) and SQLite both
56
+ * implement this; the rest of the client talks verbs, never SurrealQL.
57
+ *
58
+ * Reactivity is NOT part of this contract: the local cache is passive. The SSP
59
+ * (remote) drives change; `DataModule` writes rows here and re-reads them. The
60
+ * `epoch` field preserves the existing bucket-switch fencing (see
61
+ * `LocalDatabaseService.epoch`): an async chain captures it at start and its
62
+ * write is dropped if the epoch moved (a bucket switch) in between.
63
+ */
64
+ export interface LocalCacheEngine extends RowFetcher {
65
+ /** Monotonic store generation; bumped on every bucket switch. */
66
+ readonly epoch: number;
67
+
68
+ connect(bucketId: string): Promise<void>;
69
+ switchBucket(bucketId: string): Promise<void>;
70
+ close(): Promise<void>;
71
+
72
+ /** Run `fn` inside a single atomic transaction. */
73
+ transaction<T>(fn: (tx: EngineTx) => Promise<T>): Promise<T>;
74
+
75
+ /**
76
+ * Materialize a query, including its `.related()` tree (via §3
77
+ * decomposition). Params bind `where` `paramRef`s and any windowing id-set.
78
+ */
79
+ select(plan: QueryPlan, params?: Record<string, unknown>): Promise<Row[]>;
80
+
81
+ /** Fetch rows by primary id, preserving `ids` order; missing ids are skipped. */
82
+ selectByIds(table: string, ids: Id[], opts?: { select?: string[]; orderBy?: OrderBy }): Promise<Row[]>;
83
+
84
+ /** Single-record read by primary id, or `null`. */
85
+ getById(table: string, id: Id): Promise<Row | null>;
86
+
87
+ upsert(table: string, id: Id, data: Row, mode: 'replace' | 'merge'): Promise<void>;
88
+ patch(table: string, id: Id, patches: unknown[]): Promise<void>;
89
+ delete(table: string, id: Id): Promise<void>;
90
+ }
91
+
92
+ /**
93
+ * The full surface the client's `this.local` field depends on: the
94
+ * engine-neutral {@link LocalCacheEngine} verbs PLUS the legacy
95
+ * SurrealQL/lifecycle methods the not-yet-migrated call sites still use.
96
+ * `SurrealCacheEngine` (subclass of `LocalDatabaseService`) and
97
+ * `SqliteCacheEngine` (via a SurrealQL-vocabulary shim) both satisfy this, so
98
+ * either can back `this.local`.
99
+ *
100
+ * `getClient()` returns the underlying SurrealDB `Surreal` handle where one
101
+ * exists (SurrealDB backend); backends without one (SQLite) throw — it is only
102
+ * used by advanced/DevTools paths, never on the hot path.
103
+ */
104
+ export interface LocalStore extends LocalCacheEngine {
105
+ /**
106
+ * Whether this engine needs SurrealQL schema provisioning (`DEFINE TABLE`,
107
+ * `DEFINE FIELD`, …) run against it at init / bucket switch. SurrealDB → true;
108
+ * schemaless engines (SQLite creates tables lazily) → false, so the client
109
+ * skips the `LocalMigrator` entirely for them.
110
+ */
111
+ readonly usesSurqlSchema: boolean;
112
+ query<T extends unknown[]>(
113
+ query: string,
114
+ vars?: Record<string, unknown>,
115
+ opts?: { epoch?: number }
116
+ ): Promise<T>;
117
+ execute<T>(query: SealedQuery<T>, vars?: Record<string, unknown>, opts?: { epoch?: number }): Promise<T>;
118
+ queryUngated<T extends unknown[]>(query: string, vars?: Record<string, unknown>): Promise<T>;
119
+ switchStore(bucketId: string): Promise<void>;
120
+ beginSwitch(): () => void;
121
+ getEvents(): DatabaseEventSystem;
122
+ getClient(): unknown;
123
+ getConfig(): Sp00kyConfig<any>['database'];
124
+ readonly currentBucketId: string;
125
+ /** Which built-in backend this is. OPTIONAL: absent (custom engines) is
126
+ * reported as `'custom'` by DevTools. More robust than `instanceof` for
127
+ * engines constructed outside this package. */
128
+ readonly engineKind?: 'surrealdb' | 'sqlite';
129
+ /** Engine-specific storage numbers for DevTools (DB file size, per-table
130
+ * row counts). OPTIONAL: only engines with something to report implement it. */
131
+ getStorageDiagnostics?(opts?: { tableCounts?: boolean }): Promise<EngineStorageDiagnostics>;
132
+ /**
133
+ * Durability of this engine's local store. OPTIONAL: engines that don't
134
+ * report it (SurrealDB, custom engines) are treated as `'unknown'` by the
135
+ * client facade, so adding this needs no change on their side.
136
+ */
137
+ readonly storageHealth?: StorageHealth;
138
+ /** Fires immediately with the current snapshot, then on every change.
139
+ * Returns an unsubscribe function. */
140
+ subscribeToStorageHealth?(cb: (health: StorageHealth) => void): () => void;
141
+ }
142
+
143
+ /** Selected local cache backend. Mirrors the `persistenceClient` config pattern. */
144
+ export type LocalEngineChoice = 'surrealdb' | 'sqlite' | LocalStore;
145
+
146
+ /** Thrown when relation decomposition nests past {@link MAX_RELATION_DEPTH} —
147
+ * a guard against a cyclic schema producing unbounded fan-out. */
148
+ export class RelationCycleError extends Error {
149
+ constructor(path: string[]) {
150
+ super(`Relation nesting exceeded safe depth; possible cyclic schema: ${path.join(' -> ')}`);
151
+ this.name = 'RelationCycleError';
152
+ }
153
+ }
154
+
155
+ /** Defensive ceiling on relation nesting depth. A finite plan tree never hits
156
+ * this in practice; it exists so a malformed/cyclic plan fails loudly instead
157
+ * of running away. */
158
+ export const MAX_RELATION_DEPTH = 12;
159
+
160
+ export type { QueryPlan, RelationPlan, WhereNode };
@@ -1,11 +1,9 @@
1
- import { Surreal, SurrealTransaction } from 'surrealdb';
2
- import { createLogger, Logger } from '../logger/index';
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
- DatabaseQueryEventPayload,
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: 'spooky-client::Database::query' },
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: 'spooky-client::Database::query' },
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: 'spooky-client::Database::query' },
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: 'spooky-client::Database::close' }, 'Closing database connection');
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,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
+ }
@@ -1,4 +1,5 @@
1
- import { createEventSystem, EventDefinition, EventSystem } from '../../../events/index';
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 { Surreal } from 'surrealdb';
2
- import { Logger, createLogger } from '../logger/index';
3
- import { LocalDatabaseService } from './local';
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: LocalDatabaseService,
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: 'spooky-client::LocalMigrator::provision' },
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 _spooky_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
47
- DEFINE TABLE IF NOT EXISTS _spooky_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
48
- DEFINE TABLE IF NOT EXISTS _spooky_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
49
- DEFINE TABLE IF NOT EXISTS _spooky_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
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: 'spooky-client::LocalMigrator::provision' },
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: 'spooky-client::LocalMigrator::provision' },
72
+ { Category: 'sp00ky-client::LocalMigrator::provision' },
72
73
  `[Provisioning] (${i + 1}/${statements.length}) Executing: ${statement.substring(0, 50)}...`
73
74
  );
74
- await this.localDb.query(statement);
75
+ await this.localDb.queryUngated(statement);
75
76
  this.logger.info(
76
- { Category: 'spooky-client::LocalMigrator::provision' },
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: 'spooky-client::LocalMigrator::provision' },
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.query<any>(
94
- `SELECT hash, created_at FROM ONLY _spooky_schema ORDER BY created_at DESC LIMIT 1;`
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 (error) {
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.query(`DEFINE DATABASE _spooky_temp;`);
105
- } catch (e) {
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.query(`
111
- USE DB _spooky_temp;
111
+ await this.localDb.queryUngated(`
112
+ USE DB _00_temp;
112
113
  REMOVE DATABASE ${database};
113
114
  `);
114
- } catch (e) {
115
+ } catch (_e) {
115
116
  // Ignore error if database doesn't exist
116
117
  }
117
118
 
118
- await this.localDb.query(`
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.query(
199
- `UPSERT _spooky_schema SET hash = $hash, created_at = time::now() WHERE hash = $hash;`,
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
+ });