@spooky-sync/core 0.0.1-canary.9 → 0.0.1-canary.90

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 (65) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +907 -339
  3. package/dist/index.js +2837 -402
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/types.d.ts +543 -0
  7. package/package.json +39 -9
  8. package/skills/sp00ky-core/SKILL.md +258 -0
  9. package/skills/sp00ky-core/references/auth.md +98 -0
  10. package/skills/sp00ky-core/references/config.md +76 -0
  11. package/src/build-globals.d.ts +12 -0
  12. package/src/events/events.test.ts +2 -1
  13. package/src/events/index.ts +3 -0
  14. package/src/index.ts +9 -2
  15. package/src/modules/auth/events/index.ts +2 -1
  16. package/src/modules/auth/index.ts +59 -20
  17. package/src/modules/cache/index.ts +41 -29
  18. package/src/modules/cache/types.ts +2 -2
  19. package/src/modules/crdt/crdt-field.ts +281 -0
  20. package/src/modules/crdt/crdt-hydration.test.ts +206 -0
  21. package/src/modules/crdt/index.ts +352 -0
  22. package/src/modules/data/data.status.test.ts +108 -0
  23. package/src/modules/data/index.ts +732 -109
  24. package/src/modules/data/window-query.test.ts +52 -0
  25. package/src/modules/data/window-query.ts +130 -0
  26. package/src/modules/devtools/index.ts +115 -21
  27. package/src/modules/devtools/versions.test.ts +74 -0
  28. package/src/modules/devtools/versions.ts +81 -0
  29. package/src/modules/feature-flag/index.ts +166 -0
  30. package/src/modules/ref-tables.test.ts +66 -0
  31. package/src/modules/ref-tables.ts +69 -0
  32. package/src/modules/sync/engine.ts +101 -37
  33. package/src/modules/sync/events/index.ts +9 -2
  34. package/src/modules/sync/queue/queue-down.ts +5 -4
  35. package/src/modules/sync/queue/queue-up.ts +14 -13
  36. package/src/modules/sync/scheduler.ts +40 -3
  37. package/src/modules/sync/sync.ts +893 -59
  38. package/src/modules/sync/utils.test.ts +269 -2
  39. package/src/modules/sync/utils.ts +182 -17
  40. package/src/otel/index.ts +127 -0
  41. package/src/services/database/database.ts +11 -11
  42. package/src/services/database/events/index.ts +2 -1
  43. package/src/services/database/local-migrator.ts +21 -21
  44. package/src/services/database/local.test.ts +33 -0
  45. package/src/services/database/local.ts +192 -32
  46. package/src/services/database/remote.ts +13 -13
  47. package/src/services/logger/index.ts +6 -101
  48. package/src/services/persistence/localstorage.ts +2 -2
  49. package/src/services/persistence/resilient.ts +41 -0
  50. package/src/services/persistence/surrealdb.ts +9 -9
  51. package/src/services/stream-processor/index.ts +248 -37
  52. package/src/services/stream-processor/permissions.test.ts +47 -0
  53. package/src/services/stream-processor/permissions.ts +53 -0
  54. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  55. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  56. package/src/services/stream-processor/wasm-types.ts +18 -2
  57. package/src/sp00ky.auth-order.test.ts +65 -0
  58. package/src/sp00ky.ts +648 -0
  59. package/src/types.ts +192 -15
  60. package/src/utils/index.ts +35 -13
  61. package/src/utils/parser.ts +3 -2
  62. package/src/utils/surql.ts +24 -15
  63. package/src/utils/withRetry.test.ts +1 -1
  64. package/tsdown.config.ts +55 -1
  65. package/src/spooky.ts +0 -392
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildWindowMaterialization } from './window-query';
3
+
4
+ describe('buildWindowMaterialization', () => {
5
+ it('rewrites the game-list window query (START 30) to select the id-set, keeping ORDER BY', () => {
6
+ const surql =
7
+ 'SELECT * FROM game WHERE database = $database ORDER BY sort_index asc, date desc LIMIT 30 START 30;';
8
+ const r = buildWindowMaterialization(surql);
9
+ expect(r).not.toBeNull();
10
+ expect(r!.query).toBe('SELECT * FROM $__win ORDER BY sort_index asc, date desc');
11
+ });
12
+
13
+ it('returns null for START 0 (offset-free windows still work via the normal re-query)', () => {
14
+ const surql =
15
+ 'SELECT * FROM game WHERE database = $database ORDER BY sort_index asc, date desc LIMIT 30 START 0;';
16
+ expect(buildWindowMaterialization(surql)).toBeNull();
17
+ });
18
+
19
+ it('returns null when there is no START clause', () => {
20
+ expect(
21
+ buildWindowMaterialization('SELECT * FROM game WHERE database = $database LIMIT 30;')
22
+ ).toBeNull();
23
+ expect(buildWindowMaterialization('SELECT * FROM game;')).toBeNull();
24
+ });
25
+
26
+ it('preserves a custom projection', () => {
27
+ const surql = 'SELECT id, white, black FROM game ORDER BY date desc LIMIT 30 START 60;';
28
+ expect(buildWindowMaterialization(surql)!.query).toBe(
29
+ 'SELECT id, white, black FROM $__win ORDER BY date desc'
30
+ );
31
+ });
32
+
33
+ it('omits ORDER BY when the query had none', () => {
34
+ const surql = 'SELECT * FROM game LIMIT 30 START 30;';
35
+ expect(buildWindowMaterialization(surql)!.query).toBe('SELECT * FROM $__win');
36
+ });
37
+
38
+ it('ignores FROM/ORDER BY/LIMIT/START inside subqueries (paren-aware)', () => {
39
+ const surql =
40
+ 'SELECT *, (SELECT * FROM comment WHERE game = $parent.id ORDER BY created_at desc LIMIT 5) AS comments ' +
41
+ 'FROM game ORDER BY sort_index asc LIMIT 30 START 90;';
42
+ expect(buildWindowMaterialization(surql)!.query).toBe(
43
+ 'SELECT *, (SELECT * FROM comment WHERE game = $parent.id ORDER BY created_at desc LIMIT 5) AS comments FROM $__win ORDER BY sort_index asc'
44
+ );
45
+ });
46
+
47
+ it('does not treat a START inside a string literal as the offset', () => {
48
+ const surql = "SELECT * FROM game WHERE note = 'LIMIT 30 START 30' ORDER BY date desc LIMIT 30 START 30;";
49
+ const r = buildWindowMaterialization(surql);
50
+ expect(r!.query).toBe('SELECT * FROM $__win ORDER BY date desc');
51
+ });
52
+ });
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Rewrite a windowed (`LIMIT n START m`, m>0) SELECT so its rows are
3
+ * materialized from an explicit record-id set — the window the SSP already
4
+ * computed — instead of re-running the original query (with its `START m`)
5
+ * against the shared local DB.
6
+ *
7
+ * Why: `DataModule.processStreamUpdate` materializes a query's rows by
8
+ * re-querying the local in-browser SurrealDB with the original surql. For an
9
+ * offset query that re-applies `START m` against the *shared* local store —
10
+ * which, with sparse windowing, may hold only this window's rows — so it skips
11
+ * them all and returns nothing (the "page 2 returns 0 rows" bug). The SSP's
12
+ * materialized view (`StreamUpdate.localArray`) is exactly this window's row
13
+ * ids, so we select those ids directly and re-apply the original `ORDER BY` for
14
+ * stable display order.
15
+ *
16
+ * Returns `null` for non-offset queries (`START` absent or 0) — the caller
17
+ * keeps the normal re-query path, so only the broken case changes behavior.
18
+ *
19
+ * Preserves the `SELECT <projection>` clause and the top-level `ORDER BY`;
20
+ * drops the original `FROM`/`WHERE`/`LIMIT`/`START`. Subqueries inside the
21
+ * projection are preserved verbatim (the scanner is paren/quote aware), with
22
+ * one caveat: SurrealDB v3 drops `*` in the `SELECT *, <subquery> FROM $param`
23
+ * shape — such windowed+subquery queries are rare and were already returning 0,
24
+ * so this is no regression.
25
+ */
26
+ export function buildWindowMaterialization(
27
+ surql: string,
28
+ idsParam = '__win'
29
+ ): { query: string } | null {
30
+ const kw = scanTopLevelClauses(surql);
31
+ if (kw.startValue === null || kw.startValue <= 0) return null;
32
+ if (kw.fromIndex === null) return null;
33
+
34
+ const selectClause = surql.slice(0, kw.fromIndex).trimEnd(); // "SELECT <projection>"
35
+
36
+ let orderBy = '';
37
+ if (kw.orderByIndex !== null) {
38
+ const ends = [kw.limitIndex, kw.startIndex, kw.semicolonIndex, surql.length].filter(
39
+ (n): n is number => n !== null && n > kw.orderByIndex!
40
+ );
41
+ const end = Math.min(...ends);
42
+ orderBy = ' ' + surql.slice(kw.orderByIndex, end).trim();
43
+ }
44
+
45
+ return { query: `${selectClause} FROM $${idsParam}${orderBy}` };
46
+ }
47
+
48
+ interface TopLevelClauses {
49
+ fromIndex: number | null;
50
+ orderByIndex: number | null;
51
+ limitIndex: number | null;
52
+ startIndex: number | null;
53
+ startValue: number | null;
54
+ semicolonIndex: number | null;
55
+ }
56
+
57
+ // Single pass over the query, tracking paren depth and single-quoted strings so
58
+ // that clauses inside subqueries (which live in parens) are ignored — only the
59
+ // outermost (depth-0) clause keywords are recorded.
60
+ function scanTopLevelClauses(sql: string): TopLevelClauses {
61
+ const out: TopLevelClauses = {
62
+ fromIndex: null,
63
+ orderByIndex: null,
64
+ limitIndex: null,
65
+ startIndex: null,
66
+ startValue: null,
67
+ semicolonIndex: null,
68
+ };
69
+ let depth = 0;
70
+ let inStr = false;
71
+
72
+ for (let i = 0; i < sql.length; i++) {
73
+ const ch = sql[i];
74
+ if (inStr) {
75
+ if (ch === '\\') i++; // skip escaped char
76
+ else if (ch === "'") inStr = false;
77
+ continue;
78
+ }
79
+ if (ch === "'") { inStr = true; continue; }
80
+ if (ch === '(') { depth++; continue; }
81
+ if (ch === ')') { depth--; continue; }
82
+ if (depth !== 0) continue;
83
+ if (ch === ';' && out.semicolonIndex === null) { out.semicolonIndex = i; continue; }
84
+
85
+ // Only test keywords at a word boundary.
86
+ if (!isWordBoundary(sql, i)) continue;
87
+ if (out.fromIndex === null && matchKeyword(sql, i, 'FROM')) { out.fromIndex = i; continue; }
88
+ // FROM must come before the trailing clauses; only record them once seen.
89
+ if (out.fromIndex === null) continue;
90
+ if (out.orderByIndex === null && matchKeyword(sql, i, 'ORDER BY')) { out.orderByIndex = i; continue; }
91
+ if (out.limitIndex === null && matchKeyword(sql, i, 'LIMIT')) { out.limitIndex = i; continue; }
92
+ if (out.startIndex === null && matchKeyword(sql, i, 'START')) {
93
+ out.startIndex = i;
94
+ out.startValue = readNumberAfter(sql, i + 'START'.length);
95
+ continue;
96
+ }
97
+ }
98
+ return out;
99
+ }
100
+
101
+ function isWordBoundary(sql: string, i: number): boolean {
102
+ if (i === 0) return true;
103
+ return !/[A-Za-z0-9_]/.test(sql[i - 1]);
104
+ }
105
+
106
+ // Case-insensitive keyword match where internal whitespace (e.g. "ORDER BY")
107
+ // matches any run of whitespace, and the keyword ends on a non-word char.
108
+ function matchKeyword(sql: string, i: number, keyword: string): boolean {
109
+ const parts = keyword.split(' ');
110
+ let pos = i;
111
+ for (let p = 0; p < parts.length; p++) {
112
+ const word = parts[p];
113
+ if (sql.slice(pos, pos + word.length).toUpperCase() !== word) return false;
114
+ pos += word.length;
115
+ if (p < parts.length - 1) {
116
+ const wsStart = pos;
117
+ while (pos < sql.length && /\s/.test(sql[pos])) pos++;
118
+ if (pos === wsStart) return false; // required whitespace
119
+ }
120
+ }
121
+ // Must end on a non-word char (or end of string).
122
+ return pos >= sql.length || !/[A-Za-z0-9_]/.test(sql[pos]);
123
+ }
124
+
125
+ function readNumberAfter(sql: string, from: number): number | null {
126
+ let pos = from;
127
+ while (pos < sql.length && /\s/.test(sql[pos])) pos++;
128
+ const m = /^\d+/.exec(sql.slice(pos));
129
+ return m ? parseInt(m[0], 10) : null;
130
+ }
@@ -1,8 +1,8 @@
1
- import { LocalDatabaseService, RemoteDatabaseService } from '../../services/database/index';
2
- import { Logger } from '../../services/logger/index';
3
- import { SchemaStructure } from '@spooky-sync/query-builder';
1
+ import type { LocalDatabaseService, RemoteDatabaseService } from '../../services/database/index';
2
+ import type { Logger } from '../../services/logger/index';
3
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
4
4
  import { RecordId } from 'surrealdb';
5
- import { StreamUpdate, StreamUpdateReceiver } from '../../services/stream-processor/index';
5
+ import type { StreamUpdate, StreamUpdateReceiver } from '../../services/stream-processor/index';
6
6
  import { encodeRecordId } from '../../utils/index';
7
7
 
8
8
  // DevTools interfaces (matching extension expectations)
@@ -13,14 +13,41 @@ export interface DevToolsEvent {
13
13
  payload: any;
14
14
  }
15
15
 
16
- import { DataModule } from '../data/index';
17
- import { AuthService } from '../auth/index';
16
+ import type { DataModule } from '../data/index';
17
+ import type { AuthService } from '../auth/index';
18
18
  import { AuthEventTypes } from '../auth/events/index';
19
+ import {
20
+ type BackendInfo,
21
+ emptyBackendInfo,
22
+ parseBackendInfo,
23
+ } from './versions';
24
+
25
+ // Real bundled frontend versions, injected at build time by tsdown's
26
+ // version-define plugin (see tsdown.config.ts). The `typeof` guard keeps these
27
+ // from throwing a ReferenceError when a downstream app bundles core from source
28
+ // (where the plugin never runs); in that case they fall back to 'unknown' and
29
+ // DevTools simply reports an unknown frontend version instead of crashing.
30
+ const CORE_VERSION =
31
+ typeof __SP00KY_CORE_VERSION__ !== 'undefined' ? __SP00KY_CORE_VERSION__ : 'unknown';
32
+ const WASM_VERSION =
33
+ typeof __SP00KY_WASM_VERSION__ !== 'undefined' ? __SP00KY_WASM_VERSION__ : 'unknown';
34
+ const SURREAL_VERSION =
35
+ typeof __SP00KY_SURREAL_VERSION__ !== 'undefined' ? __SP00KY_SURREAL_VERSION__ : 'unknown';
19
36
 
20
37
  export class DevToolsService implements StreamUpdateReceiver {
21
38
  private eventsHistory: DevToolsEvent[] = [];
22
39
  private eventIdCounter = 0;
23
- private version = '1.0.0';
40
+ // Real bundled frontend version (injected at build time via tsdown `define`).
41
+ private version = CORE_VERSION;
42
+ // Backend stack info (versions + per-entity status), read via the
43
+ // `fn::spooky::info()` SurrealQL function; empty/'unavailable' until resolved.
44
+ private backendInfo: BackendInfo = emptyBackendInfo();
45
+ // Dormant until a devtools consumer (extension panel or MCP) handshakes via
46
+ // `SP00KY_DEVTOOLS_CONNECT`. While false, `notifyDevTools()`/`addEvent()` do no
47
+ // work, so prod pays zero serialization/postMessage cost for an unwatched panel.
48
+ // `window.__00__.getState()` stays live regardless, so the panel's first paint
49
+ // (the on-demand GET_STATE pull) still works before the push channel turns on.
50
+ private enabled = false;
24
51
 
25
52
  constructor(
26
53
  private databaseService: LocalDatabaseService,
@@ -32,12 +59,54 @@ export class DevToolsService implements StreamUpdateReceiver {
32
59
  ) {
33
60
  this.exposeToWindow();
34
61
 
62
+ // Stay dormant until a devtools consumer announces itself. The extension's
63
+ // page-script posts this once it detects `window.__00__`; the panel can also
64
+ // disconnect to return us to dormant. Until then we skip all serialization.
65
+ if (typeof window !== 'undefined') {
66
+ window.addEventListener('message', (e) => {
67
+ if (e.source !== window) return;
68
+ const type = (e.data as { type?: string } | undefined)?.type;
69
+ if (type === 'SP00KY_DEVTOOLS_CONNECT') {
70
+ this.enabled = true;
71
+ this.notifyDevTools();
72
+ } else if (type === 'SP00KY_DEVTOOLS_DISCONNECT') {
73
+ this.enabled = false;
74
+ }
75
+ });
76
+ }
77
+
35
78
  // Subscribe to auth events
36
79
  this.authService.eventSystem.subscribe(AuthEventTypes.AuthStateChanged, () => {
37
80
  this.notifyDevTools();
38
81
  });
39
82
 
40
- this.logger.debug({ Category: 'spooky-client::DevToolsService::init' }, 'Service initialized');
83
+ // Fire-and-forget backend version discovery; re-push state when it lands.
84
+ void this.refreshBackendVersions();
85
+
86
+ this.logger.debug({ Category: 'sp00ky-client::DevToolsService::init' }, 'Service initialized');
87
+ }
88
+
89
+ /**
90
+ * Re-read backend stack info via the `fn::spooky::info()` SurrealQL function
91
+ * over the open remote connection (no HTTP/CORS), then notify the panel.
92
+ * Never throws: on failure the info stays empty/'unavailable'.
93
+ */
94
+ private async refreshBackendVersions(): Promise<void> {
95
+ try {
96
+ // `RETURN fn::spooky::info()` → one statement result: the /info entity array.
97
+ const result = await this.remoteDatabaseService.query<unknown[]>(
98
+ 'RETURN fn::spooky::info()'
99
+ );
100
+ const first = Array.isArray(result) ? result[0] : result;
101
+ this.backendInfo = parseBackendInfo(first);
102
+ } catch (err) {
103
+ this.logger.debug(
104
+ { err, Category: 'sp00ky-client::DevToolsService::versions' },
105
+ 'fn::spooky::info() unavailable; backend versions stay unavailable'
106
+ );
107
+ this.backendInfo = emptyBackendInfo();
108
+ }
109
+ this.notifyDevTools();
41
110
  }
42
111
 
43
112
  // Get active queries directly from DataManager (single source of truth)
@@ -51,6 +120,10 @@ export class DevToolsService implements StreamUpdateReceiver {
51
120
  result.set(queryHash, {
52
121
  queryHash,
53
122
  status: 'active',
123
+ // Runtime fetch status, distinct from the `status: 'active'`
124
+ // registration flag above. `fetchStatus` is 'idle' | 'fetching'.
125
+ fetchStatus: q.status,
126
+ isFetching: q.status === 'fetching',
54
127
  createdAt:
55
128
  q.config.lastActiveAt instanceof Date
56
129
  ? q.config.lastActiveAt.getTime()
@@ -63,6 +136,10 @@ export class DevToolsService implements StreamUpdateReceiver {
63
136
  data: q.records,
64
137
  localArray: q.config.localArray,
65
138
  remoteArray: q.config.remoteArray,
139
+ // Detailed per-phase processing-time breakdown (SSP sub-phases, local/
140
+ // remote record fetch, frontend reconcile, registration). Flows to both
141
+ // the DevTools panel and the MCP (which returns activeQueries verbatim).
142
+ timings: this.dataManager.phaseTimings(q),
66
143
  });
67
144
  });
68
145
  return result;
@@ -70,7 +147,7 @@ export class DevToolsService implements StreamUpdateReceiver {
70
147
 
71
148
  public onQueryInitialized(payload: any) {
72
149
  this.logger.debug(
73
- { payload, Category: 'spooky-client::DevToolsService::onQueryInitialized' },
150
+ { payload, Category: 'sp00ky-client::DevToolsService::onQueryInitialized' },
74
151
  'QueryInitialized'
75
152
  );
76
153
  const queryHash = this.hashString(payload.queryId.toString());
@@ -87,7 +164,7 @@ export class DevToolsService implements StreamUpdateReceiver {
87
164
  this.logger.debug(
88
165
  {
89
166
  id: payload.queryId?.toString(),
90
- Category: 'spooky-client::DevToolsService::onQueryUpdated',
167
+ Category: 'sp00ky-client::DevToolsService::onQueryUpdated',
91
168
  },
92
169
  'QueryUpdated'
93
170
  );
@@ -102,7 +179,7 @@ export class DevToolsService implements StreamUpdateReceiver {
102
179
 
103
180
  public onStreamUpdate(update: StreamUpdate) {
104
181
  this.logger.debug(
105
- { update, Category: 'spooky-client::DevToolsService::onStreamUpdate' },
182
+ { update, Category: 'sp00ky-client::DevToolsService::onStreamUpdate' },
106
183
  'StreamUpdate'
107
184
  );
108
185
  this.addEvent('STREAM_UPDATE', {
@@ -142,6 +219,8 @@ export class DevToolsService implements StreamUpdateReceiver {
142
219
  }
143
220
 
144
221
  private addEvent(eventType: string, payload: any) {
222
+ // No consumer attached → skip recording (and the recursive serialize it does).
223
+ if (!this.enabled) return;
145
224
  this.eventsHistory.push({
146
225
  id: this.eventIdCounter++,
147
226
  timestamp: Date.now(),
@@ -160,6 +239,15 @@ export class DevToolsService implements StreamUpdateReceiver {
160
239
  userId: this.authService.currentUser?.id,
161
240
  },
162
241
  version: this.version,
242
+ versions: {
243
+ frontend: {
244
+ core: CORE_VERSION,
245
+ wasm: WASM_VERSION,
246
+ surrealdb: SURREAL_VERSION,
247
+ },
248
+ backend: this.backendInfo.versions,
249
+ entities: this.backendInfo.entities,
250
+ },
163
251
  database: {
164
252
  tables: this.schema.tables.map((t) => t.name),
165
253
  tableData: {},
@@ -168,11 +256,13 @@ export class DevToolsService implements StreamUpdateReceiver {
168
256
  }
169
257
 
170
258
  private notifyDevTools() {
259
+ // No consumer attached → no getState() serialization, no postMessage broadcast.
260
+ if (!this.enabled) return;
171
261
  if (typeof window !== 'undefined') {
172
262
  window.postMessage(
173
263
  {
174
- type: 'SPOOKY_STATE_CHANGED',
175
- source: 'spooky-devtools-page',
264
+ type: 'SP00KY_STATE_CHANGED',
265
+ source: 'sp00ky-devtools-page',
176
266
  state: this.getState(),
177
267
  },
178
268
  '*'
@@ -229,13 +319,14 @@ export class DevToolsService implements StreamUpdateReceiver {
229
319
 
230
320
  private exposeToWindow() {
231
321
  if (typeof window !== 'undefined') {
232
- (window as any).__SPOOKY__ = {
322
+ (window as any).__00__ = {
233
323
  version: this.version,
234
324
  getState: () => this.getState(),
235
325
  clearHistory: () => {
236
326
  this.eventsHistory = [];
237
327
  this.notifyDevTools();
238
328
  },
329
+ refreshVersions: () => this.refreshBackendVersions(),
239
330
  getTableData: async (tableName: string) => {
240
331
  try {
241
332
  // Returns the first statement result as T.
@@ -270,7 +361,7 @@ export class DevToolsService implements StreamUpdateReceiver {
270
361
  return this.serializeForDevTools(records) || [];
271
362
  } catch (e) {
272
363
  this.logger.error(
273
- { err: e, Category: 'spooky-client::DevToolsService::exposeToWindow' },
364
+ { err: e, Category: 'sp00ky-client::DevToolsService::exposeToWindow' },
274
365
  'Failed to get table data'
275
366
  );
276
367
  return [];
@@ -299,7 +390,7 @@ export class DevToolsService implements StreamUpdateReceiver {
299
390
  runQuery: async (query: string, target: 'local' | 'remote' = 'local') => {
300
391
  try {
301
392
  this.logger.debug(
302
- { query, target, Category: 'spooky-client::DevToolsService::runQuery' },
393
+ { query, target, Category: 'sp00ky-client::DevToolsService::runQuery' },
303
394
  'Running query (START)'
304
395
  );
305
396
  const service = target === 'remote' ? this.remoteDatabaseService : this.databaseService;
@@ -314,7 +405,7 @@ export class DevToolsService implements StreamUpdateReceiver {
314
405
  time: queryTime,
315
406
  resultType: typeof result,
316
407
  isArray: Array.isArray(result),
317
- Category: 'spooky-client::DevToolsService::runQuery',
408
+ Category: 'sp00ky-client::DevToolsService::runQuery',
318
409
  },
319
410
  'Database returned result'
320
411
  );
@@ -328,7 +419,7 @@ export class DevToolsService implements StreamUpdateReceiver {
328
419
  {
329
420
  serializeTime,
330
421
  serializedLength: JSON.stringify(serialized).length,
331
- Category: 'spooky-client::DevToolsService::runQuery',
422
+ Category: 'sp00ky-client::DevToolsService::runQuery',
332
423
  },
333
424
  'Serialization complete'
334
425
  );
@@ -340,7 +431,7 @@ export class DevToolsService implements StreamUpdateReceiver {
340
431
  };
341
432
  } catch (e: any) {
342
433
  this.logger.error(
343
- { err: e, query, target, Category: 'spooky-client::DevToolsService::runQuery' },
434
+ { err: e, query, target, Category: 'sp00ky-client::DevToolsService::runQuery' },
344
435
  'Query execution failed'
345
436
  );
346
437
  // Ensure we always return a string for error
@@ -353,12 +444,15 @@ export class DevToolsService implements StreamUpdateReceiver {
353
444
 
354
445
  window.postMessage(
355
446
  {
356
- type: 'SPOOKY_DETECTED',
357
- source: 'spooky-devtools-page',
447
+ type: 'SP00KY_DETECTED',
448
+ source: 'sp00ky-devtools-page',
358
449
  data: { version: this.version, detected: true },
359
450
  },
360
451
  '*'
361
452
  );
453
+
454
+ // Dispatch custom event so the devtools page-script can detect late initialization
455
+ window.dispatchEvent(new CustomEvent('sp00ky:init'));
362
456
  }
363
457
  }
364
458
  }
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ emptyBackendInfo,
4
+ emptyBackendVersions,
5
+ parseBackendInfo,
6
+ toEntityArray,
7
+ UNAVAILABLE,
8
+ } from './versions';
9
+
10
+ describe('toEntityArray', () => {
11
+ it('passes through an array, dropping non-objects', () => {
12
+ expect(toEntityArray([{ entity: 'ssp' }, null, 'x'])).toEqual([{ entity: 'ssp' }]);
13
+ });
14
+
15
+ it('wraps a single object', () => {
16
+ expect(toEntityArray({ entity: 'ssp' })).toEqual([{ entity: 'ssp' }]);
17
+ });
18
+
19
+ it('returns [] for null/undefined/primitives', () => {
20
+ expect(toEntityArray(null)).toEqual([]);
21
+ expect(toEntityArray(undefined)).toEqual([]);
22
+ expect(toEntityArray(42)).toEqual([]);
23
+ });
24
+ });
25
+
26
+ describe('parseBackendInfo', () => {
27
+ it('parses the singlenode shape (ssp only) incl. surrealdb_version', () => {
28
+ const { versions, entities } = parseBackendInfo([
29
+ { entity: 'ssp', version: '0.0.1-canary.69', surrealdb_version: '2.0.3', status: 'ready' },
30
+ ]);
31
+ expect(versions.ssp).toBe('0.0.1-canary.69');
32
+ expect(versions.surrealdb).toBe('2.0.3');
33
+ expect(versions.scheduler).toBe(UNAVAILABLE);
34
+ expect(entities).toHaveLength(1);
35
+ });
36
+
37
+ it('parses the cluster shape (scheduler + ssp + backend)', () => {
38
+ const { versions, entities } = parseBackendInfo([
39
+ { entity: 'scheduler', version: '0.9.0', surrealdb_version: '2.0.3', status: 'ready' },
40
+ { entity: 'ssp', version: '0.9.0', status: 'ready' },
41
+ { entity: 'backend', id: 'surrealdb', status: 'healthy' },
42
+ ]);
43
+ expect(versions.scheduler).toBe('0.9.0');
44
+ expect(versions.ssp).toBe('0.9.0');
45
+ expect(versions.surrealdb).toBe('2.0.3');
46
+ expect(entities).toHaveLength(3);
47
+ });
48
+
49
+ it('strips a leading surrealdb- prefix', () => {
50
+ const { versions } = parseBackendInfo([
51
+ { entity: 'ssp', version: '1.0.0', surrealdb_version: 'surrealdb-2.1.0' },
52
+ ]);
53
+ expect(versions.surrealdb).toBe('2.1.0');
54
+ });
55
+
56
+ it('takes surrealdb_version from whichever entity reports it', () => {
57
+ const { versions } = parseBackendInfo([
58
+ { entity: 'scheduler', version: '0.9.0' },
59
+ { entity: 'ssp', version: '0.9.0', surrealdb_version: '3.1.0' },
60
+ ]);
61
+ expect(versions.surrealdb).toBe('3.1.0');
62
+ });
63
+
64
+ it('tolerates a single object instead of an array', () => {
65
+ const { versions } = parseBackendInfo({ entity: 'ssp', version: '1.2.3' });
66
+ expect(versions.ssp).toBe('1.2.3');
67
+ });
68
+
69
+ it('returns all-unavailable / empty for null or garbage', () => {
70
+ expect(parseBackendInfo(null)).toEqual(emptyBackendInfo());
71
+ expect(parseBackendInfo('nope').versions).toEqual(emptyBackendVersions());
72
+ expect(parseBackendInfo([{ foo: 'bar' }]).versions).toEqual(emptyBackendVersions());
73
+ });
74
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Backend versions of the stack components, derived from the entity list the
3
+ * backend `/info` endpoint exposes (read via the `fn::spooky::info()` SurrealQL
4
+ * function). Any component that isn't reported degrades to `'unavailable'`.
5
+ */
6
+ export interface BackendVersions {
7
+ ssp: string;
8
+ scheduler: string;
9
+ surrealdb: string;
10
+ }
11
+
12
+ export const UNAVAILABLE = 'unavailable';
13
+
14
+ /**
15
+ * A single stack entity as reported by `/info` (one per ssp / scheduler /
16
+ * backend). Carries far more than versions — status, uptime, ip, views — so the
17
+ * DevTools can render the whole stack. Extra fields are preserved verbatim.
18
+ */
19
+ export interface BackendEntity {
20
+ entity: string;
21
+ id?: string;
22
+ ip?: string | null;
23
+ status?: string;
24
+ version?: string;
25
+ surrealdb_version?: string;
26
+ uptime_seconds?: number;
27
+ views?: number;
28
+ [key: string]: unknown;
29
+ }
30
+
31
+ export interface BackendInfo {
32
+ versions: BackendVersions;
33
+ entities: BackendEntity[];
34
+ }
35
+
36
+ export function emptyBackendVersions(): BackendVersions {
37
+ return { ssp: UNAVAILABLE, scheduler: UNAVAILABLE, surrealdb: UNAVAILABLE };
38
+ }
39
+
40
+ export function emptyBackendInfo(): BackendInfo {
41
+ return { versions: emptyBackendVersions(), entities: [] };
42
+ }
43
+
44
+ /** Strip a leading `surrealdb-` so versions read as bare semver (e.g. `2.0.3`). */
45
+ function normalizeServerVersion(v: string): string {
46
+ return String(v).replace(/^surrealdb-/i, '').trim();
47
+ }
48
+
49
+ /**
50
+ * Normalize whatever `RETURN fn::spooky::info()` resolves to into the entity
51
+ * array. The SurrealQL function returns the parsed `/info` array; depending on
52
+ * how the result is unwrapped it may arrive as the array itself, a single
53
+ * object, or `null`. Tolerant of all three.
54
+ */
55
+ export function toEntityArray(raw: unknown): BackendEntity[] {
56
+ if (Array.isArray(raw)) return raw.filter((e): e is BackendEntity => !!e && typeof e === 'object');
57
+ if (raw && typeof raw === 'object') return [raw as BackendEntity];
58
+ return [];
59
+ }
60
+
61
+ /**
62
+ * Derive component versions + the full entity list from a `/info` entity array.
63
+ * `surrealdb` is taken from whichever entity reports `surrealdb_version` (ssp or
64
+ * scheduler). Never throws; missing pieces stay `'unavailable'`.
65
+ */
66
+ export function parseBackendInfo(raw: unknown): BackendInfo {
67
+ const entities = toEntityArray(raw);
68
+ const versions = emptyBackendVersions();
69
+
70
+ for (const entity of entities) {
71
+ const version = entity.version ? String(entity.version) : undefined;
72
+ if (entity.entity === 'ssp' && version) versions.ssp = version;
73
+ else if (entity.entity === 'scheduler' && version) versions.scheduler = version;
74
+
75
+ if (versions.surrealdb === UNAVAILABLE && entity.surrealdb_version) {
76
+ versions.surrealdb = normalizeServerVersion(String(entity.surrealdb_version));
77
+ }
78
+ }
79
+
80
+ return { versions, entities };
81
+ }