@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.
- package/dist/public/limiter.js +2 -1
- package/dist/public/testing.d.ts +2 -0
- package/dist/public/testing.js +3 -0
- package/dist/services/inbound/contract.d.ts +19 -0
- package/dist/services/inbound/contract.js +98 -1
- package/dist/services/limiter/contract.js +37 -0
- package/dist/services/limiter/limiter.d.ts +31 -5
- package/dist/services/limiter/limiter.js +56 -17
- package/dist/services/plugins/registry.js +5 -0
- package/dist/services/run_host/contract.d.ts +54 -1
- package/dist/services/run_host/contract.js +159 -1
- package/dist/services/run_host/rpc_store.d.ts +87 -1
- package/dist/services/run_host/rpc_store.js +63 -3
- package/package.json +6 -7
package/dist/public/limiter.js
CHANGED
|
@@ -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);
|
package/dist/public/testing.d.ts
CHANGED
|
@@ -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';
|
package/dist/public/testing.js
CHANGED
|
@@ -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
|
-
|
|
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`
|
|
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
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
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:
|
|
128
|
-
*
|
|
129
|
-
*
|
|
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
|
|
189
|
+
const bucket = this.bucket(limit);
|
|
148
190
|
if (limit.calls !== undefined && limit.perMs !== undefined) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
167
|
-
if (limit.calls !== undefined)
|
|
168
|
-
bucket.
|
|
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
|
|
234
|
+
bucket(limit) {
|
|
196
235
|
let bucket = this.buckets.get(limit.key);
|
|
197
236
|
if (bucket === undefined) {
|
|
198
|
-
bucket = {
|
|
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
|
|
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.
|
|
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/
|
|
21
|
-
"@listen-fire/engine-contracts": "^0.12.0
|
|
22
|
-
"@listen-fire/
|
|
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",
|