@forgezero/runtime 0.1.21 → 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 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. 31 public modules, each imported on its own.
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
- QueueKeyStoppedError,
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
- QueueKeyStoppedError,
163
+ QueueIntakePausedError,
163
164
  } from '@forgezero/runtime/queue';
164
165
 
165
- export const selectedCapability = QueueKeyStoppedError;
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
 
@@ -972,6 +996,19 @@ scheduler.pause(); // stop scheduling, let in-flight finish
972
996
  await scheduler.runNow('rotate-provider-credentials');
973
997
  ```
974
998
 
999
+ ## Deployment handoff is typed state, not copied RAM
1000
+
1001
+ The portable lifecycle for native and containerd services is deliberately smaller than a process snapshot. First report not-ready and close new HTTP and job intake. Existing requests, streams and queue handlers drain within one deadline. Durable jobs stay in the application store with an idempotency key, expiring lease and monotonic fence, so the candidate may reclaim unfinished work without allowing the old generation to commit. An optional handoff value may carry a bounded, versioned cursor or connection coordinate after schema validation. Closures, arbitrary heap objects, sockets and Vault plaintext never cross generations; the candidate resolves the same named Vault references through its own authorized runtime boundary. This is the lifecycle ForgeZero itself must use before it is offered to tenants.
1002
+
1003
+ ```text
1004
+ old: ready=false -> close intake -> drain HTTP/streams -> drain or release fenced jobs
1005
+ new: private health -> validate handoff -> resolve named Vault refs -> accept claims
1006
+ switch: route new HTTP -> observe old deadline -> stop old or roll back
1007
+
1008
+ durable job = payload + idempotencyKey + leaseUntil + fence
1009
+ handoff value = versioned + schema-validated + size-bounded + non-secret
1010
+ ```
1011
+
975
1012
  ## Status somebody can read during an incident
976
1013
 
977
1014
  Last run, duration, result, error and consecutive failures per job. Without it a job that has been failing for a week looks exactly like a job that has been succeeding.
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 QueueKeyStoppedError(key) : new QueueStoppedError);
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 QueueKeyStoppedError(key) : new QueueStoppedError);
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 QueueKeyStoppedError(key) : new QueueStoppedError);
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.21",
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"