@oatscenter/trellis 0.100.0-rc.1
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 +59 -0
- package/auth/authorization/client_context.d.ts +61 -0
- package/auth/authorization/client_context.js +266 -0
- package/auth/authorization/nats_registry.d.ts +41 -0
- package/auth/authorization/nats_registry.js +283 -0
- package/auth/authorization/provider_cache.d.ts +90 -0
- package/auth/authorization/provider_cache.js +783 -0
- package/auth/authorization/refresh.d.ts +46 -0
- package/auth/authorization/refresh.js +246 -0
- package/auth/authorization/types.d.ts +207 -0
- package/auth/authorization/types.js +60 -0
- package/auth/authorization_context.d.ts +11 -0
- package/auth/authorization_context.js +10 -0
- package/auth/browser/flow_types.d.ts +84 -0
- package/auth/browser/flow_types.js +70 -0
- package/auth/browser/portal.d.ts +21 -0
- package/auth/browser/portal.js +307 -0
- package/auth/browser/storage.d.ts +26 -0
- package/auth/browser/storage.js +224 -0
- package/auth/browser.d.ts +11 -0
- package/auth/browser.js +10 -0
- package/auth/browser_recovery.d.ts +13 -0
- package/auth/browser_recovery.js +38 -0
- package/auth/device_activation.d.ts +134 -0
- package/auth/device_activation.js +376 -0
- package/auth/http_error.d.ts +12 -0
- package/auth/http_error.js +32 -0
- package/auth/keys.d.ts +5 -0
- package/auth/keys.js +63 -0
- package/auth/mod.d.ts +16 -0
- package/auth/mod.js +16 -0
- package/auth/proof.d.ts +31 -0
- package/auth/proof.js +95 -0
- package/auth/protocol_wasm/trellis_protocol_wasm.d.ts +138 -0
- package/auth/protocol_wasm/trellis_protocol_wasm.js +605 -0
- package/auth/protocol_wasm/trellis_protocol_wasm_bg.wasm +0 -0
- package/auth/protocol_wasm/trellis_protocol_wasm_bytes.d.ts +1 -0
- package/auth/protocol_wasm/trellis_protocol_wasm_bytes.js +2 -0
- package/auth/protocol_wasm.d.ts +179 -0
- package/auth/protocol_wasm.js +133 -0
- package/auth/session_auth.d.ts +30 -0
- package/auth/session_auth.js +77 -0
- package/auth/session_proof.d.ts +53 -0
- package/auth/session_proof.js +43 -0
- package/auth/time.d.ts +6 -0
- package/auth/time.js +7 -0
- package/auth/trellis_id.d.ts +1 -0
- package/auth/trellis_id.js +5 -0
- package/auth/utils.d.ts +10 -0
- package/auth/utils.js +69 -0
- package/auth.d.ts +1 -0
- package/auth.js +1 -0
- package/caller.d.ts +111 -0
- package/caller.js +85 -0
- package/client.d.ts +14 -0
- package/client.js +1 -0
- package/client_connect.d.ts +143 -0
- package/client_connect.js +1097 -0
- package/codec.d.ts +13 -0
- package/codec.js +192 -0
- package/connection.d.ts +107 -0
- package/connection.js +396 -0
- package/device/activation.d.ts +42 -0
- package/device/activation.js +319 -0
- package/device.d.ts +80 -0
- package/device.js +687 -0
- package/env.d.ts +2 -0
- package/env.js +10 -0
- package/errors/AuthError.d.ts +30 -0
- package/errors/AuthError.js +34 -0
- package/errors/KVError.d.ts +30 -0
- package/errors/KVError.js +36 -0
- package/errors/OperationLifecycleError.d.ts +110 -0
- package/errors/OperationLifecycleError.js +142 -0
- package/errors/RemoteError.d.ts +61 -0
- package/errors/RemoteError.js +72 -0
- package/errors/SchemaValidationError.d.ts +48 -0
- package/errors/SchemaValidationError.js +49 -0
- package/errors/StoreError.d.ts +21 -0
- package/errors/StoreError.js +31 -0
- package/errors/TransferError.d.ts +21 -0
- package/errors/TransferError.js +31 -0
- package/errors/TransportError.d.ts +29 -0
- package/errors/TransportError.js +33 -0
- package/errors/TrellisError.d.ts +15 -0
- package/errors/TrellisError.js +15 -0
- package/errors/ValidationError.d.ts +50 -0
- package/errors/ValidationError.js +59 -0
- package/errors/index.d.ts +254 -0
- package/errors/index.js +238 -0
- package/generated.d.ts +4 -0
- package/generated.js +3 -0
- package/generated_support.d.ts +120 -0
- package/generated_support.js +359 -0
- package/globals.d.ts +9 -0
- package/globals.js +16 -0
- package/health_transport.d.ts +18 -0
- package/health_transport.js +32 -0
- package/helpers.d.ts +11 -0
- package/helpers.js +53 -0
- package/index.d.ts +2 -0
- package/index.js +2 -0
- package/internal_sdk/generated/apis/auth/mod.d.ts +805 -0
- package/internal_sdk/generated/apis/auth/mod.js +958 -0
- package/internal_sdk/generated/apis/core/mod.d.ts +79 -0
- package/internal_sdk/generated/apis/core/mod.js +93 -0
- package/internal_sdk/generated/apis/events/mod.d.ts +224 -0
- package/internal_sdk/generated/apis/events/mod.js +313 -0
- package/internal_sdk/generated/apis/health/mod.d.ts +106 -0
- package/internal_sdk/generated/apis/health/mod.js +132 -0
- package/internal_sdk/generated/apis/index.d.ts +7 -0
- package/internal_sdk/generated/apis/index.js +8 -0
- package/internal_sdk/generated/apis/jobs/mod.d.ts +176 -0
- package/internal_sdk/generated/apis/jobs/mod.js +209 -0
- package/internal_sdk/generated/apis/state/mod.d.ts +104 -0
- package/internal_sdk/generated/apis/state/mod.js +139 -0
- package/internal_sdk/generated/index.d.ts +4 -0
- package/internal_sdk/generated/index.js +5 -0
- package/internal_sdk/generated/package.json +18 -0
- package/internal_sdk/generated/participants/cli/mod.d.ts +402 -0
- package/internal_sdk/generated/participants/cli/mod.js +460 -0
- package/internal_sdk/generated/participants/cli/types.d.ts +2 -0
- package/internal_sdk/generated/participants/cli/types.js +3 -0
- package/internal_sdk/generated/participants/console/mod.d.ts +489 -0
- package/internal_sdk/generated/participants/console/mod.js +579 -0
- package/internal_sdk/generated/participants/console/types.d.ts +2 -0
- package/internal_sdk/generated/participants/console/types.js +3 -0
- package/internal_sdk/generated/participants/eventsRuntime/mod.d.ts +64 -0
- package/internal_sdk/generated/participants/eventsRuntime/mod.js +42 -0
- package/internal_sdk/generated/participants/eventsRuntime/types.d.ts +2 -0
- package/internal_sdk/generated/participants/eventsRuntime/types.js +3 -0
- package/internal_sdk/generated/participants/healthRuntime/mod.d.ts +58 -0
- package/internal_sdk/generated/participants/healthRuntime/mod.js +36 -0
- package/internal_sdk/generated/participants/healthRuntime/types.d.ts +2 -0
- package/internal_sdk/generated/participants/healthRuntime/types.js +3 -0
- package/internal_sdk/generated/participants/index.d.ts +8 -0
- package/internal_sdk/generated/participants/index.js +9 -0
- package/internal_sdk/generated/participants/jobsRuntime/mod.d.ts +64 -0
- package/internal_sdk/generated/participants/jobsRuntime/mod.js +42 -0
- package/internal_sdk/generated/participants/jobsRuntime/types.d.ts +2 -0
- package/internal_sdk/generated/participants/jobsRuntime/types.js +3 -0
- package/internal_sdk/generated/participants/platform/mod.d.ts +164 -0
- package/internal_sdk/generated/participants/platform/mod.js +149 -0
- package/internal_sdk/generated/participants/platform/types.d.ts +7 -0
- package/internal_sdk/generated/participants/platform/types.js +5 -0
- package/internal_sdk/generated/participants/portal/mod.d.ts +131 -0
- package/internal_sdk/generated/participants/portal/mod.js +109 -0
- package/internal_sdk/generated/participants/portal/types.d.ts +2 -0
- package/internal_sdk/generated/participants/portal/types.js +3 -0
- package/internal_sdk/generated/types/_internal/p0.d.ts +13511 -0
- package/internal_sdk/generated/types/_internal/p0.js +6105 -0
- package/internal_sdk/generated/types/index.d.ts +2951 -0
- package/internal_sdk/generated/types/index.js +1477 -0
- package/jobs.d.ts +309 -0
- package/jobs.js +389 -0
- package/kv.d.ts +85 -0
- package/kv.js +330 -0
- package/operations.d.ts +189 -0
- package/operations.js +847 -0
- package/package.json +73 -0
- package/pagination.d.ts +25 -0
- package/pagination.js +117 -0
- package/participant.d.ts +6 -0
- package/participant.js +6 -0
- package/participant_runtime/api.d.ts +131 -0
- package/participant_runtime/api.js +42 -0
- package/participant_runtime/json.d.ts +16 -0
- package/participant_runtime/json.js +193 -0
- package/participant_runtime/metadata.d.ts +34 -0
- package/participant_runtime/metadata.js +4 -0
- package/participant_runtime/participant.d.ts +200 -0
- package/participant_runtime/participant.js +330 -0
- package/participant_runtime/schemas.d.ts +347 -0
- package/participant_runtime/schemas.js +211 -0
- package/participant_runtime/surface_names.d.ts +16 -0
- package/participant_runtime/surface_names.js +34 -0
- package/portable.d.ts +30 -0
- package/portable.js +14 -0
- package/provider.d.ts +69 -0
- package/provider.js +68 -0
- package/runtime_transport.d.ts +25 -0
- package/runtime_transport.js +48 -0
- package/service/mod.d.ts +14 -0
- package/service/mod.js +13 -0
- package/service/outbox_inbox.d.ts +259 -0
- package/service/outbox_inbox.js +908 -0
- package/service/runtime/bootstrap.d.ts +56 -0
- package/service/runtime/bootstrap.js +226 -0
- package/service/runtime/core.d.ts +46 -0
- package/service/runtime/core.js +1747 -0
- package/service/runtime/health.d.ts +97 -0
- package/service/runtime/health.js +153 -0
- package/service/runtime/health_schemas.d.ts +23 -0
- package/service/runtime/health_schemas.js +20 -0
- package/service/runtime/internal_jobs/active-job.d.ts +40 -0
- package/service/runtime/internal_jobs/active-job.js +91 -0
- package/service/runtime/internal_jobs/bindings.d.ts +40 -0
- package/service/runtime/internal_jobs/bindings.js +1 -0
- package/service/runtime/internal_jobs/cancellation-registry.d.ts +13 -0
- package/service/runtime/internal_jobs/cancellation-registry.js +55 -0
- package/service/runtime/internal_jobs/heartbeat.d.ts +33 -0
- package/service/runtime/internal_jobs/heartbeat.js +65 -0
- package/service/runtime/internal_jobs/job-manager.d.ts +126 -0
- package/service/runtime/internal_jobs/job-manager.js +821 -0
- package/service/runtime/internal_jobs/key-coordinator.d.ts +280 -0
- package/service/runtime/internal_jobs/key-coordinator.js +747 -0
- package/service/runtime/internal_jobs/projection.d.ts +3 -0
- package/service/runtime/internal_jobs/projection.js +42 -0
- package/service/runtime/internal_jobs/runtime-worker.d.ts +126 -0
- package/service/runtime/internal_jobs/runtime-worker.js +454 -0
- package/service/runtime/internal_jobs/types.d.ts +246 -0
- package/service/runtime/internal_jobs/types.js +163 -0
- package/service/runtime/logger.d.ts +2 -0
- package/service/runtime/logger.js +7 -0
- package/service/runtime/runtime.d.ts +16 -0
- package/service/runtime/runtime.js +1 -0
- package/service/runtime/service.d.ts +372 -0
- package/service/runtime/service.js +1896 -0
- package/service/runtime/transfer/download.d.ts +27 -0
- package/service/runtime/transfer/download.js +1 -0
- package/service/runtime/transfer/protocol.d.ts +15 -0
- package/service/runtime/transfer/protocol.js +46 -0
- package/service/runtime/transfer/queue.d.ts +18 -0
- package/service/runtime/transfer/queue.js +136 -0
- package/service/runtime/transfer/upload.d.ts +40 -0
- package/service/runtime/transfer/upload.js +27 -0
- package/service/runtime/transfer.d.ts +62 -0
- package/service/runtime/transfer.js +763 -0
- package/session.d.ts +862 -0
- package/session.js +3407 -0
- package/store.d.ts +71 -0
- package/store.js +412 -0
- package/store_entry.d.ts +20 -0
- package/store_entry.js +75 -0
- package/tasks.d.ts +10 -0
- package/tasks.js +28 -0
- package/telemetry/carrier.d.ts +8 -0
- package/telemetry/carrier.js +20 -0
- package/telemetry/core.d.ts +7 -0
- package/telemetry/core.js +14 -0
- package/telemetry/env.d.ts +2 -0
- package/telemetry/env.js +10 -0
- package/telemetry/init.d.ts +2 -0
- package/telemetry/init.js +7 -0
- package/telemetry/metrics.d.ts +81 -0
- package/telemetry/metrics.js +267 -0
- package/telemetry/mod.d.ts +13 -0
- package/telemetry/mod.js +8 -0
- package/telemetry/nats.d.ts +6 -0
- package/telemetry/nats.js +6 -0
- package/telemetry/result.d.ts +1 -0
- package/telemetry/result.js +18 -0
- package/telemetry/runtime.d.ts +1 -0
- package/telemetry/runtime.js +141 -0
- package/telemetry/trace.d.ts +4 -0
- package/telemetry/trace.js +26 -0
- package/telemetry/trellis.d.ts +1 -0
- package/telemetry/trellis.js +1 -0
- package/telemetry.d.ts +1 -0
- package/telemetry.js +1 -0
- package/transfer.d.ts +112 -0
- package/transfer.js +525 -0
- package/transfer_protocol.d.ts +1 -0
- package/transfer_protocol.js +13 -0
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
import { isErr, } from "@oatscenter/result";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { recordTrellisError } from "../telemetry/mod.js";
|
|
4
|
+
const outboxClaimMs = 30_000;
|
|
5
|
+
/** Raised when an outbox ID is reused for different immutable content. */
|
|
6
|
+
export class OutboxDuplicateIdentityError extends Error {
|
|
7
|
+
id;
|
|
8
|
+
constructor(id) {
|
|
9
|
+
super(`Outbox message '${id}' already exists with different immutable content`);
|
|
10
|
+
this.id = id;
|
|
11
|
+
this.name = "OutboxDuplicateIdentityError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Raised when storage reports a duplicate but the winning row cannot be read. */
|
|
15
|
+
export class OutboxStorageConflictError extends Error {
|
|
16
|
+
id;
|
|
17
|
+
constructor(id) {
|
|
18
|
+
super(`Outbox message '${id}' conflicted but no stored row was found`);
|
|
19
|
+
this.id = id;
|
|
20
|
+
this.name = "OutboxStorageConflictError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Default Trellis helper-table names for SQL outbox and inbox storage. */
|
|
24
|
+
export const defaultSqlOutboxTables = Object.freeze({
|
|
25
|
+
outbox: "trellis_outbox",
|
|
26
|
+
inbox: "trellis_inbox",
|
|
27
|
+
});
|
|
28
|
+
const sqlIdentifierPattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
29
|
+
/**
|
|
30
|
+
* Creates SQL outbox/inbox repositories plus current helper-table DDL.
|
|
31
|
+
*
|
|
32
|
+
* Use {@link getSqlOutboxMigrations} when services need versioned Trellis-owned
|
|
33
|
+
* migration artifacts for their normal database migration tooling.
|
|
34
|
+
*/
|
|
35
|
+
export function createSqlOutboxAdapter(executor, dialect, tables = defaultSqlOutboxTables) {
|
|
36
|
+
const sqlTables = validateSqlOutboxTables(tables);
|
|
37
|
+
return {
|
|
38
|
+
outbox: new SqlOutboxRepository(executor, dialect, sqlTables),
|
|
39
|
+
inbox: new SqlInboxRepository(executor, dialect, sqlTables),
|
|
40
|
+
ddl: dialect === "postgres"
|
|
41
|
+
? createPostgresOutboxSchema(sqlTables)
|
|
42
|
+
: createSqliteOutboxSchema(sqlTables),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Returns Trellis-owned SQL outbox/inbox migration artifacts for a dialect.
|
|
47
|
+
*
|
|
48
|
+
* Trellis generates the helper-table SQL, but services remain responsible for
|
|
49
|
+
* running these artifacts through their normal database migration tooling.
|
|
50
|
+
*/
|
|
51
|
+
export function getSqlOutboxMigrations(options) {
|
|
52
|
+
const tables = resolveSqlOutboxTables(options.tables);
|
|
53
|
+
const up = options.dialect === "postgres"
|
|
54
|
+
? createPostgresOutboxSchema(tables)
|
|
55
|
+
: createSqliteOutboxSchema(tables);
|
|
56
|
+
const down = createSqlOutboxDownSchema(tables);
|
|
57
|
+
return Object.freeze([
|
|
58
|
+
Object.freeze({
|
|
59
|
+
id: `trellis_sql_outbox_inbox_${options.dialect}_v1`,
|
|
60
|
+
version: 1,
|
|
61
|
+
dialect: options.dialect,
|
|
62
|
+
up,
|
|
63
|
+
down,
|
|
64
|
+
checksum: checksumMigrationSql(up, down),
|
|
65
|
+
}),
|
|
66
|
+
]);
|
|
67
|
+
}
|
|
68
|
+
/** Returns SQLite DDL for Trellis outbox and inbox tables. */
|
|
69
|
+
export function createSqliteOutboxSchema(tables = defaultSqlOutboxTables) {
|
|
70
|
+
const sqlTables = validateSqlOutboxTables(tables);
|
|
71
|
+
return [
|
|
72
|
+
`CREATE TABLE IF NOT EXISTS ${sqlTables.outbox} (id TEXT PRIMARY KEY, kind TEXT NOT NULL, name TEXT NOT NULL, subject TEXT NOT NULL, payload TEXT NOT NULL, headers TEXT NOT NULL, state TEXT NOT NULL, attempts INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, next_attempt_at TEXT, last_error TEXT, outcome TEXT)`,
|
|
73
|
+
`CREATE INDEX IF NOT EXISTS ${sqlTables.outbox}_due_idx ON ${sqlTables.outbox} (state, next_attempt_at)`,
|
|
74
|
+
`CREATE TABLE IF NOT EXISTS ${sqlTables.inbox} (message_id TEXT PRIMARY KEY, received_at TEXT NOT NULL)`,
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
/** Returns Postgres DDL for Trellis outbox and inbox tables. */
|
|
78
|
+
export function createPostgresOutboxSchema(tables = defaultSqlOutboxTables) {
|
|
79
|
+
const sqlTables = validateSqlOutboxTables(tables);
|
|
80
|
+
return [
|
|
81
|
+
`CREATE TABLE IF NOT EXISTS ${sqlTables.outbox} (id text PRIMARY KEY, kind text NOT NULL, name text NOT NULL, subject text NOT NULL, payload text NOT NULL, headers jsonb NOT NULL, state text NOT NULL, attempts integer NOT NULL, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, next_attempt_at timestamptz, last_error text, outcome jsonb)`,
|
|
82
|
+
`CREATE INDEX IF NOT EXISTS ${sqlTables.outbox}_due_idx ON ${sqlTables.outbox} (state, next_attempt_at)`,
|
|
83
|
+
`CREATE TABLE IF NOT EXISTS ${sqlTables.inbox} (message_id text PRIMARY KEY, received_at timestamptz NOT NULL)`,
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
function createSqlOutboxDownSchema(tables) {
|
|
87
|
+
const sqlTables = validateSqlOutboxTables(tables);
|
|
88
|
+
return [
|
|
89
|
+
`DROP INDEX IF EXISTS ${sqlTables.outbox}_due_idx`,
|
|
90
|
+
`DROP TABLE IF EXISTS ${sqlTables.inbox}`,
|
|
91
|
+
`DROP TABLE IF EXISTS ${sqlTables.outbox}`,
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
function resolveSqlOutboxTables(tables) {
|
|
95
|
+
return validateSqlOutboxTables({
|
|
96
|
+
outbox: tables?.outbox ?? defaultSqlOutboxTables.outbox,
|
|
97
|
+
inbox: tables?.inbox ?? defaultSqlOutboxTables.inbox,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function validateSqlOutboxTables(tables) {
|
|
101
|
+
validateSqlIdentifier("outbox", tables.outbox);
|
|
102
|
+
validateSqlIdentifier("inbox", tables.inbox);
|
|
103
|
+
return Object.freeze({ outbox: tables.outbox, inbox: tables.inbox });
|
|
104
|
+
}
|
|
105
|
+
function validateSqlIdentifier(kind, name) {
|
|
106
|
+
if (!sqlIdentifierPattern.test(name)) {
|
|
107
|
+
throw new Error(`Invalid SQL ${kind} table name "${name}". Use a simple identifier matching ${sqlIdentifierPattern.source}.`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function checksumMigrationSql(up, down) {
|
|
111
|
+
return `fnv1a64:${fnv1a64Hex([
|
|
112
|
+
"-- up",
|
|
113
|
+
...up,
|
|
114
|
+
"-- down",
|
|
115
|
+
...down,
|
|
116
|
+
].join("\n"))}`;
|
|
117
|
+
}
|
|
118
|
+
function fnv1a64Hex(input) {
|
|
119
|
+
let hash = 0xcbf29ce484222325n;
|
|
120
|
+
const prime = 0x100000001b3n;
|
|
121
|
+
for (const character of input) {
|
|
122
|
+
hash ^= BigInt(character.codePointAt(0) ?? 0);
|
|
123
|
+
hash = BigInt.asUintN(64, hash * prime);
|
|
124
|
+
}
|
|
125
|
+
return hash.toString(16).padStart(16, "0");
|
|
126
|
+
}
|
|
127
|
+
/** In-memory outbox repository intended for tests and local process adapters. */
|
|
128
|
+
export class MemoryOutboxRepository {
|
|
129
|
+
#messages = new Map();
|
|
130
|
+
async enqueue(record) {
|
|
131
|
+
const now = new Date().toISOString();
|
|
132
|
+
const id = record.id;
|
|
133
|
+
const existing = this.#messages.get(id);
|
|
134
|
+
if (existing) {
|
|
135
|
+
assertOutboxIdentity(existing, record);
|
|
136
|
+
return { ...existing, headers: { ...existing.headers } };
|
|
137
|
+
}
|
|
138
|
+
const message = {
|
|
139
|
+
id,
|
|
140
|
+
kind: record.kind,
|
|
141
|
+
name: record.name,
|
|
142
|
+
subject: record.subject,
|
|
143
|
+
payload: record.payload,
|
|
144
|
+
headers: { ...record.headers },
|
|
145
|
+
state: "pending",
|
|
146
|
+
attempts: 0,
|
|
147
|
+
createdAt: now,
|
|
148
|
+
updatedAt: now,
|
|
149
|
+
};
|
|
150
|
+
this.#messages.set(id, message);
|
|
151
|
+
return message;
|
|
152
|
+
}
|
|
153
|
+
get(id) {
|
|
154
|
+
const message = this.#messages.get(id);
|
|
155
|
+
return Promise.resolve(message ? { ...message, headers: { ...message.headers } } : undefined);
|
|
156
|
+
}
|
|
157
|
+
async claimDue(limit, now) {
|
|
158
|
+
const dueAt = now.toISOString();
|
|
159
|
+
const claimed = [];
|
|
160
|
+
for (const message of this.#messages.values()) {
|
|
161
|
+
if (claimed.length >= limit)
|
|
162
|
+
break;
|
|
163
|
+
if (message.state === "dispatched")
|
|
164
|
+
continue;
|
|
165
|
+
if (message.nextAttemptAt !== undefined && message.nextAttemptAt > dueAt) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
message.state = "claimed";
|
|
169
|
+
message.attempts += 1;
|
|
170
|
+
message.updatedAt = dueAt;
|
|
171
|
+
message.nextAttemptAt = new Date(now.getTime() + outboxClaimMs)
|
|
172
|
+
.toISOString();
|
|
173
|
+
claimed.push({ ...message, headers: { ...message.headers } });
|
|
174
|
+
}
|
|
175
|
+
return claimed;
|
|
176
|
+
}
|
|
177
|
+
async markDispatched(claim, now, outcome) {
|
|
178
|
+
const message = this.#messages.get(claim.id);
|
|
179
|
+
if (message?.state !== "claimed" || message.attempts !== claim.attempts) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
this.#messages.set(claim.id, {
|
|
183
|
+
...message,
|
|
184
|
+
state: "dispatched",
|
|
185
|
+
updatedAt: now.toISOString(),
|
|
186
|
+
nextAttemptAt: undefined,
|
|
187
|
+
lastError: undefined,
|
|
188
|
+
outcome: outcome ?? message.outcome,
|
|
189
|
+
});
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
async markFailed(claim, failure) {
|
|
193
|
+
const message = this.#messages.get(claim.id);
|
|
194
|
+
if (message?.state !== "claimed" || message.attempts !== claim.attempts) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
this.#messages.set(claim.id, {
|
|
198
|
+
...message,
|
|
199
|
+
state: "failed",
|
|
200
|
+
updatedAt: failure.now.toISOString(),
|
|
201
|
+
nextAttemptAt: failure.nextAttemptAt.toISOString(),
|
|
202
|
+
lastError: failure.error,
|
|
203
|
+
});
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
snapshot() {
|
|
207
|
+
return Array.from(this.#messages.values()).map((message) => ({
|
|
208
|
+
...message,
|
|
209
|
+
headers: { ...message.headers },
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** In-memory inbox repository intended for duplicate-suppression tests. */
|
|
214
|
+
export class MemoryInboxRepository {
|
|
215
|
+
#seen = new Set();
|
|
216
|
+
async record(messageId) {
|
|
217
|
+
if (this.#seen.has(messageId))
|
|
218
|
+
return false;
|
|
219
|
+
this.#seen.add(messageId);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** SQL-backed outbox repository over a caller-owned executor. */
|
|
224
|
+
export class SqlOutboxRepository {
|
|
225
|
+
executor;
|
|
226
|
+
dialect;
|
|
227
|
+
tables;
|
|
228
|
+
constructor(executor, dialect, tables = defaultSqlOutboxTables) {
|
|
229
|
+
this.executor = executor;
|
|
230
|
+
this.dialect = dialect;
|
|
231
|
+
this.tables = validateSqlOutboxTables(tables);
|
|
232
|
+
}
|
|
233
|
+
async enqueue(record) {
|
|
234
|
+
const now = new Date().toISOString();
|
|
235
|
+
const message = {
|
|
236
|
+
id: record.id,
|
|
237
|
+
kind: record.kind,
|
|
238
|
+
name: record.name,
|
|
239
|
+
subject: record.subject,
|
|
240
|
+
payload: record.payload,
|
|
241
|
+
headers: { ...record.headers },
|
|
242
|
+
state: "pending",
|
|
243
|
+
attempts: 0,
|
|
244
|
+
createdAt: now,
|
|
245
|
+
updatedAt: now,
|
|
246
|
+
};
|
|
247
|
+
const headers = JSON.stringify(message.headers);
|
|
248
|
+
const conflict = this.dialect === "postgres"
|
|
249
|
+
? "ON CONFLICT (id) DO NOTHING"
|
|
250
|
+
: "ON CONFLICT(id) DO NOTHING";
|
|
251
|
+
const inserted = await this.executor.query(`INSERT INTO ${this.tables.outbox} (id, kind, name, subject, payload, headers, state, attempts, created_at, updated_at, next_attempt_at, last_error, outcome) VALUES (${placeholders(this.dialect, 13)}) ${conflict} RETURNING *`, [
|
|
252
|
+
message.id,
|
|
253
|
+
message.kind,
|
|
254
|
+
message.name,
|
|
255
|
+
message.subject,
|
|
256
|
+
message.payload,
|
|
257
|
+
headers,
|
|
258
|
+
message.state,
|
|
259
|
+
message.attempts,
|
|
260
|
+
message.createdAt,
|
|
261
|
+
message.updatedAt,
|
|
262
|
+
null,
|
|
263
|
+
null,
|
|
264
|
+
null,
|
|
265
|
+
]);
|
|
266
|
+
if (inserted[0])
|
|
267
|
+
return rowToOutboxMessage(inserted[0]);
|
|
268
|
+
const existing = await this.get(record.id);
|
|
269
|
+
if (!existing)
|
|
270
|
+
throw new OutboxStorageConflictError(record.id);
|
|
271
|
+
assertOutboxIdentity(existing, record);
|
|
272
|
+
return existing;
|
|
273
|
+
}
|
|
274
|
+
async get(id) {
|
|
275
|
+
const rows = await this.executor.query(`SELECT id, kind, name, subject, payload, headers, state, attempts, created_at, updated_at, next_attempt_at, last_error, outcome FROM ${this.tables.outbox} WHERE id = ${placeholder(this.dialect, 1)} LIMIT 1`, [id]);
|
|
276
|
+
return rows[0] ? rowToOutboxMessage(rows[0]) : undefined;
|
|
277
|
+
}
|
|
278
|
+
async claimDue(limit, now) {
|
|
279
|
+
const rows = await this.executor.query(`UPDATE ${this.tables.outbox} SET state = ${placeholder(this.dialect, 1)}, attempts = attempts + 1, updated_at = ${placeholder(this.dialect, 2)}, next_attempt_at = ${placeholder(this.dialect, 3)} WHERE id IN (
|
|
280
|
+
SELECT id FROM ${this.tables.outbox} WHERE state != ${placeholder(this.dialect, 4)}
|
|
281
|
+
AND (next_attempt_at IS NULL OR next_attempt_at <= ${placeholder(this.dialect, 5)})
|
|
282
|
+
ORDER BY created_at LIMIT ${placeholder(this.dialect, 6)}${this.dialect === "postgres" ? " FOR UPDATE SKIP LOCKED" : ""}
|
|
283
|
+
) RETURNING *`, [
|
|
284
|
+
"claimed",
|
|
285
|
+
now.toISOString(),
|
|
286
|
+
new Date(now.getTime() + outboxClaimMs).toISOString(),
|
|
287
|
+
"dispatched",
|
|
288
|
+
now.toISOString(),
|
|
289
|
+
Math.max(0, limit),
|
|
290
|
+
]);
|
|
291
|
+
return rows.map(rowToOutboxMessage);
|
|
292
|
+
}
|
|
293
|
+
async markDispatched(claim, now, outcome) {
|
|
294
|
+
const outcomeJson = outcome !== undefined ? JSON.stringify(outcome) : null;
|
|
295
|
+
const rows = await this.executor.query(`UPDATE ${this.tables.outbox} SET state = ${placeholder(this.dialect, 1)}, updated_at = ${placeholder(this.dialect, 2)}, next_attempt_at = NULL, last_error = NULL, outcome = ${placeholder(this.dialect, 3)} WHERE id = ${placeholder(this.dialect, 4)} AND state = 'claimed' AND attempts = ${placeholder(this.dialect, 5)} RETURNING id`, ["dispatched", now.toISOString(), outcomeJson, claim.id, claim.attempts]);
|
|
296
|
+
return rows.length === 1;
|
|
297
|
+
}
|
|
298
|
+
async markFailed(claim, failure) {
|
|
299
|
+
const rows = await this.executor.query(`UPDATE ${this.tables.outbox} SET state = ${placeholder(this.dialect, 1)}, updated_at = ${placeholder(this.dialect, 2)}, next_attempt_at = ${placeholder(this.dialect, 3)}, last_error = ${placeholder(this.dialect, 4)} WHERE id = ${placeholder(this.dialect, 5)} AND state = 'claimed' AND attempts = ${placeholder(this.dialect, 6)} RETURNING id`, [
|
|
300
|
+
"failed",
|
|
301
|
+
failure.now.toISOString(),
|
|
302
|
+
failure.nextAttemptAt.toISOString(),
|
|
303
|
+
failure.error,
|
|
304
|
+
claim.id,
|
|
305
|
+
claim.attempts,
|
|
306
|
+
]);
|
|
307
|
+
return rows.length === 1;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** SQL-backed inbox repository over a caller-owned executor. */
|
|
311
|
+
export class SqlInboxRepository {
|
|
312
|
+
executor;
|
|
313
|
+
dialect;
|
|
314
|
+
tables;
|
|
315
|
+
constructor(executor, dialect, tables = defaultSqlOutboxTables) {
|
|
316
|
+
this.executor = executor;
|
|
317
|
+
this.dialect = dialect;
|
|
318
|
+
this.tables = validateSqlOutboxTables(tables);
|
|
319
|
+
}
|
|
320
|
+
async record(messageId, now = new Date()) {
|
|
321
|
+
const inserted = await this.executor.query(`INSERT INTO ${this.tables.inbox} (message_id, received_at) VALUES (${placeholders(this.dialect, 2)})
|
|
322
|
+
ON CONFLICT(message_id) DO NOTHING RETURNING message_id`, [messageId, now.toISOString()]);
|
|
323
|
+
return inserted.length === 1;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const KvInboxRecordSchema = Type.Object({
|
|
327
|
+
messageId: Type.String(),
|
|
328
|
+
receivedAt: Type.String(),
|
|
329
|
+
});
|
|
330
|
+
export const KvOutboxRecordSchema = Type.Object({
|
|
331
|
+
id: Type.String(),
|
|
332
|
+
kind: Type.String(),
|
|
333
|
+
name: Type.String(),
|
|
334
|
+
subject: Type.String(),
|
|
335
|
+
payload: Type.String(),
|
|
336
|
+
headers: Type.Record(Type.String(), Type.String()),
|
|
337
|
+
state: Type.Union([
|
|
338
|
+
Type.Literal("pending"),
|
|
339
|
+
Type.Literal("dispatched"),
|
|
340
|
+
Type.Literal("failed"),
|
|
341
|
+
Type.Literal("claimed"),
|
|
342
|
+
]),
|
|
343
|
+
attempts: Type.Number(),
|
|
344
|
+
createdAt: Type.String(),
|
|
345
|
+
updatedAt: Type.String(),
|
|
346
|
+
nextAttemptAt: Type.Optional(Type.String()),
|
|
347
|
+
lastError: Type.Optional(Type.String()),
|
|
348
|
+
outcome: Type.Optional(Type.String()),
|
|
349
|
+
});
|
|
350
|
+
/** Durable NATS KV outbox repository for services without SQL state. */
|
|
351
|
+
export class NatsKvOutboxRepository {
|
|
352
|
+
kv;
|
|
353
|
+
constructor(kv) {
|
|
354
|
+
this.kv = kv;
|
|
355
|
+
}
|
|
356
|
+
async enqueue(rec) {
|
|
357
|
+
const now = new Date().toISOString();
|
|
358
|
+
const record = {
|
|
359
|
+
id: rec.id,
|
|
360
|
+
kind: rec.kind,
|
|
361
|
+
name: rec.name,
|
|
362
|
+
subject: rec.subject,
|
|
363
|
+
payload: rec.payload,
|
|
364
|
+
headers: { ...rec.headers },
|
|
365
|
+
state: "pending",
|
|
366
|
+
attempts: 0,
|
|
367
|
+
createdAt: now,
|
|
368
|
+
updatedAt: now,
|
|
369
|
+
};
|
|
370
|
+
const stored = await this.kv.create(record.id, record).take();
|
|
371
|
+
if (!isErr(stored))
|
|
372
|
+
return kvRecordToOutboxMessage(record);
|
|
373
|
+
if (!hasKvReason(stored.error, "exists"))
|
|
374
|
+
throw stored.error;
|
|
375
|
+
const existing = await this.kv.getEntry(record.id).take();
|
|
376
|
+
if (isErr(existing))
|
|
377
|
+
throw existing.error;
|
|
378
|
+
if (!existing) {
|
|
379
|
+
throw new Error("outbox record missing after create conflict");
|
|
380
|
+
}
|
|
381
|
+
if (existing.value === undefined) {
|
|
382
|
+
throw new Error("outbox record is deleted");
|
|
383
|
+
}
|
|
384
|
+
const message = kvRecordToOutboxMessage(existing.value);
|
|
385
|
+
assertOutboxIdentity(message, rec);
|
|
386
|
+
return message;
|
|
387
|
+
}
|
|
388
|
+
async get(id) {
|
|
389
|
+
const loaded = await this.kv.getEntry(id).take();
|
|
390
|
+
if (isErr(loaded)) {
|
|
391
|
+
throw loaded.error;
|
|
392
|
+
}
|
|
393
|
+
if (!loaded || loaded.value === undefined)
|
|
394
|
+
return undefined;
|
|
395
|
+
return kvRecordToOutboxMessage(loaded.value);
|
|
396
|
+
}
|
|
397
|
+
async claimDue(limit, now) {
|
|
398
|
+
const keys = await this.kv.keys().take();
|
|
399
|
+
if (isErr(keys))
|
|
400
|
+
throw keys.error;
|
|
401
|
+
const dueAt = now.toISOString();
|
|
402
|
+
const claimed = [];
|
|
403
|
+
for await (const key of keys) {
|
|
404
|
+
if (claimed.length >= limit)
|
|
405
|
+
break;
|
|
406
|
+
const loaded = await this.kv.getEntry(key).take();
|
|
407
|
+
if (isErr(loaded)) {
|
|
408
|
+
throw loaded.error;
|
|
409
|
+
}
|
|
410
|
+
if (!loaded)
|
|
411
|
+
continue;
|
|
412
|
+
const record = loaded.value;
|
|
413
|
+
if (record === undefined)
|
|
414
|
+
continue;
|
|
415
|
+
if (record.state === "dispatched") {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (record.nextAttemptAt !== undefined && record.nextAttemptAt > dueAt) {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
const next = {
|
|
422
|
+
...record,
|
|
423
|
+
state: "claimed",
|
|
424
|
+
attempts: record.attempts + 1,
|
|
425
|
+
updatedAt: dueAt,
|
|
426
|
+
nextAttemptAt: new Date(now.getTime() + outboxClaimMs).toISOString(),
|
|
427
|
+
};
|
|
428
|
+
const stored = await this.kv.replace(key, loaded.revision, next).take();
|
|
429
|
+
if (isErr(stored)) {
|
|
430
|
+
if (hasKvReason(stored.error, "revision mismatch"))
|
|
431
|
+
continue;
|
|
432
|
+
throw stored.error;
|
|
433
|
+
}
|
|
434
|
+
claimed.push(kvRecordToOutboxMessage(next));
|
|
435
|
+
}
|
|
436
|
+
return claimed;
|
|
437
|
+
}
|
|
438
|
+
async markDispatched(claim, now, outcome) {
|
|
439
|
+
const loaded = await this.kv.getEntry(claim.id).take();
|
|
440
|
+
if (isErr(loaded)) {
|
|
441
|
+
throw loaded.error;
|
|
442
|
+
}
|
|
443
|
+
if (!loaded || loaded.value === undefined)
|
|
444
|
+
return false;
|
|
445
|
+
if (loaded.value.state !== "claimed" ||
|
|
446
|
+
loaded.value.attempts !== claim.attempts)
|
|
447
|
+
return false;
|
|
448
|
+
const outcomeStr = outcome !== undefined
|
|
449
|
+
? JSON.stringify(outcome)
|
|
450
|
+
: undefined;
|
|
451
|
+
const stored = await this.kv.replace(claim.id, loaded.revision, {
|
|
452
|
+
...loaded.value,
|
|
453
|
+
state: "dispatched",
|
|
454
|
+
updatedAt: now.toISOString(),
|
|
455
|
+
nextAttemptAt: undefined,
|
|
456
|
+
lastError: undefined,
|
|
457
|
+
outcome: outcomeStr ?? loaded.value.outcome,
|
|
458
|
+
}).take();
|
|
459
|
+
if (isErr(stored)) {
|
|
460
|
+
if (hasKvReason(stored.error, "revision mismatch"))
|
|
461
|
+
return false;
|
|
462
|
+
throw stored.error;
|
|
463
|
+
}
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
async markFailed(claim, failure) {
|
|
467
|
+
const loaded = await this.kv.getEntry(claim.id).take();
|
|
468
|
+
if (isErr(loaded)) {
|
|
469
|
+
throw loaded.error;
|
|
470
|
+
}
|
|
471
|
+
if (!loaded || loaded.value === undefined)
|
|
472
|
+
return false;
|
|
473
|
+
const record = loaded.value;
|
|
474
|
+
if (record.state !== "claimed" || record.attempts !== claim.attempts) {
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
const stored = await this.kv.replace(claim.id, loaded.revision, {
|
|
478
|
+
...record,
|
|
479
|
+
state: "failed",
|
|
480
|
+
updatedAt: failure.now.toISOString(),
|
|
481
|
+
nextAttemptAt: failure.nextAttemptAt.toISOString(),
|
|
482
|
+
lastError: failure.error,
|
|
483
|
+
}).take();
|
|
484
|
+
if (isErr(stored)) {
|
|
485
|
+
if (hasKvReason(stored.error, "revision mismatch"))
|
|
486
|
+
return false;
|
|
487
|
+
throw stored.error;
|
|
488
|
+
}
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/** Durable NATS KV inbox repository for event-id duplicate suppression. */
|
|
493
|
+
export class NatsKvInboxRepository {
|
|
494
|
+
kv;
|
|
495
|
+
constructor(kv) {
|
|
496
|
+
this.kv = kv;
|
|
497
|
+
}
|
|
498
|
+
async record(messageId, now = new Date()) {
|
|
499
|
+
// Durable NATS KV dedupe is useful for event handlers without SQL state, but
|
|
500
|
+
// it is not transactional with unrelated DB side effects.
|
|
501
|
+
const record = { messageId, receivedAt: now.toISOString() };
|
|
502
|
+
const stored = await this.kv.create(messageId, record);
|
|
503
|
+
const value = stored.take();
|
|
504
|
+
if (isErr(value)) {
|
|
505
|
+
const reason = value.error.toSerializable().context?.["reason"];
|
|
506
|
+
if (reason === "exists")
|
|
507
|
+
return false;
|
|
508
|
+
throw value.error;
|
|
509
|
+
}
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Coalesces outbox wakeups and drains due messages through `dispatchOutbox`.
|
|
515
|
+
*
|
|
516
|
+
* The dispatcher is process-local coordination only. Callers should invoke
|
|
517
|
+
* `notify()` after committing outbox rows so dispatch does not observe rolled
|
|
518
|
+
* back work.
|
|
519
|
+
*/
|
|
520
|
+
export class OutboxDispatcher {
|
|
521
|
+
#repository;
|
|
522
|
+
#runtime;
|
|
523
|
+
#options;
|
|
524
|
+
#wakeTimer;
|
|
525
|
+
#retryTimer;
|
|
526
|
+
#idleTimer;
|
|
527
|
+
#running = false;
|
|
528
|
+
#pending = false;
|
|
529
|
+
#stopped = false;
|
|
530
|
+
/** Creates a dispatcher over an existing outbox repository and runtime. */
|
|
531
|
+
constructor(repository, runtime, options = {}) {
|
|
532
|
+
this.#repository = repository;
|
|
533
|
+
this.#runtime = runtime;
|
|
534
|
+
this.#options = options;
|
|
535
|
+
this.#scheduleIdleRetry();
|
|
536
|
+
}
|
|
537
|
+
/** Signals that outbox work may be available and schedules a drain soon. */
|
|
538
|
+
notify() {
|
|
539
|
+
if (this.#stopped)
|
|
540
|
+
return;
|
|
541
|
+
this.#pending = true;
|
|
542
|
+
if (this.#running)
|
|
543
|
+
return;
|
|
544
|
+
this.#scheduleWakeup(this.#options.debounceMs ?? 0);
|
|
545
|
+
}
|
|
546
|
+
/** Cancels pending wakeups and prevents future dispatch work. */
|
|
547
|
+
stop() {
|
|
548
|
+
this.#stopped = true;
|
|
549
|
+
this.#pending = false;
|
|
550
|
+
this.#clearTimer("wake");
|
|
551
|
+
this.#clearTimer("retry");
|
|
552
|
+
this.#clearTimer("idle");
|
|
553
|
+
}
|
|
554
|
+
#scheduleWakeup(delayMs) {
|
|
555
|
+
if (this.#stopped)
|
|
556
|
+
return;
|
|
557
|
+
this.#clearTimer("wake");
|
|
558
|
+
this.#wakeTimer = setTimeout(() => {
|
|
559
|
+
this.#wakeTimer = undefined;
|
|
560
|
+
void this.#run();
|
|
561
|
+
}, delayMs);
|
|
562
|
+
}
|
|
563
|
+
#scheduleRetryWakeup() {
|
|
564
|
+
if (this.#stopped)
|
|
565
|
+
return;
|
|
566
|
+
if (this.#retryTimer !== undefined)
|
|
567
|
+
return;
|
|
568
|
+
this.#retryTimer = setTimeout(() => {
|
|
569
|
+
this.#retryTimer = undefined;
|
|
570
|
+
if (this.#stopped)
|
|
571
|
+
return;
|
|
572
|
+
this.#pending = true;
|
|
573
|
+
if (!this.#running)
|
|
574
|
+
this.#scheduleWakeup(0);
|
|
575
|
+
}, this.#retryDelayMs());
|
|
576
|
+
}
|
|
577
|
+
#scheduleIdleRetry() {
|
|
578
|
+
if (this.#stopped || this.#options.idleRetryMs === undefined)
|
|
579
|
+
return;
|
|
580
|
+
this.#clearTimer("idle");
|
|
581
|
+
this.#idleTimer = setTimeout(() => {
|
|
582
|
+
this.#idleTimer = undefined;
|
|
583
|
+
this.notify();
|
|
584
|
+
}, this.#options.idleRetryMs);
|
|
585
|
+
}
|
|
586
|
+
async #run() {
|
|
587
|
+
if (this.#stopped || this.#running)
|
|
588
|
+
return;
|
|
589
|
+
this.#running = true;
|
|
590
|
+
this.#clearTimer("idle");
|
|
591
|
+
try {
|
|
592
|
+
do {
|
|
593
|
+
this.#pending = false;
|
|
594
|
+
while (!this.#stopped) {
|
|
595
|
+
const result = await this.#dispatchBatch();
|
|
596
|
+
if (result.failed > 0) {
|
|
597
|
+
this.#scheduleRetryWakeup();
|
|
598
|
+
}
|
|
599
|
+
if (result.dispatched === 0 && result.failed === 0)
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
} while (this.#pending && !this.#stopped);
|
|
603
|
+
}
|
|
604
|
+
finally {
|
|
605
|
+
this.#running = false;
|
|
606
|
+
if (this.#pending && !this.#stopped) {
|
|
607
|
+
this.#scheduleWakeup(0);
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
this.#scheduleIdleRetry();
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
async #dispatchBatch() {
|
|
615
|
+
try {
|
|
616
|
+
return await dispatchOutbox(this.#repository, this.#runtime, {
|
|
617
|
+
limit: this.#options.limit,
|
|
618
|
+
retryDelayMs: this.#retryDelayMs(),
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
recordTrellisError(error, {
|
|
623
|
+
surface: "outbox",
|
|
624
|
+
direction: "dispatcher",
|
|
625
|
+
operation: "batch",
|
|
626
|
+
phase: "dispatch",
|
|
627
|
+
});
|
|
628
|
+
this.#scheduleRetryWakeup();
|
|
629
|
+
try {
|
|
630
|
+
this.#options.onError?.(error);
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
// Error callbacks must not break dispatcher recovery.
|
|
634
|
+
}
|
|
635
|
+
return { dispatched: 0, failed: 0 };
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
#retryDelayMs() {
|
|
639
|
+
return Math.max(1, this.#options.retryDelayMs ?? 1000);
|
|
640
|
+
}
|
|
641
|
+
#clearTimer(timer) {
|
|
642
|
+
if (timer === "wake" && this.#wakeTimer !== undefined) {
|
|
643
|
+
clearTimeout(this.#wakeTimer);
|
|
644
|
+
this.#wakeTimer = undefined;
|
|
645
|
+
}
|
|
646
|
+
if (timer === "retry" && this.#retryTimer !== undefined) {
|
|
647
|
+
clearTimeout(this.#retryTimer);
|
|
648
|
+
this.#retryTimer = undefined;
|
|
649
|
+
}
|
|
650
|
+
if (timer === "idle" && this.#idleTimer !== undefined) {
|
|
651
|
+
clearTimeout(this.#idleTimer);
|
|
652
|
+
this.#idleTimer = undefined;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
/** Dispatches due outbox messages through a dispatch runtime. */
|
|
657
|
+
export async function dispatchOutbox(repository, runtime, options = {}) {
|
|
658
|
+
const now = options.now ?? new Date();
|
|
659
|
+
const retryDelayMs = options.retryDelayMs ?? 1000;
|
|
660
|
+
const messages = await repository.claimDue(options.limit ?? 25, now);
|
|
661
|
+
let dispatched = 0;
|
|
662
|
+
let failed = 0;
|
|
663
|
+
for (const message of messages) {
|
|
664
|
+
if (message.kind === "event.publish") {
|
|
665
|
+
const result = await runtime.publishPreparedEvent(outboxMessageToPreparedEvent(message));
|
|
666
|
+
const value = result.take();
|
|
667
|
+
if (isErr(value)) {
|
|
668
|
+
const failedAt = options.now ?? new Date();
|
|
669
|
+
recordTrellisError(value.error, {
|
|
670
|
+
surface: "outbox",
|
|
671
|
+
direction: "dispatcher",
|
|
672
|
+
operation: message.name,
|
|
673
|
+
phase: "publish",
|
|
674
|
+
messagingSystem: "nats",
|
|
675
|
+
});
|
|
676
|
+
if (await repository.markFailed(message, {
|
|
677
|
+
error: value.error.message,
|
|
678
|
+
nextAttemptAt: new Date(failedAt.getTime() + retryDelayMs),
|
|
679
|
+
now: failedAt,
|
|
680
|
+
}))
|
|
681
|
+
failed += 1;
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
if (await repository.markDispatched(message, now))
|
|
685
|
+
dispatched += 1;
|
|
686
|
+
}
|
|
687
|
+
else if (message.kind === "job.create" || message.kind === "job.submit") {
|
|
688
|
+
if (runtime.dispatchJobSubmission) {
|
|
689
|
+
const result = await runtime.dispatchJobSubmission(message);
|
|
690
|
+
const value = result.take();
|
|
691
|
+
if (isErr(value)) {
|
|
692
|
+
const failedAt = options.now ?? new Date();
|
|
693
|
+
recordTrellisError(value.error, {
|
|
694
|
+
surface: "outbox",
|
|
695
|
+
direction: "dispatcher",
|
|
696
|
+
operation: message.name,
|
|
697
|
+
phase: "job.dispatch",
|
|
698
|
+
});
|
|
699
|
+
if (await repository.markFailed(message, {
|
|
700
|
+
error: value.error.message,
|
|
701
|
+
nextAttemptAt: new Date(failedAt.getTime() + retryDelayMs),
|
|
702
|
+
now: failedAt,
|
|
703
|
+
}))
|
|
704
|
+
failed += 1;
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
if (await repository.markDispatched(message, now, value)) {
|
|
708
|
+
dispatched += 1;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
else {
|
|
712
|
+
recordTrellisError(new Error("job dispatch not supported"), {
|
|
713
|
+
surface: "outbox",
|
|
714
|
+
direction: "dispatcher",
|
|
715
|
+
operation: message.name,
|
|
716
|
+
phase: "dispatch",
|
|
717
|
+
});
|
|
718
|
+
await repository.markDispatched(message, now, {
|
|
719
|
+
error: "job dispatch not supported",
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
else {
|
|
724
|
+
const error = `Unknown outbox record kind: ${message.kind}`;
|
|
725
|
+
recordTrellisError(new Error(error), {
|
|
726
|
+
surface: "outbox",
|
|
727
|
+
direction: "dispatcher",
|
|
728
|
+
operation: message.name,
|
|
729
|
+
phase: "dispatch",
|
|
730
|
+
});
|
|
731
|
+
await repository.markDispatched(message, now, { error });
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return { dispatched, failed };
|
|
735
|
+
}
|
|
736
|
+
/** Rehydrates a persisted outbox event row into a prepared event. */
|
|
737
|
+
export function outboxMessageToPreparedEvent(message) {
|
|
738
|
+
if (message.kind !== "event.publish") {
|
|
739
|
+
throw new Error(`Expected event.publish kind, got ${message.kind}`);
|
|
740
|
+
}
|
|
741
|
+
const payload = JSON.parse(message.payload);
|
|
742
|
+
const header = eventHeaderFromMessage(message.headers);
|
|
743
|
+
const descriptorIdentity = message.headers["Trellis-Event-Descriptor"] ??
|
|
744
|
+
message.headers["trellis-event-descriptor"];
|
|
745
|
+
if (!descriptorIdentity) {
|
|
746
|
+
throw new Error("Outbox event descriptor identity is required");
|
|
747
|
+
}
|
|
748
|
+
return Object.freeze({
|
|
749
|
+
event: message.name,
|
|
750
|
+
descriptorIdentity,
|
|
751
|
+
subject: message.subject,
|
|
752
|
+
header: Object.freeze(header),
|
|
753
|
+
payload: Object.freeze(payload),
|
|
754
|
+
encodedPayload: message.payload,
|
|
755
|
+
headers: Object.freeze({ ...message.headers }),
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
/** Adapts a prepared Trellis event into a generic outbox record. */
|
|
759
|
+
export function preparedTrellisEventToOutboxRecord(event) {
|
|
760
|
+
return {
|
|
761
|
+
id: event.header.id,
|
|
762
|
+
kind: "event.publish",
|
|
763
|
+
name: event.event,
|
|
764
|
+
subject: event.subject,
|
|
765
|
+
payload: event.encodedPayload,
|
|
766
|
+
headers: {
|
|
767
|
+
...event.headers,
|
|
768
|
+
"Nats-Msg-Id": event.header.id,
|
|
769
|
+
"Trellis-Event-Time": event.header.time,
|
|
770
|
+
"Trellis-Event-Descriptor": event.descriptorIdentity,
|
|
771
|
+
},
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
function eventHeaderFromMessage(headers) {
|
|
775
|
+
const id = headers["Nats-Msg-Id"] ?? headers["nats-msg-id"];
|
|
776
|
+
const time = headers["Trellis-Event-Time"] ?? headers["trellis-event-time"];
|
|
777
|
+
return {
|
|
778
|
+
id: typeof id === "string" ? id : "",
|
|
779
|
+
time: typeof time === "string" ? time : new Date(0).toISOString(),
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
function assertOutboxIdentity(existing, candidate) {
|
|
783
|
+
if (existing.kind !== candidate.kind || existing.name !== candidate.name ||
|
|
784
|
+
existing.subject !== candidate.subject ||
|
|
785
|
+
existing.payload !== candidate.payload ||
|
|
786
|
+
Object.keys(existing.headers).length !==
|
|
787
|
+
Object.keys(candidate.headers).length ||
|
|
788
|
+
Object.entries(existing.headers).some(([key, value]) => candidate.headers[key] !== value)) {
|
|
789
|
+
throw new OutboxDuplicateIdentityError(candidate.id);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
function rowToOutboxMessage(row) {
|
|
793
|
+
return {
|
|
794
|
+
id: stringField(row, "id"),
|
|
795
|
+
kind: kindField(row, "kind"),
|
|
796
|
+
name: stringField(row, "name"),
|
|
797
|
+
subject: stringField(row, "subject"),
|
|
798
|
+
payload: stringField(row, "payload"),
|
|
799
|
+
headers: parseHeaders(row["headers"]),
|
|
800
|
+
state: stateField(row, "state"),
|
|
801
|
+
attempts: numberField(row, "attempts"),
|
|
802
|
+
createdAt: stringField(row, "created_at"),
|
|
803
|
+
updatedAt: stringField(row, "updated_at"),
|
|
804
|
+
nextAttemptAt: optionalStringField(row, "next_attempt_at"),
|
|
805
|
+
lastError: optionalStringField(row, "last_error"),
|
|
806
|
+
outcome: parseOutcome(row["outcome"]),
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
function kvRecordToOutboxMessage(record) {
|
|
810
|
+
return {
|
|
811
|
+
id: record.id,
|
|
812
|
+
kind: kindField(record, "kind"),
|
|
813
|
+
name: record.name,
|
|
814
|
+
subject: record.subject,
|
|
815
|
+
payload: record.payload,
|
|
816
|
+
headers: { ...record.headers },
|
|
817
|
+
state: record.state,
|
|
818
|
+
attempts: record.attempts,
|
|
819
|
+
createdAt: record.createdAt,
|
|
820
|
+
updatedAt: record.updatedAt,
|
|
821
|
+
nextAttemptAt: record.nextAttemptAt,
|
|
822
|
+
lastError: record.lastError,
|
|
823
|
+
outcome: parseOutcome(record.outcome),
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
function parseOutcome(value) {
|
|
827
|
+
if (value === undefined || value === null)
|
|
828
|
+
return undefined;
|
|
829
|
+
return typeof value === "string" ? JSON.parse(value) : value;
|
|
830
|
+
}
|
|
831
|
+
function placeholder(dialect, index) {
|
|
832
|
+
return dialect === "postgres" ? `$${index}` : "?";
|
|
833
|
+
}
|
|
834
|
+
function placeholders(dialect, count) {
|
|
835
|
+
return Array.from({ length: count }, (_, index) => placeholder(dialect, index + 1)).join(", ");
|
|
836
|
+
}
|
|
837
|
+
function hasKvReason(error, reason) {
|
|
838
|
+
if (error.toSerializable().context?.["reason"] === reason)
|
|
839
|
+
return true;
|
|
840
|
+
if (reason !== "revision mismatch")
|
|
841
|
+
return false;
|
|
842
|
+
const message = error.cause instanceof Error
|
|
843
|
+
? error.cause.message.toLowerCase()
|
|
844
|
+
: "";
|
|
845
|
+
return message.includes("wrong last sequence") ||
|
|
846
|
+
message.includes("wrong last revision") ||
|
|
847
|
+
message.includes("revision mismatch") ||
|
|
848
|
+
message.includes("sequence mismatch");
|
|
849
|
+
}
|
|
850
|
+
function parseHeaders(value) {
|
|
851
|
+
if (typeof value === "string") {
|
|
852
|
+
const parsed = JSON.parse(value);
|
|
853
|
+
return recordOfStrings(parsed);
|
|
854
|
+
}
|
|
855
|
+
return recordOfStrings(value);
|
|
856
|
+
}
|
|
857
|
+
function recordOfStrings(value) {
|
|
858
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
859
|
+
throw new Error("Expected SQL headers object");
|
|
860
|
+
}
|
|
861
|
+
const out = {};
|
|
862
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
863
|
+
if (typeof entry !== "string") {
|
|
864
|
+
throw new Error("Expected SQL header value string");
|
|
865
|
+
}
|
|
866
|
+
out[key] = entry;
|
|
867
|
+
}
|
|
868
|
+
return out;
|
|
869
|
+
}
|
|
870
|
+
function stringField(row, field) {
|
|
871
|
+
const value = row[field];
|
|
872
|
+
if (typeof value !== "string") {
|
|
873
|
+
throw new Error(`Expected SQL field ${field} to be a string`);
|
|
874
|
+
}
|
|
875
|
+
return value;
|
|
876
|
+
}
|
|
877
|
+
function optionalStringField(row, field) {
|
|
878
|
+
const value = row[field];
|
|
879
|
+
if (value === null || value === undefined)
|
|
880
|
+
return undefined;
|
|
881
|
+
if (typeof value !== "string") {
|
|
882
|
+
throw new Error(`Expected SQL field ${field} to be a string`);
|
|
883
|
+
}
|
|
884
|
+
return value;
|
|
885
|
+
}
|
|
886
|
+
function numberField(row, field) {
|
|
887
|
+
const value = row[field];
|
|
888
|
+
if (typeof value !== "number") {
|
|
889
|
+
throw new Error(`Expected SQL field ${field} to be a number`);
|
|
890
|
+
}
|
|
891
|
+
return value;
|
|
892
|
+
}
|
|
893
|
+
function kindField(row, field) {
|
|
894
|
+
const value = stringField(row, field);
|
|
895
|
+
if (value === "event.publish" || value === "job.create" ||
|
|
896
|
+
value === "job.submit") {
|
|
897
|
+
return value;
|
|
898
|
+
}
|
|
899
|
+
throw new Error(`Expected SQL field ${field} to be an outbox record kind`);
|
|
900
|
+
}
|
|
901
|
+
function stateField(row, field) {
|
|
902
|
+
const value = stringField(row, field);
|
|
903
|
+
if (value === "pending" || value === "claimed" || value === "dispatched" ||
|
|
904
|
+
value === "failed") {
|
|
905
|
+
return value;
|
|
906
|
+
}
|
|
907
|
+
throw new Error(`Expected SQL field ${field} to be an outbox state`);
|
|
908
|
+
}
|