@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.
Files changed (90) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +1171 -372
  3. package/dist/index.js +5716 -1278
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/sqlite-worker.d.ts +1 -0
  7. package/dist/sqlite-worker.js +107 -0
  8. package/dist/types.d.ts +746 -0
  9. package/package.json +39 -8
  10. package/skills/sp00ky-core/SKILL.md +258 -0
  11. package/skills/sp00ky-core/references/auth.md +98 -0
  12. package/skills/sp00ky-core/references/config.md +76 -0
  13. package/src/build-globals.d.ts +12 -0
  14. package/src/events/events.test.ts +2 -1
  15. package/src/events/index.ts +3 -0
  16. package/src/index.ts +9 -2
  17. package/src/modules/auth/events/index.ts +2 -1
  18. package/src/modules/auth/index.ts +59 -20
  19. package/src/modules/cache/index.ts +77 -32
  20. package/src/modules/cache/types.ts +2 -2
  21. package/src/modules/crdt/crdt-field.ts +288 -0
  22. package/src/modules/crdt/crdt-hydration.test.ts +206 -0
  23. package/src/modules/crdt/index.ts +357 -0
  24. package/src/modules/data/data.hydration.test.ts +150 -0
  25. package/src/modules/data/data.rebind.test.ts +147 -0
  26. package/src/modules/data/data.recurring.test.ts +137 -0
  27. package/src/modules/data/data.status.test.ts +249 -0
  28. package/src/modules/data/index.ts +1228 -127
  29. package/src/modules/data/window-query.test.ts +52 -0
  30. package/src/modules/data/window-query.ts +154 -0
  31. package/src/modules/devtools/index.ts +191 -30
  32. package/src/modules/devtools/versions.test.ts +74 -0
  33. package/src/modules/devtools/versions.ts +81 -0
  34. package/src/modules/feature-flag/index.test.ts +120 -0
  35. package/src/modules/feature-flag/index.ts +209 -0
  36. package/src/modules/ref-tables.test.ts +91 -0
  37. package/src/modules/ref-tables.ts +88 -0
  38. package/src/modules/sync/engine.ts +101 -37
  39. package/src/modules/sync/events/index.ts +9 -2
  40. package/src/modules/sync/queue/queue-down.ts +12 -5
  41. package/src/modules/sync/queue/queue-up.ts +29 -14
  42. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  43. package/src/modules/sync/scheduler.ts +73 -7
  44. package/src/modules/sync/sync.health.test.ts +149 -0
  45. package/src/modules/sync/sync.subquery.test.ts +82 -0
  46. package/src/modules/sync/sync.ts +1017 -62
  47. package/src/modules/sync/utils.test.ts +269 -2
  48. package/src/modules/sync/utils.ts +182 -17
  49. package/src/otel/index.ts +127 -0
  50. package/src/services/database/cache-engine.ts +143 -0
  51. package/src/services/database/database.ts +11 -11
  52. package/src/services/database/engine-factory.ts +32 -0
  53. package/src/services/database/events/index.ts +2 -1
  54. package/src/services/database/index.ts +6 -0
  55. package/src/services/database/local-migrator.ts +28 -27
  56. package/src/services/database/local.test.ts +64 -0
  57. package/src/services/database/local.ts +452 -66
  58. package/src/services/database/plan-render.test.ts +133 -0
  59. package/src/services/database/plan-render.ts +100 -0
  60. package/src/services/database/relation-resolver.test.ts +413 -0
  61. package/src/services/database/relation-resolver.ts +0 -0
  62. package/src/services/database/remote.ts +13 -13
  63. package/src/services/database/sqlite-cache-engine.test.ts +85 -0
  64. package/src/services/database/sqlite-cache-engine.ts +699 -0
  65. package/src/services/database/sqlite-worker.ts +116 -0
  66. package/src/services/database/surql-translate.ts +291 -0
  67. package/src/services/database/surreal-cache-engine.ts +122 -0
  68. package/src/services/logger/index.ts +6 -101
  69. package/src/services/persistence/localstorage.ts +2 -2
  70. package/src/services/persistence/resilient.ts +41 -0
  71. package/src/services/persistence/surrealdb.ts +10 -10
  72. package/src/services/stream-processor/index.ts +295 -38
  73. package/src/services/stream-processor/permissions.test.ts +47 -0
  74. package/src/services/stream-processor/permissions.ts +53 -0
  75. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  76. package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
  77. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  78. package/src/services/stream-processor/wasm-types.ts +18 -2
  79. package/src/sp00ky.auth-order.test.ts +92 -0
  80. package/src/sp00ky.init-query.test.ts +185 -0
  81. package/src/sp00ky.ts +1016 -0
  82. package/src/types.ts +258 -15
  83. package/src/utils/error-classification.test.ts +44 -0
  84. package/src/utils/error-classification.ts +7 -0
  85. package/src/utils/index.ts +35 -13
  86. package/src/utils/parser.ts +3 -2
  87. package/src/utils/surql.ts +24 -15
  88. package/src/utils/withRetry.test.ts +1 -1
  89. package/tsdown.config.ts +77 -1
  90. 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 { 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,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 { 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
+ });