@agentplat/mesh-postgres 0.3.0-alpha.5
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/LICENSE +202 -0
- package/README.md +44 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +39 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations.d.ts +16 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +59 -0
- package/dist/migrations.js.map +1 -0
- package/dist/repository.d.ts +30 -0
- package/dist/repository.d.ts.map +1 -0
- package/dist/repository.js +769 -0
- package/dist/repository.js.map +1 -0
- package/migrations/001_mesh_durability.down.sql +5 -0
- package/migrations/001_mesh_durability.up.sql +112 -0
- package/package.json +49 -0
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
import { MESH_DURABILITY_SCHEMA_VERSION, MESH_DURABLE_GENESIS_DIGEST, computeMeshDurableValueDigest, createMeshDurableJournalEntry, normalizeMeshDurableScope, } from "@agentplat/mesh/durability";
|
|
2
|
+
import { canonicalizeMeshJsonBytes, parseSignedMeshEnvelope, validateSignedMeshEnvelope, } from "@agentplat/mesh-protocol";
|
|
3
|
+
import { defaultPostgresSchema, normalizePostgresIdentifier, quotePostgresIdentifier, } from "@agentplat/postgres";
|
|
4
|
+
function scopedDatabase(database, schema) {
|
|
5
|
+
const prefix = `${quotePostgresIdentifier(schema)}.`;
|
|
6
|
+
return {
|
|
7
|
+
query(text, values) {
|
|
8
|
+
return database.query(text.replaceAll("public.", prefix), values);
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* PostgreSQL implementation of the Mesh durable repository.
|
|
14
|
+
*
|
|
15
|
+
* The pool is caller-owned and is never closed by this adapter.
|
|
16
|
+
*/
|
|
17
|
+
export class PostgresMeshDurableRepository {
|
|
18
|
+
pool;
|
|
19
|
+
#schema;
|
|
20
|
+
#maximumPendingInboxRowsPerScope;
|
|
21
|
+
constructor(pool, options = {}) {
|
|
22
|
+
this.pool = pool;
|
|
23
|
+
if (!pool || typeof pool.connect !== "function") {
|
|
24
|
+
throw new TypeError("A PostgreSQL pool is required");
|
|
25
|
+
}
|
|
26
|
+
this.#schema = normalizePostgresIdentifier(options.schema ?? defaultPostgresSchema, "schema");
|
|
27
|
+
this.#maximumPendingInboxRowsPerScope = positiveInteger(options.maximumPendingInboxRowsPerScope ?? 100_000, "maximumPendingInboxRowsPerScope", 1_000_000);
|
|
28
|
+
}
|
|
29
|
+
async receive(input) {
|
|
30
|
+
const scope = normalizeMeshDurableScope(input.scope);
|
|
31
|
+
const envelope = validateInboundEnvelope(input.envelope, scope);
|
|
32
|
+
const envelopeDigest = await digestEnvelope(envelope);
|
|
33
|
+
return this.#transaction(async (database) => {
|
|
34
|
+
await database.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [scopeKey(scope)]);
|
|
35
|
+
const existing = await database.query(`SELECT envelope_digest, received_at
|
|
36
|
+
FROM public.mesh_inbox
|
|
37
|
+
WHERE tenant_id = $1 AND mesh_id = $2 AND peer_id = $3
|
|
38
|
+
AND instance_id = $4 AND message_id = $5`, [...scopeValues(scope), envelope.messageId]);
|
|
39
|
+
if (existing.rowCount === 1) {
|
|
40
|
+
if (String(existing.rows[0].envelope_digest) !== envelopeDigest) {
|
|
41
|
+
return Object.freeze({
|
|
42
|
+
accepted: false,
|
|
43
|
+
code: "message_conflict",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return Object.freeze({
|
|
47
|
+
accepted: true,
|
|
48
|
+
duplicate: true,
|
|
49
|
+
receivedAt: iso(existing.rows[0].received_at),
|
|
50
|
+
envelopeDigest,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const capacity = await database.query(`SELECT count(*)::bigint AS count
|
|
54
|
+
FROM public.mesh_inbox
|
|
55
|
+
WHERE tenant_id = $1 AND mesh_id = $2 AND peer_id = $3
|
|
56
|
+
AND instance_id = $4 AND status IN ('pending', 'processing')`, scopeValues(scope));
|
|
57
|
+
if (Number(capacity.rows[0].count) >= this.#maximumPendingInboxRowsPerScope) {
|
|
58
|
+
return Object.freeze({
|
|
59
|
+
accepted: false,
|
|
60
|
+
code: "capacity_exceeded",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const inserted = await database.query(`INSERT INTO public.mesh_inbox
|
|
64
|
+
(tenant_id, mesh_id, peer_id, instance_id, message_id,
|
|
65
|
+
envelope, envelope_digest)
|
|
66
|
+
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7)
|
|
67
|
+
RETURNING received_at`, [
|
|
68
|
+
...scopeValues(scope),
|
|
69
|
+
envelope.messageId,
|
|
70
|
+
JSON.stringify(envelope),
|
|
71
|
+
envelopeDigest,
|
|
72
|
+
]);
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
accepted: true,
|
|
75
|
+
duplicate: false,
|
|
76
|
+
receivedAt: iso(inserted.rows[0].received_at),
|
|
77
|
+
envelopeDigest,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async loadSnapshot(inputScope) {
|
|
82
|
+
const scope = normalizeMeshDurableScope(inputScope);
|
|
83
|
+
const result = await scopedDatabase(this.pool, this.#schema).query(`SELECT * FROM public.mesh_peer_snapshots
|
|
84
|
+
WHERE tenant_id = $1 AND mesh_id = $2 AND peer_id = $3
|
|
85
|
+
AND instance_id = $4`, scopeValues(scope));
|
|
86
|
+
return result.rowCount === 0
|
|
87
|
+
? undefined
|
|
88
|
+
: await mapSnapshot(result.rows[0], scope);
|
|
89
|
+
}
|
|
90
|
+
async claimInbox(input) {
|
|
91
|
+
const options = normalizeClaimOptions(input);
|
|
92
|
+
return this.#transaction(async (database) => {
|
|
93
|
+
const selected = await database.query(`SELECT message_id
|
|
94
|
+
FROM public.mesh_inbox
|
|
95
|
+
WHERE tenant_id = $1 AND mesh_id = $2 AND peer_id = $3
|
|
96
|
+
AND instance_id = $4
|
|
97
|
+
AND (
|
|
98
|
+
(status = 'pending' AND available_at <= transaction_timestamp())
|
|
99
|
+
OR
|
|
100
|
+
(status = 'processing' AND claim_expires_at <= transaction_timestamp())
|
|
101
|
+
)
|
|
102
|
+
ORDER BY received_at, message_id
|
|
103
|
+
FOR UPDATE SKIP LOCKED
|
|
104
|
+
LIMIT $5`, [...scopeValues(options.scope), options.limit]);
|
|
105
|
+
const claimed = [];
|
|
106
|
+
for (const selectedRow of selected.rows) {
|
|
107
|
+
const token = globalThis.crypto.randomUUID();
|
|
108
|
+
const updated = await database.query(`UPDATE public.mesh_inbox
|
|
109
|
+
SET status = 'processing',
|
|
110
|
+
attempts = attempts + 1,
|
|
111
|
+
claim_worker_id = $6,
|
|
112
|
+
claim_token = $7,
|
|
113
|
+
claim_generation = claim_generation + 1,
|
|
114
|
+
claim_expires_at = transaction_timestamp()
|
|
115
|
+
+ ($8::bigint * interval '1 millisecond'),
|
|
116
|
+
settled_at = NULL,
|
|
117
|
+
reason_code = NULL
|
|
118
|
+
WHERE tenant_id = $1 AND mesh_id = $2 AND peer_id = $3
|
|
119
|
+
AND instance_id = $4 AND message_id = $5
|
|
120
|
+
RETURNING *`, [
|
|
121
|
+
...scopeValues(options.scope),
|
|
122
|
+
String(selectedRow.message_id),
|
|
123
|
+
options.workerId,
|
|
124
|
+
token,
|
|
125
|
+
options.leaseDurationMs,
|
|
126
|
+
]);
|
|
127
|
+
claimed.push(await mapInbox(updated.rows[0], options.scope));
|
|
128
|
+
}
|
|
129
|
+
return Object.freeze(claimed);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
async commitInboxTransition(input) {
|
|
133
|
+
const scope = normalizeMeshDurableScope(input.inbox.scope);
|
|
134
|
+
assertCommitInput(input, scope);
|
|
135
|
+
return this.#transaction(async (database) => {
|
|
136
|
+
// A snapshot row does not exist at revision zero, so a row lock alone
|
|
137
|
+
// cannot serialize the first compare-and-swap transition.
|
|
138
|
+
await database.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [scopeKey(scope)]);
|
|
139
|
+
const inboxResult = await database.query(`SELECT *, claim_expires_at > transaction_timestamp() AS claim_live
|
|
140
|
+
FROM public.mesh_inbox
|
|
141
|
+
WHERE tenant_id = $1 AND mesh_id = $2 AND peer_id = $3
|
|
142
|
+
AND instance_id = $4 AND message_id = $5
|
|
143
|
+
FOR UPDATE`, [...scopeValues(scope), input.inbox.messageId]);
|
|
144
|
+
if (inboxResult.rowCount !== 1 ||
|
|
145
|
+
!claimMatches(inboxResult.rows[0], input.inbox.claim, "processing")) {
|
|
146
|
+
return commitConflict("claim_lost");
|
|
147
|
+
}
|
|
148
|
+
const transition = await database.query(`SELECT 1 FROM public.mesh_journal
|
|
149
|
+
WHERE tenant_id = $1 AND mesh_id = $2 AND peer_id = $3
|
|
150
|
+
AND instance_id = $4 AND transition_id = $5
|
|
151
|
+
LIMIT 1`, [...scopeValues(scope), input.transitionId]);
|
|
152
|
+
if (transition.rowCount !== 0) {
|
|
153
|
+
return commitConflict("transition_conflict");
|
|
154
|
+
}
|
|
155
|
+
const currentResult = await database.query(`SELECT * FROM public.mesh_peer_snapshots
|
|
156
|
+
WHERE tenant_id = $1 AND mesh_id = $2 AND peer_id = $3
|
|
157
|
+
AND instance_id = $4
|
|
158
|
+
FOR UPDATE`, scopeValues(scope));
|
|
159
|
+
const current = currentResult.rowCount === 0
|
|
160
|
+
? undefined
|
|
161
|
+
: await mapSnapshot(currentResult.rows[0], scope);
|
|
162
|
+
if ((current?.revision ?? 0) !== input.expectedSnapshotRevision) {
|
|
163
|
+
return commitConflict("revision_conflict");
|
|
164
|
+
}
|
|
165
|
+
const timestamp = iso((await database.query("SELECT transaction_timestamp() AS occurred_at")).rows[0].occurred_at);
|
|
166
|
+
let snapshot = current;
|
|
167
|
+
if (input.outcome === "applied") {
|
|
168
|
+
const state = cloneStrictJson(input.nextState);
|
|
169
|
+
const stateDigest = await computeMeshDurableValueDigest(state);
|
|
170
|
+
const revision = input.expectedSnapshotRevision + 1;
|
|
171
|
+
const snapshotResult = await database.query(`INSERT INTO public.mesh_peer_snapshots
|
|
172
|
+
(tenant_id, mesh_id, peer_id, instance_id, revision, state,
|
|
173
|
+
state_digest, committed_at)
|
|
174
|
+
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7,
|
|
175
|
+
transaction_timestamp())
|
|
176
|
+
ON CONFLICT (tenant_id, mesh_id, peer_id, instance_id)
|
|
177
|
+
DO UPDATE SET revision = EXCLUDED.revision,
|
|
178
|
+
state = EXCLUDED.state,
|
|
179
|
+
state_digest = EXCLUDED.state_digest,
|
|
180
|
+
committed_at = EXCLUDED.committed_at
|
|
181
|
+
RETURNING *`, [...scopeValues(scope), revision, JSON.stringify(state), stateDigest]);
|
|
182
|
+
snapshot = await mapSnapshot(snapshotResult.rows[0], scope);
|
|
183
|
+
}
|
|
184
|
+
const snapshotRevision = snapshot?.revision ?? 0;
|
|
185
|
+
const snapshotDigest = snapshot?.stateDigest ?? (await computeMeshDurableValueDigest(null));
|
|
186
|
+
for (const outbound of input.outbox) {
|
|
187
|
+
const conflict = await database.query(`SELECT 1 FROM public.mesh_outbox
|
|
188
|
+
WHERE tenant_id = $1 AND mesh_id = $2 AND peer_id = $3
|
|
189
|
+
AND instance_id = $4
|
|
190
|
+
AND (effect_id = $5 OR message_id = $6)
|
|
191
|
+
LIMIT 1`, [
|
|
192
|
+
...scopeValues(scope),
|
|
193
|
+
outbound.effectId,
|
|
194
|
+
outbound.envelope.messageId,
|
|
195
|
+
]);
|
|
196
|
+
if (conflict.rowCount !== 0) {
|
|
197
|
+
throw new CommitConflictError("outbox_conflict");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const lastJournal = await database.query(`SELECT sequence, digest FROM public.mesh_journal
|
|
201
|
+
WHERE tenant_id = $1 AND mesh_id = $2 AND peer_id = $3
|
|
202
|
+
AND instance_id = $4
|
|
203
|
+
ORDER BY sequence DESC
|
|
204
|
+
LIMIT 1`, scopeValues(scope));
|
|
205
|
+
let sequence = lastJournal.rowCount === 0
|
|
206
|
+
? 0
|
|
207
|
+
: safeInteger(lastJournal.rows[0].sequence, "journal sequence");
|
|
208
|
+
let previousDigest = lastJournal.rowCount === 0
|
|
209
|
+
? MESH_DURABLE_GENESIS_DIGEST
|
|
210
|
+
: String(lastJournal.rows[0].digest);
|
|
211
|
+
const drafts = input.journal.length === 0
|
|
212
|
+
? [
|
|
213
|
+
{
|
|
214
|
+
entryId: input.transitionId,
|
|
215
|
+
kind: input.outcome === "applied"
|
|
216
|
+
? "transition.applied"
|
|
217
|
+
: "transition.rejected",
|
|
218
|
+
...(input.reasonCode === undefined
|
|
219
|
+
? {}
|
|
220
|
+
: { reasonCode: input.reasonCode }),
|
|
221
|
+
},
|
|
222
|
+
]
|
|
223
|
+
: input.journal;
|
|
224
|
+
const journal = [];
|
|
225
|
+
for (const draft of drafts) {
|
|
226
|
+
sequence += 1;
|
|
227
|
+
const entry = await createMeshDurableJournalEntry({
|
|
228
|
+
scope,
|
|
229
|
+
sequence,
|
|
230
|
+
previousDigest,
|
|
231
|
+
transitionId: input.transitionId,
|
|
232
|
+
inboxMessageId: input.inbox.messageId,
|
|
233
|
+
snapshotRevision,
|
|
234
|
+
snapshotDigest,
|
|
235
|
+
draft,
|
|
236
|
+
occurredAt: timestamp,
|
|
237
|
+
});
|
|
238
|
+
await database.query(`INSERT INTO public.mesh_journal
|
|
239
|
+
(tenant_id, mesh_id, peer_id, instance_id, sequence, entry_id,
|
|
240
|
+
previous_digest, digest, transition_id, inbox_message_id,
|
|
241
|
+
snapshot_revision, snapshot_digest, kind, reason_code,
|
|
242
|
+
occurred_at)
|
|
243
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
|
|
244
|
+
$13, $14, $15::timestamptz)`, [
|
|
245
|
+
...scopeValues(scope),
|
|
246
|
+
entry.sequence,
|
|
247
|
+
entry.entryId,
|
|
248
|
+
entry.previousDigest,
|
|
249
|
+
entry.digest,
|
|
250
|
+
entry.transitionId,
|
|
251
|
+
entry.inboxMessageId ?? null,
|
|
252
|
+
entry.snapshotRevision,
|
|
253
|
+
entry.snapshotDigest,
|
|
254
|
+
entry.kind,
|
|
255
|
+
entry.reasonCode ?? null,
|
|
256
|
+
entry.occurredAt,
|
|
257
|
+
]);
|
|
258
|
+
journal.push(entry);
|
|
259
|
+
previousDigest = entry.digest;
|
|
260
|
+
}
|
|
261
|
+
const outbox = [];
|
|
262
|
+
for (const outbound of input.outbox) {
|
|
263
|
+
const targetPeerId = outbound.targetPeerId ??
|
|
264
|
+
(outbound.envelope.audience.kind === "peer"
|
|
265
|
+
? outbound.envelope.audience.peerId
|
|
266
|
+
: undefined);
|
|
267
|
+
const envelope = validateOutboundEnvelope(outbound.envelope, scope, targetPeerId);
|
|
268
|
+
const envelopeDigest = await digestEnvelope(envelope);
|
|
269
|
+
const inserted = await database.query(`INSERT INTO public.mesh_outbox
|
|
270
|
+
(tenant_id, mesh_id, peer_id, instance_id, effect_id, message_id,
|
|
271
|
+
target_peer_id, envelope, envelope_digest)
|
|
272
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)
|
|
273
|
+
RETURNING *`, [
|
|
274
|
+
...scopeValues(scope),
|
|
275
|
+
outbound.effectId,
|
|
276
|
+
envelope.messageId,
|
|
277
|
+
targetPeerId ?? null,
|
|
278
|
+
JSON.stringify(envelope),
|
|
279
|
+
envelopeDigest,
|
|
280
|
+
]);
|
|
281
|
+
outbox.push(await mapOutbox(inserted.rows[0], scope));
|
|
282
|
+
}
|
|
283
|
+
await database.query(`UPDATE public.mesh_inbox
|
|
284
|
+
SET status = $6,
|
|
285
|
+
settled_at = transaction_timestamp(),
|
|
286
|
+
reason_code = $7,
|
|
287
|
+
claim_worker_id = NULL,
|
|
288
|
+
claim_token = NULL,
|
|
289
|
+
claim_expires_at = NULL
|
|
290
|
+
WHERE tenant_id = $1 AND mesh_id = $2 AND peer_id = $3
|
|
291
|
+
AND instance_id = $4 AND message_id = $5`, [
|
|
292
|
+
...scopeValues(scope),
|
|
293
|
+
input.inbox.messageId,
|
|
294
|
+
input.outcome,
|
|
295
|
+
input.reasonCode ?? null,
|
|
296
|
+
]);
|
|
297
|
+
return Object.freeze({
|
|
298
|
+
committed: true,
|
|
299
|
+
...(snapshot === undefined ? {} : { snapshot }),
|
|
300
|
+
journal: Object.freeze(journal),
|
|
301
|
+
outbox: Object.freeze(outbox),
|
|
302
|
+
});
|
|
303
|
+
}).catch((error) => {
|
|
304
|
+
if (error instanceof CommitConflictError) {
|
|
305
|
+
return commitConflict(error.code);
|
|
306
|
+
}
|
|
307
|
+
throw error;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
async abandonInbox(input) {
|
|
311
|
+
const scope = normalizeMeshDurableScope(input.inbox.scope);
|
|
312
|
+
const claim = input.inbox.claim;
|
|
313
|
+
if (!claim)
|
|
314
|
+
return false;
|
|
315
|
+
const retryAfterMs = positiveInteger(input.retryAfterMs, "retryAfterMs", 3_600_000);
|
|
316
|
+
if (input.reasonCode !== undefined) {
|
|
317
|
+
boundedReason(input.reasonCode, "reasonCode");
|
|
318
|
+
}
|
|
319
|
+
const result = await scopedDatabase(this.pool, this.#schema).query(`UPDATE public.mesh_inbox
|
|
320
|
+
SET status = 'pending',
|
|
321
|
+
available_at = transaction_timestamp()
|
|
322
|
+
+ ($9::bigint * interval '1 millisecond'),
|
|
323
|
+
reason_code = $10,
|
|
324
|
+
claim_worker_id = NULL,
|
|
325
|
+
claim_token = NULL,
|
|
326
|
+
claim_expires_at = NULL
|
|
327
|
+
WHERE tenant_id = $1 AND mesh_id = $2 AND peer_id = $3
|
|
328
|
+
AND instance_id = $4 AND message_id = $5
|
|
329
|
+
AND status = 'processing' AND claim_worker_id = $6
|
|
330
|
+
AND claim_token = $7 AND claim_generation = $8`, [
|
|
331
|
+
...scopeValues(scope),
|
|
332
|
+
input.inbox.messageId,
|
|
333
|
+
claim.workerId,
|
|
334
|
+
claim.leaseToken,
|
|
335
|
+
claim.generation,
|
|
336
|
+
retryAfterMs,
|
|
337
|
+
input.reasonCode ?? null,
|
|
338
|
+
]);
|
|
339
|
+
return result.rowCount === 1;
|
|
340
|
+
}
|
|
341
|
+
async claimOutbox(input) {
|
|
342
|
+
const options = normalizeClaimOptions(input);
|
|
343
|
+
return this.#transaction(async (database) => {
|
|
344
|
+
const selected = await database.query(`SELECT effect_id
|
|
345
|
+
FROM public.mesh_outbox
|
|
346
|
+
WHERE tenant_id = $1 AND mesh_id = $2 AND peer_id = $3
|
|
347
|
+
AND instance_id = $4
|
|
348
|
+
AND (
|
|
349
|
+
(status = 'pending' AND available_at <= transaction_timestamp())
|
|
350
|
+
OR
|
|
351
|
+
(status = 'delivering' AND claim_expires_at <= transaction_timestamp())
|
|
352
|
+
)
|
|
353
|
+
ORDER BY created_at, effect_id
|
|
354
|
+
FOR UPDATE SKIP LOCKED
|
|
355
|
+
LIMIT $5`, [...scopeValues(options.scope), options.limit]);
|
|
356
|
+
const claimed = [];
|
|
357
|
+
for (const selectedRow of selected.rows) {
|
|
358
|
+
const token = globalThis.crypto.randomUUID();
|
|
359
|
+
const updated = await database.query(`UPDATE public.mesh_outbox
|
|
360
|
+
SET status = 'delivering',
|
|
361
|
+
attempts = attempts + 1,
|
|
362
|
+
claim_worker_id = $6,
|
|
363
|
+
claim_token = $7,
|
|
364
|
+
claim_generation = claim_generation + 1,
|
|
365
|
+
claim_expires_at = transaction_timestamp()
|
|
366
|
+
+ ($8::bigint * interval '1 millisecond'),
|
|
367
|
+
settled_at = NULL,
|
|
368
|
+
reason_code = NULL
|
|
369
|
+
WHERE tenant_id = $1 AND mesh_id = $2 AND peer_id = $3
|
|
370
|
+
AND instance_id = $4 AND effect_id = $5
|
|
371
|
+
RETURNING *`, [
|
|
372
|
+
...scopeValues(options.scope),
|
|
373
|
+
String(selectedRow.effect_id),
|
|
374
|
+
options.workerId,
|
|
375
|
+
token,
|
|
376
|
+
options.leaseDurationMs,
|
|
377
|
+
]);
|
|
378
|
+
claimed.push(await mapOutbox(updated.rows[0], options.scope));
|
|
379
|
+
}
|
|
380
|
+
return Object.freeze(claimed);
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
async settleOutbox(input) {
|
|
384
|
+
const scope = normalizeMeshDurableScope(input.outbox.scope);
|
|
385
|
+
const claim = input.outbox.claim;
|
|
386
|
+
if (!claim)
|
|
387
|
+
return false;
|
|
388
|
+
if (!input.settlement ||
|
|
389
|
+
(input.settlement.disposition !== "retryable" &&
|
|
390
|
+
input.settlement.disposition !== "delivered" &&
|
|
391
|
+
input.settlement.disposition !== "permanent_rejection")) {
|
|
392
|
+
throw new TypeError("Mesh PostgreSQL outbox settlement is invalid");
|
|
393
|
+
}
|
|
394
|
+
if (input.settlement.reasonCode !== undefined) {
|
|
395
|
+
boundedReason(input.settlement.reasonCode, "reasonCode");
|
|
396
|
+
}
|
|
397
|
+
const retry = input.settlement.disposition === "retryable";
|
|
398
|
+
const retryAfterMs = retry
|
|
399
|
+
? positiveInteger(input.settlement.retryAfterMs, "retryAfterMs", 3_600_000)
|
|
400
|
+
: 0;
|
|
401
|
+
const status = retry
|
|
402
|
+
? "pending"
|
|
403
|
+
: input.settlement.disposition === "delivered"
|
|
404
|
+
? "delivered"
|
|
405
|
+
: "rejected";
|
|
406
|
+
const result = await scopedDatabase(this.pool, this.#schema).query(`UPDATE public.mesh_outbox
|
|
407
|
+
SET status = $9,
|
|
408
|
+
available_at = CASE WHEN $9 = 'pending'
|
|
409
|
+
THEN transaction_timestamp()
|
|
410
|
+
+ ($10::bigint * interval '1 millisecond')
|
|
411
|
+
ELSE available_at END,
|
|
412
|
+
settled_at = CASE WHEN $9 = 'pending'
|
|
413
|
+
THEN NULL ELSE transaction_timestamp() END,
|
|
414
|
+
reason_code = $11,
|
|
415
|
+
claim_worker_id = NULL,
|
|
416
|
+
claim_token = NULL,
|
|
417
|
+
claim_expires_at = NULL
|
|
418
|
+
WHERE tenant_id = $1 AND mesh_id = $2 AND peer_id = $3
|
|
419
|
+
AND instance_id = $4 AND effect_id = $5
|
|
420
|
+
AND status = 'delivering' AND claim_worker_id = $6
|
|
421
|
+
AND claim_token = $7 AND claim_generation = $8
|
|
422
|
+
AND claim_expires_at > transaction_timestamp()`, [
|
|
423
|
+
...scopeValues(scope),
|
|
424
|
+
input.outbox.effectId,
|
|
425
|
+
claim.workerId,
|
|
426
|
+
claim.leaseToken,
|
|
427
|
+
claim.generation,
|
|
428
|
+
status,
|
|
429
|
+
retryAfterMs,
|
|
430
|
+
input.settlement.reasonCode ?? null,
|
|
431
|
+
]);
|
|
432
|
+
return result.rowCount === 1;
|
|
433
|
+
}
|
|
434
|
+
async inspectJournal(input) {
|
|
435
|
+
const scope = normalizeMeshDurableScope(input.scope);
|
|
436
|
+
const afterSequence = nonNegativeInteger(input.afterSequence ?? 0, "afterSequence");
|
|
437
|
+
const limit = positiveInteger(input.limit, "limit", 10_000);
|
|
438
|
+
const result = await scopedDatabase(this.pool, this.#schema).query(`SELECT * FROM public.mesh_journal
|
|
439
|
+
WHERE tenant_id = $1 AND mesh_id = $2 AND peer_id = $3
|
|
440
|
+
AND instance_id = $4 AND sequence > $5
|
|
441
|
+
ORDER BY sequence
|
|
442
|
+
LIMIT $6`, [...scopeValues(scope), afterSequence, limit]);
|
|
443
|
+
const entries = await Promise.all(result.rows.map((row) => mapJournal(row, scope)));
|
|
444
|
+
return Object.freeze(entries);
|
|
445
|
+
}
|
|
446
|
+
close() {
|
|
447
|
+
// The pool is caller-owned.
|
|
448
|
+
}
|
|
449
|
+
async #transaction(work) {
|
|
450
|
+
const client = await this.pool.connect();
|
|
451
|
+
const database = scopedDatabase(client, this.#schema);
|
|
452
|
+
try {
|
|
453
|
+
await client.query("BEGIN");
|
|
454
|
+
const result = await work(database);
|
|
455
|
+
await client.query("COMMIT");
|
|
456
|
+
return result;
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
await client.query("ROLLBACK").catch(() => undefined);
|
|
460
|
+
throw error;
|
|
461
|
+
}
|
|
462
|
+
finally {
|
|
463
|
+
client.release();
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
class CommitConflictError extends Error {
|
|
468
|
+
code;
|
|
469
|
+
constructor(code) {
|
|
470
|
+
super(code);
|
|
471
|
+
this.code = code;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
function commitConflict(code) {
|
|
475
|
+
return Object.freeze({ committed: false, code });
|
|
476
|
+
}
|
|
477
|
+
function validateInboundEnvelope(input, scope) {
|
|
478
|
+
const result = validateSignedMeshEnvelope(input);
|
|
479
|
+
if (!result.ok ||
|
|
480
|
+
result.value.tenantId !== scope.tenantId ||
|
|
481
|
+
result.value.meshId !== scope.meshId ||
|
|
482
|
+
(result.value.audience.kind === "peer" &&
|
|
483
|
+
result.value.audience.peerId !== scope.peerId)) {
|
|
484
|
+
throw new TypeError("Mesh durable inbound envelope scope is invalid");
|
|
485
|
+
}
|
|
486
|
+
return result.value;
|
|
487
|
+
}
|
|
488
|
+
function validateOutboundEnvelope(input, scope, targetPeerId) {
|
|
489
|
+
const result = validateSignedMeshEnvelope(input);
|
|
490
|
+
if (!result.ok ||
|
|
491
|
+
result.value.tenantId !== scope.tenantId ||
|
|
492
|
+
result.value.meshId !== scope.meshId ||
|
|
493
|
+
result.value.sender.peerId !== scope.peerId ||
|
|
494
|
+
result.value.sender.instanceId !== scope.instanceId ||
|
|
495
|
+
(result.value.audience.kind === "peer" &&
|
|
496
|
+
result.value.audience.peerId !== targetPeerId) ||
|
|
497
|
+
(result.value.audience.kind === "mesh" && !targetPeerId)) {
|
|
498
|
+
throw new TypeError("Mesh durable outbound envelope scope is invalid");
|
|
499
|
+
}
|
|
500
|
+
return result.value;
|
|
501
|
+
}
|
|
502
|
+
async function digestEnvelope(envelope) {
|
|
503
|
+
return computeMeshDurableValueDigest(envelope);
|
|
504
|
+
}
|
|
505
|
+
async function restoreEnvelope(input) {
|
|
506
|
+
const canonical = canonicalizeMeshJsonBytes(input);
|
|
507
|
+
if (!canonical.ok) {
|
|
508
|
+
throw new TypeError("Persisted Mesh envelope is invalid");
|
|
509
|
+
}
|
|
510
|
+
const parsed = parseSignedMeshEnvelope(canonical.value);
|
|
511
|
+
if (!parsed.ok)
|
|
512
|
+
throw new TypeError("Persisted Mesh envelope is invalid");
|
|
513
|
+
return parsed.value;
|
|
514
|
+
}
|
|
515
|
+
async function mapInbox(row, scope) {
|
|
516
|
+
const envelope = await restoreEnvelope(row.envelope);
|
|
517
|
+
const envelopeDigest = await digestEnvelope(envelope);
|
|
518
|
+
if (envelope.messageId !== String(row.message_id) ||
|
|
519
|
+
envelopeDigest !== String(row.envelope_digest)) {
|
|
520
|
+
throw new TypeError("Persisted Mesh inbox integrity check failed");
|
|
521
|
+
}
|
|
522
|
+
const status = String(row.status);
|
|
523
|
+
const claim = mapClaim(row, status === "processing");
|
|
524
|
+
return Object.freeze({
|
|
525
|
+
schemaVersion: MESH_DURABILITY_SCHEMA_VERSION,
|
|
526
|
+
scope,
|
|
527
|
+
messageId: envelope.messageId,
|
|
528
|
+
envelope,
|
|
529
|
+
envelopeDigest,
|
|
530
|
+
status,
|
|
531
|
+
attempts: safeInteger(row.attempts, "inbox attempts"),
|
|
532
|
+
receivedAt: iso(row.received_at),
|
|
533
|
+
availableAt: iso(row.available_at),
|
|
534
|
+
...(claim ? { claim } : {}),
|
|
535
|
+
...(row.settled_at == null ? {} : { settledAt: iso(row.settled_at) }),
|
|
536
|
+
...(row.reason_code == null ? {} : { reasonCode: String(row.reason_code) }),
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
async function mapOutbox(row, scope) {
|
|
540
|
+
const envelope = await restoreEnvelope(row.envelope);
|
|
541
|
+
const envelopeDigest = await digestEnvelope(envelope);
|
|
542
|
+
if (envelope.messageId !== String(row.message_id) ||
|
|
543
|
+
envelopeDigest !== String(row.envelope_digest)) {
|
|
544
|
+
throw new TypeError("Persisted Mesh outbox integrity check failed");
|
|
545
|
+
}
|
|
546
|
+
const status = String(row.status);
|
|
547
|
+
const claim = mapClaim(row, status === "delivering");
|
|
548
|
+
return Object.freeze({
|
|
549
|
+
schemaVersion: MESH_DURABILITY_SCHEMA_VERSION,
|
|
550
|
+
scope,
|
|
551
|
+
effectId: String(row.effect_id),
|
|
552
|
+
messageId: envelope.messageId,
|
|
553
|
+
envelope,
|
|
554
|
+
envelopeDigest,
|
|
555
|
+
...(row.target_peer_id == null
|
|
556
|
+
? {}
|
|
557
|
+
: { targetPeerId: String(row.target_peer_id) }),
|
|
558
|
+
status,
|
|
559
|
+
attempts: safeInteger(row.attempts, "outbox attempts"),
|
|
560
|
+
availableAt: iso(row.available_at),
|
|
561
|
+
createdAt: iso(row.created_at),
|
|
562
|
+
...(claim ? { claim } : {}),
|
|
563
|
+
...(row.settled_at == null ? {} : { settledAt: iso(row.settled_at) }),
|
|
564
|
+
...(row.reason_code == null ? {} : { reasonCode: String(row.reason_code) }),
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
async function mapSnapshot(row, scope) {
|
|
568
|
+
const state = cloneStrictJson(row.state);
|
|
569
|
+
const stateDigest = await computeMeshDurableValueDigest(state);
|
|
570
|
+
if (stateDigest !== String(row.state_digest)) {
|
|
571
|
+
throw new TypeError("Persisted Mesh snapshot integrity check failed");
|
|
572
|
+
}
|
|
573
|
+
return Object.freeze({
|
|
574
|
+
schemaVersion: MESH_DURABILITY_SCHEMA_VERSION,
|
|
575
|
+
scope,
|
|
576
|
+
revision: positiveInteger(safeInteger(row.revision, "snapshot revision"), "snapshot revision", Number.MAX_SAFE_INTEGER),
|
|
577
|
+
state,
|
|
578
|
+
stateDigest,
|
|
579
|
+
committedAt: iso(row.committed_at),
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
async function mapJournal(row, scope) {
|
|
583
|
+
const entry = await createMeshDurableJournalEntry({
|
|
584
|
+
scope,
|
|
585
|
+
sequence: safeInteger(row.sequence, "journal sequence"),
|
|
586
|
+
previousDigest: String(row.previous_digest),
|
|
587
|
+
transitionId: String(row.transition_id),
|
|
588
|
+
...(row.inbox_message_id == null
|
|
589
|
+
? {}
|
|
590
|
+
: { inboxMessageId: String(row.inbox_message_id) }),
|
|
591
|
+
snapshotRevision: safeInteger(row.snapshot_revision, "snapshot revision"),
|
|
592
|
+
snapshotDigest: String(row.snapshot_digest),
|
|
593
|
+
draft: {
|
|
594
|
+
entryId: String(row.entry_id),
|
|
595
|
+
kind: String(row.kind),
|
|
596
|
+
...(row.reason_code == null
|
|
597
|
+
? {}
|
|
598
|
+
: { reasonCode: String(row.reason_code) }),
|
|
599
|
+
},
|
|
600
|
+
occurredAt: iso(row.occurred_at),
|
|
601
|
+
});
|
|
602
|
+
if (entry.digest !== String(row.digest)) {
|
|
603
|
+
throw new TypeError("Persisted Mesh journal integrity check failed");
|
|
604
|
+
}
|
|
605
|
+
return entry;
|
|
606
|
+
}
|
|
607
|
+
function mapClaim(row, required) {
|
|
608
|
+
if (!required)
|
|
609
|
+
return undefined;
|
|
610
|
+
if (row.claim_worker_id == null ||
|
|
611
|
+
row.claim_token == null ||
|
|
612
|
+
row.claim_expires_at == null) {
|
|
613
|
+
throw new TypeError("Persisted Mesh claim is incomplete");
|
|
614
|
+
}
|
|
615
|
+
return Object.freeze({
|
|
616
|
+
workerId: String(row.claim_worker_id),
|
|
617
|
+
leaseToken: String(row.claim_token),
|
|
618
|
+
generation: positiveInteger(safeInteger(row.claim_generation, "claim generation"), "claim generation", Number.MAX_SAFE_INTEGER),
|
|
619
|
+
expiresAt: iso(row.claim_expires_at),
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
function claimMatches(row, claim, expectedStatus) {
|
|
623
|
+
return (claim !== undefined &&
|
|
624
|
+
row.status === expectedStatus &&
|
|
625
|
+
row.claim_live === true &&
|
|
626
|
+
row.claim_worker_id === claim.workerId &&
|
|
627
|
+
row.claim_token === claim.leaseToken &&
|
|
628
|
+
safeInteger(row.claim_generation, "claim generation") === claim.generation);
|
|
629
|
+
}
|
|
630
|
+
function assertCommitInput(input, scope) {
|
|
631
|
+
if (input.inbox.status !== "processing" ||
|
|
632
|
+
!input.inbox.claim ||
|
|
633
|
+
!scopeEquals(scope, input.inbox.scope) ||
|
|
634
|
+
!Number.isSafeInteger(input.expectedSnapshotRevision) ||
|
|
635
|
+
input.expectedSnapshotRevision < 0 ||
|
|
636
|
+
!validIdentifier(input.transitionId) ||
|
|
637
|
+
!Array.isArray(input.journal) ||
|
|
638
|
+
input.journal.length > 64 ||
|
|
639
|
+
!Array.isArray(input.outbox) ||
|
|
640
|
+
input.outbox.length > 256 ||
|
|
641
|
+
(input.outcome === "applied" && input.nextState === undefined) ||
|
|
642
|
+
(input.outcome === "rejected" &&
|
|
643
|
+
(input.nextState !== undefined ||
|
|
644
|
+
input.outbox.length !== 0 ||
|
|
645
|
+
input.reasonCode === undefined)) ||
|
|
646
|
+
(input.outcome === "applied" && input.reasonCode !== undefined) ||
|
|
647
|
+
(input.outcome !== "applied" && input.outcome !== "rejected")) {
|
|
648
|
+
throw new TypeError("Mesh durable commit input is invalid");
|
|
649
|
+
}
|
|
650
|
+
if (input.reasonCode !== undefined) {
|
|
651
|
+
boundedReason(input.reasonCode, "reasonCode");
|
|
652
|
+
}
|
|
653
|
+
for (const draft of input.journal) {
|
|
654
|
+
if (!draft ||
|
|
655
|
+
!validIdentifier(draft.entryId) ||
|
|
656
|
+
!validReason(draft.kind) ||
|
|
657
|
+
(draft.reasonCode !== undefined && !validReason(draft.reasonCode))) {
|
|
658
|
+
throw new TypeError("Mesh durable journal draft is invalid");
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
for (const outbound of input.outbox) {
|
|
662
|
+
if (!outbound ||
|
|
663
|
+
!validIdentifier(outbound.effectId) ||
|
|
664
|
+
(outbound.targetPeerId !== undefined &&
|
|
665
|
+
!validIdentifier(outbound.targetPeerId))) {
|
|
666
|
+
throw new TypeError("Mesh durable outbound draft is invalid");
|
|
667
|
+
}
|
|
668
|
+
validateOutboundEnvelope(outbound.envelope, scope, outbound.targetPeerId ??
|
|
669
|
+
(outbound.envelope?.audience?.kind === "peer"
|
|
670
|
+
? outbound.envelope.audience.peerId
|
|
671
|
+
: undefined));
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
function normalizeClaimOptions(input) {
|
|
675
|
+
if (!input || typeof input !== "object") {
|
|
676
|
+
throw new TypeError("Mesh durable claim options are required");
|
|
677
|
+
}
|
|
678
|
+
if (typeof input.workerId !== "string" ||
|
|
679
|
+
!/^[A-Za-z0-9][A-Za-z0-9._:@-]*$/u.test(input.workerId) ||
|
|
680
|
+
input.workerId.length > 256) {
|
|
681
|
+
throw new TypeError("Mesh durable workerId is invalid");
|
|
682
|
+
}
|
|
683
|
+
return Object.freeze({
|
|
684
|
+
scope: normalizeMeshDurableScope(input.scope),
|
|
685
|
+
workerId: input.workerId,
|
|
686
|
+
limit: positiveInteger(input.limit, "limit", 256),
|
|
687
|
+
leaseDurationMs: positiveInteger(input.leaseDurationMs, "leaseDurationMs", 3_600_000),
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
function cloneStrictJson(value) {
|
|
691
|
+
const canonical = canonicalizeMeshJsonBytes(value);
|
|
692
|
+
if (!canonical.ok) {
|
|
693
|
+
throw new TypeError("Mesh durable snapshot must be bounded strict JSON");
|
|
694
|
+
}
|
|
695
|
+
const parsed = JSON.parse(new TextDecoder().decode(canonical.value));
|
|
696
|
+
return deepFreezeJson(parsed);
|
|
697
|
+
}
|
|
698
|
+
function deepFreezeJson(value) {
|
|
699
|
+
if (Array.isArray(value)) {
|
|
700
|
+
for (const item of value)
|
|
701
|
+
deepFreezeJson(item);
|
|
702
|
+
return Object.freeze(value);
|
|
703
|
+
}
|
|
704
|
+
if (value !== null && typeof value === "object") {
|
|
705
|
+
for (const item of Object.values(value))
|
|
706
|
+
deepFreezeJson(item);
|
|
707
|
+
return Object.freeze(value);
|
|
708
|
+
}
|
|
709
|
+
return value;
|
|
710
|
+
}
|
|
711
|
+
function scopeValues(scope) {
|
|
712
|
+
return [scope.tenantId, scope.meshId, scope.peerId, scope.instanceId];
|
|
713
|
+
}
|
|
714
|
+
function scopeKey(scope) {
|
|
715
|
+
return JSON.stringify(scopeValues(scope));
|
|
716
|
+
}
|
|
717
|
+
function scopeEquals(left, right) {
|
|
718
|
+
return (left.tenantId === right.tenantId &&
|
|
719
|
+
left.meshId === right.meshId &&
|
|
720
|
+
left.peerId === right.peerId &&
|
|
721
|
+
left.instanceId === right.instanceId);
|
|
722
|
+
}
|
|
723
|
+
function iso(value) {
|
|
724
|
+
if (value instanceof Date)
|
|
725
|
+
return value.toISOString();
|
|
726
|
+
const parsed = new Date(String(value));
|
|
727
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
728
|
+
throw new TypeError("Persisted Mesh timestamp is invalid");
|
|
729
|
+
}
|
|
730
|
+
return parsed.toISOString();
|
|
731
|
+
}
|
|
732
|
+
function safeInteger(value, label) {
|
|
733
|
+
const number = Number(value);
|
|
734
|
+
if (!Number.isSafeInteger(number) || number < 0) {
|
|
735
|
+
throw new RangeError(`Persisted Mesh ${label} is invalid`);
|
|
736
|
+
}
|
|
737
|
+
return number;
|
|
738
|
+
}
|
|
739
|
+
function positiveInteger(value, label, maximum) {
|
|
740
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
|
|
741
|
+
throw new RangeError(`Mesh PostgreSQL ${label} is outside its range`);
|
|
742
|
+
}
|
|
743
|
+
return value;
|
|
744
|
+
}
|
|
745
|
+
function nonNegativeInteger(value, label) {
|
|
746
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
747
|
+
throw new RangeError(`Mesh PostgreSQL ${label} is outside its range`);
|
|
748
|
+
}
|
|
749
|
+
return value;
|
|
750
|
+
}
|
|
751
|
+
function validIdentifier(value) {
|
|
752
|
+
return (typeof value === "string" &&
|
|
753
|
+
value.length > 0 &&
|
|
754
|
+
new TextEncoder().encode(value).byteLength <= 256 &&
|
|
755
|
+
/^[A-Za-z0-9][A-Za-z0-9._:@-]*$/u.test(value));
|
|
756
|
+
}
|
|
757
|
+
function validReason(value) {
|
|
758
|
+
return (typeof value === "string" &&
|
|
759
|
+
value.length > 0 &&
|
|
760
|
+
value.length <= 128 &&
|
|
761
|
+
/^[a-z0-9][a-z0-9._:-]*$/u.test(value));
|
|
762
|
+
}
|
|
763
|
+
function boundedReason(value, label) {
|
|
764
|
+
if (!validReason(value)) {
|
|
765
|
+
throw new TypeError(`Mesh PostgreSQL ${label} is invalid`);
|
|
766
|
+
}
|
|
767
|
+
return value;
|
|
768
|
+
}
|
|
769
|
+
//# sourceMappingURL=repository.js.map
|