@spooky-sync/core 0.0.1-canary.20 → 0.0.1-canary.201
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +57 -0
- package/dist/index.d.ts +2184 -54
- package/dist/index.js +11515 -2399
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +276 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +421 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +434 -0
- package/dist/types.d.ts +688 -11
- package/package.json +11 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
- package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
- package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
- package/src/bucket-blurhash.test.ts +148 -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 +35 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +112 -32
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/data/data.hydration.test.ts +142 -0
- package/src/modules/data/data.membership.test.ts +462 -0
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.settled-writes.test.ts +206 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/id-set-plan.test.ts +122 -0
- package/src/modules/data/index.ts +1580 -130
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +194 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +386 -37
- package/src/modules/devtools/notify-throttle.test.ts +149 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +251 -0
- package/src/modules/feature-flag/index.ts +308 -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.test.ts +107 -0
- package/src/modules/sync/queue/queue-down.ts +35 -6
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
- package/src/modules/sync/queue/queue-up.ts +241 -57
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.retry.test.ts +156 -0
- package/src/modules/sync/scheduler.ts +158 -11
- package/src/modules/sync/sync.cleanup.test.ts +116 -0
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.heartbeat.test.ts +80 -0
- package/src/modules/sync/sync.live-removal.test.ts +134 -0
- package/src/modules/sync/sync.reconnect.test.ts +145 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.ts +1558 -99
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +201 -17
- package/src/otel/index.ts +13 -10
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +160 -0
- package/src/services/database/connection-supervisor.test.ts +289 -0
- package/src/services/database/connection-supervisor.ts +415 -0
- package/src/services/database/database.query-timeout.test.ts +83 -0
- package/src/services/database/database.ts +32 -12
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/local-migrator.ts +30 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +478 -67
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -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 +110 -14
- package/src/services/database/sqlite-cache-engine.test.ts +558 -0
- package/src/services/database/sqlite-cache-engine.ts +1257 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +104 -0
- package/src/services/database/sqlite-plan-sql.ts +106 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -0
- package/src/services/database/sqlite-select.ts +121 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +221 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +416 -0
- package/src/services/database/surreal-cache-engine.ts +141 -0
- package/src/services/logger/index.ts +3 -2
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +11 -4
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +444 -52
- 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 +216 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +23 -2
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +278 -0
- package/src/services/tabs/coordinator.test.ts +244 -0
- package/src/services/tabs/coordinator.ts +576 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +242 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +586 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.ts +1543 -0
- package/src/types.ts +496 -13
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +73 -13
- package/src/utils/parser.ts +3 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- package/src/spooky.ts +0 -395
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { DevToolsService } from './index';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A state push serializes EVERY active query's full record set and postMessage
|
|
7
|
+
* clones it again, so its cost scales with the whole client dataset. It is
|
|
8
|
+
* requested per event — including one per local DB query — so without
|
|
9
|
+
* coalescing a page load's few hundred local queries turn a few MB of rows into
|
|
10
|
+
* GBs of short-lived large-object garbage and OOM the renderer. These tests pin
|
|
11
|
+
* the coalescing, not the payload.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function harness(recordCount = 500) {
|
|
15
|
+
const posted: any[] = [];
|
|
16
|
+
const listeners: ((e: any) => void)[] = [];
|
|
17
|
+
const fakeWindow: any = {
|
|
18
|
+
postMessage: (msg: any) => posted.push(msg),
|
|
19
|
+
addEventListener: (_type: string, cb: (e: any) => void) => listeners.push(cb),
|
|
20
|
+
dispatchEvent: () => true,
|
|
21
|
+
};
|
|
22
|
+
fakeWindow.self = fakeWindow;
|
|
23
|
+
vi.stubGlobal('window', fakeWindow);
|
|
24
|
+
vi.stubGlobal('CustomEvent', class {
|
|
25
|
+
type: string;
|
|
26
|
+
constructor(type: string) {
|
|
27
|
+
this.type = type;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const noop = () => {};
|
|
32
|
+
const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
33
|
+
logger.child = () => logger;
|
|
34
|
+
|
|
35
|
+
const local: any = {
|
|
36
|
+
query: async () => [],
|
|
37
|
+
getConfig: () => ({ store: 'memory' }),
|
|
38
|
+
currentBucketId: 'anon',
|
|
39
|
+
storageHealth: { status: 'memory', fallback: false },
|
|
40
|
+
};
|
|
41
|
+
const remote: any = { query: async () => [] };
|
|
42
|
+
const auth: any = {
|
|
43
|
+
isAuthenticated: false,
|
|
44
|
+
currentUser: undefined,
|
|
45
|
+
eventSystem: { subscribe: noop },
|
|
46
|
+
};
|
|
47
|
+
// One query holding a lot of rows: the thing whose repeated serialization is
|
|
48
|
+
// what actually blows the heap.
|
|
49
|
+
const records = Array.from({ length: recordCount }, (_, i) => ({ id: `game:${i}`, pgn: 'x' }));
|
|
50
|
+
const dataManager: any = {
|
|
51
|
+
getActiveQueries: () => [
|
|
52
|
+
{
|
|
53
|
+
config: { id: new RecordId('_00_query', 'q1'), params: {} },
|
|
54
|
+
status: 'idle',
|
|
55
|
+
records,
|
|
56
|
+
updateCount: 1,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
phaseTimings: () => ({}),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const service = new DevToolsService(local, remote, logger, { tables: [] } as any, auth, dataManager);
|
|
63
|
+
// Announce a consumer, exactly like the extension's page-script does.
|
|
64
|
+
for (const cb of listeners) {
|
|
65
|
+
cb({ source: fakeWindow, data: { type: 'SP00KY_DEVTOOLS_CONNECT' } });
|
|
66
|
+
}
|
|
67
|
+
const statePushes = () => posted.filter((m) => m.type === 'SP00KY_STATE_CHANGED');
|
|
68
|
+
return { service, statePushes, posted };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
vi.useRealTimers();
|
|
73
|
+
vi.unstubAllGlobals();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('DevToolsService state-push coalescing', () => {
|
|
77
|
+
it('pushes once immediately on connect', () => {
|
|
78
|
+
vi.useFakeTimers();
|
|
79
|
+
const { statePushes } = harness();
|
|
80
|
+
expect(statePushes().length).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('collapses a burst of per-query events into ONE trailing push', () => {
|
|
84
|
+
vi.useFakeTimers();
|
|
85
|
+
const { service, statePushes } = harness();
|
|
86
|
+
const before = statePushes().length;
|
|
87
|
+
|
|
88
|
+
// What a page load looks like: hundreds of LOCAL_QUERY events, each of which
|
|
89
|
+
// used to serialize the entire query state.
|
|
90
|
+
for (let i = 0; i < 400; i++) {
|
|
91
|
+
(service as any).logEvent('LOCAL_QUERY', { query: 'SELECT * FROM game', vars: {} });
|
|
92
|
+
}
|
|
93
|
+
// Nothing extra yet — the burst is queued, not serialized 400 times.
|
|
94
|
+
expect(statePushes().length).toBe(before);
|
|
95
|
+
|
|
96
|
+
vi.advanceTimersByTime(300);
|
|
97
|
+
expect(statePushes().length).toBe(before + 1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('still pushes again once the window has passed', () => {
|
|
101
|
+
vi.useFakeTimers();
|
|
102
|
+
const { service, statePushes } = harness();
|
|
103
|
+
const before = statePushes().length;
|
|
104
|
+
|
|
105
|
+
(service as any).logEvent('A', {});
|
|
106
|
+
vi.advanceTimersByTime(300);
|
|
107
|
+
expect(statePushes().length).toBe(before + 1);
|
|
108
|
+
|
|
109
|
+
// An event arriving right after that flush is still inside the window, so it
|
|
110
|
+
// queues rather than pushing again...
|
|
111
|
+
(service as any).logEvent('B', {});
|
|
112
|
+
expect(statePushes().length).toBe(before + 1);
|
|
113
|
+
vi.advanceTimersByTime(300);
|
|
114
|
+
expect(statePushes().length).toBe(before + 2);
|
|
115
|
+
|
|
116
|
+
// ...but once the tab has been idle past the window, the next event pushes
|
|
117
|
+
// straight away, so the panel never waits on a quiet app.
|
|
118
|
+
vi.advanceTimersByTime(1000);
|
|
119
|
+
(service as any).logEvent('C', {});
|
|
120
|
+
expect(statePushes().length).toBe(before + 3);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('drops a queued push when the consumer disconnects mid-window', () => {
|
|
124
|
+
vi.useFakeTimers();
|
|
125
|
+
const { service, statePushes, posted } = harness();
|
|
126
|
+
const before = statePushes().length;
|
|
127
|
+
|
|
128
|
+
(service as any).logEvent('A', {});
|
|
129
|
+
(service as any).enabled = false; // panel closed while the push was queued
|
|
130
|
+
vi.advanceTimersByTime(300);
|
|
131
|
+
expect(statePushes().length).toBe(before);
|
|
132
|
+
expect(posted.some((m) => m.type === 'SP00KY_STATE_CHANGED' && m.state === undefined)).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('serializes the LATEST state at flush time, not at request time', () => {
|
|
136
|
+
vi.useFakeTimers();
|
|
137
|
+
const { service, statePushes } = harness();
|
|
138
|
+
|
|
139
|
+
(service as any).logEvent('FIRST', {});
|
|
140
|
+
(service as any).logEvent('SECOND', {});
|
|
141
|
+
vi.advanceTimersByTime(300);
|
|
142
|
+
|
|
143
|
+
const last = statePushes().at(-1);
|
|
144
|
+
const types = last.state.eventsHistory.map((e: any) => e.eventType);
|
|
145
|
+
// Both events of the coalesced window are present in the single push.
|
|
146
|
+
expect(types).toContain('FIRST');
|
|
147
|
+
expect(types).toContain('SECOND');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
2
|
+
import { walkOpfs } from './storage-info';
|
|
3
|
+
|
|
4
|
+
/** Minimal in-memory OPFS: directories are nested objects, files are numbers
|
|
5
|
+
* (their size) or 'locked' (getFile() throws, like a live SAHPool handle). */
|
|
6
|
+
type FakeTree = { [name: string]: FakeTree | number | 'locked' };
|
|
7
|
+
|
|
8
|
+
function makeDirHandle(tree: FakeTree): any {
|
|
9
|
+
return {
|
|
10
|
+
kind: 'directory',
|
|
11
|
+
entries: async function* () {
|
|
12
|
+
for (const [name, node] of Object.entries(tree)) {
|
|
13
|
+
if (typeof node === 'object') {
|
|
14
|
+
yield [name, makeDirHandle(node)];
|
|
15
|
+
} else {
|
|
16
|
+
yield [
|
|
17
|
+
name,
|
|
18
|
+
{
|
|
19
|
+
kind: 'file',
|
|
20
|
+
getFile: async () => {
|
|
21
|
+
if (node === 'locked') throw new DOMException('locked', 'NoModificationAllowedError');
|
|
22
|
+
return { size: node };
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stubOpfs(tree: FakeTree | null) {
|
|
33
|
+
vi.stubGlobal('navigator', tree === null ? {} : {
|
|
34
|
+
storage: { getDirectory: async () => makeDirHandle(tree) },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.unstubAllGlobals();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('walkOpfs', () => {
|
|
43
|
+
it('reports unsupported without OPFS APIs', async () => {
|
|
44
|
+
stubOpfs(null);
|
|
45
|
+
expect(await walkOpfs()).toEqual({
|
|
46
|
+
supported: false,
|
|
47
|
+
entries: [],
|
|
48
|
+
totalBytes: 0,
|
|
49
|
+
truncated: false,
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('walks recursively, sums readable sizes, and omits size for locked files', async () => {
|
|
54
|
+
stubOpfs({
|
|
55
|
+
'.sp00ky-anon': { '0000000001': 4096, '0000000002': 'locked' },
|
|
56
|
+
'other.txt': 10,
|
|
57
|
+
});
|
|
58
|
+
const res = await walkOpfs();
|
|
59
|
+
expect(res.supported).toBe(true);
|
|
60
|
+
expect(res.truncated).toBe(false);
|
|
61
|
+
// Locked file present but without a size; total counts only readable bytes.
|
|
62
|
+
expect(res.totalBytes).toBe(4106);
|
|
63
|
+
expect(res.entries).toEqual([
|
|
64
|
+
{ path: '.sp00ky-anon', kind: 'directory' },
|
|
65
|
+
{ path: '.sp00ky-anon/0000000001', kind: 'file', size: 4096 },
|
|
66
|
+
{ path: '.sp00ky-anon/0000000002', kind: 'file' },
|
|
67
|
+
{ path: 'other.txt', kind: 'file', size: 10 },
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('caps the listing and flags truncation', async () => {
|
|
72
|
+
const big: FakeTree = {};
|
|
73
|
+
for (let i = 0; i < 10; i++) big[`f${i}`] = 1;
|
|
74
|
+
stubOpfs(big);
|
|
75
|
+
const res = await walkOpfs(5);
|
|
76
|
+
expect(res.truncated).toBe(true);
|
|
77
|
+
expect(res.entries.length).toBe(5);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage diagnostics for the DevTools Storage tab: what engine backs the
|
|
3
|
+
* local cache, whether it actually persists, how much of the device's quota
|
|
4
|
+
* the origin uses, and what is physically sitting in OPFS. Assembled by
|
|
5
|
+
* `DevToolsService.getStorageInfo()`; everything here is JSON-safe.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface OpfsEntry {
|
|
9
|
+
/** Path relative to the OPFS root, e.g. `.sp00ky-anon/0000000000000001`. */
|
|
10
|
+
path: string;
|
|
11
|
+
kind: 'file' | 'directory';
|
|
12
|
+
/** Absent when the file's size can't be read (e.g. an exclusive sync access
|
|
13
|
+
* handle is held on it — exactly the case during SAHPool contention). */
|
|
14
|
+
size?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Engine-side numbers only the engine can produce (worker round-trips). */
|
|
18
|
+
export interface EngineStorageDiagnostics {
|
|
19
|
+
engine: 'sqlite';
|
|
20
|
+
bucketId: string;
|
|
21
|
+
useOpfs: boolean;
|
|
22
|
+
workerSelectConfigured: boolean;
|
|
23
|
+
/** `false` while configured `true` means the runtime downgraded to the
|
|
24
|
+
* legacy multi-hop select (stale cached worker bundle). */
|
|
25
|
+
workerSelectEffective: boolean;
|
|
26
|
+
/** page_count * page_size. */
|
|
27
|
+
dbSizeBytes?: number;
|
|
28
|
+
/** freelist_count * page_size — reclaimable via VACUUM. */
|
|
29
|
+
freelistBytes?: number;
|
|
30
|
+
tableCounts?: { table: string; rows: number }[];
|
|
31
|
+
error?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Shared-tabs coordination state. Reported ONLY when `sharedTabs: true` was
|
|
36
|
+
* configured (apps that never asked for it get `null`, so the panel shows
|
|
37
|
+
* nothing). `active: false` with a `reason` is itself the useful signal: the
|
|
38
|
+
* app asked to share one store and this tab is not, so it owns or contends for
|
|
39
|
+
* the OPFS pool alone.
|
|
40
|
+
*/
|
|
41
|
+
export interface SharedTabsInfo {
|
|
42
|
+
active: boolean;
|
|
43
|
+
/** Why inactive: a capability gate reason, or 'fell-back' when the broker
|
|
44
|
+
* was reachable but no role landed (election timeout, rejected tab). */
|
|
45
|
+
reason?: string;
|
|
46
|
+
role?: 'solo' | 'leader' | 'follower';
|
|
47
|
+
/** This tab's id (also the suffix of every mutation id it mints). */
|
|
48
|
+
tabId?: string;
|
|
49
|
+
/** Monotonic id of the current leadership term; embedded in the worker lock. */
|
|
50
|
+
leadershipId?: number;
|
|
51
|
+
/** Who owns the store: this tab when leader, else the leader's tab id. */
|
|
52
|
+
leaderTabId?: string | null;
|
|
53
|
+
/** Leader only: attached follower tabs. */
|
|
54
|
+
followers?: number;
|
|
55
|
+
/** Leader only: ingest batches relayed to followers so far. */
|
|
56
|
+
relayedBatches?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface StorageInfo {
|
|
60
|
+
at: number;
|
|
61
|
+
engine: { kind: 'surrealdb' | 'sqlite' | 'custom'; store: string; bucketId: string };
|
|
62
|
+
health: { status: 'unknown' | 'persistent' | 'memory'; fallback: boolean; error?: string };
|
|
63
|
+
/** Shared-tabs state, or null when the feature was never requested. */
|
|
64
|
+
tabs?: SharedTabsInfo | null;
|
|
65
|
+
browser: {
|
|
66
|
+
/** `navigator.storage.persisted()` — whether the origin's storage is
|
|
67
|
+
* exempt from eviction (unrelated to the OPFS pool lock). */
|
|
68
|
+
persisted?: boolean;
|
|
69
|
+
usage?: number;
|
|
70
|
+
quota?: number;
|
|
71
|
+
/** Chrome-only per-system breakdown from `estimate()`. */
|
|
72
|
+
usageDetails?: Record<string, number>;
|
|
73
|
+
error?: string;
|
|
74
|
+
};
|
|
75
|
+
opfs: {
|
|
76
|
+
supported: boolean;
|
|
77
|
+
entries: OpfsEntry[];
|
|
78
|
+
totalBytes: number;
|
|
79
|
+
truncated: boolean;
|
|
80
|
+
error?: string;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Bucket-file cache counters. `evicted*` is cumulative for this tab's
|
|
84
|
+
* session, so a number that climbs while `totalBytes` sits at the budget is
|
|
85
|
+
* the signal that `blobCache.maxBytes` is too small for the working set.
|
|
86
|
+
*/
|
|
87
|
+
blobs?: BlobCacheInfo;
|
|
88
|
+
/** Snapshot of `globalThis.__sqliteStats` (SQLite engine only). */
|
|
89
|
+
sqliteStats?: Record<string, unknown>;
|
|
90
|
+
engineDiagnostics?: EngineStorageDiagnostics;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface BlobCacheInfo {
|
|
94
|
+
entries: number;
|
|
95
|
+
totalBytes: number;
|
|
96
|
+
budgetBytes: number;
|
|
97
|
+
pinnedBytes: number;
|
|
98
|
+
evictedEntries: number;
|
|
99
|
+
evictedBytes: number;
|
|
100
|
+
/** Manifest rows rebuilt from disk at the last reconcile. */
|
|
101
|
+
reconciledEntries: number;
|
|
102
|
+
hits: number;
|
|
103
|
+
misses: number;
|
|
104
|
+
/** False when OPFS is unwritable or the quota was exhausted — the cache is
|
|
105
|
+
* running in per-tab memory and nothing survives a reload. */
|
|
106
|
+
persistent: boolean;
|
|
107
|
+
/** Over budget with only pinned or on-screen entries left: new files are no
|
|
108
|
+
* longer written rather than pinned ones being discarded. */
|
|
109
|
+
persistPaused: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Recursively list the origin's OPFS. Sizes come from `handle.getFile()`,
|
|
114
|
+
* which throws for a file another context holds an exclusive sync access
|
|
115
|
+
* handle on — SAHPool does exactly that for its whole pool, so a locked file
|
|
116
|
+
* (size omitted) is a live "who has the pool" signal, not a failure.
|
|
117
|
+
*/
|
|
118
|
+
export async function walkOpfs(maxEntries = 2000, maxDepth = 8): Promise<StorageInfo['opfs']> {
|
|
119
|
+
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
|
|
120
|
+
if (!nav?.storage?.getDirectory) {
|
|
121
|
+
return { supported: false, entries: [], totalBytes: 0, truncated: false };
|
|
122
|
+
}
|
|
123
|
+
const entries: OpfsEntry[] = [];
|
|
124
|
+
let totalBytes = 0;
|
|
125
|
+
let truncated = false;
|
|
126
|
+
try {
|
|
127
|
+
const root = await nav.storage.getDirectory();
|
|
128
|
+
const walk = async (dir: FileSystemDirectoryHandle, prefix: string, depth: number) => {
|
|
129
|
+
if (depth > maxDepth) return;
|
|
130
|
+
// entries() is standard; older lib.dom typings may lack it.
|
|
131
|
+
for await (const [name, handle] of (dir as any).entries() as AsyncIterable<
|
|
132
|
+
[string, FileSystemHandle]
|
|
133
|
+
>) {
|
|
134
|
+
if (entries.length >= maxEntries) {
|
|
135
|
+
truncated = true;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const path = prefix ? `${prefix}/${name}` : name;
|
|
139
|
+
if (handle.kind === 'directory') {
|
|
140
|
+
entries.push({ path, kind: 'directory' });
|
|
141
|
+
await walk(handle as FileSystemDirectoryHandle, path, depth + 1);
|
|
142
|
+
} else {
|
|
143
|
+
let size: number | undefined;
|
|
144
|
+
try {
|
|
145
|
+
size = (await (handle as FileSystemFileHandle).getFile()).size;
|
|
146
|
+
totalBytes += size;
|
|
147
|
+
} catch {
|
|
148
|
+
// Locked by an exclusive access handle (e.g. a live SAHPool).
|
|
149
|
+
}
|
|
150
|
+
const entry: OpfsEntry = { path, kind: 'file' };
|
|
151
|
+
if (size !== undefined) entry.size = size;
|
|
152
|
+
entries.push(entry);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
await walk(root, '', 0);
|
|
157
|
+
entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
158
|
+
return { supported: true, entries, totalBytes, truncated };
|
|
159
|
+
} catch (e) {
|
|
160
|
+
return {
|
|
161
|
+
supported: true,
|
|
162
|
+
entries,
|
|
163
|
+
totalBytes,
|
|
164
|
+
truncated,
|
|
165
|
+
error: e instanceof Error ? e.message : String(e),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -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,110 @@
|
|
|
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
|
+
/** One end-to-end sync-pipeline probe cycle recorded by the scheduler. */
|
|
20
|
+
export interface HeartbeatSample {
|
|
21
|
+
ts: number;
|
|
22
|
+
/** `null` for a failed cycle. */
|
|
23
|
+
ms: number | null;
|
|
24
|
+
ok: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* E2E heartbeat state, reported on the scheduler entity only. The scheduler
|
|
29
|
+
* times a probe row's full round trip (DB event → ingest → broadcast → SSP
|
|
30
|
+
* circuit step); `samples` is its rolling window of recent cycles, which the
|
|
31
|
+
* DevTools sparkline renders.
|
|
32
|
+
*/
|
|
33
|
+
export interface HeartbeatInfo {
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
stale: boolean;
|
|
36
|
+
/** Nothing to measure right now (e.g. no ready SSPs mid-bootstrap). */
|
|
37
|
+
blocked?: boolean;
|
|
38
|
+
blocked_reason?: string | null;
|
|
39
|
+
last_e2e_ms: number | null;
|
|
40
|
+
last_ok_epoch_ms: number | null;
|
|
41
|
+
consecutive_failures: number;
|
|
42
|
+
interval_secs: number;
|
|
43
|
+
samples?: HeartbeatSample[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface BackendEntity {
|
|
47
|
+
entity: string;
|
|
48
|
+
id?: string;
|
|
49
|
+
ip?: string | null;
|
|
50
|
+
status?: string;
|
|
51
|
+
version?: string;
|
|
52
|
+
surrealdb_version?: string;
|
|
53
|
+
uptime_seconds?: number;
|
|
54
|
+
views?: number;
|
|
55
|
+
/** Scheduler entity only. */
|
|
56
|
+
heartbeat?: HeartbeatInfo;
|
|
57
|
+
[key: string]: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface BackendInfo {
|
|
61
|
+
versions: BackendVersions;
|
|
62
|
+
entities: BackendEntity[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function emptyBackendVersions(): BackendVersions {
|
|
66
|
+
return { ssp: UNAVAILABLE, scheduler: UNAVAILABLE, surrealdb: UNAVAILABLE };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function emptyBackendInfo(): BackendInfo {
|
|
70
|
+
return { versions: emptyBackendVersions(), entities: [] };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Strip a leading `surrealdb-` so versions read as bare semver (e.g. `2.0.3`). */
|
|
74
|
+
function normalizeServerVersion(v: string): string {
|
|
75
|
+
return String(v).replace(/^surrealdb-/i, '').trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Normalize whatever `RETURN fn::spooky::info()` resolves to into the entity
|
|
80
|
+
* array. The SurrealQL function returns the parsed `/info` array; depending on
|
|
81
|
+
* how the result is unwrapped it may arrive as the array itself, a single
|
|
82
|
+
* object, or `null`. Tolerant of all three.
|
|
83
|
+
*/
|
|
84
|
+
export function toEntityArray(raw: unknown): BackendEntity[] {
|
|
85
|
+
if (Array.isArray(raw)) return raw.filter((e): e is BackendEntity => !!e && typeof e === 'object');
|
|
86
|
+
if (raw && typeof raw === 'object') return [raw as BackendEntity];
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Derive component versions + the full entity list from a `/info` entity array.
|
|
92
|
+
* `surrealdb` is taken from whichever entity reports `surrealdb_version` (ssp or
|
|
93
|
+
* scheduler). Never throws; missing pieces stay `'unavailable'`.
|
|
94
|
+
*/
|
|
95
|
+
export function parseBackendInfo(raw: unknown): BackendInfo {
|
|
96
|
+
const entities = toEntityArray(raw);
|
|
97
|
+
const versions = emptyBackendVersions();
|
|
98
|
+
|
|
99
|
+
for (const entity of entities) {
|
|
100
|
+
const version = entity.version ? String(entity.version) : undefined;
|
|
101
|
+
if (entity.entity === 'ssp' && version) versions.ssp = version;
|
|
102
|
+
else if (entity.entity === 'scheduler' && version) versions.scheduler = version;
|
|
103
|
+
|
|
104
|
+
if (versions.surrealdb === UNAVAILABLE && entity.surrealdb_version) {
|
|
105
|
+
versions.surrealdb = normalizeServerVersion(String(entity.surrealdb_version));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { versions, entities };
|
|
110
|
+
}
|