@spooky-sync/core 0.0.1-canary.7 → 0.0.1-canary.70
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 +299 -369
- package/dist/index.js +2275 -399
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/types.d.ts +460 -0
- package/package.json +37 -7
- 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 +6 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +3 -2
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +17 -20
- package/src/modules/cache/index.ts +41 -29
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +281 -0
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +352 -0
- package/src/modules/data/data.status.test.ts +108 -0
- package/src/modules/data/index.ts +662 -108
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +130 -0
- package/src/modules/devtools/index.ts +77 -21
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/ref-tables.test.ts +56 -0
- package/src/modules/ref-tables.ts +57 -0
- package/src/modules/sync/engine.ts +97 -37
- package/src/modules/sync/events/index.ts +3 -2
- package/src/modules/sync/queue/queue-down.ts +5 -4
- package/src/modules/sync/queue/queue-up.ts +14 -13
- package/src/modules/sync/scheduler.ts +2 -2
- package/src/modules/sync/sync.ts +553 -58
- package/src/modules/sync/utils.test.ts +239 -2
- package/src/modules/sync/utils.ts +166 -17
- package/src/otel/index.ts +127 -0
- package/src/services/database/database.ts +11 -11
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/local-migrator.ts +21 -21
- package/src/services/database/local.test.ts +33 -0
- package/src/services/database/local.ts +192 -32
- package/src/services/database/remote.ts +13 -13
- 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 +9 -9
- package/src/services/stream-processor/index.ts +205 -36
- 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.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +18 -2
- package/src/sp00ky.auth-order.test.ts +65 -0
- package/src/sp00ky.ts +582 -0
- package/src/types.ts +132 -13
- 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 +55 -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,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,23 @@ 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';
|
|
19
24
|
|
|
20
25
|
export class DevToolsService implements StreamUpdateReceiver {
|
|
21
26
|
private eventsHistory: DevToolsEvent[] = [];
|
|
22
27
|
private eventIdCounter = 0;
|
|
23
|
-
|
|
28
|
+
// Real bundled frontend version (injected at build time via tsdown `define`).
|
|
29
|
+
private version = __SP00KY_CORE_VERSION__;
|
|
30
|
+
// Backend stack info (versions + per-entity status), read via the
|
|
31
|
+
// `fn::spooky::info()` SurrealQL function; empty/'unavailable' until resolved.
|
|
32
|
+
private backendInfo: BackendInfo = emptyBackendInfo();
|
|
24
33
|
|
|
25
34
|
constructor(
|
|
26
35
|
private databaseService: LocalDatabaseService,
|
|
@@ -37,7 +46,33 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
37
46
|
this.notifyDevTools();
|
|
38
47
|
});
|
|
39
48
|
|
|
40
|
-
|
|
49
|
+
// Fire-and-forget backend version discovery; re-push state when it lands.
|
|
50
|
+
void this.refreshBackendVersions();
|
|
51
|
+
|
|
52
|
+
this.logger.debug({ Category: 'sp00ky-client::DevToolsService::init' }, 'Service initialized');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Re-read backend stack info via the `fn::spooky::info()` SurrealQL function
|
|
57
|
+
* over the open remote connection (no HTTP/CORS), then notify the panel.
|
|
58
|
+
* Never throws: on failure the info stays empty/'unavailable'.
|
|
59
|
+
*/
|
|
60
|
+
private async refreshBackendVersions(): Promise<void> {
|
|
61
|
+
try {
|
|
62
|
+
// `RETURN fn::spooky::info()` → one statement result: the /info entity array.
|
|
63
|
+
const result = await this.remoteDatabaseService.query<unknown[]>(
|
|
64
|
+
'RETURN fn::spooky::info()'
|
|
65
|
+
);
|
|
66
|
+
const first = Array.isArray(result) ? result[0] : result;
|
|
67
|
+
this.backendInfo = parseBackendInfo(first);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
this.logger.debug(
|
|
70
|
+
{ err, Category: 'sp00ky-client::DevToolsService::versions' },
|
|
71
|
+
'fn::spooky::info() unavailable; backend versions stay unavailable'
|
|
72
|
+
);
|
|
73
|
+
this.backendInfo = emptyBackendInfo();
|
|
74
|
+
}
|
|
75
|
+
this.notifyDevTools();
|
|
41
76
|
}
|
|
42
77
|
|
|
43
78
|
// Get active queries directly from DataManager (single source of truth)
|
|
@@ -51,6 +86,10 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
51
86
|
result.set(queryHash, {
|
|
52
87
|
queryHash,
|
|
53
88
|
status: 'active',
|
|
89
|
+
// Runtime fetch status, distinct from the `status: 'active'`
|
|
90
|
+
// registration flag above. `fetchStatus` is 'idle' | 'fetching'.
|
|
91
|
+
fetchStatus: q.status,
|
|
92
|
+
isFetching: q.status === 'fetching',
|
|
54
93
|
createdAt:
|
|
55
94
|
q.config.lastActiveAt instanceof Date
|
|
56
95
|
? q.config.lastActiveAt.getTime()
|
|
@@ -63,6 +102,10 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
63
102
|
data: q.records,
|
|
64
103
|
localArray: q.config.localArray,
|
|
65
104
|
remoteArray: q.config.remoteArray,
|
|
105
|
+
// Detailed per-phase processing-time breakdown (SSP sub-phases, local/
|
|
106
|
+
// remote record fetch, frontend reconcile, registration). Flows to both
|
|
107
|
+
// the DevTools panel and the MCP (which returns activeQueries verbatim).
|
|
108
|
+
timings: this.dataManager.phaseTimings(q),
|
|
66
109
|
});
|
|
67
110
|
});
|
|
68
111
|
return result;
|
|
@@ -70,7 +113,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
70
113
|
|
|
71
114
|
public onQueryInitialized(payload: any) {
|
|
72
115
|
this.logger.debug(
|
|
73
|
-
{ payload, Category: '
|
|
116
|
+
{ payload, Category: 'sp00ky-client::DevToolsService::onQueryInitialized' },
|
|
74
117
|
'QueryInitialized'
|
|
75
118
|
);
|
|
76
119
|
const queryHash = this.hashString(payload.queryId.toString());
|
|
@@ -87,7 +130,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
87
130
|
this.logger.debug(
|
|
88
131
|
{
|
|
89
132
|
id: payload.queryId?.toString(),
|
|
90
|
-
Category: '
|
|
133
|
+
Category: 'sp00ky-client::DevToolsService::onQueryUpdated',
|
|
91
134
|
},
|
|
92
135
|
'QueryUpdated'
|
|
93
136
|
);
|
|
@@ -102,7 +145,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
102
145
|
|
|
103
146
|
public onStreamUpdate(update: StreamUpdate) {
|
|
104
147
|
this.logger.debug(
|
|
105
|
-
{ update, Category: '
|
|
148
|
+
{ update, Category: 'sp00ky-client::DevToolsService::onStreamUpdate' },
|
|
106
149
|
'StreamUpdate'
|
|
107
150
|
);
|
|
108
151
|
this.addEvent('STREAM_UPDATE', {
|
|
@@ -160,6 +203,15 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
160
203
|
userId: this.authService.currentUser?.id,
|
|
161
204
|
},
|
|
162
205
|
version: this.version,
|
|
206
|
+
versions: {
|
|
207
|
+
frontend: {
|
|
208
|
+
core: __SP00KY_CORE_VERSION__,
|
|
209
|
+
wasm: __SP00KY_WASM_VERSION__,
|
|
210
|
+
surrealdb: __SP00KY_SURREAL_VERSION__,
|
|
211
|
+
},
|
|
212
|
+
backend: this.backendInfo.versions,
|
|
213
|
+
entities: this.backendInfo.entities,
|
|
214
|
+
},
|
|
163
215
|
database: {
|
|
164
216
|
tables: this.schema.tables.map((t) => t.name),
|
|
165
217
|
tableData: {},
|
|
@@ -171,8 +223,8 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
171
223
|
if (typeof window !== 'undefined') {
|
|
172
224
|
window.postMessage(
|
|
173
225
|
{
|
|
174
|
-
type: '
|
|
175
|
-
source: '
|
|
226
|
+
type: 'SP00KY_STATE_CHANGED',
|
|
227
|
+
source: 'sp00ky-devtools-page',
|
|
176
228
|
state: this.getState(),
|
|
177
229
|
},
|
|
178
230
|
'*'
|
|
@@ -229,13 +281,14 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
229
281
|
|
|
230
282
|
private exposeToWindow() {
|
|
231
283
|
if (typeof window !== 'undefined') {
|
|
232
|
-
(window as any).
|
|
284
|
+
(window as any).__00__ = {
|
|
233
285
|
version: this.version,
|
|
234
286
|
getState: () => this.getState(),
|
|
235
287
|
clearHistory: () => {
|
|
236
288
|
this.eventsHistory = [];
|
|
237
289
|
this.notifyDevTools();
|
|
238
290
|
},
|
|
291
|
+
refreshVersions: () => this.refreshBackendVersions(),
|
|
239
292
|
getTableData: async (tableName: string) => {
|
|
240
293
|
try {
|
|
241
294
|
// Returns the first statement result as T.
|
|
@@ -270,7 +323,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
270
323
|
return this.serializeForDevTools(records) || [];
|
|
271
324
|
} catch (e) {
|
|
272
325
|
this.logger.error(
|
|
273
|
-
{ err: e, Category: '
|
|
326
|
+
{ err: e, Category: 'sp00ky-client::DevToolsService::exposeToWindow' },
|
|
274
327
|
'Failed to get table data'
|
|
275
328
|
);
|
|
276
329
|
return [];
|
|
@@ -299,7 +352,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
299
352
|
runQuery: async (query: string, target: 'local' | 'remote' = 'local') => {
|
|
300
353
|
try {
|
|
301
354
|
this.logger.debug(
|
|
302
|
-
{ query, target, Category: '
|
|
355
|
+
{ query, target, Category: 'sp00ky-client::DevToolsService::runQuery' },
|
|
303
356
|
'Running query (START)'
|
|
304
357
|
);
|
|
305
358
|
const service = target === 'remote' ? this.remoteDatabaseService : this.databaseService;
|
|
@@ -314,7 +367,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
314
367
|
time: queryTime,
|
|
315
368
|
resultType: typeof result,
|
|
316
369
|
isArray: Array.isArray(result),
|
|
317
|
-
Category: '
|
|
370
|
+
Category: 'sp00ky-client::DevToolsService::runQuery',
|
|
318
371
|
},
|
|
319
372
|
'Database returned result'
|
|
320
373
|
);
|
|
@@ -328,7 +381,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
328
381
|
{
|
|
329
382
|
serializeTime,
|
|
330
383
|
serializedLength: JSON.stringify(serialized).length,
|
|
331
|
-
Category: '
|
|
384
|
+
Category: 'sp00ky-client::DevToolsService::runQuery',
|
|
332
385
|
},
|
|
333
386
|
'Serialization complete'
|
|
334
387
|
);
|
|
@@ -340,7 +393,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
340
393
|
};
|
|
341
394
|
} catch (e: any) {
|
|
342
395
|
this.logger.error(
|
|
343
|
-
{ err: e, query, target, Category: '
|
|
396
|
+
{ err: e, query, target, Category: 'sp00ky-client::DevToolsService::runQuery' },
|
|
344
397
|
'Query execution failed'
|
|
345
398
|
);
|
|
346
399
|
// Ensure we always return a string for error
|
|
@@ -353,12 +406,15 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
353
406
|
|
|
354
407
|
window.postMessage(
|
|
355
408
|
{
|
|
356
|
-
type: '
|
|
357
|
-
source: '
|
|
409
|
+
type: 'SP00KY_DETECTED',
|
|
410
|
+
source: 'sp00ky-devtools-page',
|
|
358
411
|
data: { version: this.version, detected: true },
|
|
359
412
|
},
|
|
360
413
|
'*'
|
|
361
414
|
);
|
|
415
|
+
|
|
416
|
+
// Dispatch custom event so the devtools page-script can detect late initialization
|
|
417
|
+
window.dispatchEvent(new CustomEvent('sp00ky:init'));
|
|
362
418
|
}
|
|
363
419
|
}
|
|
364
420
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { listRefTableFor, sanitizeUserId } from './ref-tables';
|
|
4
|
+
|
|
5
|
+
describe('listRefTableFor', () => {
|
|
6
|
+
it('returns global table in single mode regardless of user', () => {
|
|
7
|
+
expect(listRefTableFor('single', 'user:abc')).toBe('_00_list_ref');
|
|
8
|
+
expect(listRefTableFor('single', null)).toBe('_00_list_ref');
|
|
9
|
+
expect(listRefTableFor('single', undefined)).toBe('_00_list_ref');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('returns per-user table in dedicated mode with valid user id', () => {
|
|
13
|
+
expect(listRefTableFor('dedicated', 'user:abc')).toBe(
|
|
14
|
+
'_00_list_ref_user_abc'
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('accepts a RecordId object in dedicated mode', () => {
|
|
19
|
+
const rid = new RecordId('user', 'def');
|
|
20
|
+
expect(listRefTableFor('dedicated', rid)).toBe('_00_list_ref_user_def');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('falls back to global in dedicated mode when user id is missing', () => {
|
|
24
|
+
expect(listRefTableFor('dedicated', null)).toBe('_00_list_ref');
|
|
25
|
+
expect(listRefTableFor('dedicated', undefined)).toBe('_00_list_ref');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('falls back to global in dedicated mode when user id has invalid chars', () => {
|
|
29
|
+
// SurrealDB table identifiers only accept alphanumerics + underscore.
|
|
30
|
+
expect(listRefTableFor('dedicated', 'user:abc-with-dash')).toBe(
|
|
31
|
+
'_00_list_ref'
|
|
32
|
+
);
|
|
33
|
+
expect(listRefTableFor('dedicated', 'user:abc.dot')).toBe('_00_list_ref');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('sanitizeUserId', () => {
|
|
38
|
+
it('strips the user: prefix', () => {
|
|
39
|
+
expect(sanitizeUserId('user:abc123')).toBe('abc123');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('accepts plain ids without the user: prefix', () => {
|
|
43
|
+
expect(sanitizeUserId('abc123')).toBe('abc123');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('accepts RecordId objects', () => {
|
|
47
|
+
expect(sanitizeUserId(new RecordId('user', 'xyz'))).toBe('xyz');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns null for invalid id shapes', () => {
|
|
51
|
+
expect(sanitizeUserId(null)).toBeNull();
|
|
52
|
+
expect(sanitizeUserId(undefined)).toBeNull();
|
|
53
|
+
expect(sanitizeUserId('user:')).toBeNull();
|
|
54
|
+
expect(sanitizeUserId('user:has-dash')).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Client-side mirror of `packages/ssp-protocol/src/lib.rs`'s
|
|
2
|
+
// `query_table_for` / `list_ref_table_for`. Same naming convention so
|
|
3
|
+
// the LIVE subscription, the initial-fetch read, and the SSP's writes
|
|
4
|
+
// all land on the same table.
|
|
5
|
+
//
|
|
6
|
+
// The mode is currently hardcoded to `dedicated` because that's the
|
|
7
|
+
// only mode the e2e suite exercises and threading the value through
|
|
8
|
+
// codegen wasn't necessary to land the cross-session fix. If single
|
|
9
|
+
// mode ever needs to be exposed from the TS client too, the SSP server
|
|
10
|
+
// already reads it from `SPKY_SSP_REF_MODE`; add a matching codegen
|
|
11
|
+
// export then.
|
|
12
|
+
|
|
13
|
+
export type RefMode = 'single' | 'dedicated';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Default ref-storage mode for this client build. Mirrors the SSP's
|
|
17
|
+
* default (`RefMode::Dedicated`) so cross-session sync works out of the
|
|
18
|
+
* box.
|
|
19
|
+
*/
|
|
20
|
+
export const DEFAULT_REF_MODE: RefMode = 'dedicated';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Sanitize a user record id (e.g. `"user:abc"`) into the segment that
|
|
24
|
+
* goes into a dedicated table name (e.g. `"abc"`). Returns `null` if
|
|
25
|
+
* the id is missing the `user:` prefix or contains characters that
|
|
26
|
+
* aren't valid in a SurrealDB table identifier — the server-side
|
|
27
|
+
* `ssp_protocol::sanitize_user_id` uses the same predicate.
|
|
28
|
+
*
|
|
29
|
+
* Accepts both string ids (`"user:abc"`) and SurrealDB `RecordId`
|
|
30
|
+
* objects (which only stringify cleanly via `.toString()`), since
|
|
31
|
+
* `AuthService` passes the record-id object as-is to its subscribers.
|
|
32
|
+
*/
|
|
33
|
+
export function sanitizeUserId(userId: unknown): string | null {
|
|
34
|
+
if (userId === null || userId === undefined) return null;
|
|
35
|
+
const asString =
|
|
36
|
+
typeof userId === 'string'
|
|
37
|
+
? userId
|
|
38
|
+
: typeof (userId as { toString?: unknown }).toString === 'function'
|
|
39
|
+
? (userId as { toString: () => string }).toString()
|
|
40
|
+
: null;
|
|
41
|
+
if (!asString) return null;
|
|
42
|
+
const raw = asString.startsWith('user:') ? asString.slice('user:'.length) : asString;
|
|
43
|
+
if (raw.length === 0) return null;
|
|
44
|
+
if (!/^[A-Za-z0-9_]+$/.test(raw)) return null;
|
|
45
|
+
return raw;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Returns the `_00_list_ref` table name for `(mode, userId)`. Falls
|
|
50
|
+
* back to the global `_00_list_ref` when sanitization fails or in
|
|
51
|
+
* single mode.
|
|
52
|
+
*/
|
|
53
|
+
export function listRefTableFor(mode: RefMode, userId: unknown): string {
|
|
54
|
+
if (mode === 'single') return '_00_list_ref';
|
|
55
|
+
const uid = sanitizeUserId(userId);
|
|
56
|
+
return uid ? `_00_list_ref_user_${uid}` : '_00_list_ref';
|
|
57
|
+
}
|