@forgezero/runtime 0.1.22 → 0.1.24
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/README.md +28 -4
- package/dist/audit.js +20 -2
- package/dist/jobs.js +20 -2
- package/dist/lifecycle.d.ts +115 -0
- package/dist/lifecycle.js +243 -0
- package/dist/queue.d.ts +11 -0
- package/dist/queue.js +22 -3
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
# Platform runtime
|
|
9
9
|
|
|
10
|
-
The machinery behind a request handler — jobs, queues, an outbox, a hash-chained audit trail, mail, backups, schema validation and exact money.
|
|
10
|
+
The machinery behind a request handler — jobs, queues, an outbox, a hash-chained audit trail, mail, backups, schema validation and exact money. 32 public modules, each imported on its own.
|
|
11
11
|
|
|
12
12
|
## Package overview
|
|
13
13
|
|
|
@@ -54,6 +54,7 @@ These are supported consumer entry points, not every internal module shipped for
|
|
|
54
54
|
| @forgezero/runtime/slip10 | SLIP-0010 derivation for ed25519, hardened-only — BIP-32 does not work on this curve and produces halves that do not correspond. | portable | [Reference + usage](#forgezero-runtime-slip10) |
|
|
55
55
|
| @forgezero/runtime/identity | Hybrid Ed25519 + ML-DSA-65 request signing. One canonical string, so the compute agent that signs inside a guest and the API that verifies cannot drift — which two implementations of it certainly would. | portable | [Reference + usage](#forgezero-runtime-identity) |
|
|
56
56
|
| @forgezero/runtime/vault-runtime | Provider-neutral algorithms for non-exportable Vault runtime records: passwords, DEK/KEK operations, HMAC/JWS, Ed25519/secp256k1 and Ethereum/Solana address encoding. | portable | [Reference + usage](#forgezero-runtime-vault-runtime) |
|
|
57
|
+
| @forgezero/runtime/lifecycle | Framework-neutral blue/green drain coordination with exact release fencing and separately proved HTTP, WebSocket, durable-job and schedule counters. | portable | [Reference + usage](#forgezero-runtime-lifecycle) |
|
|
57
58
|
| @forgezero/runtime/schema | Validate against JSON Schema, restrict what a caller may declare, and describe a schema as a form. | portable | [Reference + usage](#forgezero-runtime-schema) |
|
|
58
59
|
| @forgezero/runtime/schema/typebox | The TypeBox validator behind that interface. | portable | [Reference + usage](#forgezero-runtime-schema-typebox) |
|
|
59
60
|
| @forgezero/runtime/finance/discounts | Promotions as arithmetic over integer minor units. They never stack — one winner — and a percentage rounds down, because rounding a discount up gives away a unit of currency per invoice forever. | portable | [Reference + usage](#forgezero-runtime-finance-discounts) |
|
|
@@ -149,7 +150,7 @@ Memory-only keyed work queue — awaited results, parallel across keys and stric
|
|
|
149
150
|
|
|
150
151
|
```text
|
|
151
152
|
import {
|
|
152
|
-
|
|
153
|
+
QueueIntakePausedError,
|
|
153
154
|
} from '@forgezero/runtime/queue';
|
|
154
155
|
```
|
|
155
156
|
|
|
@@ -159,10 +160,10 @@ This minimal executable use imports one concrete value from this exact entry poi
|
|
|
159
160
|
|
|
160
161
|
```text
|
|
161
162
|
import {
|
|
162
|
-
|
|
163
|
+
QueueIntakePausedError,
|
|
163
164
|
} from '@forgezero/runtime/queue';
|
|
164
165
|
|
|
165
|
-
export const selectedCapability =
|
|
166
|
+
export const selectedCapability = QueueIntakePausedError;
|
|
166
167
|
```
|
|
167
168
|
|
|
168
169
|
<a id="forgezero-runtime-outbox"></a>
|
|
@@ -533,6 +534,29 @@ import {
|
|
|
533
534
|
export const selectedCapability = DEFAULT_PASSWORD_ALPHABET;
|
|
534
535
|
```
|
|
535
536
|
|
|
537
|
+
<a id="forgezero-runtime-lifecycle"></a>
|
|
538
|
+
## @forgezero/runtime/lifecycle
|
|
539
|
+
|
|
540
|
+
Framework-neutral blue/green drain coordination with exact release fencing and separately proved HTTP, WebSocket, durable-job and schedule counters. This is a supported entry point. Import only the named values used by the adjacent task example; the declaration file remains the complete API reference.
|
|
541
|
+
|
|
542
|
+
```text
|
|
543
|
+
import {
|
|
544
|
+
RUNTIME_DRAIN_BEGIN_PATH,
|
|
545
|
+
} from '@forgezero/runtime/lifecycle';
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
## @forgezero/runtime/lifecycle — Use this entry point
|
|
549
|
+
|
|
550
|
+
This minimal executable use imports one concrete value from this exact entry point without a wildcard or package-root detour. Keep the value or values the application actually needs; the full named inventory remains directly above it.
|
|
551
|
+
|
|
552
|
+
```text
|
|
553
|
+
import {
|
|
554
|
+
RUNTIME_DRAIN_BEGIN_PATH,
|
|
555
|
+
} from '@forgezero/runtime/lifecycle';
|
|
556
|
+
|
|
557
|
+
export const selectedCapability = RUNTIME_DRAIN_BEGIN_PATH;
|
|
558
|
+
```
|
|
559
|
+
|
|
536
560
|
<a id="forgezero-runtime-schema"></a>
|
|
537
561
|
## @forgezero/runtime/schema
|
|
538
562
|
|
package/dist/audit.js
CHANGED
|
@@ -14,6 +14,13 @@ class QueueStoppedError extends Error {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
class QueueIntakePausedError extends Error {
|
|
18
|
+
constructor() {
|
|
19
|
+
super("queue: intake is paused for a runtime handoff");
|
|
20
|
+
this.name = "QueueIntakePausedError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
class QueueKeyStoppedError extends Error {
|
|
18
25
|
key;
|
|
19
26
|
constructor(key) {
|
|
@@ -81,6 +88,7 @@ function createQueue(options = {}) {
|
|
|
81
88
|
let sequence = 0;
|
|
82
89
|
let globallyPaused = false;
|
|
83
90
|
let accepting = true;
|
|
91
|
+
let intakePaused = false;
|
|
84
92
|
let aborted = false;
|
|
85
93
|
let completed = 0;
|
|
86
94
|
let failed = 0;
|
|
@@ -161,8 +169,8 @@ function createQueue(options = {}) {
|
|
|
161
169
|
return {
|
|
162
170
|
run(key, handler, ...args) {
|
|
163
171
|
const id = `q_${++sequence}`;
|
|
164
|
-
if (!accepting || stoppedKeys.has(key)) {
|
|
165
|
-
const refused = Promise.reject(accepting ? new
|
|
172
|
+
if (!accepting || intakePaused || stoppedKeys.has(key)) {
|
|
173
|
+
const refused = Promise.reject(!accepting ? new QueueStoppedError : intakePaused ? new QueueIntakePausedError : new QueueKeyStoppedError(key));
|
|
166
174
|
refused.catch(() => {
|
|
167
175
|
return;
|
|
168
176
|
});
|
|
@@ -242,6 +250,15 @@ function createQueue(options = {}) {
|
|
|
242
250
|
globallyPaused = false;
|
|
243
251
|
pump();
|
|
244
252
|
},
|
|
253
|
+
pauseIntake() {
|
|
254
|
+
intakePaused = true;
|
|
255
|
+
},
|
|
256
|
+
resumeIntake() {
|
|
257
|
+
if (!accepting)
|
|
258
|
+
return;
|
|
259
|
+
intakePaused = false;
|
|
260
|
+
pump();
|
|
261
|
+
},
|
|
245
262
|
snapshot() {
|
|
246
263
|
let queued = 0;
|
|
247
264
|
for (const lane of lanes.values())
|
|
@@ -252,6 +269,7 @@ function createQueue(options = {}) {
|
|
|
252
269
|
queued,
|
|
253
270
|
keys: lanes.size,
|
|
254
271
|
paused: globallyPaused,
|
|
272
|
+
intakePaused,
|
|
255
273
|
pausedKeys: [...paused],
|
|
256
274
|
stoppedKeys: [...stoppedKeys],
|
|
257
275
|
completed,
|
package/dist/jobs.js
CHANGED
|
@@ -14,6 +14,13 @@ class QueueStoppedError extends Error {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
class QueueIntakePausedError extends Error {
|
|
18
|
+
constructor() {
|
|
19
|
+
super("queue: intake is paused for a runtime handoff");
|
|
20
|
+
this.name = "QueueIntakePausedError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
class QueueKeyStoppedError extends Error {
|
|
18
25
|
key;
|
|
19
26
|
constructor(key) {
|
|
@@ -81,6 +88,7 @@ function createQueue(options = {}) {
|
|
|
81
88
|
let sequence = 0;
|
|
82
89
|
let globallyPaused = false;
|
|
83
90
|
let accepting = true;
|
|
91
|
+
let intakePaused = false;
|
|
84
92
|
let aborted = false;
|
|
85
93
|
let completed = 0;
|
|
86
94
|
let failed = 0;
|
|
@@ -161,8 +169,8 @@ function createQueue(options = {}) {
|
|
|
161
169
|
return {
|
|
162
170
|
run(key, handler, ...args) {
|
|
163
171
|
const id = `q_${++sequence}`;
|
|
164
|
-
if (!accepting || stoppedKeys.has(key)) {
|
|
165
|
-
const refused = Promise.reject(accepting ? new
|
|
172
|
+
if (!accepting || intakePaused || stoppedKeys.has(key)) {
|
|
173
|
+
const refused = Promise.reject(!accepting ? new QueueStoppedError : intakePaused ? new QueueIntakePausedError : new QueueKeyStoppedError(key));
|
|
166
174
|
refused.catch(() => {
|
|
167
175
|
return;
|
|
168
176
|
});
|
|
@@ -242,6 +250,15 @@ function createQueue(options = {}) {
|
|
|
242
250
|
globallyPaused = false;
|
|
243
251
|
pump();
|
|
244
252
|
},
|
|
253
|
+
pauseIntake() {
|
|
254
|
+
intakePaused = true;
|
|
255
|
+
},
|
|
256
|
+
resumeIntake() {
|
|
257
|
+
if (!accepting)
|
|
258
|
+
return;
|
|
259
|
+
intakePaused = false;
|
|
260
|
+
pump();
|
|
261
|
+
},
|
|
245
262
|
snapshot() {
|
|
246
263
|
let queued = 0;
|
|
247
264
|
for (const lane of lanes.values())
|
|
@@ -252,6 +269,7 @@ function createQueue(options = {}) {
|
|
|
252
269
|
queued,
|
|
253
270
|
keys: lanes.size,
|
|
254
271
|
paused: globallyPaused,
|
|
272
|
+
intakePaused,
|
|
255
273
|
pausedKeys: [...paused],
|
|
256
274
|
stoppedKeys: [...stoppedKeys],
|
|
257
275
|
completed,
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/** Generic, framework-neutral runtime drain contract used by native and OCI workloads. */
|
|
2
|
+
export declare const RUNTIME_DRAIN_BEGIN_PATH = "/.forgezero/runtime/drain/v1/begin";
|
|
3
|
+
export declare const RUNTIME_DRAIN_STATUS_PATH = "/.forgezero/runtime/drain/v1/status";
|
|
4
|
+
export interface RuntimeWorkCounters {
|
|
5
|
+
http: number;
|
|
6
|
+
websocket: number;
|
|
7
|
+
jobs: number;
|
|
8
|
+
schedules: number;
|
|
9
|
+
}
|
|
10
|
+
export interface RuntimeDrainDependencies {
|
|
11
|
+
/** Atomically refuse all new HTTP, WebSocket, job and schedule intake. */
|
|
12
|
+
drainRequests(): void;
|
|
13
|
+
/** Resolve only after all already-admitted HTTP and WebSocket work is closed. */
|
|
14
|
+
waitForRequests(): Promise<void>;
|
|
15
|
+
/** Stop schedule intake and wait for already-leased durable jobs. */
|
|
16
|
+
stopJobs(timeoutMs: number): Promise<void>;
|
|
17
|
+
/** A live, non-secret snapshot. Every counter must reach zero before acknowledgement. */
|
|
18
|
+
workCounters(): RuntimeWorkCounters;
|
|
19
|
+
/** Injectable monotonic wait used for the bounded deadline. */
|
|
20
|
+
sleep(ms: number): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export interface RuntimeDrainRequest {
|
|
23
|
+
protocol: 'forgezero-drain/v1';
|
|
24
|
+
nonce: string;
|
|
25
|
+
sourceRevision: string;
|
|
26
|
+
targetRevision: string;
|
|
27
|
+
timeoutMs: number;
|
|
28
|
+
}
|
|
29
|
+
export interface RuntimeDrainStatus extends RuntimeDrainRequest {
|
|
30
|
+
status: 'draining' | 'drained' | 'failed';
|
|
31
|
+
inFlight: RuntimeWorkCounters;
|
|
32
|
+
failure?: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* One process owns one controller. A second nonce cannot replace an in-flight
|
|
36
|
+
* drain, while an exact retry is idempotently acknowledged.
|
|
37
|
+
*/
|
|
38
|
+
export declare class RuntimeDrainController {
|
|
39
|
+
#private;
|
|
40
|
+
private readonly dependencies;
|
|
41
|
+
constructor(dependencies: RuntimeDrainDependencies);
|
|
42
|
+
begin(request: RuntimeDrainRequest): {
|
|
43
|
+
accepted: true;
|
|
44
|
+
duplicate: boolean;
|
|
45
|
+
};
|
|
46
|
+
status(nonce: string): RuntimeDrainStatus;
|
|
47
|
+
}
|
|
48
|
+
export interface RuntimeStateAdapter<T> {
|
|
49
|
+
protocol: string;
|
|
50
|
+
schemaVersion: number;
|
|
51
|
+
maximumBytes: number;
|
|
52
|
+
validate(value: unknown): value is T;
|
|
53
|
+
exportState(): Promise<{
|
|
54
|
+
cursor: string;
|
|
55
|
+
value: T;
|
|
56
|
+
}>;
|
|
57
|
+
importState(value: T, context: {
|
|
58
|
+
sourceRevision: string;
|
|
59
|
+
targetRevision: string;
|
|
60
|
+
cursor: string;
|
|
61
|
+
digest: `sha256:${string}`;
|
|
62
|
+
}): Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
export interface RuntimeStateHandoffRequest {
|
|
65
|
+
protocol: string;
|
|
66
|
+
nonce: string;
|
|
67
|
+
schemaVersion: number;
|
|
68
|
+
sourceRevision: string;
|
|
69
|
+
targetRevision: string;
|
|
70
|
+
source: {
|
|
71
|
+
url: string;
|
|
72
|
+
authorization: string;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export interface RuntimeStateEnvelope<T = unknown> {
|
|
76
|
+
protocol: string;
|
|
77
|
+
schemaVersion: number;
|
|
78
|
+
sourceRevision: string;
|
|
79
|
+
targetRevision: string;
|
|
80
|
+
cursor: string;
|
|
81
|
+
digest: `sha256:${string}`;
|
|
82
|
+
value: T;
|
|
83
|
+
}
|
|
84
|
+
export interface RuntimeStateHandoffStatus {
|
|
85
|
+
protocol: string;
|
|
86
|
+
nonce: string;
|
|
87
|
+
schemaVersion: number;
|
|
88
|
+
sourceRevision: string;
|
|
89
|
+
targetRevision: string;
|
|
90
|
+
status: 'pending' | 'acknowledged' | 'failed';
|
|
91
|
+
cursor?: string;
|
|
92
|
+
digest?: `sha256:${string}`;
|
|
93
|
+
failure?: string;
|
|
94
|
+
}
|
|
95
|
+
export type RuntimeStateFetch = (input: string, init: {
|
|
96
|
+
method: 'GET';
|
|
97
|
+
headers: Readonly<Record<string, string>>;
|
|
98
|
+
signal: AbortSignal;
|
|
99
|
+
}) => Promise<Response>;
|
|
100
|
+
/**
|
|
101
|
+
* Bounded typed state transfer. The runtime owns schema validation and value
|
|
102
|
+
* import; the Agent sees only cursor/digest acknowledgement metadata.
|
|
103
|
+
*/
|
|
104
|
+
export declare class RuntimeStateHandoffController<T> {
|
|
105
|
+
#private;
|
|
106
|
+
private readonly adapter;
|
|
107
|
+
private readonly fetchState;
|
|
108
|
+
constructor(adapter: RuntimeStateAdapter<T>, fetchState?: RuntimeStateFetch);
|
|
109
|
+
exportState(sourceRevision: string, targetRevision: string): Promise<RuntimeStateEnvelope<T>>;
|
|
110
|
+
begin(request: RuntimeStateHandoffRequest, timeoutMs: number): {
|
|
111
|
+
accepted: true;
|
|
112
|
+
duplicate: boolean;
|
|
113
|
+
};
|
|
114
|
+
status(nonce: string): RuntimeStateHandoffStatus;
|
|
115
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined")
|
|
5
|
+
return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// src/lifecycle.ts
|
|
10
|
+
var RUNTIME_DRAIN_BEGIN_PATH = "/.forgezero/runtime/drain/v1/begin";
|
|
11
|
+
var RUNTIME_DRAIN_STATUS_PATH = "/.forgezero/runtime/drain/v1/status";
|
|
12
|
+
var REVISION = /^[a-f0-9]{40}$/;
|
|
13
|
+
var NONCE = /^[A-Za-z0-9_-]{43}$/;
|
|
14
|
+
var counter = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
15
|
+
var validateCounters = (value) => {
|
|
16
|
+
if (![value.http, value.websocket, value.jobs, value.schedules].every(counter)) {
|
|
17
|
+
throw new Error("RUNTIME_DRAIN_COUNTERS_INVALID");
|
|
18
|
+
}
|
|
19
|
+
return { ...value };
|
|
20
|
+
};
|
|
21
|
+
var idle = (value) => value.http === 0 && value.websocket === 0 && value.jobs === 0 && value.schedules === 0;
|
|
22
|
+
|
|
23
|
+
class RuntimeDrainController {
|
|
24
|
+
dependencies;
|
|
25
|
+
#request = null;
|
|
26
|
+
#status = "draining";
|
|
27
|
+
#failure;
|
|
28
|
+
constructor(dependencies) {
|
|
29
|
+
this.dependencies = dependencies;
|
|
30
|
+
}
|
|
31
|
+
begin(request) {
|
|
32
|
+
if (request.protocol !== "forgezero-drain/v1" || !NONCE.test(request.nonce) || !REVISION.test(request.sourceRevision) || !REVISION.test(request.targetRevision) || !Number.isSafeInteger(request.timeoutMs) || request.timeoutMs < 100 || request.timeoutMs > 600000) {
|
|
33
|
+
throw new Error("RUNTIME_DRAIN_REQUEST_INVALID");
|
|
34
|
+
}
|
|
35
|
+
if (this.#request) {
|
|
36
|
+
const duplicate = this.#request.nonce === request.nonce && this.#request.sourceRevision === request.sourceRevision && this.#request.targetRevision === request.targetRevision;
|
|
37
|
+
if (!duplicate)
|
|
38
|
+
throw new Error("RUNTIME_DRAIN_ALREADY_STARTED");
|
|
39
|
+
return { accepted: true, duplicate: true };
|
|
40
|
+
}
|
|
41
|
+
this.#request = { ...request };
|
|
42
|
+
this.#status = "draining";
|
|
43
|
+
this.dependencies.drainRequests();
|
|
44
|
+
const work = Promise.all([
|
|
45
|
+
this.dependencies.waitForRequests(),
|
|
46
|
+
this.dependencies.stopJobs(request.timeoutMs)
|
|
47
|
+
]);
|
|
48
|
+
Promise.race([
|
|
49
|
+
work.then(() => "completed"),
|
|
50
|
+
this.dependencies.sleep(request.timeoutMs).then(() => "timeout")
|
|
51
|
+
]).then((result) => {
|
|
52
|
+
if (result === "timeout")
|
|
53
|
+
throw new Error("RUNTIME_DRAIN_TIMEOUT");
|
|
54
|
+
if (!idle(validateCounters(this.dependencies.workCounters())))
|
|
55
|
+
throw new Error("RUNTIME_DRAIN_NOT_IDLE");
|
|
56
|
+
this.#status = "drained";
|
|
57
|
+
}).catch((cause) => {
|
|
58
|
+
this.#status = "failed";
|
|
59
|
+
this.#failure = cause instanceof Error ? cause.message.slice(0, 128) : "RUNTIME_DRAIN_FAILED";
|
|
60
|
+
});
|
|
61
|
+
return { accepted: true, duplicate: false };
|
|
62
|
+
}
|
|
63
|
+
status(nonce) {
|
|
64
|
+
if (!this.#request || !NONCE.test(nonce) || nonce !== this.#request.nonce) {
|
|
65
|
+
throw new Error("RUNTIME_DRAIN_NOT_FOUND");
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
...this.#request,
|
|
69
|
+
status: this.#status,
|
|
70
|
+
inFlight: validateCounters(this.dependencies.workCounters()),
|
|
71
|
+
...this.#failure ? { failure: this.#failure } : {}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
var HANDOFF_TEXT = /^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,127}$/;
|
|
76
|
+
var CURSOR = /^[A-Za-z0-9][A-Za-z0-9_.:@/+\-=]{0,511}$/;
|
|
77
|
+
var DIGEST = /^sha256:[a-f0-9]{64}$/;
|
|
78
|
+
var AUTHORIZATION = /^Bearer [A-Za-z0-9_-]{43}$/;
|
|
79
|
+
var canonicalJson = (value, seen = new Set) => {
|
|
80
|
+
if (value === null)
|
|
81
|
+
return "null";
|
|
82
|
+
if (typeof value === "string")
|
|
83
|
+
return JSON.stringify(value);
|
|
84
|
+
if (typeof value === "boolean")
|
|
85
|
+
return value ? "true" : "false";
|
|
86
|
+
if (typeof value === "number") {
|
|
87
|
+
if (!Number.isFinite(value))
|
|
88
|
+
throw new Error("RUNTIME_STATE_VALUE_INVALID");
|
|
89
|
+
return JSON.stringify(value);
|
|
90
|
+
}
|
|
91
|
+
if (typeof value !== "object" || seen.has(value))
|
|
92
|
+
throw new Error("RUNTIME_STATE_VALUE_INVALID");
|
|
93
|
+
seen.add(value);
|
|
94
|
+
try {
|
|
95
|
+
if (Array.isArray(value))
|
|
96
|
+
return `[${value.map((entry) => canonicalJson(entry, seen)).join(",")}]`;
|
|
97
|
+
const object = value;
|
|
98
|
+
const entries = Object.keys(object).sort().map((key) => {
|
|
99
|
+
if (object[key] === undefined)
|
|
100
|
+
throw new Error("RUNTIME_STATE_VALUE_INVALID");
|
|
101
|
+
return `${JSON.stringify(key)}:${canonicalJson(object[key], seen)}`;
|
|
102
|
+
});
|
|
103
|
+
return `{${entries.join(",")}}`;
|
|
104
|
+
} finally {
|
|
105
|
+
seen.delete(value);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
var stateDigest = async (input) => {
|
|
109
|
+
const bytes = new TextEncoder().encode(canonicalJson(input));
|
|
110
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
|
111
|
+
return `sha256:${[...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
112
|
+
};
|
|
113
|
+
var assertAdapter = (adapter) => {
|
|
114
|
+
if (!HANDOFF_TEXT.test(adapter.protocol) || !Number.isSafeInteger(adapter.schemaVersion) || adapter.schemaVersion < 1 || adapter.schemaVersion > 65535 || !Number.isSafeInteger(adapter.maximumBytes) || adapter.maximumBytes < 1 || adapter.maximumBytes > 1048576) {
|
|
115
|
+
throw new Error("RUNTIME_STATE_ADAPTER_INVALID");
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
var loopbackSource = (raw) => {
|
|
119
|
+
const value = new URL(raw);
|
|
120
|
+
if (value.protocol !== "http:" || value.username || value.password || value.hash || !["127.0.0.1", "[::1]", "::1", "localhost"].includes(value.hostname)) {
|
|
121
|
+
throw new Error("RUNTIME_STATE_SOURCE_INVALID");
|
|
122
|
+
}
|
|
123
|
+
return value.toString();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
class RuntimeStateHandoffController {
|
|
127
|
+
adapter;
|
|
128
|
+
fetchState;
|
|
129
|
+
#request = null;
|
|
130
|
+
#status = null;
|
|
131
|
+
constructor(adapter, fetchState = (input, init) => fetch(input, init)) {
|
|
132
|
+
this.adapter = adapter;
|
|
133
|
+
this.fetchState = fetchState;
|
|
134
|
+
assertAdapter(adapter);
|
|
135
|
+
}
|
|
136
|
+
async exportState(sourceRevision, targetRevision) {
|
|
137
|
+
if (!REVISION.test(sourceRevision) || !REVISION.test(targetRevision))
|
|
138
|
+
throw new Error("RUNTIME_STATE_REVISION_INVALID");
|
|
139
|
+
const exported = await this.adapter.exportState();
|
|
140
|
+
if (!CURSOR.test(exported.cursor) || !this.adapter.validate(exported.value)) {
|
|
141
|
+
throw new Error("RUNTIME_STATE_EXPORT_INVALID");
|
|
142
|
+
}
|
|
143
|
+
const unsigned = {
|
|
144
|
+
protocol: this.adapter.protocol,
|
|
145
|
+
schemaVersion: this.adapter.schemaVersion,
|
|
146
|
+
sourceRevision,
|
|
147
|
+
targetRevision,
|
|
148
|
+
cursor: exported.cursor,
|
|
149
|
+
value: exported.value
|
|
150
|
+
};
|
|
151
|
+
if (new TextEncoder().encode(canonicalJson(unsigned)).byteLength > this.adapter.maximumBytes) {
|
|
152
|
+
throw new Error("RUNTIME_STATE_EXPORT_TOO_LARGE");
|
|
153
|
+
}
|
|
154
|
+
return { ...unsigned, digest: await stateDigest(unsigned) };
|
|
155
|
+
}
|
|
156
|
+
begin(request, timeoutMs) {
|
|
157
|
+
if (!NONCE.test(request.nonce) || request.protocol !== this.adapter.protocol || request.schemaVersion !== this.adapter.schemaVersion || !REVISION.test(request.sourceRevision) || !REVISION.test(request.targetRevision) || !AUTHORIZATION.test(request.source.authorization) || !Number.isSafeInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 600000) {
|
|
158
|
+
throw new Error("RUNTIME_STATE_REQUEST_INVALID");
|
|
159
|
+
}
|
|
160
|
+
const sourceUrl = loopbackSource(request.source.url);
|
|
161
|
+
if (this.#request) {
|
|
162
|
+
const duplicate = canonicalJson(this.#request) === canonicalJson(request);
|
|
163
|
+
if (!duplicate)
|
|
164
|
+
throw new Error("RUNTIME_STATE_ALREADY_STARTED");
|
|
165
|
+
return { accepted: true, duplicate: true };
|
|
166
|
+
}
|
|
167
|
+
this.#request = structuredClone(request);
|
|
168
|
+
this.#status = {
|
|
169
|
+
protocol: request.protocol,
|
|
170
|
+
nonce: request.nonce,
|
|
171
|
+
schemaVersion: request.schemaVersion,
|
|
172
|
+
sourceRevision: request.sourceRevision,
|
|
173
|
+
targetRevision: request.targetRevision,
|
|
174
|
+
status: "pending"
|
|
175
|
+
};
|
|
176
|
+
this.fetchState(sourceUrl, {
|
|
177
|
+
method: "GET",
|
|
178
|
+
headers: { Authorization: request.source.authorization },
|
|
179
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
180
|
+
}).then(async (response) => {
|
|
181
|
+
if (!response.ok)
|
|
182
|
+
throw new Error(`RUNTIME_STATE_SOURCE_HTTP_${response.status}`);
|
|
183
|
+
const length = Number(response.headers.get("content-length") ?? 0);
|
|
184
|
+
if (length > this.adapter.maximumBytes)
|
|
185
|
+
throw new Error("RUNTIME_STATE_SOURCE_TOO_LARGE");
|
|
186
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
187
|
+
if (bytes.byteLength > this.adapter.maximumBytes)
|
|
188
|
+
throw new Error("RUNTIME_STATE_SOURCE_TOO_LARGE");
|
|
189
|
+
const envelope = JSON.parse(new TextDecoder().decode(bytes));
|
|
190
|
+
if (envelope.protocol !== request.protocol || envelope.schemaVersion !== request.schemaVersion || envelope.sourceRevision !== request.sourceRevision || envelope.targetRevision !== request.targetRevision || !CURSOR.test(envelope.cursor) || !DIGEST.test(envelope.digest) || !this.adapter.validate(envelope.value)) {
|
|
191
|
+
throw new Error("RUNTIME_STATE_ENVELOPE_INVALID");
|
|
192
|
+
}
|
|
193
|
+
const expected = await stateDigest({
|
|
194
|
+
protocol: envelope.protocol,
|
|
195
|
+
schemaVersion: envelope.schemaVersion,
|
|
196
|
+
sourceRevision: envelope.sourceRevision,
|
|
197
|
+
targetRevision: envelope.targetRevision,
|
|
198
|
+
cursor: envelope.cursor,
|
|
199
|
+
value: envelope.value
|
|
200
|
+
});
|
|
201
|
+
if (expected !== envelope.digest)
|
|
202
|
+
throw new Error("RUNTIME_STATE_DIGEST_MISMATCH");
|
|
203
|
+
await this.adapter.importState(envelope.value, {
|
|
204
|
+
sourceRevision: request.sourceRevision,
|
|
205
|
+
targetRevision: request.targetRevision,
|
|
206
|
+
cursor: envelope.cursor,
|
|
207
|
+
digest: envelope.digest
|
|
208
|
+
});
|
|
209
|
+
this.#status = {
|
|
210
|
+
protocol: request.protocol,
|
|
211
|
+
nonce: request.nonce,
|
|
212
|
+
schemaVersion: request.schemaVersion,
|
|
213
|
+
sourceRevision: request.sourceRevision,
|
|
214
|
+
targetRevision: request.targetRevision,
|
|
215
|
+
status: "acknowledged",
|
|
216
|
+
cursor: envelope.cursor,
|
|
217
|
+
digest: envelope.digest
|
|
218
|
+
};
|
|
219
|
+
}).catch((cause) => {
|
|
220
|
+
this.#status = {
|
|
221
|
+
protocol: request.protocol,
|
|
222
|
+
nonce: request.nonce,
|
|
223
|
+
schemaVersion: request.schemaVersion,
|
|
224
|
+
sourceRevision: request.sourceRevision,
|
|
225
|
+
targetRevision: request.targetRevision,
|
|
226
|
+
status: "failed",
|
|
227
|
+
failure: cause instanceof Error ? cause.message.slice(0, 128) : "RUNTIME_STATE_FAILED"
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
return { accepted: true, duplicate: false };
|
|
231
|
+
}
|
|
232
|
+
status(nonce) {
|
|
233
|
+
if (!this.#status || !NONCE.test(nonce) || nonce !== this.#status.nonce)
|
|
234
|
+
throw new Error("RUNTIME_STATE_NOT_FOUND");
|
|
235
|
+
return structuredClone(this.#status);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
export {
|
|
239
|
+
RuntimeStateHandoffController,
|
|
240
|
+
RuntimeDrainController,
|
|
241
|
+
RUNTIME_DRAIN_STATUS_PATH,
|
|
242
|
+
RUNTIME_DRAIN_BEGIN_PATH
|
|
243
|
+
};
|
package/dist/queue.d.ts
CHANGED
|
@@ -80,6 +80,10 @@ export interface DrainReport {
|
|
|
80
80
|
export declare class QueueStoppedError extends Error {
|
|
81
81
|
constructor();
|
|
82
82
|
}
|
|
83
|
+
/** A reversible handoff fence refused new work while existing work drained. */
|
|
84
|
+
export declare class QueueIntakePausedError extends Error {
|
|
85
|
+
constructor();
|
|
86
|
+
}
|
|
83
87
|
export declare class QueueKeyStoppedError extends Error {
|
|
84
88
|
readonly key: string;
|
|
85
89
|
constructor(key: string);
|
|
@@ -116,6 +120,12 @@ export declare function createQueue(options?: QueueOptions): {
|
|
|
116
120
|
/** Hold everything. New submissions are accepted and wait. */
|
|
117
121
|
pause(): void;
|
|
118
122
|
resume(): void;
|
|
123
|
+
/**
|
|
124
|
+
* Refuse new submissions without stopping the queue. Already accepted work
|
|
125
|
+
* continues, so `whenIdle()` is an exact reversible handoff boundary.
|
|
126
|
+
*/
|
|
127
|
+
pauseIntake(): void;
|
|
128
|
+
resumeIntake(): void;
|
|
119
129
|
/** How much is outstanding, for a health endpoint or a drain decision. */
|
|
120
130
|
snapshot(): {
|
|
121
131
|
width: number;
|
|
@@ -123,6 +133,7 @@ export declare function createQueue(options?: QueueOptions): {
|
|
|
123
133
|
queued: number;
|
|
124
134
|
keys: number;
|
|
125
135
|
paused: boolean;
|
|
136
|
+
intakePaused: boolean;
|
|
126
137
|
pausedKeys: string[];
|
|
127
138
|
stoppedKeys: string[];
|
|
128
139
|
completed: number;
|
package/dist/queue.js
CHANGED
|
@@ -14,6 +14,13 @@ class QueueStoppedError extends Error {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
class QueueIntakePausedError extends Error {
|
|
18
|
+
constructor() {
|
|
19
|
+
super("queue: intake is paused for a runtime handoff");
|
|
20
|
+
this.name = "QueueIntakePausedError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
class QueueKeyStoppedError extends Error {
|
|
18
25
|
key;
|
|
19
26
|
constructor(key) {
|
|
@@ -81,6 +88,7 @@ function createQueue(options = {}) {
|
|
|
81
88
|
let sequence = 0;
|
|
82
89
|
let globallyPaused = false;
|
|
83
90
|
let accepting = true;
|
|
91
|
+
let intakePaused = false;
|
|
84
92
|
let aborted = false;
|
|
85
93
|
let completed = 0;
|
|
86
94
|
let failed = 0;
|
|
@@ -161,8 +169,8 @@ function createQueue(options = {}) {
|
|
|
161
169
|
return {
|
|
162
170
|
run(key, handler, ...args) {
|
|
163
171
|
const id = `q_${++sequence}`;
|
|
164
|
-
if (!accepting || stoppedKeys.has(key)) {
|
|
165
|
-
const refused = Promise.reject(accepting ? new
|
|
172
|
+
if (!accepting || intakePaused || stoppedKeys.has(key)) {
|
|
173
|
+
const refused = Promise.reject(!accepting ? new QueueStoppedError : intakePaused ? new QueueIntakePausedError : new QueueKeyStoppedError(key));
|
|
166
174
|
refused.catch(() => {
|
|
167
175
|
return;
|
|
168
176
|
});
|
|
@@ -242,6 +250,15 @@ function createQueue(options = {}) {
|
|
|
242
250
|
globallyPaused = false;
|
|
243
251
|
pump();
|
|
244
252
|
},
|
|
253
|
+
pauseIntake() {
|
|
254
|
+
intakePaused = true;
|
|
255
|
+
},
|
|
256
|
+
resumeIntake() {
|
|
257
|
+
if (!accepting)
|
|
258
|
+
return;
|
|
259
|
+
intakePaused = false;
|
|
260
|
+
pump();
|
|
261
|
+
},
|
|
245
262
|
snapshot() {
|
|
246
263
|
let queued = 0;
|
|
247
264
|
for (const lane of lanes.values())
|
|
@@ -252,6 +269,7 @@ function createQueue(options = {}) {
|
|
|
252
269
|
queued,
|
|
253
270
|
keys: lanes.size,
|
|
254
271
|
paused: globallyPaused,
|
|
272
|
+
intakePaused,
|
|
255
273
|
pausedKeys: [...paused],
|
|
256
274
|
stoppedKeys: [...stoppedKeys],
|
|
257
275
|
completed,
|
|
@@ -312,5 +330,6 @@ export {
|
|
|
312
330
|
createQueue,
|
|
313
331
|
TaskCancelledError,
|
|
314
332
|
QueueStoppedError,
|
|
315
|
-
QueueKeyStoppedError
|
|
333
|
+
QueueKeyStoppedError,
|
|
334
|
+
QueueIntakePausedError
|
|
316
335
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgezero/runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.24",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -51,6 +51,10 @@
|
|
|
51
51
|
"types": "./dist/realtime.d.ts",
|
|
52
52
|
"default": "./dist/realtime.js"
|
|
53
53
|
},
|
|
54
|
+
"./lifecycle": {
|
|
55
|
+
"types": "./dist/lifecycle.d.ts",
|
|
56
|
+
"default": "./dist/lifecycle.js"
|
|
57
|
+
},
|
|
54
58
|
"./calendar": {
|
|
55
59
|
"types": "./dist/calendar.d.ts",
|
|
56
60
|
"default": "./dist/calendar.js"
|