@sgtbeatdown/pi-tasks 0.1.9 → 0.1.11

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/src/task-store.ts CHANGED
@@ -1,437 +1,188 @@
1
- import { chmodSync, existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
4
-
5
- import { openSqliteDatabase } from "./sqlite-database";
6
- import type { SqliteDatabase } from "./sqlite-database";
7
1
  import { TERMINAL_INTENT_OPERATION, TaskProtocolError } from "./task-protocol";
8
- import type { RelayEnvelope, TaskEndpoint, TaskEvent, TaskRecord, TaskSnapshot, TerminalDeliveryState, TerminalTaskIntentType } from "./task-protocol";
9
-
10
- const SCHEMA_VERSION = 5;
11
- const OWNER_ONLY_MODE = 0o700;
12
- const OWNER_ONLY_FILE_MODE = 0o600;
13
- const ENDPOINT_GENERATION_STATE_KEY = "endpoint_generation";
14
- const ENDPOINT_BINDING_STATE_KEY = "endpoint_binding";
2
+ import type { RelayTransportBinding, RelayEnvelope, TaskEndpoint, TaskEvent, TaskRecord, TaskSnapshot, TerminalDeliveryState, TerminalTaskIntentType } from "./task-protocol";
15
3
 
16
4
  export interface TaskStoreOptions {
17
- readonly path?: string;
18
- }
19
-
20
- export interface OutboxRecord {
21
- readonly envelope: RelayEnvelope;
22
- readonly state: "pending" | "accepted";
5
+ /** Lower-only per-endpoint limits; session history, not this store, is the archive. */
6
+ readonly maxEntries?: number;
7
+ readonly maxBytes?: number;
23
8
  }
24
-
9
+ export interface OutboxRecord { readonly envelope: RelayEnvelope; readonly state: "pending" | "accepted"; }
25
10
  export interface OutboxQuarantineInput {
26
- readonly errorCode: string;
27
- readonly reason: string;
28
- readonly details: Readonly<Record<string, unknown>>;
29
- readonly quarantinedAt: number;
30
- }
31
-
32
- export interface OutboxQuarantineRecord extends OutboxQuarantineInput {
33
- readonly envelope: RelayEnvelope;
34
- readonly priorState: OutboxRecord["state"];
11
+ readonly errorCode: string; readonly reason: string;
12
+ readonly details: Readonly<Record<string, unknown>>; readonly quarantinedAt: number;
35
13
  }
36
-
14
+ export interface OutboxQuarantineRecord extends OutboxQuarantineInput { readonly envelope: RelayEnvelope; readonly priorState: OutboxRecord["state"]; }
37
15
  export interface TaskOperationRecord {
38
- readonly taskId: string;
39
- readonly operation: string;
40
- readonly logicalId: string;
41
- readonly logicalType: string;
42
- readonly envelopeIds: readonly string[];
16
+ readonly taskId: string; readonly operation: string; readonly logicalId: string;
17
+ readonly logicalType: string; readonly envelopeIds: readonly string[];
43
18
  }
44
-
45
19
  export interface TaskStore {
46
- transaction<T>(operation: () => T): T;
47
- putTask(task: Omit<TaskRecord, "events">): void;
48
- getTask(taskId: string): TaskSnapshot | undefined;
49
- listTasks(): readonly TaskSnapshot[];
50
- setStatus(taskId: string, status: TaskRecord["status"]): void;
51
- appendEvent(event: TaskEvent): boolean;
52
- putOutbox(envelope: RelayEnvelope): void;
53
- outbox(state: OutboxRecord["state"]): readonly OutboxRecord[];
54
- markOutboxAccepted(envelopeId: string): void;
55
- quarantineOutbox(envelopeId: string, input: OutboxQuarantineInput): void;
56
- quarantinedOutbox(): readonly OutboxQuarantineRecord[];
57
- persistInbox(envelope: RelayEnvelope, cursor: string): boolean;
58
- getReceiveCursor(): string;
59
- setReceiveCursor(cursor: string): void;
60
- trackRelayDeliveries(endpoint: TaskEndpoint, deliveries: readonly { readonly cursor: string; readonly envelopeId: string }[]): void;
61
- pendingRelayEnvelopeId(endpoint: TaskEndpoint, cursor: string): string | undefined;
62
- requestRelayAcknowledgement(endpoint: TaskEndpoint, cursor: string): void;
63
- requestedRelayAcknowledgements(endpoint: TaskEndpoint): readonly string[];
64
- acknowledgeRelayCursor(endpoint: TaskEndpoint, cursor: string): void;
65
- putIntent(intentId: string, taskId: string, envelopeId: string): void;
66
- putInsertionReceipt(taskId: string, eventId: string): boolean;
67
- reserveTaskOperation(input: TaskOperationRecord): { readonly created: boolean; readonly record: TaskOperationRecord };
68
- getEndpointGeneration(): string | undefined;
69
- setEndpointGeneration(generation: string): void;
70
- getEndpointBinding(): TaskEndpoint | undefined;
71
- setEndpointBinding(endpoint: TaskEndpoint): void;
72
- close(): void;
73
- }
74
-
20
+ transaction<T>(operation: () => T): T;
21
+ putTask(task: Omit<TaskRecord, "events">): void;
22
+ getTask(taskId: string): TaskSnapshot | undefined;
23
+ listTasks(): readonly TaskSnapshot[];
24
+ setStatus(taskId: string, status: TaskRecord["status"]): void;
25
+ appendEvent(event: TaskEvent): boolean;
26
+ putOutbox(envelope: RelayEnvelope): void;
27
+ outbox(state: OutboxRecord["state"]): readonly OutboxRecord[];
28
+ markOutboxAccepted(envelopeId: string): void;
29
+ quarantineOutbox(envelopeId: string, input: OutboxQuarantineInput): void;
30
+ quarantinedOutbox(): readonly OutboxQuarantineRecord[];
31
+ persistInbox(envelope: RelayEnvelope, cursor: string): boolean;
32
+ getReceiveCursor(): string;
33
+ setReceiveCursor(cursor: string): void;
34
+ trackRelayDeliveries(endpoint: TaskEndpoint, deliveries: readonly { readonly cursor: string; readonly envelopeId: string }[]): void;
35
+ pendingRelayEnvelopeId(endpoint: TaskEndpoint, cursor: string): string | undefined;
36
+ requestRelayAcknowledgement(endpoint: TaskEndpoint, cursor: string): void;
37
+ requestedRelayAcknowledgements(endpoint: TaskEndpoint): readonly string[];
38
+ acknowledgeRelayCursor(endpoint: TaskEndpoint, cursor: string): void;
39
+ putIntent(intentId: string, taskId: string, envelopeId: string): void;
40
+ putInsertionReceipt(taskId: string, eventId: string): boolean;
41
+ reserveTaskOperation(input: TaskOperationRecord): { readonly created: boolean; readonly record: TaskOperationRecord };
42
+ getEndpointGeneration(): string | undefined;
43
+ setEndpointGeneration(generation: string): void;
44
+ getEndpointBinding(): TaskEndpoint | undefined;
45
+ setEndpointBinding(endpoint: TaskEndpoint): void;
46
+ getRelayTransportBinding(): RelayTransportBinding | undefined;
47
+ setRelayTransportBinding(binding: RelayTransportBinding): void;
48
+ clear(): void;
49
+ close(): void;
50
+ }
51
+
52
+ const LIMITS = { entries: 16_384, bytes: 32 * 1024 * 1024 };
53
+ type Cell = { readonly text: string; readonly bytes: number; readonly order: number };
54
+ type Undo = { readonly table: Map<string, Cell>; readonly key: string; readonly prior: Cell | undefined };
55
+ interface Checkpoint { frontier: string; highWater: string; pending: Record<string, string>; requested: Record<string, true>; }
56
+
57
+ /** One endpoint lifetime. No SQLite, files, migrations, startup reads or replay. */
75
58
  export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
76
- const path = options.path ?? join(homedir(), ".pi", "tasks", "v2", "tasks.sqlite");
77
- preparePath(path);
78
- const database = openSqliteDatabase(path);
79
- try {
80
- initializeSchema(database);
81
- database.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
82
- } catch (error) {
83
- database.close();
84
- throw error;
85
- }
86
-
87
- const store: TaskStore = {
88
- transaction(operation) {
89
- return database.transaction(operation)();
90
- },
91
- putTask(task) {
92
- database.query(`INSERT INTO tasks (task_id, protocol_version, origin_relay, origin_id, target_relay, target_id, task, created_at, expires_at, status)
93
- VALUES ($taskId, $protocolVersion, $originRelay, $originId, $targetRelay, $targetId, $task, $createdAt, $expiresAt, $status)
94
- ON CONFLICT(task_id) DO NOTHING`).run(taskRow(task));
95
- },
96
- getTask(taskId) {
97
- const row = database.query("SELECT * FROM tasks WHERE task_id = ?").get(taskId) as TaskRow | null;
98
- if (!row) return undefined;
99
- const eventRows = database.query("SELECT * FROM events WHERE task_id = ? ORDER BY CAST(sequence AS INTEGER), event_id").all(taskId) as EventRow[];
100
- return taskFromRow(database, row, eventRows);
101
- },
102
- listTasks() {
103
- const rows = database.query("SELECT * FROM tasks ORDER BY created_at, task_id").all() as TaskRow[];
104
- const events = database.query("SELECT * FROM events ORDER BY CAST(sequence AS INTEGER), event_id").all() as EventRow[];
105
- const eventsByTask = new Map<string, EventRow[]>();
106
- for (const event of events) {
107
- const current = eventsByTask.get(event.task_id) ?? [];
108
- current.push(event);
109
- eventsByTask.set(event.task_id, current);
110
- }
111
- return rows.map((row) => taskFromRow(database, row, eventsByTask.get(row.task_id) ?? []));
112
- },
113
- setStatus(taskId, status) {
114
- database.query("UPDATE tasks SET status = ? WHERE task_id = ?").run(status, taskId);
115
- },
116
- appendEvent(event) {
117
- const result = database.query(`INSERT INTO events (event_id, task_id, type, sequence, source_relay, source_id, target_relay, target_id, occurred_at, payload)
118
- VALUES ($eventId, $taskId, $type, $sequence, $sourceRelay, $sourceId, $targetRelay, $targetId, $occurredAt, $payload)
119
- ON CONFLICT(event_id) DO NOTHING`).run(eventRow(event));
120
- return result.changes === 1;
121
- },
122
- putOutbox(envelope) {
123
- database.query("INSERT INTO outbox (envelope_id, envelope, state) VALUES (?, ?, 'pending') ON CONFLICT(envelope_id) DO NOTHING").run(envelope.envelopeId, JSON.stringify(envelope));
124
- },
125
- outbox(state) {
126
- const rows = database.query("SELECT envelope FROM outbox WHERE state = ? ORDER BY rowid").all(state) as Array<{ readonly envelope: string }>;
127
- return rows.map((row) => ({ envelope: parseEnvelope(row.envelope), state }));
128
- },
129
- markOutboxAccepted(envelopeId) {
130
- database.query("UPDATE outbox SET state = 'accepted' WHERE envelope_id = ?").run(envelopeId);
131
- },
132
- quarantineOutbox(envelopeId, input) {
133
- const row = database.query("SELECT envelope, state FROM outbox WHERE envelope_id = ?").get(envelopeId) as { readonly envelope: string; readonly state: OutboxRecord["state"] } | null;
134
- if (!row) return;
135
- database.query(`INSERT INTO outbox_quarantine (envelope, error_code, reason, details, quarantined_at, prior_state, envelope_id)
136
- VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(envelope_id) DO NOTHING`).run(
137
- row.envelope, input.errorCode, input.reason, JSON.stringify(input.details), input.quarantinedAt, row.state, envelopeId,
138
- );
139
- database.query("DELETE FROM outbox WHERE envelope_id = ?").run(envelopeId);
140
- },
141
- quarantinedOutbox() {
142
- const rows = database.query("SELECT envelope, error_code, reason, details, quarantined_at, prior_state FROM outbox_quarantine ORDER BY rowid").all() as OutboxQuarantineRow[];
143
- return rows.map((row) => ({
144
- envelope: parseEnvelope(row.envelope),
145
- errorCode: row.error_code,
146
- reason: row.reason,
147
- details: parseRecord(row.details),
148
- quarantinedAt: row.quarantined_at,
149
- priorState: row.prior_state,
150
- }));
151
- },
152
- persistInbox(envelope, cursor) {
153
- const result = database.query("INSERT INTO inbox (envelope_id, cursor, envelope) VALUES (?, ?, ?) ON CONFLICT(envelope_id) DO NOTHING").run(envelope.envelopeId, cursor, JSON.stringify(envelope));
154
- return result.changes === 1;
155
- },
156
- getReceiveCursor() {
157
- const row = database.query("SELECT value FROM relay_state WHERE name = 'receive_cursor'").get() as { readonly value: string } | null;
158
- return row?.value ?? "0";
159
- },
160
- setReceiveCursor(cursor) {
161
- database.query("INSERT INTO relay_state (name, value) VALUES ('receive_cursor', ?) ON CONFLICT(name) DO UPDATE SET value = excluded.value").run(cursor);
162
- },
163
- trackRelayDeliveries(endpoint, deliveries) {
164
- const state = deliveryCheckpoint(endpoint);
165
- const priorHighWater = BigInt(state.highWater);
166
- for (const delivery of deliveries) {
167
- const prior = state.pending[delivery.cursor];
168
- if (prior !== undefined && prior !== delivery.envelopeId) throw new TaskProtocolError("INVALID_CURSOR", "relay cursor changed envelope identity");
169
- // A previously observed but no longer pending cursor was ACKed. In
170
- // particular, legacy relays may still return ACKed rows on reread.
171
- if (BigInt(delivery.cursor) > priorHighWater) state.pending[delivery.cursor] = delivery.envelopeId;
172
- if (BigInt(delivery.cursor) > BigInt(state.highWater)) state.highWater = delivery.cursor;
173
- }
174
- writeDeliveryCheckpoint(endpoint, state);
175
- },
176
- pendingRelayEnvelopeId(endpoint, cursor) { return deliveryCheckpoint(endpoint).pending[cursor]; },
177
- requestRelayAcknowledgement(endpoint, cursor) {
178
- const state = deliveryCheckpoint(endpoint);
179
- if (state.pending[cursor] === undefined) throw new TaskProtocolError("INVALID_CURSOR", "relay delivery is not pending in this endpoint scope");
180
- (state.requested ??= {})[cursor] = true;
181
- writeDeliveryCheckpoint(endpoint, state);
182
- },
183
- requestedRelayAcknowledgements(endpoint) { return Object.keys(deliveryCheckpoint(endpoint).requested ?? {}); },
184
- acknowledgeRelayCursor(endpoint, cursor) {
185
- const state = deliveryCheckpoint(endpoint);
186
- // Concurrent confirmations of the same requested ACK are harmless.
187
- if (state.pending[cursor] === undefined) return;
188
- delete state.pending[cursor];
189
- if (state.requested) delete state.requested[cursor];
190
- const pending = Object.keys(state.pending);
191
- if (pending.length === 0) state.frontier = state.highWater;
192
- else if (BigInt(cursor) > BigInt(state.frontier) && pending.every(value => BigInt(value) > BigInt(cursor))) state.frontier = cursor;
193
- writeDeliveryCheckpoint(endpoint, state);
194
- store.setReceiveCursor(state.frontier);
195
- },
196
- putIntent(intentId, taskId, envelopeId) {
197
- database.query("INSERT INTO intents (intent_id, task_id, envelope_id) VALUES (?, ?, ?) ON CONFLICT(intent_id) DO NOTHING").run(intentId, taskId, envelopeId);
198
- },
199
- putInsertionReceipt(taskId, eventId) {
200
- const result = database.query("INSERT INTO insertion_receipts (task_id, event_id) VALUES (?, ?) ON CONFLICT(task_id, event_id) DO NOTHING").run(taskId, eventId);
201
- return result.changes === 1;
202
- },
203
- reserveTaskOperation(input) {
204
- const result = database.query(`INSERT INTO task_operations (task_id, operation, logical_id, logical_type, envelope_ids)
205
- VALUES (?, ?, ?, ?, ?) ON CONFLICT(task_id, operation) DO NOTHING`).run(input.taskId, input.operation, input.logicalId, input.logicalType, JSON.stringify(input.envelopeIds));
206
- const row = database.query("SELECT task_id, operation, logical_id, logical_type, envelope_ids FROM task_operations WHERE task_id = ? AND operation = ?").get(input.taskId, input.operation) as TaskOperationRow | null;
207
- if (!row) throw new Error("task operation reservation was not persisted");
208
- return { created: result.changes === 1, record: taskOperationFromRow(row) };
209
- },
210
- getEndpointGeneration() {
211
- const row = database.query("SELECT value FROM relay_state WHERE name = ?").get(ENDPOINT_GENERATION_STATE_KEY) as { readonly value: string } | null;
212
- return row?.value;
213
- },
214
- setEndpointGeneration(generation) {
215
- database.query("INSERT INTO relay_state (name, value) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET value = excluded.value").run(ENDPOINT_GENERATION_STATE_KEY, generation);
216
- },
217
- getEndpointBinding() {
218
- const row = database.query("SELECT value FROM relay_state WHERE name = ?").get(ENDPOINT_BINDING_STATE_KEY) as { readonly value: string } | null;
219
- return row ? parseEndpoint(row.value) : undefined;
220
- },
221
- setEndpointBinding(endpoint) {
222
- database.query("INSERT INTO relay_state (name, value) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET value = excluded.value").run(ENDPOINT_BINDING_STATE_KEY, JSON.stringify(endpoint));
223
- },
224
- close() { database.close(); },
225
- };
226
- function checkpointKey(endpoint: TaskEndpoint): string {
227
- const bound = store.getEndpointBinding();
228
- if (bound && (bound.relay !== endpoint.relay || bound.id !== endpoint.id)) throw new TaskProtocolError("RELAY_RESET", "delivery checkpoint belongs to a retired endpoint", { retryable: false });
229
- return `delivery_checkpoint:${JSON.stringify([endpoint.relay, endpoint.id])}`;
230
- }
231
- function deliveryCheckpoint(endpoint: TaskEndpoint): DeliveryCheckpoint {
232
- const row = database.query("SELECT value FROM relay_state WHERE name = ?").get(checkpointKey(endpoint)) as { readonly value: string } | null;
233
- if (row) return JSON.parse(row.value) as DeliveryCheckpoint;
234
- const frontier = store.getReceiveCursor();
235
- return { frontier, highWater: frontier, pending: {} };
236
- }
237
- function writeDeliveryCheckpoint(endpoint: TaskEndpoint, state: DeliveryCheckpoint): void {
238
- database.query("INSERT INTO relay_state (name, value) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET value = excluded.value").run(checkpointKey(endpoint), JSON.stringify(state));
239
- }
240
- return store;
241
- }
242
-
243
- interface DeliveryCheckpoint {
244
- frontier: string;
245
- highWater: string;
246
- pending: Record<string, string>;
247
- requested?: Record<string, true>;
248
- }
249
-
250
- interface TaskRow {
251
- readonly task_id: string;
252
- readonly protocol_version: string;
253
- readonly origin_relay: string;
254
- readonly origin_id: string;
255
- readonly target_relay: string;
256
- readonly target_id: string;
257
- readonly task: string;
258
- readonly created_at: number;
259
- readonly expires_at: number;
260
- readonly status: string;
261
- }
262
-
263
- interface OutboxQuarantineRow {
264
- readonly envelope: string;
265
- readonly error_code: string;
266
- readonly reason: string;
267
- readonly details: string;
268
- readonly quarantined_at: number;
269
- readonly prior_state: OutboxRecord["state"];
270
- }
271
-
272
- interface TaskOperationRow {
273
- readonly task_id: string;
274
- readonly operation: string;
275
- readonly logical_id: string;
276
- readonly logical_type: string;
277
- readonly envelope_ids: string;
278
- }
279
-
280
- interface EventRow {
281
- readonly event_id: string;
282
- readonly task_id: string;
283
- readonly type: string;
284
- readonly sequence: string;
285
- readonly source_relay: string;
286
- readonly source_id: string;
287
- readonly target_relay: string;
288
- readonly target_id: string;
289
- readonly occurred_at: number;
290
- readonly payload: string;
291
- }
292
-
293
- function initializeSchema(database: SqliteDatabase): void {
294
- const version = database.query("PRAGMA user_version").get() as { readonly user_version: number };
295
- if (version.user_version === SCHEMA_VERSION) return;
296
- if (version.user_version !== 0) throw new Error("task store schema version is unsupported");
297
- database.transaction(() => {
298
- database.exec(`
299
- CREATE TABLE tasks (task_id TEXT PRIMARY KEY, protocol_version TEXT NOT NULL, origin_relay TEXT NOT NULL, origin_id TEXT NOT NULL, target_relay TEXT NOT NULL, target_id TEXT NOT NULL, task TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, status TEXT NOT NULL);
300
- CREATE TABLE events (event_id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasks(task_id), type TEXT NOT NULL, sequence TEXT NOT NULL, source_relay TEXT NOT NULL, source_id TEXT NOT NULL, target_relay TEXT NOT NULL, target_id TEXT NOT NULL, occurred_at INTEGER NOT NULL, payload TEXT NOT NULL);
301
- CREATE TABLE outbox (envelope_id TEXT PRIMARY KEY, envelope TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('pending', 'accepted')));
302
- CREATE TABLE inbox (envelope_id TEXT PRIMARY KEY, cursor TEXT NOT NULL, envelope TEXT NOT NULL);
303
- CREATE TABLE intents (intent_id TEXT PRIMARY KEY, task_id TEXT NOT NULL, envelope_id TEXT NOT NULL);
304
- CREATE TABLE relay_state (name TEXT PRIMARY KEY, value TEXT NOT NULL);
305
- CREATE TABLE insertion_receipts (task_id TEXT NOT NULL REFERENCES tasks(task_id), event_id TEXT NOT NULL, PRIMARY KEY (task_id, event_id));
306
- CREATE TABLE outbox_quarantine (
307
- envelope_id TEXT PRIMARY KEY,
308
- envelope TEXT NOT NULL,
309
- reason TEXT NOT NULL,
310
- quarantined_at INTEGER NOT NULL,
311
- error_code TEXT NOT NULL,
312
- details TEXT NOT NULL,
313
- prior_state TEXT NOT NULL
314
- );
315
- CREATE TABLE task_operations (
316
- task_id TEXT NOT NULL REFERENCES tasks(task_id),
317
- operation TEXT NOT NULL,
318
- logical_id TEXT NOT NULL,
319
- logical_type TEXT NOT NULL,
320
- envelope_ids TEXT NOT NULL,
321
- PRIMARY KEY (task_id, operation)
322
- );
323
- CREATE INDEX tasks_origin_expiry ON tasks (origin_relay, origin_id, status, expires_at);
324
- CREATE INDEX inbox_cursor ON inbox (cursor);
325
- PRAGMA user_version = 5;
326
- `);
327
- })();
328
- }
329
-
330
- function preparePath(path: string): void {
331
- if (path === ":memory:") return;
332
- const directory = dirname(path);
333
- mkdirSync(directory, { recursive: true, mode: OWNER_ONLY_MODE });
334
- const directoryMode = statSync(directory).mode & 0o777;
335
- if ((directoryMode & 0o077) !== 0) throw new Error(`task store directory is not owner-only: ${directory}`);
336
- if (existsSync(path)) {
337
- const fileMode = statSync(path).mode & 0o777;
338
- if ((fileMode & 0o077) !== 0) throw new Error(`task store file is not owner-only: ${path}`);
339
- } else {
340
- writeFileSync(path, "", { mode: OWNER_ONLY_FILE_MODE });
341
- chmodSync(path, OWNER_ONLY_FILE_MODE);
342
- }
343
- }
344
-
345
- function taskRow(task: Omit<TaskRecord, "events">): Record<string, string | number> {
346
- return {
347
- taskId: task.taskId, protocolVersion: task.protocolVersion, originRelay: task.origin.relay, originId: task.origin.id,
348
- targetRelay: task.target.relay, targetId: task.target.id, task: task.task, createdAt: task.createdAt, expiresAt: task.expiresAt, status: task.status,
349
- };
350
- }
351
-
352
- function taskFromRow(database: SqliteDatabase, row: TaskRow, events: readonly EventRow[]): TaskSnapshot {
353
- return {
354
- taskId: row.task_id,
355
- protocolVersion: row.protocol_version,
356
- origin: { relay: row.origin_relay, id: row.origin_id },
357
- target: { relay: row.target_relay, id: row.target_id },
358
- task: row.task,
359
- createdAt: row.created_at,
360
- expiresAt: row.expires_at,
361
- status: row.status as TaskRecord["status"],
362
- events: events.map(eventFromRow),
363
- terminalDelivery: terminalDeliveryFromDatabase(database, row),
364
- };
365
- }
366
-
367
- function eventRow(event: TaskEvent): Record<string, string | number> {
368
- return {
369
- eventId: event.eventId, taskId: event.taskId, type: event.type, sequence: event.sequence,
370
- sourceRelay: event.source.relay, sourceId: event.source.id, targetRelay: event.target.relay, targetId: event.target.id,
371
- occurredAt: event.occurredAt, payload: JSON.stringify(event.payload),
372
- };
373
- }
374
-
375
- function eventFromRow(row: EventRow): TaskEvent {
376
- return {
377
- eventId: row.event_id, taskId: row.task_id, type: row.type, sequence: row.sequence,
378
- source: { relay: row.source_relay, id: row.source_id }, target: { relay: row.target_relay, id: row.target_id },
379
- occurredAt: row.occurred_at, payload: parseRecord(row.payload),
380
- };
381
- }
382
-
383
- function parseEnvelope(value: string): RelayEnvelope {
384
- return JSON.parse(value) as RelayEnvelope;
385
- }
386
-
387
- function taskOperationFromRow(row: TaskOperationRow): TaskOperationRecord {
388
- const envelopeIds = JSON.parse(row.envelope_ids) as unknown;
389
- if (!Array.isArray(envelopeIds) || !envelopeIds.every((value) => typeof value === "string" && value.length > 0)) throw new Error("task operation envelope identities are malformed");
390
- return { taskId: row.task_id, operation: row.operation, logicalId: row.logical_id, logicalType: row.logical_type, envelopeIds };
391
- }
392
-
393
- function terminalDeliveryFromDatabase(database: SqliteDatabase, task: TaskRow): TerminalDeliveryState {
394
- const operation = database.query("SELECT task_id, operation, logical_id, logical_type, envelope_ids FROM task_operations WHERE task_id = ? AND operation = ?").get(task.task_id, TERMINAL_INTENT_OPERATION) as TaskOperationRow | null;
395
- if (!operation) return { state: "not_submitted" };
396
- const record = taskOperationFromRow(operation);
397
- if (!isTerminalIntentType(record.logicalType) || record.envelopeIds.length !== 1) throw new Error("terminal task operation is malformed");
398
- const envelopeId = record.envelopeIds[0];
399
- if (envelopeId === undefined) throw new Error("terminal task operation has no envelope identity");
400
- const identity = {
401
- intentId: record.logicalId,
402
- intentType: record.logicalType,
403
- envelopeId,
404
- origin: { relay: task.origin_relay, id: task.origin_id },
405
- };
406
- const outbox = database.query("SELECT state FROM outbox WHERE envelope_id = ?").get(identity.envelopeId) as { readonly state: OutboxRecord["state"] } | null;
407
- if (outbox?.state === "pending") return { state: "pending", ...identity };
408
- if (outbox?.state === "accepted") return { state: "accepted", ...identity };
409
- const blocked = database.query("SELECT error_code, details, quarantined_at FROM outbox_quarantine WHERE envelope_id = ?").get(identity.envelopeId) as Pick<OutboxQuarantineRow, "error_code" | "details" | "quarantined_at"> | null;
410
- if (!blocked) throw new Error("terminal task operation has no durable delivery record");
411
- return {
412
- state: "delivery_blocked",
413
- ...identity,
414
- blockedAt: blocked.quarantined_at,
415
- error: { code: blocked.error_code, retryable: false, details: parseRecord(blocked.details) },
416
- };
417
- }
418
-
419
- function isTerminalIntentType(value: string): value is TerminalTaskIntentType {
420
- return value === "task.completed" || value === "task.failed" || value === "task.cancelled";
421
- }
422
-
423
- function parseEndpoint(value: string): TaskEndpoint {
424
- const endpoint = JSON.parse(value) as unknown;
425
- if (!isRecord(endpoint) || typeof endpoint.relay !== "string" || endpoint.relay.length === 0 || typeof endpoint.id !== "string" || endpoint.id.length === 0) {
426
- throw new Error("task store endpoint binding is malformed");
427
- }
428
- return { relay: endpoint.relay, id: endpoint.id };
429
- }
430
-
431
- function parseRecord(value: string): Record<string, unknown> {
432
- return JSON.parse(value) as Record<string, unknown>;
433
- }
434
-
435
- function isRecord(value: unknown): value is Record<string, unknown> {
436
- return value !== null && typeof value === "object" && !Array.isArray(value);
59
+ if (Object.keys(options).some(key => key !== "maxEntries" && key !== "maxBytes")) throw new TypeError("task state is memory-only; persistence options are unsupported");
60
+ const maxEntries = options.maxEntries ?? LIMITS.entries, maxBytes = options.maxBytes ?? LIMITS.bytes;
61
+ if (!Number.isInteger(maxEntries) || maxEntries < 1 || maxEntries > LIMITS.entries || !Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > LIMITS.bytes) throw new TypeError("invalid task memory limit");
62
+ const tables = new Map<string, Map<string, Cell>>();
63
+ const frames: Map<string, Undo>[] = [];
64
+ let bytes = 0, entries = 0, sequence = 0, closed = false;
65
+ const remember = (name: string, key: string, target: Map<string, Cell>, prior: Cell | undefined) => {
66
+ const frame = frames.at(-1), identity = JSON.stringify([name, key]);
67
+ if (frame && !frame.has(identity)) frame.set(identity, { table: target, key, prior });
68
+ };
69
+ const check = () => { if (closed) throw new TaskProtocolError("RELAY_CLOSED", "task state is closed", { retryable: false }); };
70
+ const table = (name: string) => { check(); let value = tables.get(name); if (!value) { value = new Map(); tables.set(name, value); } return value; };
71
+ const get = <T>(name: string, key: string): T | undefined => { const row = table(name).get(key); return row ? JSON.parse(row.text) as T : undefined; };
72
+ const rows = <T>(name: string): T[] => [...table(name).values()].sort((a, b) => a.order - b.order).map(row => JSON.parse(row.text) as T);
73
+ const put = (name: string, key: string, value: unknown, onlyNew = false): boolean => {
74
+ const target = table(name), prior = target.get(key);
75
+ if (onlyNew && prior) return false;
76
+ const text = JSON.stringify(value), size = Buffer.byteLength(text) + Buffer.byteLength(key) + 64;
77
+ if (bytes - (prior?.bytes ?? 0) + size > maxBytes || (!prior && entries >= maxEntries)) throw new TaskProtocolError("TASK_CAPACITY", "active task state is full", { retryable: false });
78
+ remember(name, key, target, prior);
79
+ target.set(key, { text, bytes: size, order: prior?.order ?? sequence++ });
80
+ bytes += size - (prior?.bytes ?? 0); if (!prior) entries++;
81
+ return true;
82
+ };
83
+ const remove = (name: string, key: string) => {
84
+ const target = table(name), prior = target.get(key); if (!prior) return;
85
+ remember(name, key, target, prior);
86
+ target.delete(key); bytes -= prior.bytes; entries--;
87
+ };
88
+ const pair = (a: string, b: string) => JSON.stringify([a, b]);
89
+ const requireTask = (id: string) => { if (!get("tasks", id)) throw new TaskProtocolError("TASK_NOT_FOUND", "task is not active in this endpoint lifetime"); };
90
+ const sortEvents = (a: TaskEvent, b: TaskEvent) => BigInt(a.sequence) < BigInt(b.sequence) ? -1 : BigInt(a.sequence) > BigInt(b.sequence) ? 1 : a.eventId.localeCompare(b.eventId);
91
+ const terminal = (task: Omit<TaskRecord, "events">): TerminalDeliveryState => {
92
+ const record = get<TaskOperationRecord>("operations", pair(task.taskId, TERMINAL_INTENT_OPERATION));
93
+ if (!record) return { state: "not_submitted" };
94
+ if (!["task.completed", "task.failed", "task.cancelled"].includes(record.logicalType) || record.envelopeIds.length !== 1) throw new Error("terminal operation is malformed");
95
+ const identity = { intentId: record.logicalId, intentType: record.logicalType as TerminalTaskIntentType, envelopeId: record.envelopeIds[0]!, origin: task.origin };
96
+ const outbox = get<OutboxRecord>("outbox", identity.envelopeId);
97
+ if (outbox) return { state: outbox.state, ...identity };
98
+ const blocked = get<OutboxQuarantineRecord>("quarantine", identity.envelopeId);
99
+ if (!blocked) throw new Error("terminal operation has no delivery record");
100
+ return { state: "delivery_blocked", ...identity, blockedAt: blocked.quarantinedAt, error: { code: blocked.errorCode, retryable: false, details: blocked.details } };
101
+ };
102
+ const snapshot = (task: Omit<TaskRecord, "events">, events: TaskEvent[]): TaskSnapshot => ({ ...task, events: events.sort(sortEvents), terminalDelivery: terminal(task) });
103
+ const state = <T>(key: string) => get<T>("state", key);
104
+ const checkpointKey = (endpoint: TaskEndpoint) => {
105
+ const bound = store.getEndpointBinding();
106
+ if (bound && (bound.relay !== endpoint.relay || bound.id !== endpoint.id)) throw new TaskProtocolError("RELAY_RESET", "delivery checkpoint belongs to a retired endpoint", { retryable: false });
107
+ return JSON.stringify([endpoint.relay, endpoint.id, store.getRelayTransportBinding()?.epoch]);
108
+ };
109
+ const checkpoint = (endpoint: TaskEndpoint): Checkpoint => get<Checkpoint>("checkpoints", checkpointKey(endpoint)) ?? { frontier: store.getReceiveCursor(), highWater: store.getReceiveCursor(), pending: {}, requested: {} };
110
+ const writeCheckpoint = (endpoint: TaskEndpoint, value: Checkpoint) => { put("checkpoints", checkpointKey(endpoint), value); };
111
+ const store: TaskStore = {
112
+ transaction<T>(operation: () => T): T {
113
+ check(); const priorBytes = bytes, priorEntries = entries, priorSequence = sequence;
114
+ const frame = new Map<string, Undo>(); frames.push(frame);
115
+ try {
116
+ const result = operation();
117
+ if (result instanceof Promise) throw new TypeError("task transactions must be synchronous");
118
+ const parent = frames.at(-2);
119
+ if (parent) for (const [key, item] of frame) if (!parent.has(key)) parent.set(key, item);
120
+ return result;
121
+ } catch (error) {
122
+ for (const item of [...frame.values()].reverse()) { if (item.prior) item.table.set(item.key, item.prior); else item.table.delete(item.key); }
123
+ bytes = priorBytes; entries = priorEntries; sequence = priorSequence;
124
+ throw error;
125
+ } finally { frames.pop(); }
126
+ },
127
+ putTask(task) { put("tasks", task.taskId, task, true); },
128
+ getTask(id) { const task = get<Omit<TaskRecord, "events">>("tasks", id); return task ? snapshot(task, rows<TaskEvent>("events").filter(event => event.taskId === id)) : undefined; },
129
+ listTasks() {
130
+ const events = new Map<string, TaskEvent[]>();
131
+ for (const event of rows<TaskEvent>("events")) { const list = events.get(event.taskId) ?? []; list.push(event); events.set(event.taskId, list); }
132
+ return rows<Omit<TaskRecord, "events">>("tasks").sort((a, b) => a.createdAt - b.createdAt || a.taskId.localeCompare(b.taskId)).map(task => snapshot(task, events.get(task.taskId) ?? []));
133
+ },
134
+ setStatus(id, status) { const task = get<Omit<TaskRecord, "events">>("tasks", id); if (task) put("tasks", id, { ...task, status }); },
135
+ appendEvent(event) { requireTask(event.taskId); return put("events", event.eventId, event, true); },
136
+ putOutbox(envelope) { put("outbox", envelope.envelopeId, { envelope, state: "pending" }, true); },
137
+ outbox(status) { return rows<OutboxRecord>("outbox").filter(item => item.state === status); },
138
+ markOutboxAccepted(id) { const prior = get<OutboxRecord>("outbox", id); if (prior) put("outbox", id, { ...prior, state: "accepted" }); },
139
+ quarantineOutbox(id, input) {
140
+ store.transaction(() => { const prior = get<OutboxRecord>("outbox", id); if (!prior) return;
141
+ put("quarantine", id, { ...input, envelope: prior.envelope, priorState: prior.state }, true); remove("outbox", id);
142
+ });
143
+ },
144
+ quarantinedOutbox() { return rows<OutboxQuarantineRecord>("quarantine"); },
145
+ persistInbox(envelope, cursor) { return put("inbox", envelope.envelopeId, { envelope, cursor }, true); },
146
+ getReceiveCursor() { return state<string>("cursor") ?? "0"; },
147
+ setReceiveCursor(cursor) { put("state", "cursor", cursor); },
148
+ trackRelayDeliveries(endpoint, deliveries) {
149
+ const value = checkpoint(endpoint), highWater = BigInt(value.highWater);
150
+ for (const delivery of deliveries) {
151
+ const prior = value.pending[delivery.cursor];
152
+ if (prior !== undefined && prior !== delivery.envelopeId) throw new TaskProtocolError("INVALID_CURSOR", "relay cursor changed envelope identity");
153
+ if (BigInt(delivery.cursor) > highWater) value.pending[delivery.cursor] = delivery.envelopeId;
154
+ if (BigInt(delivery.cursor) > BigInt(value.highWater)) value.highWater = delivery.cursor;
155
+ }
156
+ writeCheckpoint(endpoint, value);
157
+ },
158
+ pendingRelayEnvelopeId(endpoint, cursor) { return checkpoint(endpoint).pending[cursor]; },
159
+ requestRelayAcknowledgement(endpoint, cursor) {
160
+ const value = checkpoint(endpoint);
161
+ if (value.pending[cursor] === undefined) throw new TaskProtocolError("INVALID_CURSOR", "relay delivery is not pending in this endpoint scope");
162
+ value.requested[cursor] = true; writeCheckpoint(endpoint, value);
163
+ },
164
+ requestedRelayAcknowledgements(endpoint) { return Object.keys(checkpoint(endpoint).requested); },
165
+ acknowledgeRelayCursor(endpoint, cursor) {
166
+ store.transaction(() => {
167
+ const value = checkpoint(endpoint); if (value.pending[cursor] === undefined) return;
168
+ delete value.pending[cursor]; delete value.requested[cursor];
169
+ const pending = Object.keys(value.pending);
170
+ if (!pending.length) value.frontier = value.highWater;
171
+ else if (BigInt(cursor) > BigInt(value.frontier) && pending.every(other => BigInt(other) > BigInt(cursor))) value.frontier = cursor;
172
+ writeCheckpoint(endpoint, value); store.setReceiveCursor(value.frontier);
173
+ });
174
+ },
175
+ putIntent(intentId, taskId, envelopeId) { put("intents", intentId, { taskId, envelopeId }, true); },
176
+ putInsertionReceipt(taskId, eventId) { requireTask(taskId); return put("insertions", pair(taskId, eventId), true, true); },
177
+ reserveTaskOperation(input) { requireTask(input.taskId); const key = pair(input.taskId, input.operation); const created = put("operations", key, input, true); return { created, record: get<TaskOperationRecord>("operations", key)! }; },
178
+ getEndpointGeneration() { return state<string>("generation"); },
179
+ setEndpointGeneration(value) { put("state", "generation", value); },
180
+ getEndpointBinding() { return state<TaskEndpoint>("endpoint"); },
181
+ setEndpointBinding(value) { put("state", "endpoint", value); },
182
+ getRelayTransportBinding() { return state<RelayTransportBinding>("transport"); },
183
+ setRelayTransportBinding(value) { put("state", "transport", value); },
184
+ clear() { check(); if (frames.length) throw new Error("cannot clear task state during a transaction"); tables.clear(); bytes = entries = sequence = 0; },
185
+ close() { if (closed) return; store.clear(); closed = true; },
186
+ };
187
+ return store;
437
188
  }