@haverstack/server 0.8.0

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 (82) hide show
  1. package/.claude/settings.local.json +22 -0
  2. package/.dockerignore +10 -0
  3. package/.env.example +90 -0
  4. package/.github/pull_request_template.md +23 -0
  5. package/.github/workflows/ci.yml +55 -0
  6. package/.github/workflows/release.yml +99 -0
  7. package/.prettierignore +3 -0
  8. package/.prettierrc +7 -0
  9. package/AGENTS.md +91 -0
  10. package/CONTRIBUTING.md +234 -0
  11. package/Dockerfile +30 -0
  12. package/LICENSE +121 -0
  13. package/README.md +107 -0
  14. package/SECURITY.md +54 -0
  15. package/docs/api.md +277 -0
  16. package/docs/deployment.md +212 -0
  17. package/eslint.config.mjs +18 -0
  18. package/package.json +42 -0
  19. package/pnpm-workspace.yaml +13 -0
  20. package/src/app.ts +86 -0
  21. package/src/config.ts +176 -0
  22. package/src/index.ts +54 -0
  23. package/src/lib/changeStreams.ts +23 -0
  24. package/src/lib/frameGate.ts +42 -0
  25. package/src/lib/json.ts +27 -0
  26. package/src/lib/nonceStore.ts +103 -0
  27. package/src/lib/queryLimit.ts +11 -0
  28. package/src/lib/queryWorker/pool.ts +324 -0
  29. package/src/lib/queryWorker/protocol.ts +39 -0
  30. package/src/lib/queryWorker/worker.ts +51 -0
  31. package/src/lib/resumeBuffer.ts +268 -0
  32. package/src/lib/resumeCursor.ts +48 -0
  33. package/src/lib/tokenSweep.ts +43 -0
  34. package/src/middleware/auth.ts +77 -0
  35. package/src/middleware/errors.ts +73 -0
  36. package/src/routes/attachments.ts +126 -0
  37. package/src/routes/auth.ts +137 -0
  38. package/src/routes/changes.ts +279 -0
  39. package/src/routes/entity.ts +33 -0
  40. package/src/routes/health.ts +8 -0
  41. package/src/routes/records.ts +259 -0
  42. package/src/routes/tokens.ts +85 -0
  43. package/src/routes/types.ts +57 -0
  44. package/src/routes/wellknown.ts +55 -0
  45. package/src/shutdown.ts +56 -0
  46. package/src/stack.ts +96 -0
  47. package/src/types.ts +9 -0
  48. package/src/wireError.ts +21 -0
  49. package/tests/app.test.ts +86 -0
  50. package/tests/changeFeedClient.ts +156 -0
  51. package/tests/config.test.ts +112 -0
  52. package/tests/conformance.test.ts +2322 -0
  53. package/tests/fixtures/blockingWorker.ts +27 -0
  54. package/tests/fixtures/brokenWorker.ts +7 -0
  55. package/tests/lib/changeFeedClient.test.ts +126 -0
  56. package/tests/lib/frameGate.test.ts +93 -0
  57. package/tests/lib/queryLimit.test.ts +33 -0
  58. package/tests/lib/queryWorker.test.ts +60 -0
  59. package/tests/lib/queryWorkerPool.test.ts +129 -0
  60. package/tests/lib/resumeBuffer.test.ts +341 -0
  61. package/tests/lib/resumeCursor.test.ts +46 -0
  62. package/tests/middleware/errors.test.ts +176 -0
  63. package/tests/routes/associations.test.ts +170 -0
  64. package/tests/routes/attachments.test.ts +621 -0
  65. package/tests/routes/auth.test.ts +448 -0
  66. package/tests/routes/changes.test.ts +567 -0
  67. package/tests/routes/contentQuery.test.ts +68 -0
  68. package/tests/routes/entity.test.ts +170 -0
  69. package/tests/routes/groups.test.ts +69 -0
  70. package/tests/routes/records.test.ts +1965 -0
  71. package/tests/routes/relatedToQuery.test.ts +182 -0
  72. package/tests/routes/tokens.test.ts +157 -0
  73. package/tests/routes/tombstones.test.ts +93 -0
  74. package/tests/routes/types.test.ts +129 -0
  75. package/tests/routes/versions.test.ts +177 -0
  76. package/tests/routes/wellknown.test.ts +71 -0
  77. package/tests/setup.ts +154 -0
  78. package/tests/shutdown.test.ts +121 -0
  79. package/tests/stack.test.ts +198 -0
  80. package/tsconfig.build.json +9 -0
  81. package/tsconfig.json +19 -0
  82. package/vitest.config.ts +12 -0
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Stand-in for src/lib/queryWorker/worker.ts that blocks on command, so
3
+ * pool behavior can be tested against controlled timing rather than
4
+ * inferred from how long a real search happens to take.
5
+ *
6
+ * `filter.search === '__block__'` blocks the thread outright via
7
+ * Atomics.wait — the closest honest simulation of node:sqlite's
8
+ * uncancellable synchronous call, and unlike a spin loop it costs no CPU
9
+ * while the test waits for the deadline to fire.
10
+ */
11
+ import { parentPort } from 'node:worker_threads';
12
+ import type { QueryRequest, QueryResponse } from '../../src/lib/queryWorker/protocol.js';
13
+
14
+ if (!parentPort) throw new Error('fixture worker must run as a worker_thread');
15
+ const port = parentPort;
16
+
17
+ port.on('message', (req: QueryRequest) => {
18
+ if ((req.query.filter as { search?: string } | undefined)?.search === '__block__') {
19
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0);
20
+ return; // unreachable: only terminate() ends the wait
21
+ }
22
+ port.postMessage({
23
+ id: req.id,
24
+ ok: true,
25
+ result: { records: [], cursor: null },
26
+ } satisfies QueryResponse);
27
+ });
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Stand-in for a worker that cannot boot at all — the shape of a fatal
3
+ * misconfiguration (an unreadable or missing DB_PATH). Pins the pool's
4
+ * respawn backoff: without one, a worker failing this way would respawn as
5
+ * fast as the loop allows, forever.
6
+ */
7
+ throw new Error('__fixture_boot_failure__');
@@ -0,0 +1,126 @@
1
+ import { describe, test, expect } from 'vitest';
2
+ import { Hono } from 'hono';
3
+ import { streamSSE } from 'hono/streaming';
4
+ import { EventEmitter } from 'node:events';
5
+ import { SSEDecoder, openChangeFeed } from '../changeFeedClient.js';
6
+ import type { AppEnv } from '../../src/app.js';
7
+
8
+ describe('SSEDecoder', () => {
9
+ test('decodes a single frame delivered in one chunk', () => {
10
+ const decoder = new SSEDecoder();
11
+ const frames = decoder.push('id: AA3f1R\nevent: record\ndata: {"kind":"created"}\n\n');
12
+ expect(frames).toEqual([{ id: 'AA3f1R', event: 'record', data: { kind: 'created' } }]);
13
+ });
14
+
15
+ test('decodes multiple frames from one chunk', () => {
16
+ const decoder = new SSEDecoder();
17
+ const frames = decoder.push(
18
+ 'event: ready\ndata: {"seq":"AA3f1Q"}\n\nid: AA3f1R\nevent: record\ndata: {"kind":"created"}\n\n',
19
+ );
20
+ expect(frames).toEqual([
21
+ { event: 'ready', data: { seq: 'AA3f1Q' } },
22
+ { id: 'AA3f1R', event: 'record', data: { kind: 'created' } },
23
+ ]);
24
+ });
25
+
26
+ test('decodes a frame split across chunks, mid-line', () => {
27
+ const decoder = new SSEDecoder();
28
+ expect(decoder.push('event: rea')).toEqual([]);
29
+ expect(decoder.push('dy\ndata: {"se')).toEqual([]);
30
+ const frames = decoder.push('q":"AA3f1Q"}\n\n');
31
+ expect(frames).toEqual([{ event: 'ready', data: { seq: 'AA3f1Q' } }]);
32
+ });
33
+
34
+ test('ignores comment lines (keepalives)', () => {
35
+ const decoder = new SSEDecoder();
36
+ const frames = decoder.push(': keepalive\n\nevent: ready\ndata: {}\n\n');
37
+ expect(frames).toEqual([{ event: 'ready', data: {} }]);
38
+ });
39
+
40
+ test('a control frame with no id decodes with id absent, even right after a frame that had one', () => {
41
+ const decoder = new SSEDecoder();
42
+ const frames = decoder.push(
43
+ 'id: AA3f1R\nevent: record\ndata: {}\n\nevent: reset\ndata: {"reason":"not_supported"}\n\n',
44
+ );
45
+ expect(frames[0]!.id).toBe('AA3f1R');
46
+ expect(frames[1]!.id).toBeUndefined();
47
+ });
48
+
49
+ test('an unrecognized event name is preserved, not dropped', () => {
50
+ const decoder = new SSEDecoder();
51
+ const frames = decoder.push('event: something-new\ndata: {}\n\n');
52
+ expect(frames).toEqual([{ event: 'something-new', data: {} }]);
53
+ });
54
+
55
+ test('non-JSON data is preserved as raw text rather than throwing', () => {
56
+ const decoder = new SSEDecoder();
57
+ const frames = decoder.push('event: ready\ndata: not-json\n\n');
58
+ expect(frames).toEqual([{ event: 'ready', data: 'not-json' }]);
59
+ });
60
+ });
61
+
62
+ /**
63
+ * A throwaway SSE server, deliberately not this repo's GET /changes, so
64
+ * these pin openChangeFeed's own mechanics rather than the feed's: a
65
+ * connection opens, a mutation dispatched on a *second*, concurrent request
66
+ * reaches the still-open stream, and waitForFrames/close behave around it.
67
+ */
68
+ function buildSseTestApp() {
69
+ const emitter = new EventEmitter();
70
+ const app = new Hono<AppEnv>();
71
+ app.get('/sse-test', (c) => {
72
+ return streamSSE(c, async (stream) => {
73
+ await stream.writeSSE({ event: 'ready', data: '{}' });
74
+ for (let i = 0; i < 2; i++) {
75
+ const payload = await new Promise<{ id: string; data: unknown }>((resolve) => {
76
+ emitter.once('mutate', resolve);
77
+ });
78
+ await stream.writeSSE({
79
+ event: 'record',
80
+ id: payload.id,
81
+ data: JSON.stringify(payload.data),
82
+ });
83
+ }
84
+ });
85
+ });
86
+ app.post('/mutate', async (c) => {
87
+ const body = await c.req.json();
88
+ emitter.emit('mutate', body);
89
+ return c.json({ ok: true });
90
+ });
91
+ return app;
92
+ }
93
+
94
+ describe('openChangeFeed', () => {
95
+ test('captures the opening frame immediately, then a mutation made while open', async () => {
96
+ const app = buildSseTestApp();
97
+ const conn = await openChangeFeed(app, '/sse-test');
98
+ try {
99
+ expect(conn.status).toBe(200);
100
+ await conn.waitForFrames(1);
101
+ expect(conn.frames[0]).toEqual({ event: 'ready', data: {} });
102
+
103
+ await app.request('/mutate', {
104
+ method: 'POST',
105
+ headers: { 'Content-Type': 'application/json' },
106
+ body: JSON.stringify({ id: 'AA3f1R', data: { kind: 'created' } }),
107
+ });
108
+
109
+ const frames = await conn.waitForFrames(2);
110
+ expect(frames[1]).toEqual({ id: 'AA3f1R', event: 'record', data: { kind: 'created' } });
111
+ } finally {
112
+ await conn.close();
113
+ }
114
+ });
115
+
116
+ test('waitForFrames rejects rather than hanging when too few frames arrive', async () => {
117
+ const app = buildSseTestApp();
118
+ const conn = await openChangeFeed(app, '/sse-test');
119
+ try {
120
+ await conn.waitForFrames(1);
121
+ await expect(conn.waitForFrames(5, 50)).rejects.toThrow(/Timed out/);
122
+ } finally {
123
+ await conn.close();
124
+ }
125
+ });
126
+ });
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { FrameGate } from '../../src/lib/frameGate.js';
3
+
4
+ /** A write that never settles on its own — stands in for a stalled client. */
5
+ function neverSettles(): Promise<void> {
6
+ return new Promise(() => {});
7
+ }
8
+
9
+ /** A deferred write the test controls the resolution of. */
10
+ function deferred(): { promise: Promise<void>; resolve: () => void } {
11
+ let resolve!: () => void;
12
+ const promise = new Promise<void>((r) => {
13
+ resolve = r;
14
+ });
15
+ return { promise, resolve };
16
+ }
17
+
18
+ describe('FrameGate', () => {
19
+ it('accepts sends up to the cap without triggering overflow', () => {
20
+ let overflowed = false;
21
+ const gate = new FrameGate(2, () => {
22
+ overflowed = true;
23
+ });
24
+
25
+ expect(gate.send(neverSettles)).toBe(true);
26
+ expect(gate.send(neverSettles)).toBe(true);
27
+ expect(overflowed).toBe(false);
28
+ });
29
+
30
+ it('reports overflow exactly once a send would exceed the cap', () => {
31
+ let overflowCount = 0;
32
+ const gate = new FrameGate(1, () => {
33
+ overflowCount++;
34
+ });
35
+
36
+ expect(gate.send(neverSettles)).toBe(true); // fills the one slot, never settles
37
+ expect(gate.send(neverSettles)).toBe(false); // would be the 2nd in flight — over cap
38
+ expect(overflowCount).toBe(1);
39
+ });
40
+
41
+ it('never calls onOverflow more than once, and refuses every send after', () => {
42
+ let overflowCount = 0;
43
+ const gate = new FrameGate(1, () => {
44
+ overflowCount++;
45
+ });
46
+
47
+ gate.send(neverSettles);
48
+ gate.send(neverSettles); // triggers overflow
49
+ expect(gate.send(neverSettles)).toBe(false);
50
+ expect(gate.send(neverSettles)).toBe(false);
51
+ expect(overflowCount).toBe(1);
52
+ });
53
+
54
+ it('frees a slot once an in-flight send settles, admitting the next one', async () => {
55
+ const gate = new FrameGate(1, () => {
56
+ throw new Error('should not overflow');
57
+ });
58
+ const first = deferred();
59
+
60
+ expect(gate.send(() => first.promise)).toBe(true);
61
+ first.resolve();
62
+ // Let the write's .then/.finally microtasks run.
63
+ await Promise.resolve();
64
+ await Promise.resolve();
65
+
66
+ let secondSent = false;
67
+ expect(
68
+ gate.send(() => {
69
+ secondSent = true;
70
+ return Promise.resolve();
71
+ }),
72
+ ).toBe(true);
73
+ expect(secondSent).toBe(true);
74
+ });
75
+
76
+ it('frees a slot even when the in-flight write rejects', async () => {
77
+ const gate = new FrameGate(1, () => {
78
+ throw new Error('should not overflow');
79
+ });
80
+ const first = deferred();
81
+ const rejecting = first.promise.then(() => {
82
+ throw new Error('write failed');
83
+ });
84
+
85
+ expect(gate.send(() => rejecting)).toBe(true);
86
+ first.resolve();
87
+ await Promise.resolve();
88
+ await Promise.resolve();
89
+ await Promise.resolve();
90
+
91
+ expect(gate.send(() => Promise.resolve())).toBe(true);
92
+ });
93
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @haverstack/core/wire's parsers report `limit` as requested and never
3
+ * clamp it — a ceiling is deployment policy, not wire contract (core#240 /
4
+ * server#114). This server's ceiling is applied here, at the call site in
5
+ * records.ts, and pins the one behavioural detail worth being careful
6
+ * about in that migration: `limit=5000` in must still yield `1000` out.
7
+ */
8
+ import { describe, it, expect } from 'vitest';
9
+ import { clampLimit, MAX_QUERY_LIMIT } from '../../src/lib/queryLimit.js';
10
+
11
+ describe('clampLimit', () => {
12
+ it('clamps a limit above the ceiling down to it', () => {
13
+ expect(clampLimit({ limit: 5000 })).toEqual({ limit: MAX_QUERY_LIMIT });
14
+ });
15
+
16
+ it('leaves a limit at or under the ceiling untouched', () => {
17
+ expect(clampLimit({ limit: MAX_QUERY_LIMIT })).toEqual({ limit: MAX_QUERY_LIMIT });
18
+ expect(clampLimit({ limit: 10 })).toEqual({ limit: 10 });
19
+ });
20
+
21
+ it('leaves an absent limit absent, not defaulted', () => {
22
+ expect(clampLimit({})).toEqual({});
23
+ });
24
+
25
+ it('preserves the rest of the query', () => {
26
+ const query = { filter: { typeId: 'note@1' }, limit: 5000, cursor: 'abc' };
27
+ expect(clampLimit(query)).toEqual({
28
+ filter: { typeId: 'note@1' },
29
+ limit: MAX_QUERY_LIMIT,
30
+ cursor: 'abc',
31
+ });
32
+ });
33
+ });
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { rm } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
4
+ import { createApp } from '../../src/app.js';
5
+ import type { StackContext } from '../../src/stack.js';
6
+ import { createTestContext, testConfig, tempDbPath, logger, req } from '../setup.js';
7
+
8
+ /**
9
+ * Covers docs/spec/wire-format.md § Bounding query cost: a query that
10
+ * outlives its deadline answers 503 `timeout`, and — the part a shared,
11
+ * synchronous-in-process adapter can't otherwise offer — the server goes
12
+ * on serving other requests afterward rather than staying wedged behind
13
+ * the slow one. See src/lib/queryWorker/pool.ts.
14
+ */
15
+ describe('Query worker deadline', () => {
16
+ let dbPath: string;
17
+ let ctx: StackContext;
18
+
19
+ afterEach(async () => {
20
+ await ctx.queryWorker.close();
21
+ await ctx.stack.close();
22
+ await ctx.tokens.close();
23
+ ctx.nonces.close();
24
+ await rm(dirname(dbPath), { recursive: true, force: true }).catch(() => {});
25
+ });
26
+
27
+ it('answers 503 timeout when the deadline is exceeded, and keeps serving after', async () => {
28
+ dbPath = tempDbPath();
29
+ ctx = await createTestContext(dbPath);
30
+
31
+ // A 1ms deadline is exceeded by any real query — spawning/dispatching
32
+ // to a worker alone takes longer — so this is deterministic without
33
+ // needing a slow query to actually construct.
34
+ const impatientApp = createApp(ctx, { ...testConfig(dbPath), queryTimeoutMs: 1 }, logger);
35
+ const timedOut = await req(impatientApp, 'GET', '/records');
36
+ expect(timedOut.status).toBe(503);
37
+ expect((timedOut.data as { error: { code: string } }).error.code).toBe('timeout');
38
+
39
+ // Same underlying queryWorker pool, a normal deadline this time: the
40
+ // worker the timeout terminated must have been replaced, not left
41
+ // wedging the pool for every query dispatched after it.
42
+ const patientApp = createApp(ctx, testConfig(dbPath), logger);
43
+ const ok = await req(patientApp, 'GET', '/records');
44
+ expect(ok.status).toBe(200);
45
+ expect((ok.data as { records: unknown[] }).records).toEqual([]);
46
+
47
+ // Never routed through the worker at all — just confirms the rest of
48
+ // the app stayed responsive throughout.
49
+ const health = await req(patientApp, 'GET', '/health');
50
+ expect(health.status).toBe(200);
51
+ });
52
+
53
+ it('rejects new work once closed, and tolerates being closed twice', async () => {
54
+ dbPath = tempDbPath();
55
+ ctx = await createTestContext(dbPath);
56
+ await ctx.queryWorker.close();
57
+ await expect(ctx.queryWorker.query(null, {}, 1000)).rejects.toThrow(/closed/i);
58
+ // afterEach calls close() again — must be a no-op, not a throw.
59
+ });
60
+ });
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect, afterEach, vi } from 'vitest';
2
+ import { StackTimeoutError } from '@haverstack/core';
3
+ import type { Logger } from 'pino';
4
+ import { QueryWorkerPool } from '../../src/lib/queryWorker/pool.js';
5
+
6
+ /**
7
+ * Pool-level behavior, driven through fixture workers (tests/fixtures/)
8
+ * whose timing the test controls. tests/lib/queryWorker.test.ts covers
9
+ * the same boundary end-to-end over HTTP; this file covers the parts that
10
+ * only show up under contention and failure, where inferring behavior
11
+ * from real query latency would be guesswork.
12
+ */
13
+ const blockingWorkerUrl = new URL('../fixtures/blockingWorker.ts', import.meta.url);
14
+ const brokenWorkerUrl = new URL('../fixtures/brokenWorker.ts', import.meta.url);
15
+
16
+ /** A query the fixture worker blocks on forever, until terminated. */
17
+ const BLOCKING = { filter: { search: '__block__' } };
18
+
19
+ function stubLogger(): Logger & { errorCount: () => number } {
20
+ const error = vi.fn();
21
+ const logger = { error, info: vi.fn(), warn: vi.fn(), debug: vi.fn() } as unknown as Logger;
22
+ return Object.assign(logger, { errorCount: () => error.mock.calls.length });
23
+ }
24
+
25
+ let pool: QueryWorkerPool | null = null;
26
+
27
+ afterEach(async () => {
28
+ await pool?.close();
29
+ pool = null;
30
+ });
31
+
32
+ describe('QueryWorkerPool — execution deadline', () => {
33
+ it('terminates a blocked query at the deadline and keeps serving on a fresh worker', async () => {
34
+ pool = new QueryWorkerPool({
35
+ init: { dbPath: '/unused-by-fixture' },
36
+ poolSize: 1,
37
+ queueLimit: 16,
38
+ logger: stubLogger(),
39
+ workerUrl: blockingWorkerUrl,
40
+ });
41
+
42
+ await expect(pool.query(null, BLOCKING, 300)).rejects.toBeInstanceOf(StackTimeoutError);
43
+
44
+ // The blocked worker was terminated and replaced, not left holding the
45
+ // only slot in the pool.
46
+ await expect(pool.query(null, {}, 2000)).resolves.toMatchObject({ records: [] });
47
+ });
48
+ });
49
+
50
+ describe('QueryWorkerPool — queueing is slow, not fatal', () => {
51
+ it('does not spend a queued deadline while the request waits for a slot', async () => {
52
+ pool = new QueryWorkerPool({
53
+ init: { dbPath: '/unused-by-fixture' },
54
+ poolSize: 1,
55
+ queueLimit: 16,
56
+ logger: stubLogger(),
57
+ workerUrl: blockingWorkerUrl,
58
+ });
59
+
60
+ // Occupies the only slot in the pool for a full 400ms deadline.
61
+ const blocked = pool.query(null, BLOCKING, 400);
62
+ // Queued behind it with a 400ms deadline of its own. If that clock ran
63
+ // from acceptance — as it would if queue wait counted against the
64
+ // budget — this would time out at ~400ms having never reached a
65
+ // worker. It must instead wait, then run, then succeed.
66
+ const queued = pool.query(null, {}, 400);
67
+
68
+ await expect(blocked).rejects.toBeInstanceOf(StackTimeoutError);
69
+ await expect(queued).resolves.toMatchObject({ records: [] });
70
+ });
71
+
72
+ it('sheds load with a retryable timeout once the queue is full', async () => {
73
+ pool = new QueryWorkerPool({
74
+ init: { dbPath: '/unused-by-fixture' },
75
+ poolSize: 1,
76
+ queueLimit: 1,
77
+ logger: stubLogger(),
78
+ workerUrl: blockingWorkerUrl,
79
+ });
80
+
81
+ const blocked = pool.query(null, BLOCKING, 400); // takes the slot
82
+ const queued = pool.query(null, {}, 2000); // fills the queue (limit 1)
83
+ // Third request has nowhere to go: rejected up front rather than
84
+ // growing the queue without bound.
85
+ await expect(pool.query(null, {}, 2000)).rejects.toThrow(/queue is full/i);
86
+ await expect(pool.query(null, {}, 2000)).rejects.toBeInstanceOf(StackTimeoutError);
87
+
88
+ await expect(blocked).rejects.toBeInstanceOf(StackTimeoutError);
89
+ await expect(queued).resolves.toMatchObject({ records: [] });
90
+ });
91
+ });
92
+
93
+ describe('QueryWorkerPool — a worker that cannot boot', () => {
94
+ it('backs off instead of respawning forever, and reports the real cause', async () => {
95
+ const logger = stubLogger();
96
+ pool = new QueryWorkerPool({
97
+ init: { dbPath: '/unused-by-fixture' },
98
+ poolSize: 1,
99
+ queueLimit: 16,
100
+ logger,
101
+ workerUrl: brokenWorkerUrl,
102
+ });
103
+
104
+ // Backoff is 100/200/400/800/1600ms, then the pool marks itself
105
+ // unhealthy and stops hot-respawning. Without it this loop respawned
106
+ // as fast as a thread could be created — ~19 times in 3s, forever.
107
+ await new Promise((r) => setTimeout(r, 1500));
108
+ expect(logger.errorCount()).toBeLessThanOrEqual(6);
109
+
110
+ // A standing spawn fault surfaces as itself, not as a pile of
111
+ // deadline timeouts that hide the cause.
112
+ await expect(pool.query(null, {}, 5000)).rejects.toThrow(/__fixture_boot_failure__/);
113
+ });
114
+ });
115
+
116
+ describe('QueryWorkerPool — lifecycle', () => {
117
+ it('rejects new work once closed, and tolerates being closed twice', async () => {
118
+ pool = new QueryWorkerPool({
119
+ init: { dbPath: '/unused-by-fixture' },
120
+ poolSize: 1,
121
+ queueLimit: 16,
122
+ logger: stubLogger(),
123
+ workerUrl: blockingWorkerUrl,
124
+ });
125
+ await pool.close();
126
+ await expect(pool.query(null, {}, 1000)).rejects.toThrow(/closed/i);
127
+ // afterEach closes again — must be a no-op, not a throw.
128
+ });
129
+ });