@spooky-sync/core 0.0.1-canary.20 → 0.0.1-canary.201

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 (148) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2184 -54
  3. package/dist/index.js +11515 -2399
  4. package/dist/otel/index.d.ts +2 -2
  5. package/dist/otel/index.js +6 -6
  6. package/dist/sqlite-open.js +276 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +421 -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 +688 -11
  12. package/package.json +11 -7
  13. package/scripts/check-broker-bundle.mjs +33 -0
  14. package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
  15. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  16. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  17. package/src/bucket-blurhash.test.ts +148 -0
  18. package/src/build-globals.d.ts +12 -0
  19. package/src/events/events.test.ts +2 -1
  20. package/src/events/index.ts +3 -0
  21. package/src/index.ts +35 -2
  22. package/src/modules/app-release/index.test.ts +125 -0
  23. package/src/modules/app-release/index.ts +201 -0
  24. package/src/modules/auth/events/index.ts +2 -1
  25. package/src/modules/auth/index.ts +59 -20
  26. package/src/modules/cache/index.ts +112 -32
  27. package/src/modules/cache/types.ts +2 -2
  28. package/src/modules/crdt/crdt-field.ts +294 -0
  29. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  30. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  31. package/src/modules/crdt/index.ts +463 -0
  32. package/src/modules/crdt/loro-loader.ts +25 -0
  33. package/src/modules/data/data.hydration.test.ts +142 -0
  34. package/src/modules/data/data.membership.test.ts +462 -0
  35. package/src/modules/data/data.rebind.test.ts +147 -0
  36. package/src/modules/data/data.run.test.ts +113 -0
  37. package/src/modules/data/data.settled-writes.test.ts +206 -0
  38. package/src/modules/data/data.status.test.ts +249 -0
  39. package/src/modules/data/id-set-plan.test.ts +122 -0
  40. package/src/modules/data/index.ts +1580 -130
  41. package/src/modules/data/mutation-id.test.ts +25 -0
  42. package/src/modules/data/mutation-id.ts +35 -0
  43. package/src/modules/data/window-query.test.ts +52 -0
  44. package/src/modules/data/window-query.ts +194 -0
  45. package/src/modules/devtools/flags.ts +349 -0
  46. package/src/modules/devtools/index.ts +386 -37
  47. package/src/modules/devtools/notify-throttle.test.ts +149 -0
  48. package/src/modules/devtools/storage-info.test.ts +79 -0
  49. package/src/modules/devtools/storage-info.ts +168 -0
  50. package/src/modules/devtools/versions.test.ts +74 -0
  51. package/src/modules/devtools/versions.ts +110 -0
  52. package/src/modules/feature-flag/index.test.ts +251 -0
  53. package/src/modules/feature-flag/index.ts +308 -0
  54. package/src/modules/ref-tables.test.ts +91 -0
  55. package/src/modules/ref-tables.ts +88 -0
  56. package/src/modules/sync/engine.ts +101 -37
  57. package/src/modules/sync/events/index.ts +9 -2
  58. package/src/modules/sync/queue/queue-down.test.ts +107 -0
  59. package/src/modules/sync/queue/queue-down.ts +35 -6
  60. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  61. package/src/modules/sync/queue/queue-up.ts +241 -57
  62. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  63. package/src/modules/sync/scheduler.retry.test.ts +156 -0
  64. package/src/modules/sync/scheduler.ts +158 -11
  65. package/src/modules/sync/sync.cleanup.test.ts +116 -0
  66. package/src/modules/sync/sync.health.test.ts +149 -0
  67. package/src/modules/sync/sync.heartbeat.test.ts +80 -0
  68. package/src/modules/sync/sync.live-removal.test.ts +134 -0
  69. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  70. package/src/modules/sync/sync.subquery.test.ts +82 -0
  71. package/src/modules/sync/sync.ts +1558 -99
  72. package/src/modules/sync/utils.test.ts +269 -2
  73. package/src/modules/sync/utils.ts +201 -17
  74. package/src/otel/index.ts +13 -10
  75. package/src/services/blobs/blob-cache.test.ts +359 -0
  76. package/src/services/blobs/blob-cache.ts +603 -0
  77. package/src/services/blobs/blob-manifest.ts +227 -0
  78. package/src/services/blobs/blob-store.test.ts +77 -0
  79. package/src/services/blobs/blob-store.ts +359 -0
  80. package/src/services/blobs/blob.fixture.ts +90 -0
  81. package/src/services/blobs/index.ts +70 -0
  82. package/src/services/database/cache-engine.ts +160 -0
  83. package/src/services/database/connection-supervisor.test.ts +289 -0
  84. package/src/services/database/connection-supervisor.ts +415 -0
  85. package/src/services/database/database.query-timeout.test.ts +83 -0
  86. package/src/services/database/database.ts +32 -12
  87. package/src/services/database/engine-factory.ts +33 -0
  88. package/src/services/database/events/index.ts +2 -1
  89. package/src/services/database/index.ts +7 -0
  90. package/src/services/database/local-migrator.ts +30 -27
  91. package/src/services/database/local.test.ts +64 -0
  92. package/src/services/database/local.ts +478 -67
  93. package/src/services/database/plan-render.test.ts +159 -0
  94. package/src/services/database/plan-render.ts +108 -0
  95. package/src/services/database/relation-resolver.test.ts +413 -0
  96. package/src/services/database/relation-resolver.ts +0 -0
  97. package/src/services/database/remote.ts +110 -14
  98. package/src/services/database/sqlite-cache-engine.test.ts +558 -0
  99. package/src/services/database/sqlite-cache-engine.ts +1257 -0
  100. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  101. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  102. package/src/services/database/sqlite-open.test.ts +150 -0
  103. package/src/services/database/sqlite-open.ts +164 -0
  104. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  105. package/src/services/database/sqlite-plan-sql.ts +106 -0
  106. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  107. package/src/services/database/sqlite-select.test.ts +246 -0
  108. package/src/services/database/sqlite-select.ts +121 -0
  109. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  110. package/src/services/database/sqlite-transport.ts +221 -0
  111. package/src/services/database/sqlite-worker.ts +437 -0
  112. package/src/services/database/surql-translate.ts +416 -0
  113. package/src/services/database/surreal-cache-engine.ts +141 -0
  114. package/src/services/logger/index.ts +3 -2
  115. package/src/services/persistence/localstorage.ts +2 -2
  116. package/src/services/persistence/resilient.ts +11 -4
  117. package/src/services/persistence/surrealdb.ts +10 -10
  118. package/src/services/stream-processor/index.ts +444 -52
  119. package/src/services/stream-processor/permissions.test.ts +47 -0
  120. package/src/services/stream-processor/permissions.ts +53 -0
  121. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  122. package/src/services/stream-processor/stream-processor.reset.test.ts +216 -0
  123. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  124. package/src/services/stream-processor/wasm-types.ts +23 -2
  125. package/src/services/tabs/broker-client.ts +283 -0
  126. package/src/services/tabs/broker.test.ts +278 -0
  127. package/src/services/tabs/coordinator.test.ts +244 -0
  128. package/src/services/tabs/coordinator.ts +576 -0
  129. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  130. package/src/services/tabs/leader-locks.ts +75 -0
  131. package/src/services/tabs/protocol.ts +242 -0
  132. package/src/services/tabs/support.ts +36 -0
  133. package/src/services/tabs/tabs-broker-worker.ts +586 -0
  134. package/src/sp00ky.auth-order.test.ts +92 -0
  135. package/src/sp00ky.init-query.test.ts +183 -0
  136. package/src/sp00ky.ts +1543 -0
  137. package/src/types.ts +496 -13
  138. package/src/utils/blurhash.ts +90 -0
  139. package/src/utils/error-classification.test.ts +44 -0
  140. package/src/utils/error-classification.ts +7 -0
  141. package/src/utils/index.ts +73 -13
  142. package/src/utils/parser.ts +3 -2
  143. package/src/utils/semver.test.ts +32 -0
  144. package/src/utils/semver.ts +30 -0
  145. package/src/utils/surql.ts +30 -18
  146. package/src/utils/withRetry.test.ts +1 -1
  147. package/tsdown.config.ts +86 -1
  148. package/src/spooky.ts +0 -395
@@ -0,0 +1,201 @@
1
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
2
+ import type { DataModule } from '../data/index';
3
+ import type { Sp00kySync } from '../sync/index';
4
+ import type { AuthService } from '../auth/index';
5
+ import type { Logger } from '../../services/logger/index';
6
+ import type { QueryTimeToLive } from '../../types';
7
+ import { semverGt } from '../../utils/semver';
8
+
9
+ // One shared LIVE query over every app's release row. `_00_app_release` is
10
+ // world-readable (root-only writes), one row per app keyed by name — written
11
+ // by `spky deploy` / `spky release` / the git-linked builder. A single
12
+ // registration observes every app at once; a handle for an app with no row
13
+ // simply reports no update. Mirrors the FeatureFlagModule design.
14
+ const RELEASE_QUERY = 'SELECT * FROM _00_app_release';
15
+
16
+ interface ReleaseRow {
17
+ app?: string;
18
+ version?: string;
19
+ cache_bust?: boolean | null;
20
+ mandatory?: boolean | null;
21
+ released_at?: string;
22
+ }
23
+
24
+ export interface AppReleaseSnapshot {
25
+ /** Latest announced version for the app, or undefined when no row exists. */
26
+ version: string | undefined;
27
+ /** Clients should clear SW/caches when reloading onto this version. */
28
+ cacheBust: boolean;
29
+ /** Clients should reload/update immediately instead of asking. */
30
+ mandatory: boolean;
31
+ releasedAt: string | undefined;
32
+ }
33
+
34
+ const EMPTY_SNAPSHOT: AppReleaseSnapshot = {
35
+ version: undefined,
36
+ cacheBust: false,
37
+ mandatory: false,
38
+ releasedAt: undefined,
39
+ };
40
+
41
+ export interface AppReleaseOptions {
42
+ ttl?: QueryTimeToLive;
43
+ }
44
+
45
+ export class AppReleaseHandle {
46
+ private latest: AppReleaseSnapshot = EMPTY_SNAPSHOT;
47
+ private listeners = new Set<(s: AppReleaseSnapshot) => void>();
48
+ private onCloseFn: (() => void) | null = null;
49
+ private closed = false;
50
+
51
+ constructor(public readonly app: string) {}
52
+
53
+ set(snapshot: AppReleaseSnapshot): void {
54
+ if (this.closed) return;
55
+ this.latest = snapshot;
56
+ for (const cb of this.listeners) cb(snapshot);
57
+ }
58
+
59
+ snapshot(): AppReleaseSnapshot {
60
+ return this.latest;
61
+ }
62
+
63
+ version(): string | undefined {
64
+ return this.latest.version;
65
+ }
66
+
67
+ /** True when the announced version is semver-newer than `currentVersion`. */
68
+ updateAvailable(currentVersion: string): boolean {
69
+ return semverGt(this.latest.version, currentVersion);
70
+ }
71
+
72
+ subscribe(cb: (s: AppReleaseSnapshot) => void): () => void {
73
+ this.listeners.add(cb);
74
+ cb(this.latest);
75
+ return () => {
76
+ this.listeners.delete(cb);
77
+ };
78
+ }
79
+
80
+ onClose(cb: () => void): void {
81
+ this.onCloseFn = cb;
82
+ }
83
+
84
+ close(): void {
85
+ if (this.closed) return;
86
+ this.closed = true;
87
+ this.listeners.clear();
88
+ this.onCloseFn?.();
89
+ }
90
+ }
91
+
92
+ export interface AppReleaseModuleDeps<S extends SchemaStructure> {
93
+ dataModule: DataModule<S>;
94
+ sync: Sp00kySync<S>;
95
+ auth: AuthService<S>;
96
+ logger: Logger;
97
+ }
98
+
99
+ export class AppReleaseModule<S extends SchemaStructure> {
100
+ private logger: Logger;
101
+ private handles = new Set<AppReleaseHandle>();
102
+ private authUnsubscribe: (() => void) | null = null;
103
+ private lastUserId: string | null = null;
104
+
105
+ private querySubscription: (() => void) | null = null;
106
+ private starting = false;
107
+ private ttl: QueryTimeToLive = '10m';
108
+ private snapshots = new Map<string, AppReleaseSnapshot>();
109
+ private loaded = false;
110
+
111
+ constructor(private deps: AppReleaseModuleDeps<S>) {
112
+ this.logger = deps.logger.child({ service: 'AppReleaseModule' });
113
+ }
114
+
115
+ init(): void {
116
+ if (this.authUnsubscribe) return;
117
+ // Auth changes re-register the shared query (a new session invalidates the
118
+ // old SSP plan). The table itself is world-readable, so the data is the
119
+ // same for every user — this is purely plumbing hygiene.
120
+ this.authUnsubscribe = this.deps.auth.subscribe((userId) => {
121
+ if (userId === this.lastUserId) return;
122
+ this.lastUserId = userId;
123
+ void this.refresh();
124
+ });
125
+ }
126
+
127
+ release(app: string, options: AppReleaseOptions = {}): AppReleaseHandle {
128
+ const handle = new AppReleaseHandle(app);
129
+ this.handles.add(handle);
130
+ handle.onClose(() => this.handles.delete(handle));
131
+ if (options.ttl) this.ttl = options.ttl;
132
+ if (this.loaded) {
133
+ handle.set(this.snapshots.get(app) ?? EMPTY_SNAPSHOT);
134
+ }
135
+ void this.ensureStarted();
136
+ return handle;
137
+ }
138
+
139
+ async closeAll(): Promise<void> {
140
+ this.authUnsubscribe?.();
141
+ this.authUnsubscribe = null;
142
+ this.teardownQuery();
143
+ for (const handle of [...this.handles]) handle.close();
144
+ }
145
+
146
+ private async refresh(): Promise<void> {
147
+ this.teardownQuery();
148
+ this.loaded = false;
149
+ this.snapshots.clear();
150
+ await this.ensureStarted();
151
+ }
152
+
153
+ private teardownQuery(): void {
154
+ this.querySubscription?.();
155
+ this.querySubscription = null;
156
+ }
157
+
158
+ private async ensureStarted(): Promise<void> {
159
+ if (this.querySubscription || this.starting || this.handles.size === 0) return;
160
+ this.starting = true;
161
+ try {
162
+ const hash = await this.deps.dataModule.query(
163
+ '_00_app_release' as any,
164
+ RELEASE_QUERY,
165
+ {},
166
+ this.ttl,
167
+ );
168
+ this.deps.sync.enqueueDownEvent({ type: 'register', payload: { hash } });
169
+ this.querySubscription = this.deps.dataModule.subscribe(
170
+ hash,
171
+ (records) => this.applyRecords(records as ReleaseRow[]),
172
+ { immediate: true },
173
+ );
174
+ } catch (err) {
175
+ this.logger.warn(
176
+ { err, Category: 'sp00ky-client::AppReleaseModule::register' },
177
+ 'Failed to register app release query',
178
+ );
179
+ } finally {
180
+ this.starting = false;
181
+ }
182
+ }
183
+
184
+ private applyRecords(records: ReleaseRow[]): void {
185
+ this.snapshots.clear();
186
+ for (const row of records ?? []) {
187
+ if (row && typeof row.app === 'string' && typeof row.version === 'string') {
188
+ this.snapshots.set(row.app, {
189
+ version: row.version,
190
+ cacheBust: row.cache_bust === true,
191
+ mandatory: row.mandatory === true,
192
+ releasedAt: row.released_at,
193
+ });
194
+ }
195
+ }
196
+ this.loaded = true;
197
+ for (const handle of this.handles) {
198
+ handle.set(this.snapshots.get(handle.app) ?? EMPTY_SNAPSHOT);
199
+ }
200
+ }
201
+ }
@@ -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 AuthEventTypes = {
4
5
  AuthStateChanged: 'AUTH_STATE_CHANGED',
@@ -1,17 +1,14 @@
1
- import { RemoteDatabaseService } from '../../services/database/remote';
2
- import { LocalDatabaseService } from '../../services/database/local';
3
- import { DataModule } from '../data/index';
4
- import {
1
+ import type { RemoteDatabaseService } from '../../services/database/remote';
2
+ import type {
5
3
  SchemaStructure,
6
4
  AccessDefinition,
7
5
  ColumnSchema,
8
6
  TypeNameToTypeMap,
9
7
  } from '@spooky-sync/query-builder';
10
- import { Logger } from '../../services/logger/index';
11
- import { encodeRecordId } from '../../utils/index';
8
+ import type { Logger } from '../../services/logger/index';
12
9
  export * from './events/index';
13
10
  import { AuthEventTypes, createAuthEventSystem } from './events/index';
14
- import { PersistenceClient } from '../../types';
11
+ import type { PersistenceClient } from '../../types';
15
12
 
16
13
  // Helper to pretty print types
17
14
  type Prettify<T> = {
@@ -38,11 +35,41 @@ type ExtractAccessParams<
38
35
  }>
39
36
  : never;
40
37
 
38
+ /**
39
+ * Read the `AC` (access-method name) claim from a SurrealDB record-access
40
+ * JWT without verifying it — we only need the claim, the server enforces the
41
+ * token. Returns null on any malformed input. The in-browser SSP needs this
42
+ * to resolve `$access` in table permission predicates (mirrors the session's
43
+ * `$access` that the server's `fn::query::register` reads).
44
+ */
45
+ function decodeAccessFromToken(token: string): string | null {
46
+ try {
47
+ const payload = token.split('.')[1];
48
+ if (!payload) return null;
49
+ let b64 = payload.replace(/-/g, '+').replace(/_/g, '/');
50
+ b64 += '='.repeat((4 - (b64.length % 4)) % 4);
51
+ const json =
52
+ typeof atob === 'function' ? atob(b64) : Buffer.from(b64, 'base64').toString('binary');
53
+ const claims = JSON.parse(json) as Record<string, unknown>;
54
+ const ac = claims.AC ?? claims.ac;
55
+ return typeof ac === 'string' ? ac : null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
41
61
  export class AuthService<S extends SchemaStructure> {
42
62
  // State
43
63
  public token: string | null = null;
44
64
  public currentUser: any | null = null;
45
65
  public isAuthenticated: boolean = false;
66
+ /**
67
+ * The record-access method name for the current session (e.g. `"account"`),
68
+ * derived from the token's `AC` claim. Consumed by the in-browser SSP's
69
+ * permission injection so `$access`-gated table predicates resolve locally,
70
+ * mirroring the server's `$access`. Null when logged out.
71
+ */
72
+ public access: string | null = null;
46
73
  public isLoading: boolean = true;
47
74
 
48
75
  private events = createAuthEventSystem();
@@ -95,11 +122,11 @@ export class AuthService<S extends SchemaStructure> {
95
122
  this.isLoading = true;
96
123
 
97
124
  try {
98
- const token = accessToken || (await this.persistenceClient.get<string>('spooky_auth_token'));
125
+ const token = accessToken || (await this.persistenceClient.get<string>('sp00ky_auth_token'));
99
126
 
100
127
  if (!token) {
101
128
  this.logger.debug(
102
- { Category: 'spooky-client::AuthService::check' },
129
+ { Category: 'sp00ky-client::AuthService::check' },
103
130
  'No token found in storage or arguments'
104
131
  );
105
132
  this.isLoading = false;
@@ -119,13 +146,13 @@ export class AuthService<S extends SchemaStructure> {
119
146
 
120
147
  if (user && user.id) {
121
148
  this.logger.info(
122
- { user, Category: 'spooky-client::AuthService::check' },
149
+ { user, Category: 'sp00ky-client::AuthService::check' },
123
150
  'Auth check complete (via $auth.id)'
124
151
  );
125
152
  await this.setSession(token, user);
126
153
  } else {
127
154
  this.logger.warn(
128
- { Category: 'spooky-client::AuthService::check' },
155
+ { Category: 'sp00ky-client::AuthService::check' },
129
156
  '$auth.id empty, attempting manual user fetch'
130
157
  );
131
158
 
@@ -140,13 +167,13 @@ export class AuthService<S extends SchemaStructure> {
140
167
 
141
168
  if (manualUser && manualUser.id) {
142
169
  this.logger.info(
143
- { user: manualUser, Category: 'spooky-client::AuthService::check' },
170
+ { user: manualUser, Category: 'sp00ky-client::AuthService::check' },
144
171
  'Auth check complete (via manual fetch)'
145
172
  );
146
173
  await this.setSession(token, manualUser);
147
174
  } else {
148
175
  this.logger.warn(
149
- { Category: 'spooky-client::AuthService::check' },
176
+ { Category: 'sp00ky-client::AuthService::check' },
150
177
  'Token valid but user not found via fallback'
151
178
  );
152
179
  await this.signOut();
@@ -154,7 +181,7 @@ export class AuthService<S extends SchemaStructure> {
154
181
  }
155
182
  } catch (error) {
156
183
  this.logger.error(
157
- { error, stack: (error as Error).stack, Category: 'spooky-client::AuthService::check' },
184
+ { error, stack: (error as Error).stack, Category: 'sp00ky-client::AuthService::check' },
158
185
  'Auth check failed'
159
186
  );
160
187
  await this.signOut();
@@ -170,12 +197,13 @@ export class AuthService<S extends SchemaStructure> {
170
197
  this.token = null;
171
198
  this.currentUser = null;
172
199
  this.isAuthenticated = false;
200
+ this.access = null;
173
201
 
174
- await this.persistenceClient.remove('spooky_auth_token');
202
+ await this.persistenceClient.remove('sp00ky_auth_token');
175
203
 
176
204
  try {
177
205
  await this.remote.getClient().invalidate();
178
- } catch (e) {
206
+ } catch (_e) {
179
207
  // Ignore invalidation errors
180
208
  }
181
209
 
@@ -186,10 +214,21 @@ export class AuthService<S extends SchemaStructure> {
186
214
  this.token = token;
187
215
  this.currentUser = user;
188
216
  this.isAuthenticated = true;
189
- await this.persistenceClient.set('spooky_auth_token', token);
217
+ // Resolve the access-method name (e.g. "account") for in-browser SSP
218
+ // permission injection. Prefer the token's `AC` claim; fall back to the
219
+ // schema's sole record-access method if the claim is absent.
220
+ this.access = decodeAccessFromToken(token) ?? this.defaultAccessName();
221
+ await this.persistenceClient.set('sp00ky_auth_token', token);
190
222
  this.notifyListeners();
191
223
  }
192
224
 
225
+ /** Fallback when the token carries no `AC` claim: if the schema defines
226
+ * exactly one record-access method, assume the session used it. */
227
+ private defaultAccessName(): string | null {
228
+ const names = Object.keys(this.schema.access ?? {});
229
+ return names.length === 1 ? names[0] : null;
230
+ }
231
+
193
232
  async signUp<Name extends keyof S['access'] & string>(
194
233
  accessName: Name,
195
234
  params: ExtractAccessParams<S, Name, 'signup'>
@@ -212,7 +251,7 @@ export class AuthService<S extends SchemaStructure> {
212
251
  }
213
252
 
214
253
  this.logger.info(
215
- { accessName, runtimeParams, Category: 'spooky-client::AuthService::signUp' },
254
+ { accessName, runtimeParams, Category: 'sp00ky-client::AuthService::signUp' },
216
255
  'Attempting signup'
217
256
  );
218
257
 
@@ -222,7 +261,7 @@ export class AuthService<S extends SchemaStructure> {
222
261
  });
223
262
 
224
263
  this.logger.info(
225
- { Category: 'spooky-client::AuthService::signUp' },
264
+ { Category: 'sp00ky-client::AuthService::signUp' },
226
265
  'Signup successful, token received'
227
266
  );
228
267
 
@@ -253,7 +292,7 @@ export class AuthService<S extends SchemaStructure> {
253
292
  }
254
293
 
255
294
  this.logger.info(
256
- { accessName, Category: 'spooky-client::AuthService::signIn' },
295
+ { accessName, Category: 'sp00ky-client::AuthService::signIn' },
257
296
  'Attempting signin'
258
297
  );
259
298
 
@@ -1,13 +1,14 @@
1
- import { LocalDatabaseService } from '../../services/database/index';
2
- import {
1
+ import type { LocalStore } from '../../services/database/index';
2
+ import { StaleEpochError } from '../../services/database/index';
3
+ import type {
3
4
  StreamProcessorService,
4
5
  StreamUpdate,
5
6
  StreamUpdateReceiver,
6
7
  } from '../../services/stream-processor/index';
7
- import { Logger } from '../../services/logger/index';
8
+ import type { Logger } from '../../services/logger/index';
8
9
  import { parseRecordIdString, encodeRecordId, surql } from '../../utils/index';
9
- import { CacheRecord, QueryConfig } from './types';
10
- import { RecordVersionArray } from '../../types';
10
+ import type { CacheRecord, QueryConfig } from './types';
11
+ import type { RecordVersionArray } from '../../types';
11
12
 
12
13
  export * from './types';
13
14
 
@@ -17,13 +18,26 @@ export * from './types';
17
18
  * Single responsibility: Handle all local storage operations and DBSP ingestion.
18
19
  * This module acts as the bridge between data operations and persistence.
19
20
  */
21
+ /** One ingested change, in exactly the shape `ingestMany` consumes. Shared
22
+ * with the tabs protocol so a leader can relay its ingests to followers. */
23
+ export interface CacheIngestTuple {
24
+ table: string;
25
+ op: 'CREATE' | 'UPDATE' | 'DELETE';
26
+ id: string;
27
+ record: Record<string, unknown>;
28
+ }
29
+
20
30
  export class CacheModule implements StreamUpdateReceiver {
21
31
  private logger: Logger;
22
32
  private streamUpdateCallback: (update: StreamUpdate) => void;
23
33
  private versionLookups: Record<string, number> = {};
34
+ /** Shared-tabs leader: fan every committed ingest out to follower circuits.
35
+ * Fired AFTER the local tx (the rows are already in the shared store, so a
36
+ * follower only needs the circuit feed). Never set on followers. */
37
+ private ingestRelay: ((tuples: CacheIngestTuple[]) => void) | null = null;
24
38
 
25
39
  constructor(
26
- private local: LocalDatabaseService,
40
+ private local: LocalStore,
27
41
  private streamProcessor: StreamProcessorService,
28
42
  streamUpdateCallback: (update: StreamUpdate) => void,
29
43
  logger: Logger
@@ -43,17 +57,43 @@ export class CacheModule implements StreamUpdateReceiver {
43
57
  {
44
58
  queryHash: update.queryHash,
45
59
  arrayLength: update.localArray?.length,
46
- Category: 'spooky-client::CacheModule::onStreamUpdate',
60
+ Category: 'sp00ky-client::CacheModule::onStreamUpdate',
47
61
  },
48
62
  'Stream update received'
49
63
  );
50
64
  this.streamUpdateCallback(update);
51
65
  }
52
66
 
67
+ setIngestRelay(cb: ((tuples: CacheIngestTuple[]) => void) | null): void {
68
+ this.ingestRelay = cb;
69
+ }
70
+
71
+ /**
72
+ * Shared-tabs follower: feed relayed tuples into THIS tab's circuit only.
73
+ * The rows are already in the shared store (the leader wrote them), so no
74
+ * local write happens here; the normal chain then runs: SSP -> stream update
75
+ * -> DataModule debounce -> materializeRecords (re-reads via the port
76
+ * transport) -> this tab's subscriptions fire with this tab's hashes.
77
+ */
78
+ applyRelayedIngest(tuples: CacheIngestTuple[]): void {
79
+ for (const t of tuples) {
80
+ const rv = (t.record as { _00_rv?: number } | undefined)?._00_rv;
81
+ if (t.op === 'DELETE') delete this.versionLookups[t.id];
82
+ else if (typeof rv === 'number') this.versionLookups[t.id] = rv;
83
+ }
84
+ this.streamProcessor.ingestMany(tuples as Parameters<StreamProcessorService['ingestMany']>[0]);
85
+ }
86
+
53
87
  public lookup(recordId: string): number {
54
88
  return this.versionLookups[recordId] ?? 0;
55
89
  }
56
90
 
91
+ /** Drop the version cache on a bucket switch — a stale version would make
92
+ * the sync diff skip fetching a body the new bucket legitimately needs. */
93
+ public clearVersionLookups(): void {
94
+ this.versionLookups = {};
95
+ }
96
+
57
97
  /**
58
98
  * Save a single record to local DB and ingest into DBSP
59
99
  * Used by mutations (create/update)
@@ -70,10 +110,17 @@ export class CacheModule implements StreamUpdateReceiver {
70
110
  async saveBatch(records: CacheRecord[], skipDbInsert: boolean = false): Promise<void> {
71
111
  if (records.length === 0) return;
72
112
 
113
+ // Fence against bucket switches: this batch's records were derived from
114
+ // reads against the CURRENT store/user. If a switch lands while we await
115
+ // the (gated) local write, the write throws StaleEpochError and the whole
116
+ // batch — including the SSP ingest — is dropped: the new bucket re-syncs
117
+ // its own data from the server.
118
+ const epoch = this.local.epoch;
119
+
73
120
  this.logger.debug(
74
121
  {
75
122
  count: records.length,
76
- Category: 'spooky-client::CacheModule::saveBatch',
123
+ Category: 'sp00ky-client::CacheModule::saveBatch',
77
124
  },
78
125
  'Saving record batch'
79
126
  );
@@ -85,7 +132,7 @@ export class CacheModule implements StreamUpdateReceiver {
85
132
  ...record,
86
133
  record: {
87
134
  ...record.record,
88
- spooky_rv: record.version,
135
+ _00_rv: record.version,
89
136
  },
90
137
  };
91
138
  });
@@ -94,7 +141,12 @@ export class CacheModule implements StreamUpdateReceiver {
94
141
  const query = surql.seal<void>(
95
142
  surql.tx(
96
143
  populatedRecords.map((_, i) => {
97
- return surql.upsert(`id${i}`, `content${i}`);
144
+ // MERGE, not REPLACE: the remote payload omits local-only
145
+ // fields (`_00_crdt`, `_00_cursor`) injected by the CLI's
146
+ // local schema, so REPLACE would wipe the persisted CRDT
147
+ // snapshot on every sync-down round-trip and break offline
148
+ // reload of formatted text.
149
+ return surql.upsertMerge(`id${i}`, `content${i}`);
98
150
  })
99
151
  )
100
152
  );
@@ -111,23 +163,38 @@ export class CacheModule implements StreamUpdateReceiver {
111
163
  {} as Record<string, any>
112
164
  );
113
165
 
114
- await this.local.execute(query, params);
166
+ await this.local.execute(query, params, { epoch });
115
167
  }
116
168
 
117
- // 2. Batch ingest into DBSP (use populatedRecords which has spooky_rv set)
118
- for (const record of populatedRecords) {
169
+ // Late fence for the skipDbInsert path (no gated write above to trip on).
170
+ if (this.local.epoch !== epoch) throw new StaleEpochError();
171
+
172
+ // 2. Bulk ingest into DBSP (use populatedRecords which has _00_rv set).
173
+ // ingestMany coalesces the per-record stream updates into a single
174
+ // notification per affected query — the UI then updates once, after the
175
+ // whole batch is ingested, instead of row-by-row.
176
+ const bulk = populatedRecords.map((record) => {
119
177
  const recordId = encodeRecordId(record.record.id);
120
178
  this.versionLookups[recordId] = record.version;
121
- this.streamProcessor.ingest(record.table, record.op, recordId, record.record);
122
- }
179
+ return { table: record.table, op: record.op, id: recordId, record: record.record };
180
+ });
181
+ this.streamProcessor.ingestMany(bulk);
182
+ this.ingestRelay?.(bulk as CacheIngestTuple[]);
123
183
 
124
184
  this.logger.debug(
125
- { count: records.length, Category: 'spooky-client::CacheModule::saveBatch' },
185
+ { count: records.length, Category: 'sp00ky-client::CacheModule::saveBatch' },
126
186
  'Batch saved successfully'
127
187
  );
128
188
  } catch (err) {
189
+ if (err instanceof StaleEpochError) {
190
+ this.logger.debug(
191
+ { count: records.length, Category: 'sp00ky-client::CacheModule::saveBatch' },
192
+ 'Dropped batch from before a bucket switch'
193
+ );
194
+ return;
195
+ }
129
196
  this.logger.error(
130
- { err, count: records.length, Category: 'spooky-client::CacheModule::saveBatch' },
197
+ { err, count: records.length, Category: 'sp00ky-client::CacheModule::saveBatch' },
131
198
  'Failed to save batch'
132
199
  );
133
200
  throw err;
@@ -137,29 +204,39 @@ export class CacheModule implements StreamUpdateReceiver {
137
204
  /**
138
205
  * Delete a record from local DB and ingest deletion into DBSP
139
206
  */
140
- async delete(table: string, id: string, skipDbDelete: boolean = false): Promise<void> {
207
+ async delete(table: string, id: string, skipDbDelete: boolean = false, recordData: Record<string, any> = {}): Promise<void> {
141
208
  this.logger.debug(
142
- { table, id, Category: 'spooky-client::CacheModule::delete' },
209
+ { table, id, Category: 'sp00ky-client::CacheModule::delete' },
143
210
  'Deleting record'
144
211
  );
145
212
 
213
+ const epoch = this.local.epoch;
146
214
  try {
147
215
  // 1. Delete from local database
148
216
  if (!skipDbDelete) {
149
- await this.local.query('DELETE $id', { id: parseRecordIdString(id) });
217
+ await this.local.query('DELETE $id', { id: parseRecordIdString(id) }, { epoch });
150
218
  }
219
+ if (this.local.epoch !== epoch) throw new StaleEpochError();
151
220
 
152
- // 2. Ingest deletion into DBSP
221
+ // 2. Ingest deletion into DBSP (pass record data so predicates can be matched)
153
222
  delete this.versionLookups[id];
154
- await this.streamProcessor.ingest(table, 'DELETE', id, {});
223
+ this.streamProcessor.ingest(table, 'DELETE', id, recordData);
224
+ this.ingestRelay?.([{ table, op: 'DELETE', id, record: recordData }]);
155
225
 
156
226
  this.logger.debug(
157
- { table, id, Category: 'spooky-client::CacheModule::delete' },
227
+ { table, id, Category: 'sp00ky-client::CacheModule::delete' },
158
228
  'Record deleted successfully'
159
229
  );
160
230
  } catch (err) {
231
+ if (err instanceof StaleEpochError) {
232
+ this.logger.debug(
233
+ { table, id, Category: 'sp00ky-client::CacheModule::delete' },
234
+ 'Dropped delete from before a bucket switch'
235
+ );
236
+ return;
237
+ }
161
238
  this.logger.error(
162
- { err, table, id, Category: 'spooky-client::CacheModule::delete' },
239
+ { err, table, id, Category: 'sp00ky-client::CacheModule::delete' },
163
240
  'Failed to delete record'
164
241
  );
165
242
  throw err;
@@ -170,12 +247,15 @@ export class CacheModule implements StreamUpdateReceiver {
170
247
  * Register a query with DBSP to create a materialized view
171
248
  * Returns the initial result array
172
249
  */
173
- registerQuery(config: QueryConfig): { localArray: RecordVersionArray } {
250
+ registerQuery(config: QueryConfig): {
251
+ localArray: RecordVersionArray;
252
+ registrationTimings?: { parseMs: number; planMs: number; snapshotMs: number };
253
+ } {
174
254
  this.logger.debug(
175
255
  {
176
256
  queryHash: config.queryHash,
177
257
  surql: config.surql,
178
- Category: 'spooky-client::CacheModule::registerQuery',
258
+ Category: 'sp00ky-client::CacheModule::registerQuery',
179
259
  },
180
260
  'Registering query'
181
261
  );
@@ -202,15 +282,15 @@ export class CacheModule implements StreamUpdateReceiver {
202
282
  {
203
283
  queryHash: config.queryHash,
204
284
  arrayLength: update.localArray?.length,
205
- Category: 'spooky-client::CacheModule::registerQuery',
285
+ Category: 'sp00ky-client::CacheModule::registerQuery',
206
286
  },
207
287
  'Query registered successfully'
208
288
  );
209
289
 
210
- return { localArray: update.localArray };
290
+ return { localArray: update.localArray, registrationTimings: update.registration };
211
291
  } catch (err) {
212
292
  this.logger.error(
213
- { err, queryHash: config.queryHash, Category: 'spooky-client::CacheModule::registerQuery' },
293
+ { err, queryHash: config.queryHash, Category: 'sp00ky-client::CacheModule::registerQuery' },
214
294
  'Failed to register query'
215
295
  );
216
296
  throw err;
@@ -222,18 +302,18 @@ export class CacheModule implements StreamUpdateReceiver {
222
302
  */
223
303
  unregisterQuery(queryHash: string): void {
224
304
  this.logger.debug(
225
- { queryHash, Category: 'spooky-client::CacheModule::unregisterQuery' },
305
+ { queryHash, Category: 'sp00ky-client::CacheModule::unregisterQuery' },
226
306
  'Unregistering query'
227
307
  );
228
308
  try {
229
309
  this.streamProcessor.unregisterQueryPlan(queryHash);
230
310
  this.logger.debug(
231
- { queryHash, Category: 'spooky-client::CacheModule::unregisterQuery' },
311
+ { queryHash, Category: 'sp00ky-client::CacheModule::unregisterQuery' },
232
312
  'Query unregistered successfully'
233
313
  );
234
314
  } catch (err) {
235
315
  this.logger.error(
236
- { err, queryHash, Category: 'spooky-client::CacheModule::unregisterQuery' },
316
+ { err, queryHash, Category: 'sp00ky-client::CacheModule::unregisterQuery' },
237
317
  'Failed to unregister query'
238
318
  );
239
319
  }
@@ -1,5 +1,5 @@
1
- import { RecordId, Duration } from 'surrealdb';
2
- import { QueryTimeToLive, RecordVersionArray } from '../../types';
1
+ import type { RecordId, Duration } from 'surrealdb';
2
+ import type { QueryTimeToLive } from '../../types';
3
3
 
4
4
  export type RecordWithId = Record<string, any> & { id: RecordId<string> };
5
5