@listen-fire/engine 0.12.0-rc.8 → 0.12.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.
@@ -3,7 +3,8 @@
3
3
  // (`limitRequest`, then `limited` around the call), the slot a host fills
4
4
  // (`installLimiter`), the in-process limiter a host starts from, and a
5
5
  // vendor's rate-limit wait read (`vendorWaitMs`) and turned into a throttle on
6
- // the caller's key (`throttleAfter`).
6
+ // the caller's key (`throttleAfter`); a vendor's remaining budget on a success
7
+ // (`vendorBudget`) closes the key early once it is spent (`budgetAfter`).
7
8
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
8
9
  if (k2 === undefined) k2 = k;
9
10
  var desc = Object.getOwnPropertyDescriptor(m, k);
@@ -3,3 +3,5 @@ export * from '../services/run_host/contract';
3
3
  export * from '../services/browser/contract';
4
4
  export * from '../services/browser/fake_browser';
5
5
  export * from '../services/limiter/contract';
6
+ export { RpcStoreBackend } from '../services/run_host/rpc_store';
7
+ export type { NewDelivery, NewWorkRow } from '../services/run_host/rpc_store';
@@ -18,8 +18,11 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
18
18
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
19
19
  };
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.RpcStoreBackend = void 0;
21
22
  __exportStar(require("../services/movement_engine/test_host"), exports);
22
23
  __exportStar(require("../services/run_host/contract"), exports);
23
24
  __exportStar(require("../services/browser/contract"), exports);
24
25
  __exportStar(require("../services/browser/fake_browser"), exports);
25
26
  __exportStar(require("../services/limiter/contract"), exports);
27
+ var rpc_store_1 = require("../services/run_host/rpc_store");
28
+ Object.defineProperty(exports, "RpcStoreBackend", { enumerable: true, get: function () { return rpc_store_1.RpcStoreBackend; } });
@@ -10,6 +10,10 @@ export interface ContractProvider {
10
10
  scheme: InboundSignatureScheme;
11
11
  signatureHeaders: string[];
12
12
  intercepts?: InboundIntercept[];
13
+ /** The header carrying the provider's id for each delivery. */
14
+ deliveryId?: {
15
+ header: string;
16
+ };
13
17
  }
14
18
  export interface ContractWorld {
15
19
  /** The providers the host serves itself. */
@@ -58,6 +62,21 @@ export interface InboundContractCase {
58
62
  run(backend: InboundContractBackend): Promise<void>;
59
63
  }
60
64
  export declare const INBOUND_CONTRACT_CASES: InboundContractCase[];
65
+ /** A kept delivery, as the receipt cases read it. */
66
+ export interface ContractDelivery {
67
+ externalId: string | null;
68
+ source: string;
69
+ eventCount: number;
70
+ }
71
+ export interface DeliveryReceiptContractBackend extends InboundContractBackend {
72
+ /** Every delivery the host kept, most recent first. */
73
+ deliveries(): Promise<ContractDelivery[]>;
74
+ }
75
+ export interface DeliveryReceiptContractCase {
76
+ name: string;
77
+ run(backend: DeliveryReceiptContractBackend): Promise<void>;
78
+ }
79
+ export declare const DELIVERY_RECEIPT_CONTRACT_CASES: DeliveryReceiptContractCase[];
61
80
  /** The world as an InboundStore + PushStore, for hosts whose doors are
62
81
  * wired to injected stores (the express runner); a host with its own
63
82
  * storage arranges the same world in it instead. */
@@ -20,7 +20,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
20
20
  return (mod && mod.__esModule) ? mod : { "default": mod };
21
21
  };
22
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
- exports.INBOUND_CONTRACT_CASES = exports.CONTRACT_PUSH_TYPE = exports.CONTRACT_SLACK_KEY = exports.CONTRACT_PROVIDER_KEY = void 0;
23
+ exports.DELIVERY_RECEIPT_CONTRACT_CASES = exports.INBOUND_CONTRACT_CASES = exports.CONTRACT_PUSH_TYPE = exports.CONTRACT_SLACK_KEY = exports.CONTRACT_PROVIDER_KEY = void 0;
24
24
  exports.memoryInboundStores = memoryInboundStores;
25
25
  const strict_1 = __importDefault(require("node:assert/strict"));
26
26
  const node_crypto_1 = require("node:crypto");
@@ -370,6 +370,103 @@ exports.INBOUND_CONTRACT_CASES = [
370
370
  },
371
371
  },
372
372
  ];
373
+ const CRM_WITH_IDS = { ...CRM, deliveryId: { header: 'x-crm-delivery' } };
374
+ const withIds = (overrides = {}) => ({ providers: [CRM_WITH_IDS, SLACK], ...overrides });
375
+ const crmSignedAs = (id) => (body) => ({ ...crmSigned(body), 'x-crm-delivery': id });
376
+ const EMIT_DOOR = `/api/public/adapter/${exports.CONTRACT_PUSH_TYPE}/emit`;
377
+ const pushCredential = () => ({ authorization: 'Bearer push-credential' });
378
+ exports.DELIVERY_RECEIPT_CONTRACT_CASES = [
379
+ {
380
+ name: 'a delivery is kept under the id its declared header carries',
381
+ async run(backend) {
382
+ await arrangeAnd(backend, withIds());
383
+ const answer = await post(backend, CRM_DOOR, json(TWO_EVENTS), crmSignedAs('d-1'));
384
+ strict_1.default.deepEqual([answer.status, answer.body], [200, { ok: true, eventsProcessed: 2 }]);
385
+ strict_1.default.deepEqual(await backend.deliveries(), [{ externalId: 'd-1', source: 'webhook', eventCount: 2 }]);
386
+ },
387
+ },
388
+ {
389
+ name: 'a redelivery under the same id is acked as a duplicate and queues nothing',
390
+ async run(backend) {
391
+ await arrangeAnd(backend, withIds());
392
+ const first = await post(backend, CRM_DOOR, json({ ...TWO_EVENTS, checkpoint: { cursor: 'c-1' } }), crmSignedAs('d-1'));
393
+ strict_1.default.equal(first.status, 200);
394
+ const again = await post(backend, CRM_DOOR, json({ ...TWO_EVENTS, checkpoint: { cursor: 'c-2' } }), crmSignedAs('d-1'));
395
+ strict_1.default.deepEqual([again.status, again.body], [200, { ok: true, duplicate: true, eventsProcessed: 0 }]);
396
+ const seen = await backend.observed();
397
+ strict_1.default.deepEqual([seen.enqueued, seen.checkpoints.length, seen.notified], [2, 1, 1]);
398
+ strict_1.default.equal((await backend.deliveries()).length, 1);
399
+ },
400
+ },
401
+ {
402
+ name: 'without the header each delivery is its own, and none is a duplicate',
403
+ async run(backend) {
404
+ await arrangeAnd(backend, withIds());
405
+ for (let i = 0; i < 2; i += 1) {
406
+ const answer = await post(backend, CRM_DOOR, json(TWO_EVENTS), crmSigned);
407
+ strict_1.default.deepEqual([answer.status, answer.body], [200, { ok: true, eventsProcessed: 2 }]);
408
+ }
409
+ strict_1.default.equal((await backend.observed()).enqueued, 4);
410
+ const kept = await backend.deliveries();
411
+ strict_1.default.deepEqual(kept.map((d) => d.externalId), [null, null]);
412
+ },
413
+ },
414
+ {
415
+ name: 'the same id at another subscription is not a duplicate',
416
+ async run(backend) {
417
+ await arrangeAnd(backend, withIds({
418
+ subscriptions: [
419
+ { id: 'sub-crm', teamId: TEAM, providerKey: exports.CONTRACT_PROVIDER_KEY, secret: SECRET },
420
+ { id: 'sub-crm-2', teamId: TEAM, providerKey: exports.CONTRACT_PROVIDER_KEY, secret: SECRET },
421
+ ],
422
+ }));
423
+ for (const subscription of ['sub-crm', 'sub-crm-2']) {
424
+ const answer = await post(backend, door('contract_crm', subscription), json(TWO_EVENTS), crmSignedAs('d-1'));
425
+ strict_1.default.deepEqual([answer.status, answer.body], [200, { ok: true, eventsProcessed: 2 }]);
426
+ }
427
+ strict_1.default.equal((await backend.observed()).enqueued, 4);
428
+ strict_1.default.deepEqual((await backend.deliveries()).map((d) => d.externalId), ['d-1', 'd-1']);
429
+ },
430
+ },
431
+ {
432
+ name: 'a delivery whose wakes failed is kept without its id, so the retry is kept too',
433
+ async run(backend) {
434
+ await arrangeAnd(backend, withIds({ faults: { wakeKeys: true } }));
435
+ for (let i = 0; i < 2; i += 1) {
436
+ const answer = await post(backend, CRM_DOOR, json(TWO_EVENTS), crmSignedAs('d-1'));
437
+ strict_1.default.equal(answer.status, 500);
438
+ }
439
+ strict_1.default.equal((await backend.observed()).enqueued, 4);
440
+ strict_1.default.deepEqual((await backend.deliveries()).map((d) => d.externalId), [null, null]);
441
+ },
442
+ },
443
+ {
444
+ name: "emit's deliveryId keeps the batch out the second time",
445
+ async run(backend) {
446
+ await arrangeAnd(backend);
447
+ const batch = json({ events: [{ payload: { n: 1 } }], deliveryId: 'b-1' });
448
+ const first = await post(backend, EMIT_DOOR, batch, pushCredential);
449
+ strict_1.default.deepEqual([first.status, first.body], [202, { accepted: 1 }]);
450
+ const again = await post(backend, EMIT_DOOR, batch, pushCredential);
451
+ strict_1.default.deepEqual([again.status, again.body], [202, { accepted: 0, duplicate: true }]);
452
+ const seen = await backend.observed();
453
+ strict_1.default.deepEqual([seen.enqueued, seen.notified], [1, 1]);
454
+ strict_1.default.deepEqual(await backend.deliveries(), [{ externalId: 'b-1', source: 'emit', eventCount: 1 }]);
455
+ },
456
+ },
457
+ {
458
+ name: "an emit's deliveryId that is not a non-empty string is a 400",
459
+ async run(backend) {
460
+ await arrangeAnd(backend);
461
+ for (const deliveryId of ['', 7, null]) {
462
+ const answer = await post(backend, EMIT_DOOR, json({ events: [{ payload: { n: 1 } }], deliveryId }), pushCredential);
463
+ strict_1.default.equal(answer.status, 400);
464
+ strict_1.default.deepEqual(answer.body.issues?.[0]?.path, 'deliveryId');
465
+ }
466
+ strict_1.default.deepEqual(await backend.deliveries(), []);
467
+ },
468
+ },
469
+ ];
373
470
  // ── A reference backend's stores, in memory ───────────────────────────────
374
471
  /** The world as an InboundStore + PushStore, for hosts whose doors are
375
472
  * wired to injected stores (the express runner); a host with its own
@@ -34,6 +34,18 @@ function refusedUntil(grant) {
34
34
  strict_1.default.ok(Number.isFinite(at), `retryAt ${grant.retryAt} is an instant`);
35
35
  return at;
36
36
  }
37
+ /** A grant, with the instants either side of the request: the limiter
38
+ * counted the call somewhere between them. */
39
+ async function granted(limiter, at) {
40
+ const before = Date.now();
41
+ permitOf(await limiter.acquire(at));
42
+ return { before, after: Date.now() };
43
+ }
44
+ /** `instant` is `call` plus `windowMs`, within the call's request (a
45
+ * millisecond of slack for a limiter rounding to whole milliseconds). */
46
+ function assertBetween(instant, call, windowMs, message) {
47
+ strict_1.default.ok(instant >= call.before + windowMs - 1 && instant <= call.after + windowMs + 1, `${message}: retryAt is ${instant - call.before - windowMs}ms after the call's request plus the window, expected within ${call.after - call.before}ms`);
48
+ }
37
49
  exports.limiterContractCases = [
38
50
  {
39
51
  name: 'a call under no limit is granted, and releasing a permit twice, or one never given, changes nothing',
@@ -69,6 +81,31 @@ exports.limiterContractCases = [
69
81
  permitOf(await limiter.acquire(at));
70
82
  },
71
83
  },
84
+ {
85
+ // A token bucket refills while the window still holds the burst, so a
86
+ // burst then a trickle puts nearly twice the calls in one window. Calls
87
+ // are spaced well apart so the wire's latency cannot blur which have left.
88
+ name: 'a rolling window never holds more than the declared calls',
89
+ async run(limiter) {
90
+ const at = request([{ key: key('rolling'), calls: 3, perMs: 1_000 }]);
91
+ const first = await granted(limiter, at);
92
+ await sleep(300);
93
+ const second = await granted(limiter, at);
94
+ await granted(limiter, at);
95
+ await sleep(first.before + 500 - Date.now());
96
+ // The bucket would have refilled one call by now; the window still holds three.
97
+ const oldestLeaves = refusedUntil(await limiter.acquire(at));
98
+ assertBetween(oldestLeaves, first, 1_000, 'refused until the oldest call leaves the window');
99
+ await sleep(oldestLeaves - Date.now() + 20);
100
+ const fourth = await granted(limiter, at);
101
+ const nextLeaves = refusedUntil(await limiter.acquire(at));
102
+ assertBetween(nextLeaves, second, 1_000, 'exactly one call left, so one more; then refused until the next leaves');
103
+ await sleep(nextLeaves - Date.now() + 20);
104
+ await granted(limiter, at);
105
+ await granted(limiter, at);
106
+ assertBetween(refusedUntil(await limiter.acquire(at)), fourth, 1_000, 'two left, so two more, and no third');
107
+ },
108
+ },
72
109
  {
73
110
  name: 'a call costing more counts for more against a rate limit',
74
111
  async run(limiter) {
@@ -1,9 +1,10 @@
1
1
  import type { WireDeclaredLimitShape } from '@listen-fire/engine-contracts';
2
- export { vendorWaitMs } from '@listen-fire/module-runtime/vendor_wait';
2
+ import { type VendorBudget } from '@listen-fire/module-runtime/vendor_wait';
3
+ export { vendorBudget, vendorWaitMs, type VendorBudget } from '@listen-fire/module-runtime/vendor_wait';
3
4
  /** One limit a request is under, resolved to the bucket it counts in. */
4
5
  export interface LimitBucket {
5
6
  key: string;
6
- /** At most `calls` per `perMs`; the two go together. */
7
+ /** At most `calls` in any `perMs` (a rolling window); the two go together. */
7
8
  calls?: number;
8
9
  perMs?: number;
9
10
  concurrent?: number;
@@ -69,12 +70,37 @@ export declare function limited<T>(limiter: Limiter, request: LimitRequest, call
69
70
  * throttle on `caller`'s key: every call made as that caller, by any host
70
71
  * sharing `limiter`, is refused until then, and `acquireOrWait` waits it out. */
71
72
  export declare function throttleAfter(limiter: Limiter, caller: Caller, waitMs: number, now?: number): Promise<void>;
73
+ /** A vendor's budget reported on a success (`vendorBudget`), taken early: a
74
+ * vendor out of budget (`remaining` 0) closes the caller's key until it
75
+ * resets, as its 429 would, so the next call waits rather than earning one.
76
+ * A count above zero changes nothing. The window the limiter keeps is the
77
+ * module's declared budget, which the module's author set on purpose; the
78
+ * vendor's count is advisory (shared with other clients of the same key,
79
+ * stale by the time it is read), so it is never used to reshape that window. */
80
+ export declare function budgetAfter(limiter: Limiter, caller: Caller, budget: VendorBudget | undefined, now?: number): Promise<void>;
72
81
  /** The limiter filling the slot: the built-in until a host installs another. */
73
82
  export declare function limiterSlot(): Limiter;
74
83
  export declare function installLimiter(limiter: Limiter): void;
75
- /** The limits kept in this process: a token bucket per `calls`/`perMs` limit
76
- * (full at first, refilled evenly), a count of held permits per
77
- * `concurrent` one, and the throttles providers reported. */
84
+ /** One call counted in a rate limit's window: when it was granted, and for how much. */
85
+ export interface WindowCall {
86
+ at: number;
87
+ cost: number;
88
+ }
89
+ /** What one call counts for in a window of `calls`: a call costing more
90
+ * than the whole window takes all of it, so it waits for an empty one. */
91
+ export declare function windowCost(cost: number, calls: number): number;
92
+ /** The calls still in the window at `now`: a call granted at `at` leaves it at `at + perMs`. */
93
+ export declare function inWindow(calls: readonly WindowCall[], perMs: number, now: number): WindowCall[];
94
+ /** When a call costing `cost` fits a window of `limit.calls` per `limit.perMs`
95
+ * already holding `window` (in it at `now`, oldest first): undefined if it
96
+ * fits now, else the instant enough of the oldest calls have left. */
97
+ export declare function windowRetryAt(window: readonly WindowCall[], limit: {
98
+ calls: number;
99
+ perMs: number;
100
+ }, cost: number): number | undefined;
101
+ /** The limits kept in this process: the calls in each `calls`/`perMs`
102
+ * limit's rolling window, a count of held permits per `concurrent` one,
103
+ * and the throttles providers reported. */
78
104
  export declare class MemoryLimiter implements Limiter {
79
105
  private readonly buckets;
80
106
  private readonly permits;
@@ -12,17 +12,22 @@
12
12
  // a manifest. The built-in, `@lf/memory-limiter`, keeps them in this process
13
13
  // (`MemoryLimiter`); lf's Rust one is the same (crates/lf/src/limiter.rs).
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.MemoryLimiter = exports.HOLD_MS = exports.vendorWaitMs = void 0;
15
+ exports.MemoryLimiter = exports.HOLD_MS = exports.vendorWaitMs = exports.vendorBudget = void 0;
16
16
  exports.throttleKey = throttleKey;
17
17
  exports.durationMs = durationMs;
18
18
  exports.limitRequest = limitRequest;
19
19
  exports.acquireOrWait = acquireOrWait;
20
20
  exports.limited = limited;
21
21
  exports.throttleAfter = throttleAfter;
22
+ exports.budgetAfter = budgetAfter;
22
23
  exports.limiterSlot = limiterSlot;
23
24
  exports.installLimiter = installLimiter;
25
+ exports.windowCost = windowCost;
26
+ exports.inWindow = inWindow;
27
+ exports.windowRetryAt = windowRetryAt;
24
28
  const node_crypto_1 = require("node:crypto");
25
29
  var vendor_wait_1 = require("@listen-fire/module-runtime/vendor_wait");
30
+ Object.defineProperty(exports, "vendorBudget", { enumerable: true, get: function () { return vendor_wait_1.vendorBudget; } });
26
31
  Object.defineProperty(exports, "vendorWaitMs", { enumerable: true, get: function () { return vendor_wait_1.vendorWaitMs; } });
27
32
  /** No call outlives a segment's wall clock (its default), so a permit its
28
33
  * holder never released lapses then. */
@@ -110,6 +115,18 @@ async function limited(limiter, request, call) {
110
115
  async function throttleAfter(limiter, caller, waitMs, now = Date.now()) {
111
116
  await limiter.throttle({ key: throttleKey(caller), until: new Date(now + waitMs).toISOString() });
112
117
  }
118
+ /** A vendor's budget reported on a success (`vendorBudget`), taken early: a
119
+ * vendor out of budget (`remaining` 0) closes the caller's key until it
120
+ * resets, as its 429 would, so the next call waits rather than earning one.
121
+ * A count above zero changes nothing. The window the limiter keeps is the
122
+ * module's declared budget, which the module's author set on purpose; the
123
+ * vendor's count is advisory (shared with other clients of the same key,
124
+ * stale by the time it is read), so it is never used to reshape that window. */
125
+ async function budgetAfter(limiter, caller, budget, now = Date.now()) {
126
+ if (budget === undefined || budget.remaining > 0 || budget.resetAt <= now)
127
+ return;
128
+ await throttleAfter(limiter, caller, budget.resetAt - now, now);
129
+ }
113
130
  // ── The slot this process's calls ask ───────────────────────────────────────
114
131
  let slot;
115
132
  /** The limiter filling the slot: the built-in until a host installs another. */
@@ -120,13 +137,38 @@ function limiterSlot() {
120
137
  function installLimiter(limiter) {
121
138
  slot = limiter;
122
139
  }
140
+ /** What one call counts for in a window of `calls`: a call costing more
141
+ * than the whole window takes all of it, so it waits for an empty one. */
142
+ function windowCost(cost, calls) {
143
+ return Math.min(cost, calls);
144
+ }
145
+ /** The calls still in the window at `now`: a call granted at `at` leaves it at `at + perMs`. */
146
+ function inWindow(calls, perMs, now) {
147
+ return calls.filter((call) => call.at + perMs > now);
148
+ }
149
+ /** When a call costing `cost` fits a window of `limit.calls` per `limit.perMs`
150
+ * already holding `window` (in it at `now`, oldest first): undefined if it
151
+ * fits now, else the instant enough of the oldest calls have left. */
152
+ function windowRetryAt(window, limit, cost) {
153
+ let held = window.reduce((sum, call) => sum + call.cost, 0);
154
+ const needed = windowCost(cost, limit.calls);
155
+ if (held + needed <= limit.calls)
156
+ return undefined;
157
+ for (const call of window) {
158
+ held -= call.cost;
159
+ if (held + needed <= limit.calls)
160
+ return call.at + limit.perMs;
161
+ }
162
+ // Unreachable while every call in the window costs at most `limit.calls`.
163
+ return undefined;
164
+ }
123
165
  // ── @lf/memory-limiter ──────────────────────────────────────────────────────
124
166
  /** How soon a call refused for concurrency asks again, if no release wakes
125
167
  * it first (a permit lapsing frees a place with no release). */
126
168
  const CONCURRENCY_RECHECK_MS = 1_000;
127
- /** The limits kept in this process: a token bucket per `calls`/`perMs` limit
128
- * (full at first, refilled evenly), a count of held permits per
129
- * `concurrent` one, and the throttles providers reported. */
169
+ /** The limits kept in this process: the calls in each `calls`/`perMs`
170
+ * limit's rolling window, a count of held permits per `concurrent` one,
171
+ * and the throttles providers reported. */
130
172
  class MemoryLimiter {
131
173
  buckets = new Map();
132
174
  permits = new Map();
@@ -144,15 +186,12 @@ class MemoryLimiter {
144
186
  let retryAt;
145
187
  const later = (at) => (retryAt = retryAt === undefined ? at : Math.max(retryAt, at));
146
188
  for (const limit of request.limits) {
147
- const bucket = this.bucket(limit, now);
189
+ const bucket = this.bucket(limit);
148
190
  if (limit.calls !== undefined && limit.perMs !== undefined) {
149
- const rate = limit.calls / limit.perMs;
150
- bucket.tokens = Math.min(limit.calls, bucket.tokens + (now - bucket.filledAt) * rate);
151
- bucket.filledAt = now;
152
- // A call costing more than the whole window waits for a full one.
153
- const cost = Math.min(request.cost, limit.calls);
154
- if (bucket.tokens < cost)
155
- later(now + Math.ceil((cost - bucket.tokens) / rate));
191
+ bucket.calls = inWindow(bucket.calls, limit.perMs, now);
192
+ const room = windowRetryAt(bucket.calls, { calls: limit.calls, perMs: limit.perMs }, request.cost);
193
+ if (room !== undefined)
194
+ later(room);
156
195
  }
157
196
  if (limit.concurrent !== undefined && bucket.held >= limit.concurrent) {
158
197
  const soonestLapse = Math.min(...[...this.permits.values()].map((p) => p.lapsesAt));
@@ -163,9 +202,9 @@ class MemoryLimiter {
163
202
  return { granted: false, retryAt: new Date(retryAt).toISOString() };
164
203
  const holds = [];
165
204
  for (const limit of request.limits) {
166
- const bucket = this.bucket(limit, now);
167
- if (limit.calls !== undefined)
168
- bucket.tokens -= Math.min(request.cost, limit.calls);
205
+ const bucket = this.bucket(limit);
206
+ if (limit.calls !== undefined && limit.perMs !== undefined)
207
+ bucket.calls.push({ at: now, cost: windowCost(request.cost, limit.calls) });
169
208
  if (limit.concurrent !== undefined) {
170
209
  bucket.held += 1;
171
210
  holds.push(limit.key);
@@ -192,10 +231,10 @@ class MemoryLimiter {
192
231
  this.waiters.add(wake);
193
232
  return { wait, stop: () => this.waiters.delete(wake) };
194
233
  }
195
- bucket(limit, now) {
234
+ bucket(limit) {
196
235
  let bucket = this.buckets.get(limit.key);
197
236
  if (bucket === undefined) {
198
- bucket = { tokens: limit.calls ?? 0, filledAt: now, held: 0 };
237
+ bucket = { calls: [], held: 0 };
199
238
  this.buckets.set(limit.key, bucket);
200
239
  }
201
240
  return bucket;
@@ -511,6 +511,11 @@ const WEB_RENDER_SIGNATURE = {
511
511
  description: 'The page: "a4" (the default) or "letter".',
512
512
  },
513
513
  { name: 'margin', type: { kind: 'number' }, description: 'The margin on every side, in points (a whole number from 0 to 200). The default is 30.' },
514
+ {
515
+ name: 'name',
516
+ type: { kind: 'string' },
517
+ description: 'The file\'s name, as the system it is written to shows it: ".pdf" is added when it does not end with it. The default is "movement-artifact.pdf".',
518
+ },
514
519
  ],
515
520
  dataDependency: 'none',
516
521
  // The browser may load the images and styles an HTML page names.
@@ -1,5 +1,6 @@
1
1
  import { type RunRecords } from './rules';
2
- import type { CallbackStore, RunHostStore } from './store';
2
+ import type { CallbackStore, ClaimedWork, RunHostStore } from './store';
3
+ import type { DeliveryRow, NewDelivery, NewWorkRow, RunRow } from './rpc_store';
3
4
  export interface ContractBackend {
4
5
  store: RunHostStore;
5
6
  /** The tenant every run of the case belongs to. */
@@ -34,3 +35,55 @@ export interface RecordsContractCase {
34
35
  }
35
36
  export declare const callbackContractCases: RecordsContractCase[];
36
37
  export declare const cancelContractCases: RecordsContractCase[];
38
+ export interface DeliveryContractBackend {
39
+ /** The tenant every row of the case belongs to. */
40
+ teamId: string;
41
+ keepDelivery(input: {
42
+ subscriptionId: string;
43
+ checkpoint?: unknown;
44
+ delivery: NewDelivery;
45
+ work: NewWorkRow[];
46
+ }): Promise<{
47
+ kept: boolean;
48
+ }>;
49
+ deliveries(input: {
50
+ subscriptionId?: string | null;
51
+ since?: string | null;
52
+ limit: number;
53
+ }): Promise<DeliveryRow[]>;
54
+ delivery(input: {
55
+ id: string;
56
+ }): Promise<DeliveryRow | null>;
57
+ sweepDeliveries(input: {
58
+ before: string;
59
+ limit: number;
60
+ }): Promise<number>;
61
+ /** The subscription's saved pull cursor. */
62
+ checkpoint(input: {
63
+ subscriptionId: string;
64
+ }): Promise<{
65
+ checkpoint: unknown;
66
+ } | null>;
67
+ startRun(input: {
68
+ id: string;
69
+ file: string;
70
+ movement: string;
71
+ startedAt: string;
72
+ input: string;
73
+ deliveryId?: string;
74
+ }): Promise<void>;
75
+ completeRun(runId: string): Promise<void>;
76
+ runGet(input: {
77
+ runId: string;
78
+ }): Promise<RunRow | null>;
79
+ claimDue(input: {
80
+ limit: number;
81
+ leaseMs: number;
82
+ kinds?: readonly string[];
83
+ }): Promise<ClaimedWork[]>;
84
+ }
85
+ export interface DeliveryContractCase {
86
+ name: string;
87
+ run(backend: DeliveryContractBackend): Promise<void>;
88
+ }
89
+ export declare const deliveryContractCases: DeliveryContractCase[];
@@ -13,8 +13,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
13
13
  return (mod && mod.__esModule) ? mod : { "default": mod };
14
14
  };
15
15
  Object.defineProperty(exports, "__esModule", { value: true });
16
- exports.cancelContractCases = exports.callbackContractCases = exports.runHostContractCases = void 0;
16
+ exports.deliveryContractCases = exports.cancelContractCases = exports.callbackContractCases = exports.runHostContractCases = void 0;
17
17
  const strict_1 = __importDefault(require("node:assert/strict"));
18
+ const node_crypto_1 = require("node:crypto");
18
19
  const errors_1 = require("../movement_engine/errors");
19
20
  const drain_1 = require("./drain");
20
21
  const rules_1 = require("./rules");
@@ -932,3 +933,160 @@ exports.cancelContractCases = [
932
933
  },
933
934
  },
934
935
  ];
936
+ const DAYS = (days) => PAST(days * 24 * 60);
937
+ function newDelivery(extra = {}) {
938
+ return {
939
+ id: (0, node_crypto_1.randomUUID)(),
940
+ externalId: null,
941
+ receivedAt: new Date().toISOString(),
942
+ source: 'webhook',
943
+ inline: false,
944
+ replayOf: null,
945
+ events: [{ external: 'crm', event: { n: 1 } }],
946
+ ...extra,
947
+ };
948
+ }
949
+ /** One of the delivery's events queued for the drain, as lf's doors queue it. */
950
+ function fireRow(deliveryId, n) {
951
+ return { kind: 'fire_event', key: uniqueKey('fire_event'), payload: { external: 'crm', event: { n }, deliveryId }, dueAt: PAST(1), teamId: null };
952
+ }
953
+ async function claimedKeys(backend, keys) {
954
+ const claimed = await backend.claimDue({ limit: 1000, leaseMs: 60_000, kinds: ['fire_event'] });
955
+ return claimed.filter((w) => keys.includes(w.key));
956
+ }
957
+ async function startDeliveryRun(backend, deliveryId) {
958
+ const id = (0, node_crypto_1.randomUUID)();
959
+ await backend.startRun({ id, file: 'f', movement: 'm', startedAt: new Date().toISOString(), input: '{}', ...(deliveryId !== undefined ? { deliveryId } : {}) });
960
+ return id;
961
+ }
962
+ /** A first delivery under `externalId`, then its redelivery (a new id, another
963
+ * cursor, another row): the second is kept out and leaves nothing. */
964
+ async function redelivered(backend, opts) {
965
+ const subscriptionId = uniqueKey('sub');
966
+ const externalId = uniqueKey('delivery');
967
+ const first = newDelivery({ externalId });
968
+ const firstRow = fireRow(first.id, 1);
969
+ strict_1.default.deepEqual(await backend.keepDelivery({ subscriptionId, checkpoint: { cursor: 'c-1' }, delivery: first, work: [firstRow] }), { kept: true });
970
+ if (opts.claimBetween) {
971
+ const [claimed] = await claimedKeys(backend, [firstRow.key]);
972
+ strict_1.default.ok(claimed, "the first's row was queued");
973
+ }
974
+ const second = newDelivery({ externalId });
975
+ const secondRow = fireRow(second.id, 2);
976
+ strict_1.default.deepEqual(await backend.keepDelivery({ subscriptionId, checkpoint: { cursor: 'c-2' }, delivery: second, work: [secondRow] }), { kept: false });
977
+ strict_1.default.deepEqual(await backend.checkpoint({ subscriptionId }), { checkpoint: { cursor: 'c-1' } }, "the cursor is still the first's");
978
+ strict_1.default.deepEqual(await claimedKeys(backend, [secondRow.key]), [], 'the redelivery queued nothing');
979
+ strict_1.default.equal(await backend.delivery({ id: second.id }), null);
980
+ strict_1.default.deepEqual((await backend.deliveries({ subscriptionId, limit: 10 })).map((d) => d.id), [first.id]);
981
+ }
982
+ exports.deliveryContractCases = [
983
+ {
984
+ name: 'deliveries: a delivery is kept with its work as one write',
985
+ async run(backend) {
986
+ const subscriptionId = uniqueKey('sub');
987
+ const delivery = newDelivery({ externalId: uniqueKey('delivery'), events: [{ external: 'crm', event: { n: 1 } }, { external: 'crm', event: { n: 2 } }] });
988
+ const work = [fireRow(delivery.id, 1), fireRow(delivery.id, 2)];
989
+ strict_1.default.deepEqual(await backend.keepDelivery({ subscriptionId, checkpoint: { cursor: 'c-1' }, delivery, work }), { kept: true });
990
+ const keys = work.map((w) => w.key);
991
+ strict_1.default.deepEqual((await claimedKeys(backend, keys)).map((w) => w.key).sort(), [...keys].sort());
992
+ strict_1.default.deepEqual(await backend.checkpoint({ subscriptionId }), { checkpoint: { cursor: 'c-1' } });
993
+ strict_1.default.deepEqual(await backend.delivery({ id: delivery.id }), {
994
+ id: delivery.id,
995
+ subscriptionId,
996
+ externalId: delivery.externalId,
997
+ receivedAt: delivery.receivedAt,
998
+ source: 'webhook',
999
+ inline: false,
1000
+ replayOf: null,
1001
+ eventCount: 2,
1002
+ runs: [],
1003
+ events: delivery.events,
1004
+ });
1005
+ },
1006
+ },
1007
+ {
1008
+ name: 'deliveries: a duplicate externalId is not kept, and writes nothing',
1009
+ run: (backend) => redelivered(backend, { claimBetween: true }),
1010
+ },
1011
+ {
1012
+ name: "deliveries: a redelivery while the first's work is still queued is kept out",
1013
+ run: (backend) => redelivered(backend, { claimBetween: false }),
1014
+ },
1015
+ {
1016
+ name: 'deliveries: the same externalId on another subscription, or none, is kept',
1017
+ async run(backend) {
1018
+ const [one, other] = [uniqueKey('sub'), uniqueKey('sub')];
1019
+ const externalId = uniqueKey('delivery');
1020
+ const keep = async (subscriptionId, delivery) => (await backend.keepDelivery({ subscriptionId, delivery, work: [] })).kept;
1021
+ const kept = [newDelivery({ externalId }), newDelivery({ externalId }), newDelivery(), newDelivery()];
1022
+ strict_1.default.equal(await keep(one, kept[0]), true);
1023
+ strict_1.default.equal(await keep(other, kept[1]), true, 'another subscription');
1024
+ strict_1.default.equal(await keep(one, kept[2]), true, 'no external id');
1025
+ strict_1.default.equal(await keep(one, kept[3]), true, 'no external id, again');
1026
+ const listed = (await backend.deliveries({ limit: 100 })).map((d) => d.id);
1027
+ for (const delivery of kept)
1028
+ strict_1.default.ok(listed.includes(delivery.id), delivery.id);
1029
+ },
1030
+ },
1031
+ {
1032
+ name: 'deliveries: a delivery lists its runs',
1033
+ async run(backend) {
1034
+ const subscriptionId = uniqueKey('sub');
1035
+ const delivery = newDelivery();
1036
+ await backend.keepDelivery({ subscriptionId, delivery, work: [] });
1037
+ const running = await startDeliveryRun(backend, delivery.id);
1038
+ const completed = await startDeliveryRun(backend, delivery.id);
1039
+ const unnamed = await startDeliveryRun(backend, undefined);
1040
+ await backend.completeRun(completed);
1041
+ const [row] = await backend.deliveries({ subscriptionId, limit: 10 });
1042
+ const byId = (a, b) => a.id.localeCompare(b.id);
1043
+ strict_1.default.deepEqual([...(row?.runs ?? [])].sort(byId), [
1044
+ { id: running, status: 'running' },
1045
+ { id: completed, status: 'completed' },
1046
+ ].sort(byId));
1047
+ strict_1.default.equal(row?.events, undefined, 'a listing carries no events');
1048
+ strict_1.default.equal((await backend.runGet({ runId: running }))?.deliveryId, delivery.id);
1049
+ strict_1.default.equal((await backend.runGet({ runId: unnamed }))?.deliveryId, null);
1050
+ },
1051
+ },
1052
+ {
1053
+ name: 'deliveries: listing filters by subscription and since, most recent first, at most limit',
1054
+ async run(backend) {
1055
+ const [one, other] = [uniqueKey('sub'), uniqueKey('sub')];
1056
+ const at = (minutesAgo) => newDelivery({ receivedAt: PAST(minutesAgo) });
1057
+ const [oldest, middle, newest, elsewhere] = [at(30), at(20), at(10), at(5)];
1058
+ for (const [subscriptionId, delivery] of [[one, oldest], [one, middle], [one, newest], [other, elsewhere]]) {
1059
+ await backend.keepDelivery({ subscriptionId, delivery, work: [] });
1060
+ }
1061
+ const ids = async (input) => (await backend.deliveries(input)).map((d) => d.id);
1062
+ strict_1.default.deepEqual(await ids({ subscriptionId: one, limit: 10 }), [newest.id, middle.id, oldest.id]);
1063
+ strict_1.default.deepEqual(await ids({ subscriptionId: one, since: middle.receivedAt, limit: 10 }), [newest.id, middle.id]);
1064
+ strict_1.default.deepEqual(await ids({ subscriptionId: one, limit: 1 }), [newest.id]);
1065
+ const all = await ids({ limit: 100 });
1066
+ strict_1.default.deepEqual(all.filter((id) => [oldest.id, middle.id, newest.id, elsewhere.id].includes(id)), [elsewhere.id, newest.id, middle.id, oldest.id]);
1067
+ },
1068
+ },
1069
+ {
1070
+ name: 'deliveries: the sweep forgets settled old deliveries only',
1071
+ async run(backend) {
1072
+ const subscriptionId = uniqueKey('sub');
1073
+ const old = async (work = () => []) => {
1074
+ const delivery = newDelivery({ receivedAt: DAYS(30) });
1075
+ await backend.keepDelivery({ subscriptionId, delivery, work: work(delivery.id) });
1076
+ return delivery.id;
1077
+ };
1078
+ const settled = await old();
1079
+ await backend.completeRun(await startDeliveryRun(backend, settled));
1080
+ const running = await old();
1081
+ await startDeliveryRun(backend, running);
1082
+ const queued = await old((id) => [{ ...fireRow(id, 1), dueAt: FUTURE(60) }]);
1083
+ const fresh = newDelivery();
1084
+ await backend.keepDelivery({ subscriptionId, delivery: fresh, work: [] });
1085
+ strict_1.default.equal(await backend.sweepDeliveries({ before: DAYS(14), limit: 100 }), 1);
1086
+ strict_1.default.equal(await backend.delivery({ id: settled }), null, 'old, its run settled: forgotten');
1087
+ strict_1.default.ok(await backend.delivery({ id: running }), 'its run is still running');
1088
+ strict_1.default.ok(await backend.delivery({ id: queued }), 'a fire_event row still names it');
1089
+ strict_1.default.ok(await backend.delivery({ id: fresh.id }), 'not old enough');
1090
+ },
1091
+ },
1092
+ ];
@@ -2,6 +2,55 @@ import { z } from 'zod';
2
2
  import { type SlotAddress } from '@listen-fire/module-runtime/module_wire/wire';
3
3
  import type { RunRecords } from './rules';
4
4
  import type { CallbackStore, RunHostStore } from './store';
5
+ export declare const DELIVERY_SOURCES: readonly ["webhook", "hook", "emit", "cli"];
6
+ export type DeliverySource = (typeof DELIVERY_SOURCES)[number];
7
+ /** `inbound.keepDelivery`'s `delivery`. */
8
+ export interface NewDelivery {
9
+ id: string;
10
+ /** The provider's id for the delivery: a second one under it, at the same subscription, is kept out. */
11
+ externalId?: string | null;
12
+ receivedAt: string;
13
+ source: DeliverySource;
14
+ inline: boolean;
15
+ replayOf?: string | null;
16
+ events: unknown[];
17
+ }
18
+ /** A work row as `inbound.keepDelivery` queues it (`work.enqueue`'s input). */
19
+ export interface NewWorkRow {
20
+ kind: string;
21
+ key: string;
22
+ payload: unknown;
23
+ dueAt?: string | null;
24
+ teamId?: string | null;
25
+ }
26
+ export interface DeliveryRun {
27
+ id: string;
28
+ status: string;
29
+ }
30
+ /** A kept delivery; `events` only from `inbound.delivery`. */
31
+ export interface DeliveryRow {
32
+ id: string;
33
+ subscriptionId: string;
34
+ externalId: string | null;
35
+ receivedAt: string;
36
+ source: DeliverySource;
37
+ inline: boolean;
38
+ replayOf: string | null;
39
+ eventCount: number;
40
+ runs: DeliveryRun[];
41
+ events?: unknown[];
42
+ }
43
+ /** A run row as `runs.get` answers it. */
44
+ export interface RunRow {
45
+ id: string;
46
+ file: string;
47
+ movement: string;
48
+ status: string;
49
+ startedAt: string;
50
+ input: string;
51
+ outcome: string | null;
52
+ deliveryId: string | null;
53
+ }
5
54
  export declare class RpcStoreBackend {
6
55
  readonly store: RunHostStore;
7
56
  readonly callbacks: CallbackStore;
@@ -14,14 +63,51 @@ export declare class RpcStoreBackend {
14
63
  /** One call; its result read by `schema`. A fenced write refused because
15
64
  * the run's lease is gone throws the core's `RunLeaseLost`. */
16
65
  call<S extends z.ZodType>(method: string, params: object, schema: S): Promise<z.infer<S>>;
17
- /** A run row the backend can park against (the contract's `newRun`). */
66
+ /** A run row the backend can park against (the contract's `newRun`);
67
+ * `deliveryId` names the delivery whose event started it. */
18
68
  startRun(input: {
19
69
  id: string;
20
70
  file: string;
21
71
  movement: string;
22
72
  startedAt: string;
23
73
  input: string;
74
+ deliveryId?: string;
24
75
  }): Promise<void>;
76
+ /** A run's row, or null (`runs.get`). */
77
+ runGet(input: {
78
+ runId: string;
79
+ }): Promise<RunRow | null>;
80
+ /** The door's one write: the receipt, the cursor (absent: left as it is)
81
+ * and the work rows; `kept: false`, and nothing written, for a duplicate. */
82
+ keepDelivery(input: {
83
+ subscriptionId: string;
84
+ checkpoint?: unknown;
85
+ delivery: NewDelivery;
86
+ work: NewWorkRow[];
87
+ }): Promise<{
88
+ kept: boolean;
89
+ }>;
90
+ /** Kept deliveries, most recent first, without their events. */
91
+ deliveries(input: {
92
+ subscriptionId?: string | null;
93
+ since?: string | null;
94
+ limit: number;
95
+ }): Promise<DeliveryRow[]>;
96
+ /** One kept delivery with its events, or null. */
97
+ delivery(input: {
98
+ id: string;
99
+ }): Promise<DeliveryRow | null>;
100
+ /** Forget settled deliveries received before `before`: how many went. */
101
+ sweepDeliveries(input: {
102
+ before: string;
103
+ limit: number;
104
+ }): Promise<number>;
105
+ /** A subscription's saved pull cursor (`inbound.checkpoint`). */
106
+ checkpoint(input: {
107
+ subscriptionId: string;
108
+ }): Promise<{
109
+ checkpoint: unknown;
110
+ } | null>;
25
111
  /** An operator asks to cancel the run (the records contract's `requestCancel`). */
26
112
  requestCancel(input: {
27
113
  runId: string;
@@ -13,7 +13,7 @@
13
13
  // `run_lease_lost` (`data: { runId }`) is rethrown as the core's
14
14
  // `RunLeaseLost`, so the rules over the store see the one refusal they handle.
15
15
  Object.defineProperty(exports, "__esModule", { value: true });
16
- exports.RpcStoreBackend = void 0;
16
+ exports.RpcStoreBackend = exports.DELIVERY_SOURCES = void 0;
17
17
  const zod_1 = require("zod");
18
18
  const engine_contracts_1 = require("@listen-fire/engine-contracts");
19
19
  const slots_1 = require("@listen-fire/engine-contracts/slots");
@@ -47,6 +47,33 @@ const Claim = zod_1.z.discriminatedUnion('kind', [
47
47
  zod_1.z.object({ kind: zod_1.z.literal('closed'), callback: Callback }),
48
48
  zod_1.z.object({ kind: zod_1.z.literal('expired'), callback: Callback }),
49
49
  ]);
50
+ // ── Delivery receipts (`inbound.*`) ─────────────────────────────────────────
51
+ // What a door keeps of one delivery: the events it produced, in the door's one
52
+ // write. Only lf's doors call these, so they live on this client rather than
53
+ // on `RunHostStore`.
54
+ exports.DELIVERY_SOURCES = ['webhook', 'hook', 'emit', 'cli'];
55
+ const Delivery = zod_1.z.object({
56
+ id: zod_1.z.string(),
57
+ subscriptionId: zod_1.z.string(),
58
+ externalId: zod_1.z.string().nullable(),
59
+ receivedAt: zod_1.z.string(),
60
+ source: zod_1.z.enum(exports.DELIVERY_SOURCES),
61
+ inline: zod_1.z.boolean(),
62
+ replayOf: zod_1.z.string().nullable(),
63
+ eventCount: zod_1.z.number(),
64
+ runs: zod_1.z.array(zod_1.z.object({ id: zod_1.z.string(), status: zod_1.z.string() })),
65
+ events: zod_1.z.array(zod_1.z.unknown()).optional(),
66
+ });
67
+ const Run = zod_1.z.object({
68
+ id: zod_1.z.string(),
69
+ file: zod_1.z.string(),
70
+ movement: zod_1.z.string(),
71
+ status: zod_1.z.string(),
72
+ startedAt: zod_1.z.string(),
73
+ input: zod_1.z.string(),
74
+ outcome: zod_1.z.string().nullable(),
75
+ deliveryId: zod_1.z.string().nullable(),
76
+ });
50
77
  /** The work claim the server reads: no more than it needs, whatever the caller spreads in. */
51
78
  function claimOf(work) {
52
79
  return { id: work.id, kind: work.kind, key: work.key, claim: work.claim, ...(work.dueAt !== undefined ? { dueAt: work.dueAt } : {}) };
@@ -83,9 +110,42 @@ class RpcStoreBackend {
83
110
  throw error;
84
111
  }
85
112
  }
86
- /** A run row the backend can park against (the contract's `newRun`). */
113
+ /** A run row the backend can park against (the contract's `newRun`);
114
+ * `deliveryId` names the delivery whose event started it. */
87
115
  async startRun(input) {
88
- await this.call('runs.start', input, Nothing);
116
+ await this.call('runs.start', { ...input, deliveryId: input.deliveryId ?? null }, Nothing);
117
+ }
118
+ /** A run's row, or null (`runs.get`). */
119
+ runGet(input) {
120
+ return this.call('runs.get', input, Run.nullable());
121
+ }
122
+ /** The door's one write: the receipt, the cursor (absent: left as it is)
123
+ * and the work rows; `kept: false`, and nothing written, for a duplicate. */
124
+ keepDelivery(input) {
125
+ const { delivery, work } = input;
126
+ const params = {
127
+ subscriptionId: input.subscriptionId,
128
+ ...('checkpoint' in input ? { checkpoint: input.checkpoint } : {}),
129
+ delivery: { ...delivery, externalId: delivery.externalId ?? null, replayOf: delivery.replayOf ?? null },
130
+ work: work.map((row) => ({ ...row, dueAt: row.dueAt ?? null, teamId: row.teamId ?? null })),
131
+ };
132
+ return this.call('inbound.keepDelivery', params, zod_1.z.object({ kept: zod_1.z.boolean() }));
133
+ }
134
+ /** Kept deliveries, most recent first, without their events. */
135
+ deliveries(input) {
136
+ return this.call('inbound.deliveries', { subscriptionId: input.subscriptionId ?? null, since: input.since ?? null, limit: input.limit }, zod_1.z.array(Delivery));
137
+ }
138
+ /** One kept delivery with its events, or null. */
139
+ delivery(input) {
140
+ return this.call('inbound.delivery', input, Delivery.nullable());
141
+ }
142
+ /** Forget settled deliveries received before `before`: how many went. */
143
+ sweepDeliveries(input) {
144
+ return this.call('inbound.sweepDeliveries', input, zod_1.z.number());
145
+ }
146
+ /** A subscription's saved pull cursor (`inbound.checkpoint`). */
147
+ checkpoint(input) {
148
+ return this.call('inbound.checkpoint', input, zod_1.z.object({ checkpoint: zod_1.z.unknown() }).nullable());
89
149
  }
90
150
  /** An operator asks to cancel the run (the records contract's `requestCancel`). */
91
151
  async requestCancel(input) {
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@listen-fire/engine",
3
3
  "license": "Apache-2.0",
4
- "version": "0.12.0-rc.8",
4
+ "version": "0.12.0",
5
5
  "description": "The movement engine for this release's language and its host contract: the Rust engine's shim, the EngineHost families, the run host, the embed kit, and the adapter and model types they reach. Every shell (the hosted product, the `lf` command line, an embedder) runs the same engine. A run pinned to a release this one does not read is refused by name. Embedders reach it only through public/* (the exports map); the internals resolve only under the `movement-engine-internals` condition, which only test runners set",
6
6
  "engines": {
7
- "node": "22.20.0"
7
+ "node": ">=22.23.4 <23"
8
8
  },
9
9
  "exports": {
10
10
  "./package.json": "./package.json",
@@ -17,9 +17,9 @@
17
17
  "dependencies": {
18
18
  "safe-stable-stringify": "2.5.0",
19
19
  "zod": "4.1.12",
20
- "@listen-fire/movement-lang": "^0.12.0-rc.8",
21
- "@listen-fire/engine-contracts": "^0.12.0-rc.8",
22
- "@listen-fire/module-runtime": "^0.12.0-rc.8"
20
+ "@listen-fire/module-runtime": "^0.12.0",
21
+ "@listen-fire/engine-contracts": "^0.12.0",
22
+ "@listen-fire/movement-lang": "^0.12.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/jest": "29.5.14",
@@ -35,8 +35,7 @@
35
35
  "dist"
36
36
  ],
37
37
  "publishConfig": {
38
- "access": "public",
39
- "tag": "next"
38
+ "access": "public"
40
39
  },
41
40
  "scripts": {
42
41
  "code:type-check": "tsc --pretty --noEmit",