@haverstack/server 0.8.0 → 0.9.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 (197) hide show
  1. package/dist/app.d.ts +8 -0
  2. package/dist/app.d.ts.map +1 -0
  3. package/dist/app.js +70 -0
  4. package/dist/app.js.map +1 -0
  5. package/dist/config.d.ts +21 -0
  6. package/dist/config.d.ts.map +1 -0
  7. package/dist/config.js +115 -0
  8. package/dist/config.js.map +1 -0
  9. package/dist/index.d.ts +16 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +12 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/lib/changeStreams.d.ts +17 -0
  14. package/dist/lib/changeStreams.d.ts.map +1 -0
  15. package/{src/lib/changeStreams.ts → dist/lib/changeStreams.js} +12 -12
  16. package/dist/lib/changeStreams.js.map +1 -0
  17. package/dist/lib/frameGate.d.ts +26 -0
  18. package/dist/lib/frameGate.d.ts.map +1 -0
  19. package/dist/lib/frameGate.js +44 -0
  20. package/dist/lib/frameGate.js.map +1 -0
  21. package/dist/lib/json.d.ts +10 -0
  22. package/dist/lib/json.d.ts.map +1 -0
  23. package/dist/lib/json.js +27 -0
  24. package/dist/lib/json.js.map +1 -0
  25. package/dist/lib/nonceStore.d.ts +50 -0
  26. package/dist/lib/nonceStore.d.ts.map +1 -0
  27. package/dist/lib/nonceStore.js +96 -0
  28. package/dist/lib/nonceStore.js.map +1 -0
  29. package/dist/lib/queryLimit.d.ts +4 -0
  30. package/dist/lib/queryLimit.d.ts.map +1 -0
  31. package/{src/lib/queryLimit.ts → dist/lib/queryLimit.js} +5 -6
  32. package/dist/lib/queryLimit.js.map +1 -0
  33. package/dist/lib/queryWorker/pool.d.ts +60 -0
  34. package/dist/lib/queryWorker/pool.d.ts.map +1 -0
  35. package/dist/lib/queryWorker/pool.js +278 -0
  36. package/dist/lib/queryWorker/pool.js.map +1 -0
  37. package/{src/lib/queryWorker/protocol.ts → dist/lib/queryWorker/protocol.d.ts} +21 -18
  38. package/dist/lib/queryWorker/protocol.d.ts.map +1 -0
  39. package/dist/lib/queryWorker/protocol.js +2 -0
  40. package/dist/lib/queryWorker/protocol.js.map +1 -0
  41. package/dist/lib/queryWorker/worker.d.ts +2 -0
  42. package/dist/lib/queryWorker/worker.d.ts.map +1 -0
  43. package/{src/lib/queryWorker/worker.ts → dist/lib/queryWorker/worker.js} +19 -22
  44. package/dist/lib/queryWorker/worker.js.map +1 -0
  45. package/dist/lib/resumeBuffer.d.ts +143 -0
  46. package/dist/lib/resumeBuffer.d.ts.map +1 -0
  47. package/dist/lib/resumeBuffer.js +216 -0
  48. package/dist/lib/resumeBuffer.js.map +1 -0
  49. package/dist/lib/resumeCursor.d.ts +16 -0
  50. package/dist/lib/resumeCursor.d.ts.map +1 -0
  51. package/{src/lib/resumeCursor.ts → dist/lib/resumeCursor.js} +22 -24
  52. package/dist/lib/resumeCursor.js.map +1 -0
  53. package/dist/lib/tokenSweep.d.ts +17 -0
  54. package/dist/lib/tokenSweep.d.ts.map +1 -0
  55. package/dist/lib/tokenSweep.js +40 -0
  56. package/dist/lib/tokenSweep.js.map +1 -0
  57. package/dist/main.d.ts +2 -0
  58. package/dist/main.d.ts.map +1 -0
  59. package/dist/main.js +46 -0
  60. package/dist/main.js.map +1 -0
  61. package/dist/middleware/auth.d.ts +25 -0
  62. package/dist/middleware/auth.d.ts.map +1 -0
  63. package/dist/middleware/auth.js +74 -0
  64. package/dist/middleware/auth.js.map +1 -0
  65. package/dist/middleware/errors.d.ts +17 -0
  66. package/dist/middleware/errors.d.ts.map +1 -0
  67. package/dist/middleware/errors.js +64 -0
  68. package/dist/middleware/errors.js.map +1 -0
  69. package/dist/routes/attachments.d.ts +5 -0
  70. package/dist/routes/attachments.d.ts.map +1 -0
  71. package/dist/routes/attachments.js +104 -0
  72. package/dist/routes/attachments.js.map +1 -0
  73. package/dist/routes/auth.d.ts +6 -0
  74. package/dist/routes/auth.d.ts.map +1 -0
  75. package/dist/routes/auth.js +110 -0
  76. package/dist/routes/auth.js.map +1 -0
  77. package/dist/routes/changes.d.ts +22 -0
  78. package/dist/routes/changes.d.ts.map +1 -0
  79. package/dist/routes/changes.js +233 -0
  80. package/dist/routes/changes.js.map +1 -0
  81. package/dist/routes/entity.d.ts +5 -0
  82. package/dist/routes/entity.d.ts.map +1 -0
  83. package/dist/routes/entity.js +30 -0
  84. package/dist/routes/entity.js.map +1 -0
  85. package/dist/routes/health.d.ts +4 -0
  86. package/dist/routes/health.d.ts.map +1 -0
  87. package/dist/routes/health.js +7 -0
  88. package/dist/routes/health.js.map +1 -0
  89. package/dist/routes/records.d.ts +5 -0
  90. package/dist/routes/records.d.ts.map +1 -0
  91. package/dist/routes/records.js +231 -0
  92. package/dist/routes/records.js.map +1 -0
  93. package/dist/routes/tokens.d.ts +5 -0
  94. package/dist/routes/tokens.d.ts.map +1 -0
  95. package/dist/routes/tokens.js +65 -0
  96. package/dist/routes/tokens.js.map +1 -0
  97. package/dist/routes/types.d.ts +5 -0
  98. package/dist/routes/types.d.ts.map +1 -0
  99. package/dist/routes/types.js +46 -0
  100. package/dist/routes/types.js.map +1 -0
  101. package/dist/routes/wellknown.d.ts +6 -0
  102. package/dist/routes/wellknown.d.ts.map +1 -0
  103. package/dist/routes/wellknown.js +45 -0
  104. package/dist/routes/wellknown.js.map +1 -0
  105. package/dist/shutdown.d.ts +23 -0
  106. package/dist/shutdown.d.ts.map +1 -0
  107. package/dist/shutdown.js +32 -0
  108. package/dist/shutdown.js.map +1 -0
  109. package/dist/stack.d.ts +20 -0
  110. package/dist/stack.d.ts.map +1 -0
  111. package/dist/stack.js +50 -0
  112. package/dist/stack.js.map +1 -0
  113. package/dist/testing.d.ts +49 -0
  114. package/dist/testing.d.ts.map +1 -0
  115. package/dist/testing.js +130 -0
  116. package/dist/testing.js.map +1 -0
  117. package/{src/types.ts → dist/types.d.ts} +5 -5
  118. package/dist/types.d.ts.map +1 -0
  119. package/dist/types.js +2 -0
  120. package/dist/types.js.map +1 -0
  121. package/{src/wireError.ts → dist/wireError.d.ts} +2 -9
  122. package/dist/wireError.d.ts.map +1 -0
  123. package/dist/wireError.js +13 -0
  124. package/dist/wireError.js.map +1 -0
  125. package/package.json +37 -12
  126. package/.claude/settings.local.json +0 -22
  127. package/.dockerignore +0 -10
  128. package/.env.example +0 -90
  129. package/.github/pull_request_template.md +0 -23
  130. package/.github/workflows/ci.yml +0 -55
  131. package/.github/workflows/release.yml +0 -99
  132. package/.prettierignore +0 -3
  133. package/.prettierrc +0 -7
  134. package/AGENTS.md +0 -91
  135. package/CONTRIBUTING.md +0 -234
  136. package/Dockerfile +0 -30
  137. package/SECURITY.md +0 -54
  138. package/docs/api.md +0 -277
  139. package/docs/deployment.md +0 -212
  140. package/eslint.config.mjs +0 -18
  141. package/pnpm-workspace.yaml +0 -13
  142. package/src/app.ts +0 -86
  143. package/src/config.ts +0 -176
  144. package/src/index.ts +0 -54
  145. package/src/lib/frameGate.ts +0 -42
  146. package/src/lib/json.ts +0 -27
  147. package/src/lib/nonceStore.ts +0 -103
  148. package/src/lib/queryWorker/pool.ts +0 -324
  149. package/src/lib/resumeBuffer.ts +0 -268
  150. package/src/lib/tokenSweep.ts +0 -43
  151. package/src/middleware/auth.ts +0 -77
  152. package/src/middleware/errors.ts +0 -73
  153. package/src/routes/attachments.ts +0 -126
  154. package/src/routes/auth.ts +0 -137
  155. package/src/routes/changes.ts +0 -279
  156. package/src/routes/entity.ts +0 -33
  157. package/src/routes/health.ts +0 -8
  158. package/src/routes/records.ts +0 -259
  159. package/src/routes/tokens.ts +0 -85
  160. package/src/routes/types.ts +0 -57
  161. package/src/routes/wellknown.ts +0 -55
  162. package/src/shutdown.ts +0 -56
  163. package/src/stack.ts +0 -96
  164. package/tests/app.test.ts +0 -86
  165. package/tests/changeFeedClient.ts +0 -156
  166. package/tests/config.test.ts +0 -112
  167. package/tests/conformance.test.ts +0 -2322
  168. package/tests/fixtures/blockingWorker.ts +0 -27
  169. package/tests/fixtures/brokenWorker.ts +0 -7
  170. package/tests/lib/changeFeedClient.test.ts +0 -126
  171. package/tests/lib/frameGate.test.ts +0 -93
  172. package/tests/lib/queryLimit.test.ts +0 -33
  173. package/tests/lib/queryWorker.test.ts +0 -60
  174. package/tests/lib/queryWorkerPool.test.ts +0 -129
  175. package/tests/lib/resumeBuffer.test.ts +0 -341
  176. package/tests/lib/resumeCursor.test.ts +0 -46
  177. package/tests/middleware/errors.test.ts +0 -176
  178. package/tests/routes/associations.test.ts +0 -170
  179. package/tests/routes/attachments.test.ts +0 -621
  180. package/tests/routes/auth.test.ts +0 -448
  181. package/tests/routes/changes.test.ts +0 -567
  182. package/tests/routes/contentQuery.test.ts +0 -68
  183. package/tests/routes/entity.test.ts +0 -170
  184. package/tests/routes/groups.test.ts +0 -69
  185. package/tests/routes/records.test.ts +0 -1965
  186. package/tests/routes/relatedToQuery.test.ts +0 -182
  187. package/tests/routes/tokens.test.ts +0 -157
  188. package/tests/routes/tombstones.test.ts +0 -93
  189. package/tests/routes/types.test.ts +0 -129
  190. package/tests/routes/versions.test.ts +0 -177
  191. package/tests/routes/wellknown.test.ts +0 -71
  192. package/tests/setup.ts +0 -154
  193. package/tests/shutdown.test.ts +0 -121
  194. package/tests/stack.test.ts +0 -198
  195. package/tsconfig.build.json +0 -9
  196. package/tsconfig.json +0 -19
  197. package/vitest.config.ts +0 -12
@@ -1,11 +1,10 @@
1
- import type { StackQuery } from '@haverstack/core';
2
-
3
1
  // core's parsers (@haverstack/core/wire) report `limit` as requested and
4
2
  // never clamp it — a ceiling is deployment policy, not wire contract. This
5
3
  // server's ceiling, applied at the call site in records.ts.
6
4
  export const MAX_QUERY_LIMIT = 1000;
7
-
8
- export function clampLimit(query: StackQuery): StackQuery {
9
- if (query.limit === undefined) return query;
10
- return { ...query, limit: Math.min(query.limit, MAX_QUERY_LIMIT) };
5
+ export function clampLimit(query) {
6
+ if (query.limit === undefined)
7
+ return query;
8
+ return { ...query, limit: Math.min(query.limit, MAX_QUERY_LIMIT) };
11
9
  }
10
+ //# sourceMappingURL=queryLimit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queryLimit.js","sourceRoot":"","sources":["../../src/lib/queryLimit.ts"],"names":[],"mappings":"AAEA,yEAAyE;AACzE,2EAA2E;AAC3E,4DAA4D;AAC5D,MAAM,CAAC,MAAM,eAAe,GAAG,IAAI,CAAC;AAEpC,MAAM,UAAU,UAAU,CAAC,KAAiB;IAC1C,IAAI,KAAK,CAAC,KAAK,KAAK,SAAS;QAAE,OAAO,KAAK,CAAC;IAC5C,OAAO,EAAE,GAAG,KAAK,EAAE,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,EAAE,eAAe,CAAC,EAAE,CAAC;AACrE,CAAC"}
@@ -0,0 +1,60 @@
1
+ import type { StackQuery, QueryResult, TokenSession } from '@haverstack/core';
2
+ import type { Logger } from 'pino';
3
+ import type { QueryWorkerInit } from './protocol.js';
4
+ export type QueryWorkerPoolOptions = {
5
+ init: QueryWorkerInit;
6
+ poolSize: number;
7
+ /** Maximum requests waiting for a slot before the pool sheds load. */
8
+ queueLimit: number;
9
+ logger: Logger;
10
+ /**
11
+ * Worker entry point. Defaults to this directory's worker; overridable
12
+ * so tests can drive the pool with a fixture worker whose timing they
13
+ * control, rather than inferring pool behavior from real query latency.
14
+ */
15
+ workerUrl?: URL;
16
+ };
17
+ export declare class QueryWorkerPool {
18
+ private readonly slots;
19
+ private readonly pending;
20
+ /** FIFO of requests waiting for a slot to free up. */
21
+ private readonly waiting;
22
+ private nextId;
23
+ private closed;
24
+ private consecutiveFailures;
25
+ private lastFailure;
26
+ private readonly init;
27
+ private readonly queueLimit;
28
+ private readonly logger;
29
+ private readonly workerUrl;
30
+ constructor(opts: QueryWorkerPoolOptions);
31
+ /**
32
+ * True once every recent spawn attempt has failed — a standing fault
33
+ * (bad DB_PATH, unreadable file) rather than one unlucky crash.
34
+ */
35
+ private get unhealthy();
36
+ private startWorker;
37
+ private handleMessage;
38
+ /**
39
+ * A worker crashed or exited on its own — not a deadline recycle, which
40
+ * is deliberate and handled by recycleSlot() without backoff.
41
+ */
42
+ private handleWorkerDeath;
43
+ /** Deliberate recycle after a deadline: capacity is restored at once, no backoff. */
44
+ private recycleSlot;
45
+ private teardown;
46
+ private scheduleRespawn;
47
+ private failQueued;
48
+ private drainWaiting;
49
+ /** Hand a request to a worker and start its execution clock — see the module doc. */
50
+ private startExecution;
51
+ private onDeadline;
52
+ /**
53
+ * Run a query on the pool. `deadlineMs` bounds execution only; a request
54
+ * that waits for a slot waits without penalty, until the queue itself is
55
+ * full.
56
+ */
57
+ query(session: TokenSession | null, stackQuery: StackQuery, deadlineMs: number): Promise<QueryResult>;
58
+ close(): Promise<void>;
59
+ }
60
+ //# sourceMappingURL=pool.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pool.d.ts","sourceRoot":"","sources":["../../../src/lib/queryWorker/pool.ts"],"names":[],"mappings":"AA0CA,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAG9E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AACnC,OAAO,KAAK,EAA+B,eAAe,EAAE,MAAM,eAAe,CAAC;AAgClF,MAAM,MAAM,sBAAsB,GAAG;IACnC,IAAI,EAAE,eAAe,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,sEAAsE;IACtE,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,SAAS,CAAC,EAAE,GAAG,CAAC;CACjB,CAAC;AAEF,qBAAa,eAAe;IAC1B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAc;IACpC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA8B;IACtD,sDAAsD;IACtD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAgD;IACxE,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,mBAAmB,CAAK;IAChC,OAAO,CAAC,WAAW,CAAsB;IAEzC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAkB;IACvC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAM;gBAEpB,IAAI,EAAE,sBAAsB;IAYxC;;;OAGG;IACH,OAAO,KAAK,SAAS,GAEpB;IAED,OAAO,CAAC,WAAW;IAqBnB,OAAO,CAAC,aAAa;IAmBrB;;;OAGG;IACH,OAAO,CAAC,iBAAiB;IA4BzB,qFAAqF;IACrF,OAAO,CAAC,WAAW;IAMnB,OAAO,CAAC,QAAQ;IAShB,OAAO,CAAC,eAAe;IAQvB,OAAO,CAAC,UAAU;IAUlB,OAAO,CAAC,YAAY;IAepB,qFAAqF;IACrF,OAAO,CAAC,cAAc;IAMtB,OAAO,CAAC,UAAU;IAWlB;;;;OAIG;IACH,KAAK,CACH,OAAO,EAAE,YAAY,GAAG,IAAI,EAC5B,UAAU,EAAE,UAAU,EACtB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,WAAW,CAAC;IAyBjB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAuB7B"}
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Main-thread side of the query worker boundary. Owns a small, fixed-size
3
+ * pool of workers (src/lib/queryWorker/worker.ts), each with its own
4
+ * LocalAdapter connection to the same stack.db, and dispatches
5
+ * ScopedStack.query() calls to them. The operator-facing account of why
6
+ * this exists is docs/deployment.md § Bounding query cost.
7
+ *
8
+ * Two bounds, deliberately not one:
9
+ *
10
+ * - `deadlineMs` bounds **execution**, and its clock starts when a
11
+ * request reaches a worker, not when it was accepted here. Execution
12
+ * is the only part that needs bounding: node:sqlite exposes no
13
+ * `sqlite3_interrupt` equivalent, so a query already inside the engine
14
+ * cannot be cancelled in place. Queueing makes a request slow, never
15
+ * failed.
16
+ *
17
+ * - `queueLimit` bounds **the wait**, so "slow" cannot quietly become
18
+ * unbounded in latency or retained memory. Past it the pool sheds load
19
+ * with StackTimeoutError — a retryable 503, which is honest about
20
+ * overload in a way a silently growing queue is not.
21
+ *
22
+ * Past its deadline the only lever left is termination: the caller is
23
+ * answered at once and the worker is terminated and replaced, so a stuck
24
+ * query costs one slot rather than wedging the pool. That recycle needs no
25
+ * backoff, being rate-limited by construction at `poolSize` terminations
26
+ * per `deadlineMs`.
27
+ *
28
+ * An *unexpected* death is the opposite case and does back off: respawning
29
+ * a worker that can't boot — an unreadable DB_PATH, say — as fast as the
30
+ * loop allows would spin threads and flood logs. After
31
+ * MAX_CONSECUTIVE_SPAWN_FAILURES the pool reports itself unhealthy and
32
+ * fails queries with the underlying spawn error, so a fatal
33
+ * misconfiguration surfaces as itself rather than as a pile of timeouts.
34
+ * Respawns continue at the capped interval, so the pool heals once the
35
+ * cause clears.
36
+ *
37
+ * Terminating mid-write is safe: WAL journaling leaves an uncommitted
38
+ * transaction simply absent once the file is reopened. Only query() routes
39
+ * through this pool — src/routes/records.ts holds the reason writes and
40
+ * by-id reads stay on the main thread.
41
+ */
42
+ import { Worker } from 'node:worker_threads';
43
+ import { StackTimeoutError } from '@haverstack/core';
44
+ import { deserializeError } from '@haverstack/wire-types';
45
+ // import.meta.url ends in .ts under tsx (dev) and vitest (test), and in
46
+ // .js once tsc has compiled this file into dist/ — so which worker file
47
+ // and loader to use falls out of how *this* module was loaded, with no
48
+ // separate NODE_ENV check to keep in sync.
49
+ const RUNNING_FROM_SOURCE = import.meta.url.endsWith('.ts');
50
+ const WORKER_URL = new URL(RUNNING_FROM_SOURCE ? './worker.ts' : './worker.js', import.meta.url);
51
+ const WORKER_EXEC_ARGV = RUNNING_FROM_SOURCE ? ['--import', 'tsx'] : [];
52
+ /** Backoff schedule for respawning a worker that died unexpectedly. */
53
+ const RESPAWN_BASE_DELAY_MS = 100;
54
+ const RESPAWN_MAX_DELAY_MS = 30_000;
55
+ /** Consecutive unexpected deaths after which the pool reports itself unhealthy. */
56
+ const MAX_CONSECUTIVE_SPAWN_FAILURES = 5;
57
+ export class QueryWorkerPool {
58
+ slots = [];
59
+ pending = new Map();
60
+ /** FIFO of requests waiting for a slot to free up. */
61
+ waiting = [];
62
+ nextId = 1;
63
+ closed = false;
64
+ consecutiveFailures = 0;
65
+ lastFailure = null;
66
+ init;
67
+ queueLimit;
68
+ logger;
69
+ workerUrl;
70
+ constructor(opts) {
71
+ this.init = opts.init;
72
+ this.queueLimit = opts.queueLimit;
73
+ this.logger = opts.logger;
74
+ this.workerUrl = opts.workerUrl ?? WORKER_URL;
75
+ for (let i = 0; i < opts.poolSize; i++) {
76
+ const slot = { worker: null, busyWith: null, respawnTimer: null };
77
+ this.slots.push(slot);
78
+ this.startWorker(slot);
79
+ }
80
+ }
81
+ /**
82
+ * True once every recent spawn attempt has failed — a standing fault
83
+ * (bad DB_PATH, unreadable file) rather than one unlucky crash.
84
+ */
85
+ get unhealthy() {
86
+ return this.consecutiveFailures >= MAX_CONSECUTIVE_SPAWN_FAILURES;
87
+ }
88
+ startWorker(slot) {
89
+ if (this.closed)
90
+ return;
91
+ slot.respawnTimer = null;
92
+ const worker = new Worker(this.workerUrl, {
93
+ workerData: this.init,
94
+ execArgv: WORKER_EXEC_ARGV,
95
+ });
96
+ slot.worker = worker;
97
+ slot.busyWith = null;
98
+ worker.on('message', (msg) => this.handleMessage(slot, msg));
99
+ worker.on('error', (err) => this.handleWorkerDeath(slot, err));
100
+ worker.on('exit', (code) => {
101
+ if (!this.closed && code !== 0) {
102
+ this.handleWorkerDeath(slot, new Error(`query worker exited with code ${code}`));
103
+ }
104
+ });
105
+ // postMessage before a worker finishes booting is fine — worker_threads
106
+ // queues it until the thread installs its listener.
107
+ this.drainWaiting(slot);
108
+ }
109
+ handleMessage(slot, msg) {
110
+ // A worker that answers is a working worker: clear any standing
111
+ // failure state so an earlier rough patch can't leave the pool
112
+ // permanently marked unhealthy.
113
+ this.consecutiveFailures = 0;
114
+ this.lastFailure = null;
115
+ slot.busyWith = null;
116
+ const pending = this.pending.get(msg.id);
117
+ // Already timed out (and rejected) before the worker answered — the
118
+ // late reply just frees the slot, above.
119
+ if (pending) {
120
+ this.pending.delete(msg.id);
121
+ if (pending.timer)
122
+ clearTimeout(pending.timer);
123
+ if (msg.ok)
124
+ pending.resolve(msg.result);
125
+ else
126
+ pending.reject(msg.wire ? deserializeError(msg.wire.body) : new Error(msg.message));
127
+ }
128
+ this.drainWaiting(slot);
129
+ }
130
+ /**
131
+ * A worker crashed or exited on its own — not a deadline recycle, which
132
+ * is deliberate and handled by recycleSlot() without backoff.
133
+ */
134
+ handleWorkerDeath(slot, err) {
135
+ const failedId = slot.busyWith;
136
+ if (failedId !== null) {
137
+ const pending = this.pending.get(failedId);
138
+ if (pending) {
139
+ this.pending.delete(failedId);
140
+ if (pending.timer)
141
+ clearTimeout(pending.timer);
142
+ pending.reject(err);
143
+ }
144
+ }
145
+ this.teardown(slot);
146
+ this.consecutiveFailures++;
147
+ this.lastFailure = err;
148
+ const delay = Math.min(RESPAWN_BASE_DELAY_MS * 2 ** (this.consecutiveFailures - 1), RESPAWN_MAX_DELAY_MS);
149
+ this.logger.error({ err, consecutiveFailures: this.consecutiveFailures, retryInMs: delay }, 'Query worker died unexpectedly; respawning after backoff');
150
+ // Nothing is going to serve the queue any time soon — answer it with
151
+ // the real cause instead of letting it wait out a fault that isn't
152
+ // going to clear on its own.
153
+ if (this.unhealthy)
154
+ this.failQueued(err);
155
+ this.scheduleRespawn(slot, delay);
156
+ }
157
+ /** Deliberate recycle after a deadline: capacity is restored at once, no backoff. */
158
+ recycleSlot(slot) {
159
+ if (this.closed)
160
+ return;
161
+ this.teardown(slot);
162
+ this.startWorker(slot);
163
+ }
164
+ teardown(slot) {
165
+ const worker = slot.worker;
166
+ slot.worker = null;
167
+ slot.busyWith = null;
168
+ if (!worker)
169
+ return;
170
+ worker.removeAllListeners();
171
+ void worker.terminate().catch(() => { });
172
+ }
173
+ scheduleRespawn(slot, delay) {
174
+ if (this.closed)
175
+ return;
176
+ if (slot.respawnTimer)
177
+ clearTimeout(slot.respawnTimer);
178
+ slot.respawnTimer = setTimeout(() => this.startWorker(slot), delay);
179
+ // A pending respawn must not by itself keep the process alive.
180
+ slot.respawnTimer.unref();
181
+ }
182
+ failQueued(err) {
183
+ for (const { id } of this.waiting.splice(0)) {
184
+ const pending = this.pending.get(id);
185
+ if (!pending)
186
+ continue;
187
+ this.pending.delete(id);
188
+ if (pending.timer)
189
+ clearTimeout(pending.timer);
190
+ pending.reject(err);
191
+ }
192
+ }
193
+ drainWaiting(slot) {
194
+ if (this.closed || !slot.worker || slot.busyWith !== null)
195
+ return;
196
+ // Looped, not recursive: a long run of already-abandoned queue entries
197
+ // shouldn't cost stack depth.
198
+ for (;;) {
199
+ const next = this.waiting.shift();
200
+ if (!next)
201
+ return;
202
+ const pending = this.pending.get(next.id);
203
+ // Abandoned while queued (pool went unhealthy, or close()).
204
+ if (!pending)
205
+ continue;
206
+ this.startExecution(slot, next.id, next.req, pending);
207
+ return;
208
+ }
209
+ }
210
+ /** Hand a request to a worker and start its execution clock — see the module doc. */
211
+ startExecution(slot, id, req, pending) {
212
+ pending.timer = setTimeout(() => this.onDeadline(id), pending.deadlineMs);
213
+ slot.busyWith = id;
214
+ slot.worker.postMessage(req);
215
+ }
216
+ onDeadline(id) {
217
+ const pending = this.pending.get(id);
218
+ if (!pending)
219
+ return;
220
+ this.pending.delete(id);
221
+ pending.reject(new StackTimeoutError(`Query exceeded the ${pending.deadlineMs}ms execution deadline`));
222
+ const slot = this.slots.find((s) => s.busyWith === id);
223
+ if (slot)
224
+ this.recycleSlot(slot);
225
+ }
226
+ /**
227
+ * Run a query on the pool. `deadlineMs` bounds execution only; a request
228
+ * that waits for a slot waits without penalty, until the queue itself is
229
+ * full.
230
+ */
231
+ query(session, stackQuery, deadlineMs) {
232
+ if (this.closed)
233
+ return Promise.reject(new Error('Query worker pool is closed'));
234
+ // A standing spawn fault is a server fault, not a slow query: report
235
+ // the cause rather than making every caller wait out a deadline.
236
+ if (this.unhealthy) {
237
+ return Promise.reject(this.lastFailure ?? new Error('Query worker pool is unavailable'));
238
+ }
239
+ if (this.waiting.length >= this.queueLimit) {
240
+ return Promise.reject(new StackTimeoutError(`Query queue is full (${this.queueLimit} waiting); the server is shedding load rather than queueing further`));
241
+ }
242
+ const id = this.nextId++;
243
+ const req = { id, session, query: stackQuery };
244
+ return new Promise((resolve, reject) => {
245
+ const pending = { resolve, reject, deadlineMs, timer: null };
246
+ this.pending.set(id, pending);
247
+ const idle = this.slots.find((s) => s.worker !== null && s.busyWith === null);
248
+ if (idle)
249
+ this.startExecution(idle, id, req, pending);
250
+ else
251
+ this.waiting.push({ id, req });
252
+ });
253
+ }
254
+ async close() {
255
+ this.closed = true;
256
+ for (const pending of this.pending.values()) {
257
+ if (pending.timer)
258
+ clearTimeout(pending.timer);
259
+ pending.reject(new Error('Query worker pool is closing'));
260
+ }
261
+ this.pending.clear();
262
+ this.waiting.length = 0;
263
+ await Promise.all(this.slots.map(async (slot) => {
264
+ if (slot.respawnTimer) {
265
+ clearTimeout(slot.respawnTimer);
266
+ slot.respawnTimer = null;
267
+ }
268
+ const worker = slot.worker;
269
+ slot.worker = null;
270
+ slot.busyWith = null;
271
+ if (!worker)
272
+ return;
273
+ worker.removeAllListeners();
274
+ await worker.terminate();
275
+ }));
276
+ }
277
+ }
278
+ //# sourceMappingURL=pool.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pool.js","sourceRoot":"","sources":["../../../src/lib/queryWorker/pool.ts"],"names":[],"mappings":"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"}
@@ -9,7 +9,6 @@
9
9
  */
10
10
  import type { StackQuery, QueryResult, TokenSession } from '@haverstack/core';
11
11
  import type { WireError } from '@haverstack/wire-types';
12
-
13
12
  /**
14
13
  * Passed as `workerData` when a query worker is spawned. dbPath is the
15
14
  * whole of it: a worker calls LocalAdapter.open(), which reads timezone
@@ -17,23 +16,27 @@ import type { WireError } from '@haverstack/wire-types';
17
16
  * the initialize path takes one as input, and workers never initialize.
18
17
  */
19
18
  export type QueryWorkerInit = {
20
- dbPath: string;
19
+ dbPath: string;
21
20
  };
22
-
23
21
  export type QueryRequest = {
24
- id: number;
25
- /** null = anonymous (Stack.asEntity(null)), matching records.ts's scopeFor(). */
26
- session: TokenSession | null;
27
- query: StackQuery;
22
+ id: number;
23
+ /** null = anonymous (Stack.asEntity(null)), matching records.ts's scopeFor(). */
24
+ session: TokenSession | null;
25
+ query: StackQuery;
28
26
  };
29
-
30
- export type QueryResponse =
31
- | { id: number; ok: true; result: QueryResult }
32
- | {
33
- id: number;
34
- ok: false;
35
- /** Set when the failure is a StackError with a wire mapping; reconstructed via deserializeError(). */
36
- wire: { status: number; body: WireError } | null;
37
- /** Always set, for logging — the only signal left when `wire` is null (an unanticipated bug). */
38
- message: string;
39
- };
27
+ export type QueryResponse = {
28
+ id: number;
29
+ ok: true;
30
+ result: QueryResult;
31
+ } | {
32
+ id: number;
33
+ ok: false;
34
+ /** Set when the failure is a StackError with a wire mapping; reconstructed via deserializeError(). */
35
+ wire: {
36
+ status: number;
37
+ body: WireError;
38
+ } | null;
39
+ /** Always set, for logging — the only signal left when `wire` is null (an unanticipated bug). */
40
+ message: string;
41
+ };
42
+ //# sourceMappingURL=protocol.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../../../src/lib/queryWorker/protocol.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAC9E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAExD;;;;;GAKG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,iFAAiF;IACjF,OAAO,EAAE,YAAY,GAAG,IAAI,CAAC;IAC7B,KAAK,EAAE,UAAU,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,aAAa,GACrB;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,WAAW,CAAA;CAAE,GAC7C;IACE,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,KAAK,CAAC;IACV,sGAAsG;IACtG,IAAI,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,SAAS,CAAA;KAAE,GAAG,IAAI,CAAC;IACjD,iGAAiG;IACjG,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=protocol.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.js","sourceRoot":"","sources":["../../../src/lib/queryWorker/protocol.ts"],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=worker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../../../src/lib/queryWorker/worker.ts"],"names":[],"mappings":""}
@@ -18,34 +18,31 @@ import { parentPort, workerData } from 'node:worker_threads';
18
18
  import { LocalAdapter } from '@haverstack/adapter-local';
19
19
  import { Stack } from '@haverstack/core';
20
20
  import { serializeError } from '@haverstack/wire-types';
21
- import type { QueryRequest, QueryResponse, QueryWorkerInit } from './protocol.js';
22
-
23
21
  if (!parentPort) {
24
- throw new Error('query worker must be run as a worker_thread, not the main thread');
22
+ throw new Error('query worker must be run as a worker_thread, not the main thread');
25
23
  }
26
-
27
- const init = workerData as QueryWorkerInit;
28
-
24
+ const init = workerData;
29
25
  // The database already exists — the main thread's initStack() created or
30
26
  // opened it, and seeded system types, before any worker is spawned (see
31
27
  // pool.ts). open() (not openOrInitialize()) reflects that: there is no
32
28
  // first-run path to handle here.
33
29
  const adapter = await LocalAdapter.open({ path: init.dbPath });
34
30
  const stack = await Stack.create(adapter);
35
-
36
- parentPort.on('message', async (req: QueryRequest) => {
37
- const port = parentPort!;
38
- try {
39
- const scoped = req.session ? stack.forSession(req.session) : stack.asEntity(null);
40
- const result = await scoped.query(req.query);
41
- port.postMessage({ id: req.id, ok: true, result } satisfies QueryResponse);
42
- } catch (err) {
43
- const wire = serializeError(err);
44
- port.postMessage({
45
- id: req.id,
46
- ok: false,
47
- wire,
48
- message: err instanceof Error ? err.message : String(err),
49
- } satisfies QueryResponse);
50
- }
31
+ parentPort.on('message', async (req) => {
32
+ const port = parentPort;
33
+ try {
34
+ const scoped = req.session ? stack.forSession(req.session) : stack.asEntity(null);
35
+ const result = await scoped.query(req.query);
36
+ port.postMessage({ id: req.id, ok: true, result });
37
+ }
38
+ catch (err) {
39
+ const wire = serializeError(err);
40
+ port.postMessage({
41
+ id: req.id,
42
+ ok: false,
43
+ wire,
44
+ message: err instanceof Error ? err.message : String(err),
45
+ });
46
+ }
51
47
  });
48
+ //# sourceMappingURL=worker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker.js","sourceRoot":"","sources":["../../../src/lib/queryWorker/worker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AACzC,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAGxD,IAAI,CAAC,UAAU,EAAE,CAAC;IAChB,MAAM,IAAI,KAAK,CAAC,kEAAkE,CAAC,CAAC;AACtF,CAAC;AAED,MAAM,IAAI,GAAG,UAA6B,CAAC;AAE3C,yEAAyE;AACzE,wEAAwE;AACxE,uEAAuE;AACvE,iCAAiC;AACjC,MAAM,OAAO,GAAG,MAAM,YAAY,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC;AAC/D,MAAM,KAAK,GAAG,MAAM,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;AAE1C,UAAU,CAAC,EAAE,CAAC,SAAS,EAAE,KAAK,EAAE,GAAiB,EAAE,EAAE;IACnD,MAAM,IAAI,GAAG,UAAW,CAAC;IACzB,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QAClF,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QAC7C,IAAI,CAAC,WAAW,CAAC,EAAE,EAAE,EAAE,GAAG,CAAC,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAA0B,CAAC,CAAC;IAC7E,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,IAAI,GAAG,cAAc,CAAC,GAAG,CAAC,CAAC;QACjC,IAAI,CAAC,WAAW,CAAC;YACf,EAAE,EAAE,GAAG,CAAC,EAAE;YACV,EAAE,EAAE,KAAK;YACT,IAAI;YACJ,OAAO,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC;SAClC,CAAC,CAAC;IAC7B,CAAC;AACH,CAAC,CAAC,CAAC"}
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Per-(session, filter) resume buffers for GET /changes. Sits downstream
3
+ * of each connection's `ScopedStack.subscribe()`, so every entry here is a
4
+ * frame that subscription already delivered — something this session was
5
+ * cleared to see at the moment it happened. That is what keeps replay a
6
+ * pure re-send needing nothing from core, where a single server-wide
7
+ * buffer would owe a permission check per frame per reader.
8
+ *
9
+ * A buffer mints its own cursors (its position is a private counter, not a
10
+ * slice of some global sequence) and is fed independently of any one HTTP
11
+ * connection: it keeps collecting for a bounded window *after* the last
12
+ * connection using it disconnects, which is what lets a reconnect learn
13
+ * about changes made while nobody was listening. Depth-bounded on top of
14
+ * that: a session that stays away long enough to overflow the ring gets
15
+ * `overflow` rather than a silently incomplete replay.
16
+ */
17
+ import type { ChangeFilter, RecordChange, Unsubscribe } from '@haverstack/core';
18
+ import type { WireRecordChange } from '@haverstack/wire-types';
19
+ export type ResumeEntry = {
20
+ n: number;
21
+ recordId: string;
22
+ isPurge: boolean;
23
+ /** Wire-ready, seq already attached. */
24
+ frame: WireRecordChange;
25
+ };
26
+ export type EntriesAfterResult = {
27
+ status: 'ok';
28
+ entries: ResumeEntry[];
29
+ }
30
+ /** The buffer is the right one, but depth eviction already dropped what would be needed. */
31
+ | {
32
+ status: 'overflow';
33
+ }
34
+ /** A position this buffer never held, or no longer holds any record of. */
35
+ | {
36
+ status: 'cursor_expired';
37
+ };
38
+ /** One session+filter's resume buffer. Data and notification only — lifecycle lives in the registry. */
39
+ export declare class ResumeBuffer {
40
+ private readonly depth;
41
+ readonly id: string;
42
+ private readonly entries;
43
+ private currentN;
44
+ private evictedUpTo;
45
+ private readonly listeners;
46
+ /** Connections currently holding this buffer open. Registry-managed. */
47
+ liveCount: number;
48
+ /** Set by the registry when liveCount drops to 0; cleared on reacquire. */
49
+ disconnectedAt: number | null;
50
+ constructor(depth: number);
51
+ /** The cursor naming this buffer's current position — what `ready` reports. */
52
+ headCursor(): string;
53
+ /**
54
+ * Record one already-permission-filtered change, minting its cursor.
55
+ * Notifies every currently attached live connection with the same frame
56
+ * a replay would later produce.
57
+ */
58
+ append(change: RecordChange): ResumeEntry;
59
+ /** Attach a live connection. Returns the detach function. */
60
+ subscribeLive(listener: (entry: ResumeEntry) => void): () => void;
61
+ /**
62
+ * Everything after position `n`, or why that can't be answered. `n` is
63
+ * this server's own minted position, already decoded and matched to
64
+ * this buffer's id by the caller.
65
+ */
66
+ entriesAfter(n: number): EntriesAfterResult;
67
+ }
68
+ export type ResumeBufferKeyParts = {
69
+ /** Both null for an anonymous connection. */
70
+ principalId: string | null;
71
+ subjectId: string | null;
72
+ filter: ChangeFilter;
73
+ includeRecords: boolean;
74
+ includeUnlisted: boolean;
75
+ };
76
+ /**
77
+ * Reproducible across a reconnect that re-sends the same query params —
78
+ * order-independent on the parts of `filter` that don't carry order of
79
+ * their own (`typeId`/`kinds` are matched as sets, not sequences, so
80
+ * re-sending them in a different order must key the same buffer).
81
+ *
82
+ * Two filters that mean different things must never key the same buffer.
83
+ * A buffer opens exactly one `ScopedStack.subscribe()`, carrying the
84
+ * filter of whichever connection minted it, so a collision hands one of
85
+ * the two colliding connections the *other's* filter: either a silent gap
86
+ * (events it asked for and never hears about, with no `reset` to tell it
87
+ * so) or frames outside its filter, which "filtering is exact, not
88
+ * advisory" forbids. See docs/spec/wire-format.md § Change feed.
89
+ *
90
+ * Hence an object rather than a positional array: `JSON.stringify` drops
91
+ * an undefined *property* but renders an undefined *array element* as
92
+ * `null`, which would collapse "no parentId filter" onto `parentId: null`
93
+ * — "root records only", a filter the wire format defines. An absent part
94
+ * is an absent key, which no present value can imitate. Key order is fixed
95
+ * by construction below.
96
+ */
97
+ export declare function resumeBufferKey(parts: ResumeBufferKeyParts): string;
98
+ export type ResumeBufferRegistryOptions = {
99
+ /** Max entries retained per buffer before the oldest is dropped. */
100
+ depth: number;
101
+ /** How long a buffer keeps being fed after its last connection disconnects, before it's dropped entirely. */
102
+ retentionMs: number;
103
+ };
104
+ /**
105
+ * Owns every live resume buffer, keyed by (session, filter, includeRecords).
106
+ * A buffer's underlying `ScopedStack.subscribe()` is opened once, when the
107
+ * buffer is first minted, and kept open — including across a gap with zero
108
+ * live connections — until the buffer itself is evicted.
109
+ */
110
+ export declare class ResumeBufferRegistry {
111
+ private readonly opts;
112
+ private readonly buffers;
113
+ private readonly unsubscribes;
114
+ private readonly evictionTimers;
115
+ /**
116
+ * Buffers whose subscription is still opening. A key lands in `buffers`
117
+ * only once its `ScopedStack.subscribe()` has resolved, so this is what
118
+ * keeps two connections arriving in the same tick from opening two
119
+ * subscriptions for one key — they await the same attempt and share
120
+ * whichever way it settles.
121
+ */
122
+ private readonly acquiring;
123
+ constructor(opts: ResumeBufferRegistryOptions);
124
+ /**
125
+ * Get this key's buffer, creating one and opening its scoped subscription
126
+ * if none is currently retained. `subscribeToStack` is called at most
127
+ * once per buffer instance.
128
+ *
129
+ * A failed subscription registers nothing: the next connection on this
130
+ * key retries it. Registering a buffer that nothing is feeding would
131
+ * hand every later connection on that key a `ready` frame followed by
132
+ * permanent silence — a gap with no `reset` to announce it, which is
133
+ * the one failure the feed contract calls untrustworthy.
134
+ */
135
+ acquire(key: string, subscribeToStack: (onChange: (change: RecordChange) => void) => Promise<Unsubscribe>): Promise<ResumeBuffer>;
136
+ /** Detach one connection from this key's buffer, starting its retention countdown once none remain. */
137
+ release(key: string): void;
138
+ private cancelEviction;
139
+ private evict;
140
+ /** Tear down every retained buffer immediately — for shutdown. */
141
+ closeAll(): void;
142
+ }
143
+ //# sourceMappingURL=resumeBuffer.d.ts.map