@spooky-sync/core 0.0.1-canary.17 → 0.0.1-canary.170

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 (139) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2046 -365
  3. package/dist/index.js +11016 -2476
  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 +997 -0
  12. package/package.json +14 -7
  13. package/scripts/check-broker-bundle.mjs +33 -0
  14. package/skills/{spooky-core → sp00ky-core}/SKILL.md +94 -18
  15. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  16. package/skills/{spooky-core → sp00ky-core}/references/config.md +4 -4
  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 +24 -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 +210 -0
  29. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  30. package/src/modules/crdt/index.ts +463 -0
  31. package/src/modules/crdt/loro-loader.ts +25 -0
  32. package/src/modules/data/data.hydration.test.ts +142 -0
  33. package/src/modules/data/data.membership.test.ts +297 -0
  34. package/src/modules/data/data.rebind.test.ts +147 -0
  35. package/src/modules/data/data.run.test.ts +113 -0
  36. package/src/modules/data/data.status.test.ts +249 -0
  37. package/src/modules/data/id-set-plan.test.ts +122 -0
  38. package/src/modules/data/index.ts +1403 -129
  39. package/src/modules/data/mutation-id.test.ts +25 -0
  40. package/src/modules/data/mutation-id.ts +35 -0
  41. package/src/modules/data/window-query.test.ts +52 -0
  42. package/src/modules/data/window-query.ts +194 -0
  43. package/src/modules/devtools/flags.ts +349 -0
  44. package/src/modules/devtools/index.ts +382 -37
  45. package/src/modules/devtools/notify-throttle.test.ts +149 -0
  46. package/src/modules/devtools/storage-info.test.ts +79 -0
  47. package/src/modules/devtools/storage-info.ts +168 -0
  48. package/src/modules/devtools/versions.test.ts +74 -0
  49. package/src/modules/devtools/versions.ts +81 -0
  50. package/src/modules/feature-flag/index.test.ts +251 -0
  51. package/src/modules/feature-flag/index.ts +308 -0
  52. package/src/modules/ref-tables.test.ts +91 -0
  53. package/src/modules/ref-tables.ts +88 -0
  54. package/src/modules/sync/engine.ts +101 -37
  55. package/src/modules/sync/events/index.ts +9 -2
  56. package/src/modules/sync/queue/queue-down.ts +12 -5
  57. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  58. package/src/modules/sync/queue/queue-up.ts +216 -56
  59. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  60. package/src/modules/sync/scheduler.ts +77 -9
  61. package/src/modules/sync/sync.health.test.ts +149 -0
  62. package/src/modules/sync/sync.live-removal.test.ts +134 -0
  63. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  64. package/src/modules/sync/sync.subquery.test.ts +82 -0
  65. package/src/modules/sync/sync.ts +1456 -92
  66. package/src/modules/sync/utils.test.ts +269 -2
  67. package/src/modules/sync/utils.ts +182 -17
  68. package/src/otel/index.ts +127 -0
  69. package/src/services/blobs/blob-cache.test.ts +359 -0
  70. package/src/services/blobs/blob-cache.ts +603 -0
  71. package/src/services/blobs/blob-manifest.ts +227 -0
  72. package/src/services/blobs/blob-store.test.ts +77 -0
  73. package/src/services/blobs/blob-store.ts +359 -0
  74. package/src/services/blobs/blob.fixture.ts +90 -0
  75. package/src/services/blobs/index.ts +70 -0
  76. package/src/services/database/cache-engine.ts +160 -0
  77. package/src/services/database/connection-supervisor.test.ts +289 -0
  78. package/src/services/database/connection-supervisor.ts +415 -0
  79. package/src/services/database/database.query-timeout.test.ts +83 -0
  80. package/src/services/database/database.ts +32 -12
  81. package/src/services/database/engine-factory.ts +33 -0
  82. package/src/services/database/events/index.ts +2 -1
  83. package/src/services/database/index.ts +7 -0
  84. package/src/services/database/local-migrator.ts +30 -27
  85. package/src/services/database/local.test.ts +64 -0
  86. package/src/services/database/local.ts +478 -67
  87. package/src/services/database/plan-render.test.ts +159 -0
  88. package/src/services/database/plan-render.ts +108 -0
  89. package/src/services/database/relation-resolver.test.ts +413 -0
  90. package/src/services/database/relation-resolver.ts +0 -0
  91. package/src/services/database/remote.ts +110 -14
  92. package/src/services/database/sqlite-cache-engine.test.ts +464 -0
  93. package/src/services/database/sqlite-cache-engine.ts +1159 -0
  94. package/src/services/database/sqlite-open.test.ts +150 -0
  95. package/src/services/database/sqlite-open.ts +164 -0
  96. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  97. package/src/services/database/sqlite-plan-sql.ts +106 -0
  98. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  99. package/src/services/database/sqlite-select.test.ts +246 -0
  100. package/src/services/database/sqlite-select.ts +113 -0
  101. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  102. package/src/services/database/sqlite-transport.ts +219 -0
  103. package/src/services/database/sqlite-worker.ts +437 -0
  104. package/src/services/database/surql-translate.ts +291 -0
  105. package/src/services/database/surreal-cache-engine.ts +141 -0
  106. package/src/services/logger/index.ts +6 -109
  107. package/src/services/persistence/localstorage.ts +2 -2
  108. package/src/services/persistence/resilient.ts +11 -4
  109. package/src/services/persistence/surrealdb.ts +10 -10
  110. package/src/services/stream-processor/index.ts +444 -52
  111. package/src/services/stream-processor/permissions.test.ts +47 -0
  112. package/src/services/stream-processor/permissions.ts +53 -0
  113. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  114. package/src/services/stream-processor/stream-processor.reset.test.ts +216 -0
  115. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  116. package/src/services/stream-processor/wasm-types.ts +23 -2
  117. package/src/services/tabs/broker-client.ts +283 -0
  118. package/src/services/tabs/broker.test.ts +278 -0
  119. package/src/services/tabs/coordinator.test.ts +244 -0
  120. package/src/services/tabs/coordinator.ts +576 -0
  121. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  122. package/src/services/tabs/leader-locks.ts +75 -0
  123. package/src/services/tabs/protocol.ts +242 -0
  124. package/src/services/tabs/support.ts +36 -0
  125. package/src/services/tabs/tabs-broker-worker.ts +586 -0
  126. package/src/sp00ky.auth-order.test.ts +92 -0
  127. package/src/sp00ky.init-query.test.ts +183 -0
  128. package/src/sp00ky.ts +1424 -0
  129. package/src/types.ts +467 -15
  130. package/src/utils/error-classification.test.ts +44 -0
  131. package/src/utils/error-classification.ts +7 -0
  132. package/src/utils/index.ts +73 -13
  133. package/src/utils/parser.ts +3 -2
  134. package/src/utils/semver.test.ts +32 -0
  135. package/src/utils/semver.ts +30 -0
  136. package/src/utils/surql.ts +30 -18
  137. package/src/utils/withRetry.test.ts +1 -1
  138. package/tsdown.config.ts +86 -1
  139. package/src/spooky.ts +0 -395
@@ -1,20 +1,47 @@
1
+ import type {
2
+ Diagnostic,
3
+ SurrealEvents} from 'surrealdb';
1
4
  import {
2
5
  applyDiagnostics,
3
6
  createRemoteEngines,
4
- Diagnostic,
5
7
  Surreal,
6
- SurrealTransaction,
7
8
  } from 'surrealdb';
8
- import { SpookyConfig } from '../../types';
9
- import { Logger } from '../logger/index';
9
+ import type { ConnectionState, ReconnectConfig, Sp00kyConfig } from '../../types';
10
+ import type { Logger } from '../logger/index';
10
11
  import { AbstractDatabaseService } from './database';
11
12
  import { createDatabaseEventSystem, DatabaseEventTypes } from './events/index';
12
13
 
14
+ /** Defaults for {@link ReconnectConfig}. See that type for the rationale. */
15
+ export const RECONNECT_DEFAULTS = {
16
+ attempts: -1,
17
+ retryDelayMax: 15_000,
18
+ heartbeatIntervalMs: 20_000,
19
+ heartbeatTimeoutMs: 10_000,
20
+ superviseRetryDelayMaxMs: 15_000,
21
+ } as const;
22
+
23
+ /** Fill in {@link RECONNECT_DEFAULTS} for every field the caller omitted. */
24
+ export function resolveReconnectConfig(
25
+ input?: ReconnectConfig
26
+ ): Required<ReconnectConfig> {
27
+ return { ...RECONNECT_DEFAULTS, ...input };
28
+ }
29
+
30
+ /** Transport events the SDK publishes, mapped 1:1 to {@link ConnectionState}. */
31
+ export type RemoteConnectionEvent = ConnectionState | 'error';
32
+
13
33
  export class RemoteDatabaseService extends AbstractDatabaseService {
14
- private config: SpookyConfig<any>['database'];
34
+ private config: Sp00kyConfig<any>['database'];
15
35
  protected eventType = DatabaseEventTypes.RemoteQuery;
36
+ private readonly reconnectConfig: Required<ReconnectConfig>;
37
+ /**
38
+ * In-flight `connect()`, so concurrent callers (boot + supervisor revive +
39
+ * an `online` event landing at the same moment) share one attempt instead of
40
+ * racing two sockets. Cleared on settle, so a later call always reconnects.
41
+ */
42
+ private connecting: Promise<void> | null = null;
16
43
 
17
- constructor(config: SpookyConfig<any>['database'], logger: Logger) {
44
+ constructor(config: Sp00kyConfig<any>['database'], logger: Logger) {
18
45
  const events = createDatabaseEventSystem();
19
46
  super(
20
47
  new Surreal({
@@ -29,7 +56,7 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
29
56
  type,
30
57
  phase,
31
58
  service: 'surrealdb:remote',
32
- Category: 'spooky-client::RemoteDatabaseService::diagnostics',
59
+ Category: 'sp00ky-client::RemoteDatabaseService::diagnostics',
33
60
  },
34
61
  `Remote SurrealDB diagnostics captured ${type}:${phase}`
35
62
  );
@@ -41,13 +68,68 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
41
68
  events
42
69
  );
43
70
  this.config = config;
71
+ this.reconnectConfig = resolveReconnectConfig(config.reconnect);
72
+ this.queryTimeoutMs = Math.max(0, config.queryTimeoutMs ?? 60_000);
44
73
  }
45
74
 
46
- getConfig(): SpookyConfig<any>['database'] {
75
+ getConfig(): Sp00kyConfig<any>['database'] {
47
76
  return this.config;
48
77
  }
49
78
 
79
+ /** Resolved reconnect tunables; the supervisor reads its own knobs here. */
80
+ getReconnectConfig(): Required<ReconnectConfig> {
81
+ return this.reconnectConfig;
82
+ }
83
+
84
+ /** Current transport state as reported by the SDK. */
85
+ getStatus(): ConnectionState {
86
+ return this.client.status as ConnectionState;
87
+ }
88
+
89
+ /**
90
+ * Observe transport events. Thin passthrough so callers (the supervisor,
91
+ * sync, CRDT) don't have to reach through `getClient()`.
92
+ */
93
+ subscribeConnection<K extends RemoteConnectionEvent>(
94
+ event: K,
95
+ cb: (...payload: SurrealEvents[K]) => void
96
+ ): () => void {
97
+ return this.client.subscribe(event, cb);
98
+ }
99
+
100
+ /**
101
+ * Tear the socket down on purpose. Used by the heartbeat watchdog when a
102
+ * socket stops answering but never closes: `close()` makes the SDK publish
103
+ * `disconnected`, which is what drives the supervisor's revive loop.
104
+ */
105
+ async forceClose(): Promise<void> {
106
+ try {
107
+ await this.client.close();
108
+ } catch (err) {
109
+ this.logger.debug(
110
+ { err, Category: 'sp00ky-client::RemoteDatabaseService::forceClose' },
111
+ 'forceClose failed; treating the socket as gone anyway'
112
+ );
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Open (or re-open) the remote connection.
118
+ *
119
+ * Safe to call repeatedly: concurrent calls share the in-flight attempt, and
120
+ * a call after a `disconnected` builds a fresh socket. `use()` and
121
+ * `authenticate()` are re-applied here for the cold path; the SDK also
122
+ * replays them itself on its own internal reconnects.
123
+ */
50
124
  async connect(): Promise<void> {
125
+ if (this.connecting) return this.connecting;
126
+ this.connecting = this.doConnect().finally(() => {
127
+ this.connecting = null;
128
+ });
129
+ return this.connecting;
130
+ }
131
+
132
+ private async doConnect(): Promise<void> {
51
133
  const { endpoint, token, namespace, database } = this.getConfig();
52
134
  if (endpoint) {
53
135
  this.logger.info(
@@ -55,12 +137,26 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
55
137
  endpoint,
56
138
  namespace,
57
139
  database,
58
- Category: 'spooky-client::RemoteDatabaseService::connect',
140
+ Category: 'sp00ky-client::RemoteDatabaseService::connect',
59
141
  },
60
142
  'Connecting to remote database'
61
143
  );
62
144
  try {
63
- await this.client.connect(endpoint);
145
+ // Without explicit options the SDK caps itself at 5 attempts (~62s of
146
+ // outage) and then gives up permanently — nothing would re-open the
147
+ // socket after that. `attempts: -1` keeps it trying; the supervisor
148
+ // still covers the case where the SDK terminates the engine because its
149
+ // post-reconnect handshake threw.
150
+ await this.client.connect(endpoint, {
151
+ reconnect: {
152
+ enabled: true,
153
+ attempts: this.reconnectConfig.attempts,
154
+ retryDelay: 1_000,
155
+ retryDelayMax: this.reconnectConfig.retryDelayMax,
156
+ retryDelayMultiplier: 2,
157
+ retryDelayJitter: 0.1,
158
+ },
159
+ });
64
160
  await this.client.use({
65
161
  namespace,
66
162
  database,
@@ -68,25 +164,25 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
68
164
 
69
165
  if (token) {
70
166
  this.logger.debug(
71
- { Category: 'spooky-client::RemoteDatabaseService::connect' },
167
+ { Category: 'sp00ky-client::RemoteDatabaseService::connect' },
72
168
  'Authenticating with token'
73
169
  );
74
170
  await this.client.authenticate(token);
75
171
  }
76
172
  this.logger.info(
77
- { Category: 'spooky-client::RemoteDatabaseService::connect' },
173
+ { Category: 'sp00ky-client::RemoteDatabaseService::connect' },
78
174
  'Connected to remote database'
79
175
  );
80
176
  } catch (err) {
81
177
  this.logger.error(
82
- { err, Category: 'spooky-client::RemoteDatabaseService::connect' },
178
+ { err, Category: 'sp00ky-client::RemoteDatabaseService::connect' },
83
179
  'Failed to connect to remote database'
84
180
  );
85
181
  throw err;
86
182
  }
87
183
  } else {
88
184
  this.logger.warn(
89
- { Category: 'spooky-client::RemoteDatabaseService::connect' },
185
+ { Category: 'sp00ky-client::RemoteDatabaseService::connect' },
90
186
  'No endpoint configured for remote database'
91
187
  );
92
188
  }
@@ -0,0 +1,464 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { pureWriteOpResult, SqliteCacheEngine } from './sqlite-cache-engine';
4
+ import { stubTransport } from './sqlite-transport.fixture';
5
+ import { translateSurql } from './surql-translate';
6
+ import type { SqlOp } from './surql-translate';
7
+ import { surql } from '../../utils/surql';
8
+
9
+ function makeLogger(): any {
10
+ const noop = () => {};
11
+ const l: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
12
+ l.child = () => l;
13
+ return l;
14
+ }
15
+
16
+ /**
17
+ * A fake transport handler that models the real SQLite worker's open/closed
18
+ * lifecycle: `open` opens the DB, `close` closes it, and `exec`/`run` REJECT
19
+ * with "sqlite: DB not open" when the DB isn't currently open — exactly the
20
+ * failure the fix targets. Records every message `type` (across transport
21
+ * generations) so tests can also inspect dispatch order.
22
+ */
23
+ function lifecycleHandler(log: string[]) {
24
+ let dbOpen = false;
25
+ return (type: string) => {
26
+ log.push(type);
27
+ if (type === 'open') {
28
+ dbOpen = true;
29
+ return { persisted: true };
30
+ }
31
+ if (type === 'close') {
32
+ dbOpen = false;
33
+ return {};
34
+ }
35
+ if (type === 'exec' || type === 'select' || type === 'run' || type === 'batch') {
36
+ if (!dbOpen) throw new Error('sqlite: DB not open');
37
+ if (type === 'exec') return { rows: [] };
38
+ if (type === 'select') return { rows: [], relationFetches: 0 };
39
+ }
40
+ return {};
41
+ };
42
+ }
43
+
44
+ // Regression: switchBucket must run close → reopen as a single serialized
45
+ // opQueue entry. Otherwise a read/write enqueued during an auth/bucket change
46
+ // dispatches to the just-closed worker → "sqlite: DB not open" (the crash on
47
+ // sign-in). The invariant: no `exec` may appear between a `close` and the next
48
+ // `open` in the message stream.
49
+ describe('SqliteCacheEngine.switchBucket serialization', () => {
50
+ it('never dispatches an op to a closed DB during a bucket switch', async () => {
51
+ const log: string[] = [];
52
+ const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
53
+ stubTransport(engine, lifecycleHandler(log));
54
+
55
+ await engine.connect('anon');
56
+ expect(log).toContain('open');
57
+
58
+ // Fire a switch and a concurrent read (exactly what the sign-in query
59
+ // re-registration does). With the old non-atomic switch the read's `exec`
60
+ // dispatched to the closed/terminated worker and REJECTED ("sqlite: DB not
61
+ // open" / "not connected") — the uncaught crash. The atomic switch makes the
62
+ // read wait for the reopen and resolve against the new bucket.
63
+ const switching = engine.switchBucket('user:abc');
64
+ const reading = engine.getById('_00_query', 'h1');
65
+ await expect(Promise.all([switching, reading])).resolves.toBeDefined();
66
+ await expect(reading).resolves.toBeNull(); // missing row → null, not a throw
67
+
68
+ // And the close→reopen ran with no op wedged between them.
69
+ const closeIdx = log.indexOf('close');
70
+ const openAfter = log.indexOf('open', closeIdx + 1);
71
+ expect(openAfter).toBeGreaterThan(closeIdx);
72
+ expect(log.slice(closeIdx + 1, openAfter)).not.toContain('exec');
73
+ expect(engine.currentBucketId).toBe('user:abc');
74
+ });
75
+ });
76
+
77
+ // Regression: DEFINE is a noop on this engine, so the `_00_*` internal tables
78
+ // the migrator DEFINEs are never physically created — a fresh bucket that READS
79
+ // one before any write (the sync layer selects `_00_query` at startup) threw
80
+ // "no such table: _00_query" and wedged the client on "Loading database". The
81
+ // fix seeds them inside `open`; assert the open message carries them.
82
+ describe('SqliteCacheEngine system-table seeding', () => {
83
+ it('passes the _00_* system tables to the worker open (fresh-bucket safe)', async () => {
84
+ const msgs: any[] = [];
85
+ const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
86
+ stubTransport(engine, (type, payload) => {
87
+ msgs.push({ type, payload });
88
+ return type === 'open' ? { persisted: true } : {};
89
+ });
90
+
91
+ await engine.connect('user:fresh');
92
+ const open = msgs.find((m) => m.type === 'open');
93
+ expect(open?.payload?.systemTables).toContain('_00_query');
94
+ expect(open?.payload?.systemTables).toContain('_00_pending_mutations');
95
+ });
96
+ });
97
+
98
+ // The worker's `persisted`/`opfsError` reply used to die in a `logger.info`
99
+ // line, so a host app running pino at `fatal` (whitepawn does) could not tell a
100
+ // disk-backed store from a full-RAM one. It now lands on the engine as
101
+ // observable state the app can render.
102
+ describe('SqliteCacheEngine storage health', () => {
103
+ /** Engine wired to a worker whose `open` replies with `openReply`. */
104
+ function makeEngine(openReply: Record<string, unknown>, opts?: { useOpfs?: boolean }) {
105
+ const logs: { level: string; msg: string; meta: any }[] = [];
106
+ const logger: any = {};
107
+ for (const level of ['debug', 'info', 'warn', 'error', 'trace']) {
108
+ logger[level] = (meta: any, msg: string) => logs.push({ level, msg, meta });
109
+ }
110
+ logger.child = () => logger;
111
+
112
+ const engine = new SqliteCacheEngine(
113
+ { namespace: 'n', database: 'd' } as any,
114
+ logger,
115
+ opts ?? {}
116
+ );
117
+ stubTransport(engine, (type) => (type === 'open' ? openReply : {}));
118
+ return { engine, logs };
119
+ }
120
+
121
+ it('publishes a persistent store and logs no error', async () => {
122
+ const { engine, logs } = makeEngine({ persisted: true });
123
+ await engine.connect('user:abc');
124
+
125
+ expect(engine.storageHealth).toEqual({
126
+ status: 'persistent',
127
+ fallback: false,
128
+ error: undefined,
129
+ });
130
+ expect(logs.some((l) => l.level === 'error')).toBe(false);
131
+ });
132
+
133
+ it('publishes the fallback, its reason, and an error log when OPFS is lost', async () => {
134
+ const { engine, logs } = makeEngine({
135
+ persisted: false,
136
+ opfsError: 'NoModificationAllowedError: locked',
137
+ });
138
+ await engine.connect('user:abc');
139
+
140
+ expect(engine.storageHealth).toEqual({
141
+ status: 'memory',
142
+ fallback: true,
143
+ error: 'NoModificationAllowedError: locked',
144
+ });
145
+ const err = logs.find((l) => l.level === 'error');
146
+ expect(err?.msg).toContain('IN MEMORY');
147
+ expect(err?.meta.opfsError).toBe('NoModificationAllowedError: locked');
148
+ // Inspectable from the console without any logging configured.
149
+ expect((globalThis as any).__sqliteStats.persisted).toBe(false);
150
+ });
151
+
152
+ // A subscriber almost always attaches AFTER connect() (components mount
153
+ // later), so an immediate fire is the only way it learns about a fallback.
154
+ it('fires a late subscriber with the current snapshot', async () => {
155
+ const { engine } = makeEngine({ persisted: false, opfsError: 'boom' });
156
+ await engine.connect('user:abc');
157
+
158
+ const seen: any[] = [];
159
+ const unsub = engine.subscribeToStorageHealth((h) => seen.push(h));
160
+ expect(seen).toEqual([{ status: 'memory', fallback: true, error: 'boom' }]);
161
+ unsub();
162
+ });
163
+
164
+ // `store: 'memory'` asked for RAM, so it is not a fallback and must not warn.
165
+ it('does not flag a configured in-memory store as a fallback', async () => {
166
+ const { engine, logs } = makeEngine({ persisted: false }, { useOpfs: false });
167
+ await engine.connect('user:abc');
168
+
169
+ expect(engine.storageHealth).toEqual({ status: 'memory', fallback: false, error: undefined });
170
+ expect(logs.some((l) => l.level === 'error')).toBe(false);
171
+ });
172
+ });
173
+
174
+ // Storage numbers for the DevTools Storage tab: DB size via the pragmas, row
175
+ // counts on demand, and the configured-vs-effective workerSelect split. Errors
176
+ // must land in `error` (the worker may be mid bucket-switch), never throw.
177
+ describe('SqliteCacheEngine.getStorageDiagnostics', () => {
178
+ function makeEngine(execRows: (sql: string) => unknown[]) {
179
+ const noop = () => {};
180
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
181
+ logger.child = () => logger;
182
+ const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, logger);
183
+ stubTransport(engine, (type, payload) =>
184
+ type === 'open' ? { persisted: true } : type === 'exec' ? { rows: execRows(payload.sql) } : {}
185
+ );
186
+ return engine;
187
+ }
188
+
189
+ it('reports size, freelist, and per-table counts', async () => {
190
+ const engine = makeEngine((sql) => {
191
+ if (sql.includes('pragma_page_count')) return [{ bytes: 40960, freelist: 4096 }];
192
+ if (sql.includes('sqlite_master')) return [{ name: '_00_query' }, { name: 'game' }];
193
+ if (sql.includes('COUNT(*)'))
194
+ return [
195
+ { t: '_00_query', n: 3 },
196
+ { t: 'game', n: 12 },
197
+ ];
198
+ return [];
199
+ });
200
+ await engine.connect('user:abc');
201
+
202
+ const diag = await engine.getStorageDiagnostics({ tableCounts: true });
203
+ expect(diag.engine).toBe('sqlite');
204
+ expect(diag.bucketId).toBe('user:abc');
205
+ expect(diag.dbSizeBytes).toBe(40960);
206
+ expect(diag.freelistBytes).toBe(4096);
207
+ expect(diag.tableCounts).toEqual([
208
+ { table: '_00_query', rows: 3 },
209
+ { table: 'game', rows: 12 },
210
+ ]);
211
+ // Default config: workerSelect on, never downgraded.
212
+ expect(diag.workerSelectConfigured).toBe(true);
213
+ expect(diag.workerSelectEffective).toBe(true);
214
+ });
215
+
216
+ it('skips table counts unless asked and never throws on a dead worker', async () => {
217
+ const engine = makeEngine(() => [{ bytes: 8192, freelist: 0 }]);
218
+ await engine.connect('anon');
219
+
220
+ const diag = await engine.getStorageDiagnostics();
221
+ expect(diag.tableCounts).toBeUndefined();
222
+
223
+ // No worker at all → the failure lands in `error`, not as a throw.
224
+ const cold = makeEngine(() => []);
225
+ const coldDiag = await cold.getStorageDiagnostics();
226
+ expect(coldDiag.error).toContain('not connected');
227
+ expect(coldDiag.bucketId).toBe('anon');
228
+ });
229
+ });
230
+
231
+ // Shared-tabs engine role modes: the engine object survives every role change
232
+ // (one monotonic epoch per tab), followers speak the same protocol over a
233
+ // MessagePort, and the leaderless window parks ops instead of failing the UI.
234
+ describe('SqliteCacheEngine role modes', () => {
235
+ function makeSharedEngine() {
236
+ const log: string[] = [];
237
+ const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger(), {
238
+ shared: true,
239
+ useOpfs: true,
240
+ });
241
+ stubTransport(engine, lifecycleHandler(log));
242
+ return { engine, log };
243
+ }
244
+
245
+ /** A fake follower dbPort: replies like the worker over postMessage. */
246
+ function fakeLeaderPort() {
247
+ const port: any = {
248
+ onmessage: null as null | ((ev: { data: any }) => void),
249
+ onmessageerror: null,
250
+ started: false,
251
+ closed: false,
252
+ start() {
253
+ this.started = true;
254
+ },
255
+ close() {
256
+ this.closed = true;
257
+ },
258
+ postMessage(msg: any) {
259
+ queueMicrotask(() => {
260
+ const rest =
261
+ msg.type === 'exec'
262
+ ? { rows: [] }
263
+ : msg.type === 'select'
264
+ ? { rows: [], relationFetches: 0 }
265
+ : {};
266
+ port.onmessage?.({ data: { id: msg.id, ok: true, wt: 0, ...rest } });
267
+ });
268
+ },
269
+ };
270
+ return port;
271
+ }
272
+
273
+ it('adoptOwner opens under the worker lock and wipes stale _00_query rows', async () => {
274
+ const { engine, log } = makeSharedEngine();
275
+ const health = await engine.adoptOwner('anon', {
276
+ workerLockName: 'sp00ky-tabs:fp:anon:worker:1',
277
+ allowMemoryFallback: false,
278
+ resumeHeld: false,
279
+ });
280
+ expect(health.status).toBe('persistent');
281
+ expect(engine.storageHealth.role).toBe('leader');
282
+ expect(log[0]).toBe('open');
283
+ expect(log).toContain('run'); // the DELETE _00_query wipe
284
+ expect(engine.currentBucketId).toBe('anon');
285
+ });
286
+
287
+ it('adoptAttached serves reads over the leader port and reports follower role', async () => {
288
+ const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger(), {
289
+ shared: true,
290
+ });
291
+ const port = fakeLeaderPort();
292
+ await engine.adoptAttached(
293
+ port,
294
+ { bucketId: 'anon', storageHealth: { status: 'persistent', fallback: false } },
295
+ () => {}
296
+ );
297
+ expect(port.started).toBe(true);
298
+ expect(engine.storageHealth).toMatchObject({ status: 'persistent', role: 'follower' });
299
+ await expect(engine.getById('game', 'game:1')).resolves.toBeNull();
300
+ expect((globalThis as any).__sqliteStats.proxiedOps).toBeGreaterThan(0);
301
+ });
302
+
303
+ it('parks ops through a leader loss and releases them on promotion', async () => {
304
+ const { engine } = makeSharedEngine();
305
+ const port = fakeLeaderPort();
306
+ await engine.adoptAttached(
307
+ port,
308
+ { bucketId: 'anon', storageHealth: { status: 'persistent', fallback: false } },
309
+ () => {}
310
+ );
311
+ const epochBefore = engine.epoch;
312
+ engine.onLeaderLost('leader tab closed');
313
+ expect(engine.epoch).toBe(epochBefore + 1);
314
+
315
+ // Issued during the leaderless window: must not reject immediately.
316
+ const read = engine.getById('_00_query', 'h1');
317
+ let settled = false;
318
+ void read.finally(() => {
319
+ settled = true;
320
+ });
321
+ await new Promise((r) => setTimeout(r, 10));
322
+ expect(settled).toBe(false);
323
+
324
+ await engine.adoptOwner('anon', {
325
+ workerLockName: 'sp00ky-tabs:fp:anon:worker:2',
326
+ allowMemoryFallback: false,
327
+ resumeHeld: false,
328
+ });
329
+ await expect(read).resolves.toBeNull();
330
+ expect(engine.storageHealth.role).toBe('leader');
331
+ });
332
+
333
+ it('bumps the epoch on promotion after having had a store (fences in-flight chains)', async () => {
334
+ const { engine } = makeSharedEngine();
335
+ await engine.adoptOwner('anon', {
336
+ workerLockName: 'sp00ky-tabs:fp:anon:worker:1',
337
+ allowMemoryFallback: false,
338
+ resumeHeld: false,
339
+ });
340
+ const before = engine.epoch;
341
+ await engine.adoptOwner('anon', {
342
+ workerLockName: 'sp00ky-tabs:fp:anon:worker:3',
343
+ allowMemoryFallback: false,
344
+ resumeHeld: false,
345
+ });
346
+ expect(engine.epoch).toBeGreaterThan(before);
347
+ });
348
+ });
349
+
350
+ // `pureWriteOpResult` is the single source of truth for what a pure-write op
351
+ // contributes to a query's per-statement results. The batched fast path in
352
+ // `query()` and the per-op `execOp` path BOTH route through it, so a caller that
353
+ // reads a statement's output (e.g. `create()` reads `resultIndex:0` for the new
354
+ // row + its id) sees the same shape either way.
355
+ describe('pureWriteOpResult', () => {
356
+ it('echoes the written row (with id) for an upsert — no read-back', () => {
357
+ const op: SqlOp = {
358
+ kind: 'upsert',
359
+ id: 'connection:CONN_abc',
360
+ data: { provider: 'chesscom', username: 'hikaru' },
361
+ mode: 'replace',
362
+ };
363
+ expect(pureWriteOpResult(op)).toEqual({
364
+ provider: 'chesscom',
365
+ username: 'hikaru',
366
+ id: 'connection:CONN_abc',
367
+ });
368
+ });
369
+
370
+ it('stringifies a RecordId id via stableKey', () => {
371
+ const op: SqlOp = {
372
+ kind: 'upsert',
373
+ id: new RecordId('connection', 'CONN_abc'),
374
+ data: { provider: 'lichess' },
375
+ mode: 'replace',
376
+ };
377
+ expect(pureWriteOpResult(op)).toEqual({ provider: 'lichess', id: 'connection:CONN_abc' });
378
+ });
379
+
380
+ it('yields [] for delete / deleteAll and null for noop', () => {
381
+ expect(pureWriteOpResult({ kind: 'delete', id: 'game:1' })).toEqual([]);
382
+ expect(pureWriteOpResult({ kind: 'deleteAll', table: 'game' })).toEqual([]);
383
+ expect(pureWriteOpResult({ kind: 'noop' })).toBeNull();
384
+ });
385
+ });
386
+
387
+ // Regression: a single `create()` compiles to an all-upsert transaction
388
+ // (createSet for the row + createMutation for the pending-mutation log) and
389
+ // extracts `resultIndex:0` for the created row. The SQLite fast path must return
390
+ // that row (with its id) at that index — returning `[]` there dropped the id and
391
+ // crashed the reconcile in `encodeRecordId` ("reading 'table'").
392
+ describe('create() tx result shaping (fast path parity)', () => {
393
+ it('resultIndex:0 carries the created row with its id', () => {
394
+ const rid = new RecordId('connection', 'CONN_abc');
395
+ const mid = new RecordId('_00_pending_mutations', '1');
396
+ const vars = {
397
+ id: rid,
398
+ mid,
399
+ data_provider: 'chesscom',
400
+ data_username: 'hikaru',
401
+ };
402
+
403
+ // Same statement pair DataModule.create emits.
404
+ const sealed = surql.seal(
405
+ surql.tx([
406
+ surql.createSet('id', [
407
+ { key: 'provider', variable: 'data_provider' },
408
+ { key: 'username', variable: 'data_username' },
409
+ ]),
410
+ surql.createMutation('create', 'mid', 'id', 'data'),
411
+ ]),
412
+ { resultIndex: 0 }
413
+ );
414
+
415
+ const { transaction, ops } = translateSurql(sealed.sql, vars);
416
+ expect(transaction).toBe(true);
417
+ // Both statements are upserts → the engine takes the all-write fast path.
418
+ expect(ops.every((o) => o.kind === 'upsert')).toBe(true);
419
+
420
+ // Fast-path shaping: [null (BEGIN), ...one result per statement].
421
+ const shaped = [null, ...ops.map(pureWriteOpResult)];
422
+ const created = sealed.extract(shaped) as unknown as { id: unknown; provider: string };
423
+
424
+ expect(created.id).toBe('connection:CONN_abc');
425
+ expect(created.provider).toBe('chesscom');
426
+ });
427
+
428
+ // The outbox row is the ONLY copy of a pending create once the in-memory
429
+ // UpEvent is gone (reload, or a shared-tabs follower whose row the leader
430
+ // replays). It must carry the payload: `processUpEvent` does
431
+ // `Object.keys(event.data)`, so a row written with `data` unbound produces a
432
+ // create that can never be sent AND blocks every later mutation behind it.
433
+ //
434
+ // Note the vars: the RECORD is written from per-key `data_<field>` vars, so
435
+ // it is easy to emit `data = $data` and never bind `data` at the call site.
436
+ // That is exactly the bug this asserts against.
437
+ it('the outbox row carries the whole payload, not just the record id', () => {
438
+ const payload = { provider: 'chesscom', username: 'hikaru' };
439
+ const vars = {
440
+ id: new RecordId('connection', 'CONN_abc'),
441
+ mid: new RecordId('_00_pending_mutations', '1'),
442
+ data: payload,
443
+ data_provider: 'chesscom',
444
+ data_username: 'hikaru',
445
+ };
446
+
447
+ const sealed = surql.seal(
448
+ surql.tx([
449
+ surql.createSet('id', [
450
+ { key: 'provider', variable: 'data_provider' },
451
+ { key: 'username', variable: 'data_username' },
452
+ ]),
453
+ surql.createMutation('create', 'mid', 'id', 'data'),
454
+ ]),
455
+ { resultIndex: 0 }
456
+ );
457
+
458
+ const { ops } = translateSurql(sealed.sql, vars);
459
+ const outboxRow = pureWriteOpResult(ops[1]) as Record<string, unknown>;
460
+
461
+ expect(outboxRow.mutationType).toBe('create');
462
+ expect(outboxRow.data).toEqual(payload);
463
+ });
464
+ });