@spooky-sync/core 0.0.1-canary.30 → 0.0.1-canary.300

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 (257) hide show
  1. package/AGENTS.md +81 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2026 -137
  4. package/dist/index.js +14939 -2432
  5. package/dist/live.d.ts +141 -0
  6. package/dist/live.js +709 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/otel/index.js +3 -3
  9. package/dist/pure.d.ts +329 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +358 -0
  12. package/dist/sqlite-worker.d.ts +1 -0
  13. package/dist/sqlite-worker.js +439 -0
  14. package/dist/sw.d.ts +207 -0
  15. package/dist/sw.js +393 -0
  16. package/dist/tabs-broker-worker.d.ts +8 -0
  17. package/dist/tabs-broker-worker.js +472 -0
  18. package/dist/types.d.ts +1765 -38
  19. package/dist/worker-edges.js +423 -0
  20. package/dist/worker-impersonation.js +60 -0
  21. package/dist/worker-ref-tables.d.ts +46 -0
  22. package/dist/worker-types.d.ts +112 -0
  23. package/dist/worker-types.js +73 -0
  24. package/package.json +27 -7
  25. package/scripts/check-broker-bundle.mjs +33 -0
  26. package/scripts/check-worker-entries.mjs +172 -0
  27. package/skills/sp00ky-core/SKILL.md +31 -6
  28. package/skills/sp00ky-core/references/config.md +6 -0
  29. package/src/boot/auth-flip.saga.test.ts +82 -0
  30. package/src/boot/auth-flip.saga.ts +62 -0
  31. package/src/boot/boot.saga.test.ts +158 -0
  32. package/src/boot/boot.saga.ts +131 -0
  33. package/src/boot/bucket-switch.saga.test.ts +187 -0
  34. package/src/boot/bucket-switch.saga.ts +133 -0
  35. package/src/boot/preload.saga.test.ts +51 -0
  36. package/src/boot/preload.saga.ts +31 -0
  37. package/src/bucket-blurhash.test.ts +148 -0
  38. package/src/build-globals.d.ts +12 -0
  39. package/src/client/bucket-handle.test.ts +117 -0
  40. package/src/client/bucket-handle.ts +227 -0
  41. package/src/client/router.test.ts +58 -0
  42. package/src/client/router.ts +100 -0
  43. package/src/client/runtime.test.ts +195 -0
  44. package/src/client/runtime.ts +296 -0
  45. package/src/client/services.ts +370 -0
  46. package/src/client/sp00ky-client.test.ts +303 -0
  47. package/src/client/sp00ky-client.ts +586 -0
  48. package/src/events/events.test.ts +2 -1
  49. package/src/events/index.ts +3 -0
  50. package/src/index.ts +69 -1
  51. package/src/kernel/constants.test.ts +19 -0
  52. package/src/kernel/constants.ts +80 -0
  53. package/src/kernel/effects.test.ts +40 -0
  54. package/src/kernel/effects.ts +161 -0
  55. package/src/kernel/events.ts +57 -0
  56. package/src/kernel/interpreter.test.ts +116 -0
  57. package/src/kernel/interpreter.ts +148 -0
  58. package/src/kernel/saga.test.ts +89 -0
  59. package/src/kernel/saga.ts +73 -0
  60. package/src/live/index.test.ts +478 -0
  61. package/src/live/index.ts +1013 -0
  62. package/src/modules/app-release/index.test.ts +125 -0
  63. package/src/modules/app-release/index.ts +209 -0
  64. package/src/modules/auth/auth.local-first.test.ts +141 -0
  65. package/src/modules/auth/events/index.ts +9 -2
  66. package/src/modules/auth/impersonation-banner.ts +443 -0
  67. package/src/modules/auth/impersonation.test.ts +355 -0
  68. package/src/modules/auth/impersonation.ts +179 -0
  69. package/src/modules/auth/index.ts +386 -17
  70. package/src/modules/crdt/crdt-field.ts +294 -0
  71. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  72. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  73. package/src/modules/crdt/index.ts +463 -0
  74. package/src/modules/crdt/loro-loader.ts +25 -0
  75. package/src/modules/devtools/flags.ts +349 -0
  76. package/src/modules/devtools/index.ts +746 -95
  77. package/src/modules/devtools/mutations.test.ts +92 -0
  78. package/src/modules/devtools/mutations.ts +160 -0
  79. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  80. package/src/modules/devtools/state-shape.test.ts +263 -0
  81. package/src/modules/devtools/storage-info.test.ts +79 -0
  82. package/src/modules/devtools/storage-info.ts +168 -0
  83. package/src/modules/devtools/versions.test.ts +74 -0
  84. package/src/modules/devtools/versions.ts +110 -0
  85. package/src/modules/feature-flag/index.test.ts +251 -0
  86. package/src/modules/feature-flag/index.ts +316 -0
  87. package/src/modules/ref-tables.test.ts +91 -0
  88. package/src/modules/ref-tables.ts +88 -0
  89. package/src/modules/web-push/index.test.ts +824 -0
  90. package/src/modules/web-push/index.ts +1027 -0
  91. package/src/mutation/jobs.test.ts +25 -0
  92. package/src/mutation/jobs.ts +41 -0
  93. package/src/mutation/mutation-id.test.ts +37 -0
  94. package/src/mutation/mutation-id.ts +35 -0
  95. package/src/mutation/pending-writes.integration.test.ts +175 -0
  96. package/src/mutation/push.saga.test.ts +285 -0
  97. package/src/mutation/push.saga.ts +217 -0
  98. package/src/mutation/rows.test.ts +231 -0
  99. package/src/mutation/rows.ts +427 -0
  100. package/src/mutation/tray.saga.test.ts +63 -0
  101. package/src/mutation/tray.saga.ts +48 -0
  102. package/src/mutation/write.saga.test.ts +316 -0
  103. package/src/mutation/write.saga.ts +268 -0
  104. package/src/otel/index.ts +10 -7
  105. package/src/pure.ts +65 -0
  106. package/src/push/types.ts +174 -0
  107. package/src/query/env.test.ts +23 -0
  108. package/src/query/env.ts +56 -0
  109. package/src/query/fetch.saga.test.ts +199 -0
  110. package/src/query/fetch.saga.ts +120 -0
  111. package/src/query/hash.test.ts +14 -0
  112. package/src/query/hash.ts +18 -0
  113. package/src/query/id-set-plan.test.ts +132 -0
  114. package/src/query/lifecycle.saga.test.ts +274 -0
  115. package/src/query/lifecycle.saga.ts +159 -0
  116. package/src/query/materialize.saga.test.ts +115 -0
  117. package/src/query/materialize.saga.ts +84 -0
  118. package/src/query/materialize.test.ts +58 -0
  119. package/src/query/materialize.ts +57 -0
  120. package/src/query/membership.saga.test.ts +350 -0
  121. package/src/query/membership.saga.ts +278 -0
  122. package/src/query/membership.test.ts +118 -0
  123. package/src/query/membership.ts +201 -0
  124. package/src/query/register.saga.test.ts +293 -0
  125. package/src/query/register.saga.ts +226 -0
  126. package/src/query/render-set.test.ts +47 -0
  127. package/src/query/render-set.ts +71 -0
  128. package/src/query/sql.test.ts +63 -0
  129. package/src/query/sql.ts +93 -0
  130. package/src/query/window-query.test.ts +70 -0
  131. package/src/query/window-query.ts +195 -0
  132. package/src/services/blobs/blob-cache.test.ts +359 -0
  133. package/src/services/blobs/blob-cache.ts +603 -0
  134. package/src/services/blobs/blob-manifest.ts +227 -0
  135. package/src/services/blobs/blob-store.test.ts +77 -0
  136. package/src/services/blobs/blob-store.ts +359 -0
  137. package/src/services/blobs/blob.fixture.ts +90 -0
  138. package/src/services/blobs/index.ts +70 -0
  139. package/src/services/database/cache-engine.ts +199 -0
  140. package/src/services/database/connection-supervisor.test.ts +318 -0
  141. package/src/services/database/connection-supervisor.ts +422 -0
  142. package/src/services/database/database.query-timeout.test.ts +140 -0
  143. package/src/services/database/database.responses.test.ts +43 -0
  144. package/src/services/database/database.ts +146 -61
  145. package/src/services/database/engine-factory.ts +33 -0
  146. package/src/services/database/errors.ts +34 -0
  147. package/src/services/database/events/index.ts +2 -1
  148. package/src/services/database/index.ts +7 -0
  149. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  150. package/src/services/database/local-migrator.integration.test.ts +110 -0
  151. package/src/services/database/local-migrator.ts +59 -16
  152. package/src/services/database/local.test.ts +64 -0
  153. package/src/services/database/local.ts +487 -63
  154. package/src/services/database/plan-render.test.ts +159 -0
  155. package/src/services/database/plan-render.ts +108 -0
  156. package/src/services/database/relation-resolver.test.ts +467 -0
  157. package/src/services/database/relation-resolver.ts +0 -0
  158. package/src/services/database/remote.ts +213 -6
  159. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  160. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  161. package/src/services/database/sqlite-cache-engine.ts +1372 -0
  162. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  163. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  164. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  165. package/src/services/database/sqlite-lock-verify.ts +45 -0
  166. package/src/services/database/sqlite-open.test.ts +150 -0
  167. package/src/services/database/sqlite-open.ts +164 -0
  168. package/src/services/database/sqlite-plan-sql.test.ts +139 -0
  169. package/src/services/database/sqlite-plan-sql.ts +179 -0
  170. package/src/services/database/sqlite-projection.test.ts +121 -0
  171. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  172. package/src/services/database/sqlite-select.test.ts +246 -0
  173. package/src/services/database/sqlite-select.ts +131 -0
  174. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  175. package/src/services/database/sqlite-transport.ts +224 -0
  176. package/src/services/database/sqlite-worker.ts +437 -0
  177. package/src/services/database/surql-translate.ts +416 -0
  178. package/src/services/database/surreal-cache-engine.ts +161 -0
  179. package/src/services/logger/index.test.ts +132 -0
  180. package/src/services/logger/index.ts +189 -7
  181. package/src/services/persistence/localstorage.ts +2 -2
  182. package/src/services/persistence/resilient.ts +10 -3
  183. package/src/services/persistence/surrealdb.ts +4 -4
  184. package/src/services/stream-processor/index.ts +779 -67
  185. package/src/services/stream-processor/permissions.test.ts +47 -0
  186. package/src/services/stream-processor/permissions.ts +53 -0
  187. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  188. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  189. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  190. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  191. package/src/services/stream-processor/wasm-types.ts +58 -2
  192. package/src/services/tabs/broker-client.ts +283 -0
  193. package/src/services/tabs/broker.test.ts +327 -0
  194. package/src/services/tabs/coordinator.test.ts +386 -0
  195. package/src/services/tabs/coordinator.ts +638 -0
  196. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  197. package/src/services/tabs/leader-locks.ts +75 -0
  198. package/src/services/tabs/protocol.ts +262 -0
  199. package/src/services/tabs/support.ts +36 -0
  200. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  201. package/src/sp00ky.ts +5 -395
  202. package/src/state/client-state.ts +200 -0
  203. package/src/state/lifecycle.test.ts +118 -0
  204. package/src/state/lifecycle.ts +86 -0
  205. package/src/state/reducers.test.ts +304 -0
  206. package/src/state/reducers.ts +526 -0
  207. package/src/state/selectors.test.ts +167 -0
  208. package/src/state/selectors.ts +216 -0
  209. package/src/sw/index.test.ts +582 -0
  210. package/src/sw/index.ts +607 -0
  211. package/src/sw/store.ts +96 -0
  212. package/src/sync/connection.saga.test.ts +86 -0
  213. package/src/sync/connection.saga.ts +85 -0
  214. package/src/sync/live.saga.test.ts +178 -0
  215. package/src/sync/live.saga.ts +107 -0
  216. package/src/sync/policy.health.test.ts +56 -0
  217. package/src/sync/policy.test.ts +659 -0
  218. package/src/sync/policy.ts +502 -0
  219. package/src/sync/poll.saga.test.ts +51 -0
  220. package/src/sync/poll.saga.ts +36 -0
  221. package/src/sync/tabs.saga.test.ts +78 -0
  222. package/src/sync/tabs.saga.ts +96 -0
  223. package/src/testing/adapters.ts +133 -0
  224. package/src/testing/build.ts +63 -0
  225. package/src/testing/fake-services.ts +48 -0
  226. package/src/testing/run-pure.test.ts +92 -0
  227. package/src/testing/run-pure.ts +128 -0
  228. package/src/testing/services.ts +17 -0
  229. package/src/types.ts +624 -10
  230. package/src/utils/blurhash.ts +90 -0
  231. package/src/utils/edges.ts +39 -0
  232. package/src/utils/error-classification.test.ts +54 -0
  233. package/src/utils/error-classification.ts +25 -0
  234. package/src/utils/index.ts +75 -9
  235. package/src/utils/parser.test.ts +67 -102
  236. package/src/utils/parser.ts +64 -2
  237. package/src/utils/semver.test.ts +32 -0
  238. package/src/utils/semver.ts +30 -0
  239. package/src/utils/sha256.ts +15 -0
  240. package/src/utils/surql.ts +30 -18
  241. package/src/utils/utils.test.ts +2 -1
  242. package/tsdown.config.ts +117 -3
  243. package/vitest.config.ts +48 -0
  244. package/src/modules/cache/index.ts +0 -241
  245. package/src/modules/cache/types.ts +0 -19
  246. package/src/modules/data/data.test.ts +0 -58
  247. package/src/modules/data/index.ts +0 -879
  248. package/src/modules/sync/engine.ts +0 -173
  249. package/src/modules/sync/events/index.ts +0 -77
  250. package/src/modules/sync/index.ts +0 -3
  251. package/src/modules/sync/queue/index.ts +0 -2
  252. package/src/modules/sync/queue/queue-down.ts +0 -89
  253. package/src/modules/sync/queue/queue-up.ts +0 -223
  254. package/src/modules/sync/scheduler.ts +0 -84
  255. package/src/modules/sync/sync.ts +0 -421
  256. package/src/modules/sync/utils.test.ts +0 -311
  257. package/src/modules/sync/utils.ts +0 -167
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { LogTap, createLogger, stringifyLog } from './index';
3
+
4
+ // Vitest runs in node, where `pino` resolves to its node build and ignores the
5
+ // `browser` options the tap hangs off. Apps bundle the browser build.
6
+ // The specifier is widened to `string`: pino ships no types for that entry.
7
+ vi.mock('pino', async () => ({ default: (await import('pino/browser.js' as string)).default }));
8
+
9
+ afterEach(() => {
10
+ vi.restoreAllMocks();
11
+ vi.unstubAllGlobals();
12
+ });
13
+
14
+ function silentConsole() {
15
+ return vi.spyOn(console, 'log').mockImplementation(() => {});
16
+ }
17
+
18
+ describe('LogTap', () => {
19
+ it('records what the logger writes, child bindings included', () => {
20
+ silentConsole();
21
+ const tap = new LogTap('info');
22
+ const logger = createLogger('info', undefined, tap);
23
+ logger.child({ service: 'Database' }).warn({ Category: 'x' }, 'slow query');
24
+ logger.debug('not at this level');
25
+ const { entries, head } = tap.read();
26
+ expect(entries).toHaveLength(1);
27
+ expect(head).toBe(1);
28
+ expect(entries[0]).toMatchObject({ seq: 1, level: 40, msg: 'slow query' });
29
+ expect(JSON.parse(entries[0].line)).toMatchObject({ service: 'Database', Category: 'x' });
30
+ });
31
+
32
+ it('captures below the console level without printing it, on existing children too', () => {
33
+ const printed = silentConsole();
34
+ const tap = new LogTap('info');
35
+ const logger = createLogger('info', undefined, tap);
36
+ const child = logger.child({ service: 'Sync' });
37
+ child.debug('before');
38
+ tap.setCaptureLevel('debug');
39
+ child.debug('after');
40
+ logger.info('shown');
41
+ expect(tap.read().entries.map((e) => e.msg)).toEqual(['after', 'shown']);
42
+ expect(printed).toHaveBeenCalledTimes(1);
43
+ expect(String(printed.mock.calls[0][0])).toContain('shown');
44
+ });
45
+
46
+ it('keeps the capture level for the tab session', () => {
47
+ const store = new Map<string, string>();
48
+ vi.stubGlobal('sessionStorage', {
49
+ getItem: (k: string) => store.get(k) ?? null,
50
+ setItem: (k: string, v: string) => void store.set(k, v),
51
+ removeItem: (k: string) => void store.delete(k),
52
+ });
53
+ new LogTap('info').setCaptureLevel('trace');
54
+ expect(new LogTap('info').captureLevel).toBe('trace');
55
+ new LogTap('info').setCaptureLevel('info');
56
+ expect(store.size).toBe(0);
57
+ });
58
+
59
+ it('is a bounded ring that reports what a slow reader missed', () => {
60
+ silentConsole();
61
+ const tap = new LogTap('info');
62
+ const logger = createLogger('info', undefined, tap);
63
+ for (let i = 0; i < LogTap.CAPACITY + 10; i++) logger.info(`line ${i}`);
64
+ const all = tap.read();
65
+ expect(all.entries).toHaveLength(LogTap.CAPACITY);
66
+ expect(all.dropped).toBe(10);
67
+ const delta = tap.read(all.head - 2);
68
+ expect(delta.entries.map((e) => e.msg)).toEqual([`line ${LogTap.CAPACITY + 8}`, `line ${LogTap.CAPACITY + 9}`]);
69
+ expect(delta.dropped).toBe(0);
70
+ });
71
+
72
+ it('does not count cleared lines as dropped', () => {
73
+ silentConsole();
74
+ const tap = new LogTap('info');
75
+ const logger = createLogger('info', undefined, tap);
76
+ logger.info('a');
77
+ logger.info('b');
78
+ tap.clear();
79
+ logger.info('c');
80
+ expect(tap.read(0)).toMatchObject({ dropped: 0, head: 3 });
81
+ expect(tap.read(0).entries.map((e) => e.msg)).toEqual(['c']);
82
+ });
83
+
84
+ it('caps a huge line', () => {
85
+ silentConsole();
86
+ const tap = new LogTap('info');
87
+ createLogger('info', undefined, tap).info({ blob: 'x'.repeat(LogTap.LINE_MAX * 2) }, 'big');
88
+ const [entry] = tap.read().entries;
89
+ expect(entry.line.length).toBe(LogTap.LINE_MAX + 1);
90
+ expect(entry.msg).toBe('big');
91
+ });
92
+
93
+ it('notifies listeners and survives one that throws', () => {
94
+ silentConsole();
95
+ const tap = new LogTap('info');
96
+ const logger = createLogger('info', undefined, tap);
97
+ const seen: number[] = [];
98
+ tap.subscribe(() => {
99
+ throw new Error('listener bug');
100
+ });
101
+ tap.subscribe(() => seen.push(tap.head));
102
+ expect(() => logger.info('x')).not.toThrow();
103
+ expect(seen).toEqual([1]);
104
+ });
105
+
106
+ it('keeps the otel transmit at the configured level when capture goes lower', () => {
107
+ silentConsole();
108
+ const sent: string[] = [];
109
+ const tap = new LogTap('info');
110
+ const logger = createLogger('info', { send: (level) => void sent.push(level) }, tap);
111
+ tap.setCaptureLevel('debug');
112
+ logger.debug('d');
113
+ logger.info('i');
114
+ expect(sent).toEqual(['info']);
115
+ });
116
+ });
117
+
118
+ describe('stringifyLog', () => {
119
+ it('keeps errors readable and never throws on bigints or cycles', () => {
120
+ const o: Record<string, unknown> = { err: new Error('boom'), n: 10n };
121
+ o.self = o;
122
+ const parsed = JSON.parse(stringifyLog(o));
123
+ expect(parsed.err).toMatchObject({ type: 'Error', message: 'boom' });
124
+ expect(parsed.n).toBe('10');
125
+ expect(parsed.self).toBe('[Circular]');
126
+ });
127
+
128
+ it('does not mistake a shared reference for a cycle', () => {
129
+ const shared = { a: 1 };
130
+ expect(JSON.parse(stringifyLog({ x: shared, y: shared }))).toEqual({ x: { a: 1 }, y: { a: 1 } });
131
+ });
132
+ });
@@ -1,22 +1,204 @@
1
- import pino, { Level, type Logger as PinoLogger, type LoggerOptions } from 'pino';
2
- import { PinoTransmit } from '../../types';
1
+ import pino, { type Level, type Logger as PinoLogger, type LoggerOptions } from 'pino';
2
+ import type { PinoTransmit } from '../../types';
3
3
 
4
4
  export type Logger = PinoLogger;
5
5
 
6
- export function createLogger(level: Level = 'info', transmit?: PinoTransmit): Logger {
6
+ /** One line the logger wrote, as the DevTools Logs tab and the MCP read it. */
7
+ export interface CapturedLog {
8
+ /** Monotonic for the page's lifetime; a reader resumes after the last one it holds. */
9
+ seq: number;
10
+ time: number;
11
+ /** Pino's numeric level: 10 trace … 60 fatal. */
12
+ level: number;
13
+ msg: string;
14
+ /** The whole log object as JSON (what the console printed), capped at {@link LogTap.LINE_MAX}. */
15
+ line: string;
16
+ }
17
+
18
+ export interface LogRead {
19
+ entries: CapturedLog[];
20
+ /** `seq` of the newest captured line; pass it back as `after` to resume. */
21
+ head: number;
22
+ /** Lines after `after` the ring overwrote before they were read (cleared ones excluded). */
23
+ dropped: number;
24
+ consoleLevel: Level;
25
+ captureLevel: Level;
26
+ }
27
+
28
+ const LEVELS = pino.levels.values as Record<Level, number>;
29
+ const CAPTURE_KEY = 'sp00ky:devtools:logCapture';
30
+
31
+ function isLevel(value: unknown): value is Level {
32
+ return typeof value === 'string' && value in LEVELS;
33
+ }
34
+
35
+ /**
36
+ * JSON for a log object that can never throw into the call site: Errors keep
37
+ * their message and stack (plain `JSON.stringify` prints `{}`), bigints become
38
+ * strings, and a cycle prints `[Circular]` instead of throwing.
39
+ */
40
+ export function stringifyLog(o: unknown): string {
41
+ const ancestors: unknown[] = [];
42
+ try {
43
+ return JSON.stringify(o, function (this: unknown, _key: string, value: unknown) {
44
+ if (typeof value === 'bigint') return value.toString();
45
+ if (value instanceof Error) return { type: value.name, message: value.message, stack: value.stack };
46
+ if (typeof value !== 'object' || value === null) return value;
47
+ while (ancestors.length > 0 && ancestors.at(-1) !== this) ancestors.pop();
48
+ if (ancestors.includes(value)) return '[Circular]';
49
+ ancestors.push(value);
50
+ return value;
51
+ });
52
+ } catch {
53
+ const msg = (o as { msg?: unknown } | null)?.msg;
54
+ return JSON.stringify({ msg: typeof msg === 'string' ? msg : '[unserializable log object]' });
55
+ }
56
+ }
57
+
58
+ /**
59
+ * The page-side log buffer behind the DevTools Logs tab. Always on and
60
+ * bounded: a line costs the JSON the console write produces anyway, and a
61
+ * panel opened after boot still sees the boot.
62
+ *
63
+ * It also owns the logger's level. The console prints at the configured level
64
+ * only; `setCaptureLevel` changes what is RECORDED (debug without flooding the
65
+ * console), live on every child logger, and keeps it for this tab's session so
66
+ * a reload captures its boot at that level too.
67
+ */
68
+ export class LogTap {
69
+ static readonly CAPACITY = 500;
70
+ static readonly LINE_MAX = 4000;
71
+
72
+ private readonly ring: CapturedLog[] = [];
73
+ private seq = 0;
74
+ /** `seq` at the last `clear()`: lines up to here are gone on purpose, not dropped. */
75
+ private clearedThrough = 0;
76
+ private readonly listeners = new Set<() => void>();
77
+ private readonly loggers: Array<WeakRef<Logger>> = [];
78
+ private captureLevelValue: Level;
79
+
80
+ constructor(readonly consoleLevel: Level) {
81
+ this.captureLevelValue = consoleLevel;
82
+ try {
83
+ const saved = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(CAPTURE_KEY) : null;
84
+ if (isLevel(saved)) this.captureLevelValue = saved;
85
+ } catch {
86
+ // Storage blocked: capture at the console level.
87
+ }
88
+ }
89
+
90
+ /** `seq` of the newest captured line. */
91
+ get head(): number {
92
+ return this.seq;
93
+ }
94
+
95
+ get captureLevel(): Level {
96
+ return this.captureLevelValue;
97
+ }
98
+
99
+ /** What pino has to produce for both the console and the buffer. */
100
+ get effectiveLevel(): Level {
101
+ return LEVELS[this.captureLevelValue] < LEVELS[this.consoleLevel] ? this.captureLevelValue : this.consoleLevel;
102
+ }
103
+
104
+ /** Whether the console prints a line at this numeric level. */
105
+ printsToConsole(level: number): boolean {
106
+ return level >= LEVELS[this.consoleLevel];
107
+ }
108
+
109
+ /** Remember a logger (the root and every child) so a level change reaches it. */
110
+ track(logger: Logger): void {
111
+ this.loggers.push(new WeakRef(logger));
112
+ }
113
+
114
+ setCaptureLevel(level: Level): void {
115
+ if (!isLevel(level)) throw new Error(`unknown log level: ${String(level)}`);
116
+ this.captureLevelValue = level;
117
+ try {
118
+ if (typeof sessionStorage !== 'undefined') {
119
+ if (level === this.consoleLevel) sessionStorage.removeItem(CAPTURE_KEY);
120
+ else sessionStorage.setItem(CAPTURE_KEY, level);
121
+ }
122
+ } catch {
123
+ // Storage blocked: the change holds until the page goes away.
124
+ }
125
+ const effective = this.effectiveLevel;
126
+ for (let i = this.loggers.length - 1; i >= 0; i--) {
127
+ const logger = this.loggers[i].deref();
128
+ if (logger) logger.level = effective;
129
+ else this.loggers.splice(i, 1);
130
+ }
131
+ }
132
+
133
+ record(o: Record<string, unknown>, line: string): void {
134
+ const level = typeof o.level === 'number' ? o.level : LEVELS.info;
135
+ if (level < LEVELS[this.captureLevelValue]) return;
136
+ this.ring.push({
137
+ seq: ++this.seq,
138
+ time: typeof o.time === 'number' ? o.time : Date.now(),
139
+ level,
140
+ msg: typeof o.msg === 'string' ? o.msg : '',
141
+ line: line.length > LogTap.LINE_MAX ? `${line.slice(0, LogTap.LINE_MAX)}…` : line,
142
+ });
143
+ if (this.ring.length > LogTap.CAPACITY) this.ring.shift();
144
+ for (const cb of this.listeners) {
145
+ try {
146
+ cb();
147
+ } catch {
148
+ // A listener must never turn a log call into a throw.
149
+ }
150
+ }
151
+ }
152
+
153
+ /** Lines after `after` (0 = everything still buffered), oldest first. */
154
+ read(after = 0, limit = LogTap.CAPACITY): LogRead {
155
+ const first = this.ring[0]?.seq ?? this.seq + 1;
156
+ const fresh = this.ring.filter((e) => e.seq > after);
157
+ return {
158
+ entries: fresh.slice(Math.max(0, fresh.length - limit)),
159
+ head: this.seq,
160
+ dropped: Math.max(0, first - Math.max(after, this.clearedThrough) - 1),
161
+ consoleLevel: this.consoleLevel,
162
+ captureLevel: this.captureLevelValue,
163
+ };
164
+ }
165
+
166
+ clear(): void {
167
+ this.ring.length = 0;
168
+ this.clearedThrough = this.seq;
169
+ }
170
+
171
+ /** Called synchronously inside the log call: keep it cheap, never log from it. */
172
+ subscribe(cb: () => void): () => void {
173
+ this.listeners.add(cb);
174
+ return () => void this.listeners.delete(cb);
175
+ }
176
+ }
177
+
178
+ export function createLogger(level: Level = 'info', transmit?: PinoTransmit, tap?: LogTap): Logger {
7
179
  const browserConfig: LoggerOptions['browser'] = {
8
180
  asObject: true,
9
181
  write: (o: any) => {
10
- console.log(JSON.stringify(o));
182
+ const line = stringifyLog(o);
183
+ if (!tap || tap.printsToConsole(o?.level)) {
184
+ // oxlint-disable-next-line no-console
185
+ console.log(line);
186
+ }
187
+ tap?.record(o, line);
11
188
  },
12
189
  };
13
190
 
14
191
  if (transmit) {
15
- browserConfig.transmit = transmit;
192
+ // Pinned to the configured level: a lower capture level for DevTools must
193
+ // not start shipping debug lines to the collector.
194
+ browserConfig.transmit = { ...transmit, level: transmit.level ?? level };
16
195
  }
17
196
 
18
- return pino({
19
- level,
197
+ const logger = pino({
198
+ level: tap ? tap.effectiveLevel : level,
20
199
  browser: browserConfig,
200
+ onChild: (child) => tap?.track(child),
21
201
  });
202
+ tap?.track(logger);
203
+ return logger;
22
204
  }
@@ -1,5 +1,5 @@
1
- import { Logger } from 'pino';
2
- import { PersistenceClient } from '../../types';
1
+ import type { Logger } from 'pino';
2
+ import type { PersistenceClient } from '../../types';
3
3
 
4
4
  export class LocalStoragePersistenceClient implements PersistenceClient {
5
5
  private logger: Logger;
@@ -1,5 +1,5 @@
1
- import { Logger } from 'pino';
2
- import { PersistenceClient } from '../../types';
1
+ import type { Logger } from 'pino';
2
+ import type { PersistenceClient } from '../../types';
3
3
 
4
4
  export class ResilientPersistenceClient implements PersistenceClient {
5
5
  private logger: Logger;
@@ -23,7 +23,14 @@ export class ResilientPersistenceClient implements PersistenceClient {
23
23
  { key, error: e, Category: 'sp00ky-client::ResilientPersistenceClient::get' },
24
24
  'Persistence read failed, dropping key'
25
25
  );
26
- await this.inner.remove(key).catch(() => {});
26
+ // Best-effort cleanup of the corrupt key; if removal also fails, the key
27
+ // just gets dropped again on the next failed read, so the failure is safe.
28
+ await this.inner.remove(key).catch((removeErr) => {
29
+ this.logger.debug(
30
+ { key, error: removeErr, Category: 'sp00ky-client::ResilientPersistenceClient::get' },
31
+ 'Failed to drop corrupt persistence key'
32
+ );
33
+ });
27
34
  return null;
28
35
  }
29
36
  }
@@ -1,13 +1,13 @@
1
- import { PersistenceClient } from '../../types';
1
+ import type { PersistenceClient } from '../../types';
2
2
  import { parseRecordIdString, surql } from '../../utils/index';
3
- import { Logger } from 'pino';
4
- import { AbstractDatabaseService } from '../database/database';
3
+ import type { Logger } from 'pino';
4
+ import type { LocalStore } from '../database/cache-engine';
5
5
 
6
6
  export class SurrealDBPersistenceClient implements PersistenceClient {
7
7
  private logger: Logger;
8
8
 
9
9
  constructor(
10
- private db: AbstractDatabaseService,
10
+ private db: LocalStore,
11
11
  logger: Logger
12
12
  ) {
13
13
  this.logger = logger.child({ service: 'PersistenceClient:SurrealDb' });