@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.210

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2514 -58
  3. package/dist/index.js +12561 -2449
  4. package/dist/otel/index.d.ts +2 -2
  5. package/dist/otel/index.js +6 -6
  6. package/dist/sqlite-open.js +303 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +439 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +472 -0
  11. package/dist/types.d.ts +751 -11
  12. package/package.json +11 -7
  13. package/scripts/check-broker-bundle.mjs +33 -0
  14. package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
  15. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  16. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  17. package/src/bucket-blurhash.test.ts +148 -0
  18. package/src/build-globals.d.ts +12 -0
  19. package/src/events/events.test.ts +2 -1
  20. package/src/events/index.ts +3 -0
  21. package/src/index.ts +36 -2
  22. package/src/modules/app-release/index.test.ts +125 -0
  23. package/src/modules/app-release/index.ts +201 -0
  24. package/src/modules/auth/auth.local-first.test.ts +101 -0
  25. package/src/modules/auth/events/index.ts +2 -1
  26. package/src/modules/auth/index.ts +127 -24
  27. package/src/modules/cache/cache.relay.test.ts +95 -0
  28. package/src/modules/cache/index.ts +163 -43
  29. package/src/modules/cache/types.ts +2 -2
  30. package/src/modules/crdt/crdt-field.ts +294 -0
  31. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  32. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  33. package/src/modules/crdt/index.ts +463 -0
  34. package/src/modules/crdt/loro-loader.ts +25 -0
  35. package/src/modules/data/data.hydration.test.ts +142 -0
  36. package/src/modules/data/data.membership.test.ts +523 -0
  37. package/src/modules/data/data.notify-table.test.ts +41 -0
  38. package/src/modules/data/data.pending-ids.test.ts +199 -0
  39. package/src/modules/data/data.rebind.test.ts +170 -0
  40. package/src/modules/data/data.rematerialize.test.ts +114 -0
  41. package/src/modules/data/data.run.test.ts +113 -0
  42. package/src/modules/data/data.settled-writes.test.ts +206 -0
  43. package/src/modules/data/data.status.test.ts +249 -0
  44. package/src/modules/data/id-set-plan.test.ts +122 -0
  45. package/src/modules/data/index.ts +1815 -151
  46. package/src/modules/data/mutation-id.test.ts +25 -0
  47. package/src/modules/data/mutation-id.ts +35 -0
  48. package/src/modules/data/window-query.test.ts +52 -0
  49. package/src/modules/data/window-query.ts +194 -0
  50. package/src/modules/devtools/flags.ts +349 -0
  51. package/src/modules/devtools/index.ts +450 -46
  52. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  53. package/src/modules/devtools/state-shape.test.ts +146 -0
  54. package/src/modules/devtools/storage-info.test.ts +79 -0
  55. package/src/modules/devtools/storage-info.ts +168 -0
  56. package/src/modules/devtools/versions.test.ts +74 -0
  57. package/src/modules/devtools/versions.ts +110 -0
  58. package/src/modules/feature-flag/index.test.ts +251 -0
  59. package/src/modules/feature-flag/index.ts +308 -0
  60. package/src/modules/ref-tables.test.ts +91 -0
  61. package/src/modules/ref-tables.ts +88 -0
  62. package/src/modules/sync/engine.ts +164 -82
  63. package/src/modules/sync/events/index.ts +9 -2
  64. package/src/modules/sync/queue/queue-down.test.ts +180 -0
  65. package/src/modules/sync/queue/queue-down.ts +80 -13
  66. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  67. package/src/modules/sync/queue/queue-up.ts +241 -57
  68. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  69. package/src/modules/sync/scheduler.retry.test.ts +237 -0
  70. package/src/modules/sync/scheduler.ts +215 -13
  71. package/src/modules/sync/sync.cleanup.test.ts +116 -0
  72. package/src/modules/sync/sync.health.test.ts +149 -0
  73. package/src/modules/sync/sync.heartbeat.test.ts +80 -0
  74. package/src/modules/sync/sync.live-removal.test.ts +175 -0
  75. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  76. package/src/modules/sync/sync.subquery.test.ts +82 -0
  77. package/src/modules/sync/sync.tabs.test.ts +249 -0
  78. package/src/modules/sync/sync.ts +1726 -99
  79. package/src/modules/sync/utils.test.ts +269 -2
  80. package/src/modules/sync/utils.ts +201 -17
  81. package/src/otel/index.ts +13 -10
  82. package/src/services/blobs/blob-cache.test.ts +359 -0
  83. package/src/services/blobs/blob-cache.ts +603 -0
  84. package/src/services/blobs/blob-manifest.ts +227 -0
  85. package/src/services/blobs/blob-store.test.ts +77 -0
  86. package/src/services/blobs/blob-store.ts +359 -0
  87. package/src/services/blobs/blob.fixture.ts +90 -0
  88. package/src/services/blobs/index.ts +70 -0
  89. package/src/services/database/cache-engine.ts +193 -0
  90. package/src/services/database/connection-supervisor.test.ts +289 -0
  91. package/src/services/database/connection-supervisor.ts +415 -0
  92. package/src/services/database/database.query-timeout.test.ts +83 -0
  93. package/src/services/database/database.ts +41 -12
  94. package/src/services/database/engine-factory.ts +33 -0
  95. package/src/services/database/errors.ts +34 -0
  96. package/src/services/database/events/index.ts +2 -1
  97. package/src/services/database/index.ts +7 -0
  98. package/src/services/database/local-migrator.ts +30 -27
  99. package/src/services/database/local.test.ts +64 -0
  100. package/src/services/database/local.ts +484 -67
  101. package/src/services/database/plan-render.test.ts +159 -0
  102. package/src/services/database/plan-render.ts +108 -0
  103. package/src/services/database/relation-resolver.test.ts +413 -0
  104. package/src/services/database/relation-resolver.ts +0 -0
  105. package/src/services/database/remote.ts +110 -14
  106. package/src/services/database/sqlite-cache-engine.test.ts +616 -0
  107. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  108. package/src/services/database/sqlite-cache-engine.ts +1358 -0
  109. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  110. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  111. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  112. package/src/services/database/sqlite-lock-verify.ts +45 -0
  113. package/src/services/database/sqlite-open.test.ts +150 -0
  114. package/src/services/database/sqlite-open.ts +164 -0
  115. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  116. package/src/services/database/sqlite-plan-sql.ts +138 -0
  117. package/src/services/database/sqlite-projection.test.ts +99 -0
  118. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  119. package/src/services/database/sqlite-select.test.ts +246 -0
  120. package/src/services/database/sqlite-select.ts +131 -0
  121. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  122. package/src/services/database/sqlite-transport.ts +224 -0
  123. package/src/services/database/sqlite-worker.ts +437 -0
  124. package/src/services/database/surql-translate.ts +416 -0
  125. package/src/services/database/surreal-cache-engine.ts +161 -0
  126. package/src/services/logger/index.ts +3 -2
  127. package/src/services/persistence/localstorage.ts +2 -2
  128. package/src/services/persistence/resilient.ts +11 -4
  129. package/src/services/persistence/surrealdb.ts +10 -10
  130. package/src/services/stream-processor/index.ts +796 -84
  131. package/src/services/stream-processor/permissions.test.ts +47 -0
  132. package/src/services/stream-processor/permissions.ts +53 -0
  133. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  134. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  135. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  136. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  137. package/src/services/stream-processor/wasm-types.ts +59 -3
  138. package/src/services/tabs/broker-client.ts +283 -0
  139. package/src/services/tabs/broker.test.ts +327 -0
  140. package/src/services/tabs/coordinator.test.ts +365 -0
  141. package/src/services/tabs/coordinator.ts +633 -0
  142. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  143. package/src/services/tabs/leader-locks.ts +75 -0
  144. package/src/services/tabs/protocol.ts +258 -0
  145. package/src/services/tabs/support.ts +36 -0
  146. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  147. package/src/sp00ky.auth-order.test.ts +92 -0
  148. package/src/sp00ky.init-query.test.ts +183 -0
  149. package/src/sp00ky.local-first.test.ts +60 -0
  150. package/src/sp00ky.ts +1693 -0
  151. package/src/types.ts +528 -13
  152. package/src/utils/blurhash.ts +90 -0
  153. package/src/utils/error-classification.test.ts +44 -0
  154. package/src/utils/error-classification.ts +7 -0
  155. package/src/utils/index.ts +79 -13
  156. package/src/utils/parser.test.ts +49 -120
  157. package/src/utils/parser.ts +32 -2
  158. package/src/utils/semver.test.ts +32 -0
  159. package/src/utils/semver.ts +30 -0
  160. package/src/utils/surql.ts +30 -18
  161. package/src/utils/withRetry.test.ts +1 -1
  162. package/tsdown.config.ts +86 -1
  163. package/src/spooky.ts +0 -395
@@ -0,0 +1,154 @@
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 on connect, on the next macrotask', () => {
78
+ vi.useFakeTimers();
79
+ const { statePushes } = harness();
80
+ // Never inline: a push serializes the state, and the request can come
81
+ // from inside an ingest or a mutation the app is awaiting.
82
+ expect(statePushes().length).toBe(0);
83
+ vi.advanceTimersByTime(0);
84
+ expect(statePushes().length).toBe(1);
85
+ });
86
+
87
+ it('collapses a burst of per-query events into ONE trailing push', () => {
88
+ vi.useFakeTimers();
89
+ const { service, statePushes } = harness();
90
+ const before = statePushes().length;
91
+
92
+ // What a page load looks like: hundreds of LOCAL_QUERY events, each of which
93
+ // used to serialize the entire query state.
94
+ for (let i = 0; i < 400; i++) {
95
+ (service as any).logEvent('LOCAL_QUERY', { query: 'SELECT * FROM game', vars: {} });
96
+ }
97
+ // Nothing extra yet — the burst is queued, not serialized 400 times.
98
+ expect(statePushes().length).toBe(before);
99
+
100
+ vi.advanceTimersByTime(300);
101
+ expect(statePushes().length).toBe(before + 1);
102
+ });
103
+
104
+ it('still pushes again once the window has passed', () => {
105
+ vi.useFakeTimers();
106
+ const { service, statePushes } = harness();
107
+ const before = statePushes().length;
108
+
109
+ (service as any).logEvent('A', {});
110
+ vi.advanceTimersByTime(300);
111
+ expect(statePushes().length).toBe(before + 1);
112
+
113
+ // An event arriving right after that flush is still inside the window, so it
114
+ // queues rather than pushing again...
115
+ (service as any).logEvent('B', {});
116
+ expect(statePushes().length).toBe(before + 1);
117
+ vi.advanceTimersByTime(300);
118
+ expect(statePushes().length).toBe(before + 2);
119
+
120
+ // ...but once the tab has been idle past the window, the next event pushes
121
+ // straight away, so the panel never waits on a quiet app.
122
+ vi.advanceTimersByTime(1000);
123
+ (service as any).logEvent('C', {});
124
+ vi.advanceTimersByTime(0);
125
+ expect(statePushes().length).toBe(before + 3);
126
+ });
127
+
128
+ it('drops a queued push when the consumer disconnects mid-window', () => {
129
+ vi.useFakeTimers();
130
+ const { service, statePushes, posted } = harness();
131
+ const before = statePushes().length;
132
+
133
+ (service as any).logEvent('A', {});
134
+ (service as any).enabled = false; // panel closed while the push was queued
135
+ vi.advanceTimersByTime(300);
136
+ expect(statePushes().length).toBe(before);
137
+ expect(posted.some((m) => m.type === 'SP00KY_STATE_CHANGED' && m.state === undefined)).toBe(false);
138
+ });
139
+
140
+ it('serializes the LATEST state at flush time, not at request time', () => {
141
+ vi.useFakeTimers();
142
+ const { service, statePushes } = harness();
143
+
144
+ (service as any).logEvent('FIRST', {});
145
+ (service as any).logEvent('SECOND', {});
146
+ vi.advanceTimersByTime(300);
147
+
148
+ const last = statePushes().at(-1);
149
+ const types = last.state.eventsHistory.map((e: any) => e.eventType);
150
+ // Both events of the coalesced window are present in the single push.
151
+ expect(types).toContain('FIRST');
152
+ expect(types).toContain('SECOND');
153
+ });
154
+ });
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { DevToolsService } from './index';
4
+
5
+ /**
6
+ * The pushed state used to carry every view's full record set (and both
7
+ * membership arrays), deep-cloned once by the serializer and again by
8
+ * postMessage, on every sync event, from inside the ingest call stack. On a
9
+ * client holding a few thousand rows that is what the main thread was doing
10
+ * instead of finishing the write the app was awaiting. Pushes now carry counts
11
+ * and capped ids; rows are pulled per view on demand.
12
+ */
13
+ function harness(recordCount = 500) {
14
+ const posted: any[] = [];
15
+ const listeners: ((e: any) => void)[] = [];
16
+ const fakeWindow: any = {
17
+ postMessage: (msg: any) => posted.push(msg),
18
+ addEventListener: (_type: string, cb: (e: any) => void) => listeners.push(cb),
19
+ dispatchEvent: () => true,
20
+ };
21
+ fakeWindow.self = fakeWindow;
22
+ vi.stubGlobal('window', fakeWindow);
23
+ vi.stubGlobal('CustomEvent', class {
24
+ type: string;
25
+ constructor(type: string) {
26
+ this.type = type;
27
+ }
28
+ });
29
+ const noop = () => {};
30
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
31
+ logger.child = () => logger;
32
+ const infoQueries: string[] = [];
33
+ const local: any = {
34
+ query: async (sql: string) => {
35
+ infoQueries.push(sql);
36
+ return [];
37
+ },
38
+ getConfig: () => ({ store: 'memory' }),
39
+ currentBucketId: 'anon',
40
+ storageHealth: { status: 'memory', fallback: false },
41
+ };
42
+ const remote: any = { query: async () => [] };
43
+ const auth: any = { isAuthenticated: false, currentUser: undefined, eventSystem: { subscribe: noop } };
44
+ const records = Array.from({ length: recordCount }, (_, i) => ({ id: `game:${i}`, pgn: 'x' }));
45
+ const localArray = records.map((r) => [r.id, 1] as [string, number]);
46
+ const id = new RecordId('_00_query', 'q1');
47
+ const query = {
48
+ config: { id, params: {}, localArray, remoteArray: localArray, surql: 'SELECT * FROM game' },
49
+ status: 'idle',
50
+ records,
51
+ updateCount: 1,
52
+ };
53
+ const dataManager: any = {
54
+ getActiveQueries: () => [query],
55
+ getQueryById: (rid: RecordId<string>) => (String(rid) === String(id) ? query : undefined),
56
+ phaseTimings: () => ({}),
57
+ };
58
+ const service = new DevToolsService(local, remote, logger, { tables: [] } as any, auth, dataManager);
59
+ for (const cb of listeners) cb({ source: fakeWindow, data: { type: 'SP00KY_DEVTOOLS_CONNECT' } });
60
+ const statePushes = () => posted.filter((m) => m.type === 'SP00KY_STATE_CHANGED');
61
+ return { service, statePushes, posted, fakeWindow, infoQueries };
62
+ }
63
+
64
+ describe('DevTools pushed state shape', () => {
65
+ afterEach(() => {
66
+ vi.unstubAllGlobals();
67
+ vi.useRealTimers();
68
+ });
69
+
70
+ it('carries counts and capped ids, never the rows', async () => {
71
+ vi.useFakeTimers();
72
+ const { service, statePushes } = harness(500);
73
+ service.onQueryUpdated({ queryId: 'x', records: [] });
74
+ await vi.advanceTimersByTimeAsync(300);
75
+ const push = statePushes().at(-1);
76
+ expect(push).toBeDefined();
77
+ const q: any = Object.values(push.state.activeQueries)[0];
78
+ expect(q.data).toBeUndefined();
79
+ expect(q.localArray).toBeUndefined();
80
+ expect(q.remoteArray).toBeUndefined();
81
+ expect(q.dataSize).toBe(500);
82
+ expect(q.localCount).toBe(500);
83
+ expect(q.remoteCount).toBe(500);
84
+ expect(q.localIds).toHaveLength(200);
85
+ expect(q.idsTruncated).toBe(true);
86
+ });
87
+
88
+ it('never pushes synchronously inside the call that requested it', async () => {
89
+ vi.useFakeTimers();
90
+ const { service, statePushes } = harness(5);
91
+ // Connect already queued one push; drain it so the next request is "idle".
92
+ await vi.advanceTimersByTimeAsync(300);
93
+ const before = statePushes().length;
94
+ service.onStreamUpdate({ queryHash: 'q1', localArray: [], op: 'CREATE' });
95
+ expect(statePushes().length).toBe(before);
96
+ await vi.advanceTimersByTimeAsync(0);
97
+ expect(statePushes().length).toBe(before + 1);
98
+ });
99
+
100
+ it('serves the rows of one view on demand', () => {
101
+ const { service, fakeWindow } = harness(3);
102
+ void service;
103
+ const state = fakeWindow.__00__.getState();
104
+ const hash = Number(Object.keys(state.activeQueries)[0]);
105
+ const rows = fakeWindow.__00__.getQueryRows(hash);
106
+ expect(rows.data).toHaveLength(3);
107
+ expect(rows.localArray).toHaveLength(3);
108
+ expect(fakeWindow.__00__.getQueryRows(12345)).toBeNull();
109
+ });
110
+
111
+ it('records a stream update as counts, not the membership array', async () => {
112
+ vi.useFakeTimers();
113
+ const { service, statePushes } = harness(2);
114
+ service.onStreamUpdate({ queryHash: 'q1', localArray: [['a', 1], ['b', 1]], op: 'UPDATE' });
115
+ await vi.advanceTimersByTimeAsync(300);
116
+ const push = statePushes().at(-1);
117
+ const ev = push.state.eventsHistory.find((e: any) => e.eventType === 'STREAM_UPDATE');
118
+ expect(ev.payload.localCount).toBe(2);
119
+ expect(ev.payload.updates).toBeUndefined();
120
+ expect(ev.payload.localArray).toBeUndefined();
121
+ });
122
+
123
+ it('ignores a synthetic re-materialize', async () => {
124
+ vi.useFakeTimers();
125
+ const { service, statePushes } = harness(1);
126
+ await vi.advanceTimersByTimeAsync(300);
127
+ const before = statePushes().length;
128
+ service.onStreamUpdate({ queryHash: 'q1', localArray: [], op: 'UPDATE', synthetic: true });
129
+ await vi.advanceTimersByTimeAsync(300);
130
+ expect(statePushes().length).toBe(before);
131
+ });
132
+
133
+ it('refreshes the table list on an explicit pull, not on a push', async () => {
134
+ vi.useFakeTimers();
135
+ const { service, fakeWindow, infoQueries } = harness(1);
136
+ const connectRefreshes = infoQueries.filter((q) => q.includes('INFO FOR DB')).length;
137
+ for (let i = 0; i < 10; i++) {
138
+ service.onStreamUpdate({ queryHash: 'q1', localArray: [], op: 'UPDATE' });
139
+ await vi.advanceTimersByTimeAsync(300);
140
+ }
141
+ expect(infoQueries.filter((q) => q.includes('INFO FOR DB')).length).toBe(connectRefreshes);
142
+ vi.setSystemTime(Date.now() + 60_000);
143
+ fakeWindow.__00__.getState();
144
+ expect(infoQueries.filter((q) => q.includes('INFO FOR DB')).length).toBe(connectRefreshes + 1);
145
+ });
146
+ });
@@ -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
+ });