@spooky-sync/core 0.0.1-canary.13 → 0.0.1-canary.131
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.
- package/AGENTS.md +56 -0
- package/dist/index.d.ts +1171 -372
- package/dist/index.js +5726 -1277
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +107 -0
- package/dist/types.d.ts +746 -0
- package/package.json +39 -8
- package/skills/sp00ky-core/SKILL.md +258 -0
- package/skills/sp00ky-core/references/auth.md +98 -0
- package/skills/sp00ky-core/references/config.md +76 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +9 -2
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +77 -32
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +288 -0
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +357 -0
- package/src/modules/data/data.hydration.test.ts +150 -0
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.recurring.test.ts +137 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/index.ts +1234 -127
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +154 -0
- package/src/modules/devtools/index.ts +191 -30
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/feature-flag/index.test.ts +120 -0
- package/src/modules/feature-flag/index.ts +209 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +101 -37
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.ts +12 -5
- package/src/modules/sync/queue/queue-up.ts +29 -14
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.ts +73 -7
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.ts +1017 -62
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +182 -17
- package/src/otel/index.ts +127 -0
- package/src/services/database/cache-engine.ts +143 -0
- package/src/services/database/database.ts +11 -11
- package/src/services/database/engine-factory.ts +32 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +6 -0
- package/src/services/database/local-migrator.ts +28 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +452 -66
- package/src/services/database/plan-render.test.ts +133 -0
- package/src/services/database/plan-render.ts +100 -0
- package/src/services/database/relation-resolver.test.ts +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +13 -13
- package/src/services/database/sqlite-cache-engine.test.ts +85 -0
- package/src/services/database/sqlite-cache-engine.ts +699 -0
- package/src/services/database/sqlite-worker.ts +116 -0
- package/src/services/database/surql-translate.ts +291 -0
- package/src/services/database/surreal-cache-engine.ts +139 -0
- package/src/services/logger/index.ts +6 -101
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +41 -0
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +295 -38
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +18 -2
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +185 -0
- package/src/sp00ky.ts +1016 -0
- package/src/types.ts +258 -15
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +35 -13
- package/src/utils/parser.ts +3 -2
- package/src/utils/surql.ts +24 -15
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +77 -1
- 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,154 @@
|
|
|
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
|
+
import type { QueryPlan } from '@spooky-sync/query-builder';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Plan-level window materialization: restrict a windowed query's base rows to
|
|
30
|
+
* exactly the SSP-computed id-set, dropping `where`/`limit`/`offset` but keeping
|
|
31
|
+
* `orderBy`, `select` and `relations`. The engine-neutral counterpart of
|
|
32
|
+
* {@link buildWindowMaterialization} (which does the same by string surgery for
|
|
33
|
+
* the raw-SurrealQL path). Returns `null` for non-offset queries so the caller
|
|
34
|
+
* keeps the normal re-query path.
|
|
35
|
+
*/
|
|
36
|
+
export function buildWindowMaterializationPlan(
|
|
37
|
+
plan: QueryPlan,
|
|
38
|
+
ids: unknown[]
|
|
39
|
+
): QueryPlan | null {
|
|
40
|
+
if (plan.offset === undefined || plan.offset <= 0) return null;
|
|
41
|
+
return {
|
|
42
|
+
...plan,
|
|
43
|
+
ids,
|
|
44
|
+
where: undefined,
|
|
45
|
+
limit: undefined,
|
|
46
|
+
offset: undefined,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function buildWindowMaterialization(
|
|
51
|
+
surql: string,
|
|
52
|
+
idsParam = '__win'
|
|
53
|
+
): { query: string } | null {
|
|
54
|
+
const kw = scanTopLevelClauses(surql);
|
|
55
|
+
if (kw.startValue === null || kw.startValue <= 0) return null;
|
|
56
|
+
if (kw.fromIndex === null) return null;
|
|
57
|
+
|
|
58
|
+
const selectClause = surql.slice(0, kw.fromIndex).trimEnd(); // "SELECT <projection>"
|
|
59
|
+
|
|
60
|
+
let orderBy = '';
|
|
61
|
+
if (kw.orderByIndex !== null) {
|
|
62
|
+
const ends = [kw.limitIndex, kw.startIndex, kw.semicolonIndex, surql.length].filter(
|
|
63
|
+
(n): n is number => n !== null && n > kw.orderByIndex!
|
|
64
|
+
);
|
|
65
|
+
const end = Math.min(...ends);
|
|
66
|
+
orderBy = ' ' + surql.slice(kw.orderByIndex, end).trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { query: `${selectClause} FROM $${idsParam}${orderBy}` };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface TopLevelClauses {
|
|
73
|
+
fromIndex: number | null;
|
|
74
|
+
orderByIndex: number | null;
|
|
75
|
+
limitIndex: number | null;
|
|
76
|
+
startIndex: number | null;
|
|
77
|
+
startValue: number | null;
|
|
78
|
+
semicolonIndex: number | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Single pass over the query, tracking paren depth and single-quoted strings so
|
|
82
|
+
// that clauses inside subqueries (which live in parens) are ignored — only the
|
|
83
|
+
// outermost (depth-0) clause keywords are recorded.
|
|
84
|
+
function scanTopLevelClauses(sql: string): TopLevelClauses {
|
|
85
|
+
const out: TopLevelClauses = {
|
|
86
|
+
fromIndex: null,
|
|
87
|
+
orderByIndex: null,
|
|
88
|
+
limitIndex: null,
|
|
89
|
+
startIndex: null,
|
|
90
|
+
startValue: null,
|
|
91
|
+
semicolonIndex: null,
|
|
92
|
+
};
|
|
93
|
+
let depth = 0;
|
|
94
|
+
let inStr = false;
|
|
95
|
+
|
|
96
|
+
for (let i = 0; i < sql.length; i++) {
|
|
97
|
+
const ch = sql[i];
|
|
98
|
+
if (inStr) {
|
|
99
|
+
if (ch === '\\') i++; // skip escaped char
|
|
100
|
+
else if (ch === "'") inStr = false;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (ch === "'") { inStr = true; continue; }
|
|
104
|
+
if (ch === '(') { depth++; continue; }
|
|
105
|
+
if (ch === ')') { depth--; continue; }
|
|
106
|
+
if (depth !== 0) continue;
|
|
107
|
+
if (ch === ';' && out.semicolonIndex === null) { out.semicolonIndex = i; continue; }
|
|
108
|
+
|
|
109
|
+
// Only test keywords at a word boundary.
|
|
110
|
+
if (!isWordBoundary(sql, i)) continue;
|
|
111
|
+
if (out.fromIndex === null && matchKeyword(sql, i, 'FROM')) { out.fromIndex = i; continue; }
|
|
112
|
+
// FROM must come before the trailing clauses; only record them once seen.
|
|
113
|
+
if (out.fromIndex === null) continue;
|
|
114
|
+
if (out.orderByIndex === null && matchKeyword(sql, i, 'ORDER BY')) { out.orderByIndex = i; continue; }
|
|
115
|
+
if (out.limitIndex === null && matchKeyword(sql, i, 'LIMIT')) { out.limitIndex = i; continue; }
|
|
116
|
+
if (out.startIndex === null && matchKeyword(sql, i, 'START')) {
|
|
117
|
+
out.startIndex = i;
|
|
118
|
+
out.startValue = readNumberAfter(sql, i + 'START'.length);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isWordBoundary(sql: string, i: number): boolean {
|
|
126
|
+
if (i === 0) return true;
|
|
127
|
+
return !/[A-Za-z0-9_]/.test(sql[i - 1]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Case-insensitive keyword match where internal whitespace (e.g. "ORDER BY")
|
|
131
|
+
// matches any run of whitespace, and the keyword ends on a non-word char.
|
|
132
|
+
function matchKeyword(sql: string, i: number, keyword: string): boolean {
|
|
133
|
+
const parts = keyword.split(' ');
|
|
134
|
+
let pos = i;
|
|
135
|
+
for (let p = 0; p < parts.length; p++) {
|
|
136
|
+
const word = parts[p];
|
|
137
|
+
if (sql.slice(pos, pos + word.length).toUpperCase() !== word) return false;
|
|
138
|
+
pos += word.length;
|
|
139
|
+
if (p < parts.length - 1) {
|
|
140
|
+
const wsStart = pos;
|
|
141
|
+
while (pos < sql.length && /\s/.test(sql[pos])) pos++;
|
|
142
|
+
if (pos === wsStart) return false; // required whitespace
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Must end on a non-word char (or end of string).
|
|
146
|
+
return pos >= sql.length || !/[A-Za-z0-9_]/.test(sql[pos]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readNumberAfter(sql: string, from: number): number | null {
|
|
150
|
+
let pos = from;
|
|
151
|
+
while (pos < sql.length && /\s/.test(sql[pos])) pos++;
|
|
152
|
+
const m = /^\d+/.exec(sql.slice(pos));
|
|
153
|
+
return m ? parseInt(m[0], 10) : null;
|
|
154
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Logger } from '../../services/logger/index';
|
|
3
|
-
import { SchemaStructure } from '@spooky-sync/query-builder';
|
|
1
|
+
import type { LocalStore, 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,17 +13,52 @@ 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
|
+
UNAVAILABLE,
|
|
24
|
+
} from './versions';
|
|
25
|
+
|
|
26
|
+
// Real bundled frontend versions, injected at build time by tsdown's
|
|
27
|
+
// version-define plugin (see tsdown.config.ts). The `typeof` guard keeps these
|
|
28
|
+
// from throwing a ReferenceError when a downstream app bundles core from source
|
|
29
|
+
// (where the plugin never runs); in that case they fall back to 'unknown' and
|
|
30
|
+
// DevTools simply reports an unknown frontend version instead of crashing.
|
|
31
|
+
const CORE_VERSION =
|
|
32
|
+
typeof __SP00KY_CORE_VERSION__ !== 'undefined' ? __SP00KY_CORE_VERSION__ : 'unknown';
|
|
33
|
+
const WASM_VERSION =
|
|
34
|
+
typeof __SP00KY_WASM_VERSION__ !== 'undefined' ? __SP00KY_WASM_VERSION__ : 'unknown';
|
|
35
|
+
const SURREAL_VERSION =
|
|
36
|
+
typeof __SP00KY_SURREAL_VERSION__ !== 'undefined' ? __SP00KY_SURREAL_VERSION__ : 'unknown';
|
|
19
37
|
|
|
20
38
|
export class DevToolsService implements StreamUpdateReceiver {
|
|
21
39
|
private eventsHistory: DevToolsEvent[] = [];
|
|
22
40
|
private eventIdCounter = 0;
|
|
23
|
-
|
|
41
|
+
// Real bundled frontend version (injected at build time via tsdown `define`).
|
|
42
|
+
private version = CORE_VERSION;
|
|
43
|
+
// Backend stack info (versions + per-entity status), read via the
|
|
44
|
+
// `fn::spooky::info()` SurrealQL function; empty/'unavailable' until resolved.
|
|
45
|
+
private backendInfo: BackendInfo = emptyBackendInfo();
|
|
46
|
+
// Dormant until a devtools consumer (extension panel or MCP) handshakes via
|
|
47
|
+
// `SP00KY_DEVTOOLS_CONNECT`. While false, `notifyDevTools()`/`addEvent()` do no
|
|
48
|
+
// work, so prod pays zero serialization/postMessage cost for an unwatched panel.
|
|
49
|
+
// `window.__00__.getState()` stays live regardless, so the panel's first paint
|
|
50
|
+
// (the on-demand GET_STATE pull) still works before the push channel turns on.
|
|
51
|
+
private enabled = false;
|
|
52
|
+
|
|
53
|
+
// Full local table list (incl. internal `_00_*`), enumerated from the local DB
|
|
54
|
+
// via our own reliable `service.query` — the DevTools panel's page-eval query
|
|
55
|
+
// bridge is unreliable under load, so the panel relies on this instead.
|
|
56
|
+
private localTables: string[] = [];
|
|
57
|
+
private localTablesFetching = false;
|
|
58
|
+
private localTablesAt = 0;
|
|
24
59
|
|
|
25
60
|
constructor(
|
|
26
|
-
private databaseService:
|
|
61
|
+
private databaseService: LocalStore,
|
|
27
62
|
private remoteDatabaseService: RemoteDatabaseService,
|
|
28
63
|
private logger: Logger,
|
|
29
64
|
private schema: SchemaStructure,
|
|
@@ -32,12 +67,62 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
32
67
|
) {
|
|
33
68
|
this.exposeToWindow();
|
|
34
69
|
|
|
35
|
-
//
|
|
70
|
+
// Stay dormant until a devtools consumer announces itself. The extension's
|
|
71
|
+
// page-script posts this once it detects `window.__00__`; the panel can also
|
|
72
|
+
// disconnect to return us to dormant. Until then we skip all serialization.
|
|
73
|
+
if (typeof window !== 'undefined') {
|
|
74
|
+
window.addEventListener('message', (e) => {
|
|
75
|
+
if (e.source !== window) return;
|
|
76
|
+
const type = (e.data as { type?: string } | undefined)?.type;
|
|
77
|
+
if (type === 'SP00KY_DEVTOOLS_CONNECT') {
|
|
78
|
+
this.enabled = true;
|
|
79
|
+
this.notifyDevTools();
|
|
80
|
+
} else if (type === 'SP00KY_DEVTOOLS_DISCONNECT') {
|
|
81
|
+
this.enabled = false;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Subscribe to auth events. The initial fire-and-forget version fetch (below)
|
|
87
|
+
// races the remote connection; on the free plan the remote DB (SurrealDB
|
|
88
|
+
// Cloud) has no guest access, so `fn::spooky::info()` is only callable once
|
|
89
|
+
// signed in. Re-fetch when auth resolves — until the versions actually land —
|
|
90
|
+
// instead of leaving them 'unavailable' forever.
|
|
36
91
|
this.authService.eventSystem.subscribe(AuthEventTypes.AuthStateChanged, () => {
|
|
37
|
-
this.
|
|
92
|
+
if (this.authService.isAuthenticated && this.backendInfo.versions.ssp === UNAVAILABLE) {
|
|
93
|
+
void this.refreshBackendVersions();
|
|
94
|
+
} else {
|
|
95
|
+
this.notifyDevTools();
|
|
96
|
+
}
|
|
38
97
|
});
|
|
39
98
|
|
|
40
|
-
|
|
99
|
+
// Fire-and-forget backend version discovery; re-push state when it lands.
|
|
100
|
+
void this.refreshBackendVersions();
|
|
101
|
+
|
|
102
|
+
this.logger.debug({ Category: 'sp00ky-client::DevToolsService::init' }, 'Service initialized');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Re-read backend stack info via the `fn::spooky::info()` SurrealQL function
|
|
107
|
+
* over the open remote connection (no HTTP/CORS), then notify the panel.
|
|
108
|
+
* Never throws: on failure the info stays empty/'unavailable'.
|
|
109
|
+
*/
|
|
110
|
+
private async refreshBackendVersions(): Promise<void> {
|
|
111
|
+
try {
|
|
112
|
+
// `RETURN fn::spooky::info()` → one statement result: the /info entity array.
|
|
113
|
+
const result = await this.remoteDatabaseService.query<unknown[]>(
|
|
114
|
+
'RETURN fn::spooky::info()'
|
|
115
|
+
);
|
|
116
|
+
const first = Array.isArray(result) ? result[0] : result;
|
|
117
|
+
this.backendInfo = parseBackendInfo(first);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
this.logger.debug(
|
|
120
|
+
{ err, Category: 'sp00ky-client::DevToolsService::versions' },
|
|
121
|
+
'fn::spooky::info() unavailable; backend versions stay unavailable'
|
|
122
|
+
);
|
|
123
|
+
this.backendInfo = emptyBackendInfo();
|
|
124
|
+
}
|
|
125
|
+
this.notifyDevTools();
|
|
41
126
|
}
|
|
42
127
|
|
|
43
128
|
// Get active queries directly from DataManager (single source of truth)
|
|
@@ -48,21 +133,33 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
48
133
|
const queries = this.dataManager.getActiveQueries();
|
|
49
134
|
queries.forEach((q) => {
|
|
50
135
|
const queryHash = this.hashString(encodeRecordId(q.config.id));
|
|
136
|
+
const createdAt =
|
|
137
|
+
q.config.lastActiveAt instanceof Date
|
|
138
|
+
? q.config.lastActiveAt.getTime()
|
|
139
|
+
: new Date(q.config.lastActiveAt || Date.now()).getTime();
|
|
51
140
|
result.set(queryHash, {
|
|
52
141
|
queryHash,
|
|
53
142
|
status: 'active',
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
143
|
+
// Runtime fetch status, distinct from the `status: 'active'`
|
|
144
|
+
// registration flag above. `fetchStatus` is 'idle' | 'fetching'.
|
|
145
|
+
fetchStatus: q.status,
|
|
146
|
+
isFetching: q.status === 'fetching',
|
|
147
|
+
createdAt,
|
|
148
|
+
// Real last-update time; before the first update it equals createdAt.
|
|
149
|
+
// (Previously Date.now(), which reset the column on every state push.)
|
|
150
|
+
lastUpdate: q.lastUpdatedAt ?? createdAt,
|
|
59
151
|
updateCount: q.updateCount,
|
|
152
|
+
ttl: q.config.ttl,
|
|
60
153
|
query: q.config.surql,
|
|
61
154
|
variables: q.config.params || {},
|
|
62
155
|
dataSize: q.records?.length || 0,
|
|
63
156
|
data: q.records,
|
|
64
157
|
localArray: q.config.localArray,
|
|
65
158
|
remoteArray: q.config.remoteArray,
|
|
159
|
+
// Detailed per-phase processing-time breakdown (SSP sub-phases, local/
|
|
160
|
+
// remote record fetch, frontend reconcile, registration). Flows to both
|
|
161
|
+
// the DevTools panel and the MCP (which returns activeQueries verbatim).
|
|
162
|
+
timings: this.dataManager.phaseTimings(q),
|
|
66
163
|
});
|
|
67
164
|
});
|
|
68
165
|
return result;
|
|
@@ -70,7 +167,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
70
167
|
|
|
71
168
|
public onQueryInitialized(payload: any) {
|
|
72
169
|
this.logger.debug(
|
|
73
|
-
{ payload, Category: '
|
|
170
|
+
{ payload, Category: 'sp00ky-client::DevToolsService::onQueryInitialized' },
|
|
74
171
|
'QueryInitialized'
|
|
75
172
|
);
|
|
76
173
|
const queryHash = this.hashString(payload.queryId.toString());
|
|
@@ -87,7 +184,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
87
184
|
this.logger.debug(
|
|
88
185
|
{
|
|
89
186
|
id: payload.queryId?.toString(),
|
|
90
|
-
Category: '
|
|
187
|
+
Category: 'sp00ky-client::DevToolsService::onQueryUpdated',
|
|
91
188
|
},
|
|
92
189
|
'QueryUpdated'
|
|
93
190
|
);
|
|
@@ -102,7 +199,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
102
199
|
|
|
103
200
|
public onStreamUpdate(update: StreamUpdate) {
|
|
104
201
|
this.logger.debug(
|
|
105
|
-
{ update, Category: '
|
|
202
|
+
{ update, Category: 'sp00ky-client::DevToolsService::onStreamUpdate' },
|
|
106
203
|
'StreamUpdate'
|
|
107
204
|
);
|
|
108
205
|
this.addEvent('STREAM_UPDATE', {
|
|
@@ -142,6 +239,8 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
142
239
|
}
|
|
143
240
|
|
|
144
241
|
private addEvent(eventType: string, payload: any) {
|
|
242
|
+
// No consumer attached → skip recording (and the recursive serialize it does).
|
|
243
|
+
if (!this.enabled) return;
|
|
145
244
|
this.eventsHistory.push({
|
|
146
245
|
id: this.eventIdCounter++,
|
|
147
246
|
timestamp: Date.now(),
|
|
@@ -151,7 +250,50 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
151
250
|
if (this.eventsHistory.length > 100) this.eventsHistory.shift();
|
|
152
251
|
}
|
|
153
252
|
|
|
253
|
+
/** Unwrap a SurrealDB `INFO FOR DB` result to its `{ tables, ... }` object. */
|
|
254
|
+
private unwrapInfo(res: any): any {
|
|
255
|
+
if (!Array.isArray(res) || !res[0]) return null;
|
|
256
|
+
const first = res[0];
|
|
257
|
+
if (first && typeof first === 'object' && 'result' in first) return first.result;
|
|
258
|
+
if (Array.isArray(first)) return first[0];
|
|
259
|
+
return first;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Refresh the cached full local-table list from `INFO FOR DB`. Fire-and-forget
|
|
264
|
+
* and throttled — called from getState() so the panel gets every table
|
|
265
|
+
* (including internal `_00_*`) without running its own (flaky) queries.
|
|
266
|
+
*/
|
|
267
|
+
private refreshLocalTables(): void {
|
|
268
|
+
if (this.localTablesFetching) return;
|
|
269
|
+
const now = Date.now();
|
|
270
|
+
if (now - this.localTablesAt < 3000) return;
|
|
271
|
+
this.localTablesFetching = true;
|
|
272
|
+
void this.databaseService
|
|
273
|
+
.query<any>('INFO FOR DB')
|
|
274
|
+
.then((res) => {
|
|
275
|
+
const info = this.unwrapInfo(res);
|
|
276
|
+
this.localTablesAt = Date.now();
|
|
277
|
+
if (info && info.tables) {
|
|
278
|
+
const names = Object.keys(info.tables);
|
|
279
|
+
const changed =
|
|
280
|
+
names.length !== this.localTables.length ||
|
|
281
|
+
names.some((n, i) => n !== this.localTables[i]);
|
|
282
|
+
this.localTables = names;
|
|
283
|
+
if (changed) this.notifyDevTools();
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
.catch(() => {
|
|
287
|
+
// Ignore — fall back to the declared app schema below.
|
|
288
|
+
})
|
|
289
|
+
.finally(() => {
|
|
290
|
+
this.localTablesFetching = false;
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
154
294
|
private getState() {
|
|
295
|
+
// Keep the full local-table list fresh (throttled, non-blocking).
|
|
296
|
+
this.refreshLocalTables();
|
|
155
297
|
return this.serializeForDevTools({
|
|
156
298
|
eventsHistory: [...this.eventsHistory],
|
|
157
299
|
activeQueries: Object.fromEntries(this.getActiveQueries()),
|
|
@@ -160,19 +302,34 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
160
302
|
userId: this.authService.currentUser?.id,
|
|
161
303
|
},
|
|
162
304
|
version: this.version,
|
|
305
|
+
versions: {
|
|
306
|
+
frontend: {
|
|
307
|
+
core: CORE_VERSION,
|
|
308
|
+
wasm: WASM_VERSION,
|
|
309
|
+
surrealdb: SURREAL_VERSION,
|
|
310
|
+
},
|
|
311
|
+
backend: this.backendInfo.versions,
|
|
312
|
+
entities: this.backendInfo.entities,
|
|
313
|
+
},
|
|
163
314
|
database: {
|
|
164
|
-
|
|
315
|
+
// Prefer the live local-table list (includes internal `_00_*`); fall
|
|
316
|
+
// back to the declared app schema until the first enumeration lands.
|
|
317
|
+
tables: this.localTables.length
|
|
318
|
+
? this.localTables
|
|
319
|
+
: this.schema.tables.map((t) => t.name),
|
|
165
320
|
tableData: {},
|
|
166
321
|
},
|
|
167
322
|
});
|
|
168
323
|
}
|
|
169
324
|
|
|
170
325
|
private notifyDevTools() {
|
|
326
|
+
// No consumer attached → no getState() serialization, no postMessage broadcast.
|
|
327
|
+
if (!this.enabled) return;
|
|
171
328
|
if (typeof window !== 'undefined') {
|
|
172
329
|
window.postMessage(
|
|
173
330
|
{
|
|
174
|
-
type: '
|
|
175
|
-
source: '
|
|
331
|
+
type: 'SP00KY_STATE_CHANGED',
|
|
332
|
+
source: 'sp00ky-devtools-page',
|
|
176
333
|
state: this.getState(),
|
|
177
334
|
},
|
|
178
335
|
'*'
|
|
@@ -229,13 +386,14 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
229
386
|
|
|
230
387
|
private exposeToWindow() {
|
|
231
388
|
if (typeof window !== 'undefined') {
|
|
232
|
-
(window as any).
|
|
389
|
+
(window as any).__00__ = {
|
|
233
390
|
version: this.version,
|
|
234
391
|
getState: () => this.getState(),
|
|
235
392
|
clearHistory: () => {
|
|
236
393
|
this.eventsHistory = [];
|
|
237
394
|
this.notifyDevTools();
|
|
238
395
|
},
|
|
396
|
+
refreshVersions: () => this.refreshBackendVersions(),
|
|
239
397
|
getTableData: async (tableName: string) => {
|
|
240
398
|
try {
|
|
241
399
|
// Returns the first statement result as T.
|
|
@@ -270,7 +428,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
270
428
|
return this.serializeForDevTools(records) || [];
|
|
271
429
|
} catch (e) {
|
|
272
430
|
this.logger.error(
|
|
273
|
-
{ err: e, Category: '
|
|
431
|
+
{ err: e, Category: 'sp00ky-client::DevToolsService::exposeToWindow' },
|
|
274
432
|
'Failed to get table data'
|
|
275
433
|
);
|
|
276
434
|
return [];
|
|
@@ -299,7 +457,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
299
457
|
runQuery: async (query: string, target: 'local' | 'remote' = 'local') => {
|
|
300
458
|
try {
|
|
301
459
|
this.logger.debug(
|
|
302
|
-
{ query, target, Category: '
|
|
460
|
+
{ query, target, Category: 'sp00ky-client::DevToolsService::runQuery' },
|
|
303
461
|
'Running query (START)'
|
|
304
462
|
);
|
|
305
463
|
const service = target === 'remote' ? this.remoteDatabaseService : this.databaseService;
|
|
@@ -314,7 +472,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
314
472
|
time: queryTime,
|
|
315
473
|
resultType: typeof result,
|
|
316
474
|
isArray: Array.isArray(result),
|
|
317
|
-
Category: '
|
|
475
|
+
Category: 'sp00ky-client::DevToolsService::runQuery',
|
|
318
476
|
},
|
|
319
477
|
'Database returned result'
|
|
320
478
|
);
|
|
@@ -328,7 +486,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
328
486
|
{
|
|
329
487
|
serializeTime,
|
|
330
488
|
serializedLength: JSON.stringify(serialized).length,
|
|
331
|
-
Category: '
|
|
489
|
+
Category: 'sp00ky-client::DevToolsService::runQuery',
|
|
332
490
|
},
|
|
333
491
|
'Serialization complete'
|
|
334
492
|
);
|
|
@@ -340,7 +498,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
340
498
|
};
|
|
341
499
|
} catch (e: any) {
|
|
342
500
|
this.logger.error(
|
|
343
|
-
{ err: e, query, target, Category: '
|
|
501
|
+
{ err: e, query, target, Category: 'sp00ky-client::DevToolsService::runQuery' },
|
|
344
502
|
'Query execution failed'
|
|
345
503
|
);
|
|
346
504
|
// Ensure we always return a string for error
|
|
@@ -353,12 +511,15 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
353
511
|
|
|
354
512
|
window.postMessage(
|
|
355
513
|
{
|
|
356
|
-
type: '
|
|
357
|
-
source: '
|
|
514
|
+
type: 'SP00KY_DETECTED',
|
|
515
|
+
source: 'sp00ky-devtools-page',
|
|
358
516
|
data: { version: this.version, detected: true },
|
|
359
517
|
},
|
|
360
518
|
'*'
|
|
361
519
|
);
|
|
520
|
+
|
|
521
|
+
// Dispatch custom event so the devtools page-script can detect late initialization
|
|
522
|
+
window.dispatchEvent(new CustomEvent('sp00ky:init'));
|
|
362
523
|
}
|
|
363
524
|
}
|
|
364
525
|
}
|
|
@@ -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
|
+
});
|