@agentplat/mesh-postgres 0.3.0-beta.1 → 0.3.0-beta.4
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 +31 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +60 -5
- package/dist/migrations.js.map +1 -1
- package/dist/planning-recovery.d.ts +163 -0
- package/dist/planning-recovery.d.ts.map +1 -0
- package/dist/planning-recovery.js +706 -0
- package/dist/planning-recovery.js.map +1 -0
- package/dist/repository.d.ts +3 -2
- package/dist/repository.d.ts.map +1 -1
- package/dist/repository.js +334 -39
- package/dist/repository.js.map +1 -1
- package/migrations/003_planning_recovery_durability.down.sql +2 -0
- package/migrations/003_planning_recovery_durability.up.sql +56 -0
- package/migrations/004_mesh_outbox_dependencies.down.sql +6 -0
- package/migrations/004_mesh_outbox_dependencies.up.sql +32 -0
- package/package.json +5 -4
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
import { assertSnapshotHighWatersNotLoweredV1, createCollectivePlanningSnapshotV1, restorePlanningReducerSnapshotV1, validatePlanningReducerSnapshotV1, } from "@agentplat/collective-planning";
|
|
2
|
+
import { computeMeshDurableValueDigest } from "@agentplat/mesh/durability";
|
|
3
|
+
import { canonicalizeMeshJsonBytes } from "@agentplat/mesh-protocol";
|
|
4
|
+
import { defaultPostgresSchema, normalizePostgresIdentifier, quotePostgresIdentifier, } from "@agentplat/postgres";
|
|
5
|
+
export const PLANNING_RECOVERY_DURABILITY_SCHEMA_VERSION = 1;
|
|
6
|
+
export const PLANNING_RECOVERY_DURABILITY_GENESIS_DIGEST = "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
7
|
+
/** Canonical constructor; it validates the real reducer snapshot before persistence. */
|
|
8
|
+
export async function createPlanningRecoveryDurableStateV1(input) {
|
|
9
|
+
const value = exactObject(input, ["scope", "generation", "planningSnapshot", "recovery"], "state_input");
|
|
10
|
+
const scope = normalizeScope(value.scope);
|
|
11
|
+
const generation = positiveInteger(value.generation, "generation");
|
|
12
|
+
const planningSnapshot = validatePlanningReducerSnapshotV1(value.planningSnapshot);
|
|
13
|
+
assertSnapshotScope(scope, planningSnapshot);
|
|
14
|
+
const recovery = await validatePlanningRecoveryStateV1(value.recovery);
|
|
15
|
+
const highWaters = deriveHighWaters(planningSnapshot, recovery);
|
|
16
|
+
const stateDigest = await digest({
|
|
17
|
+
schemaVersion: 1,
|
|
18
|
+
scope,
|
|
19
|
+
generation,
|
|
20
|
+
planningSnapshot,
|
|
21
|
+
recovery,
|
|
22
|
+
highWaters,
|
|
23
|
+
});
|
|
24
|
+
return Object.freeze({
|
|
25
|
+
schemaVersion: 1,
|
|
26
|
+
scope,
|
|
27
|
+
generation,
|
|
28
|
+
planningSnapshot,
|
|
29
|
+
recovery,
|
|
30
|
+
highWaters,
|
|
31
|
+
stateDigest,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
export async function createPlanningRecoveryStateV1(input) {
|
|
35
|
+
const value = exactObject(input, [
|
|
36
|
+
"schemaVersion",
|
|
37
|
+
"assignmentEpochHighWater",
|
|
38
|
+
"replaySequenceHighWater",
|
|
39
|
+
"revocationHighWater",
|
|
40
|
+
"budgetReservationHighWater",
|
|
41
|
+
"fencingToken",
|
|
42
|
+
], "recovery_state_input");
|
|
43
|
+
if (value.schemaVersion !== 1)
|
|
44
|
+
throw new TypeError("planning_recovery_schema_version_invalid");
|
|
45
|
+
const result = {
|
|
46
|
+
schemaVersion: 1,
|
|
47
|
+
assignmentEpochHighWater: nonNegativeInteger(value.assignmentEpochHighWater, "assignmentEpochHighWater"),
|
|
48
|
+
replaySequenceHighWater: nonNegativeInteger(value.replaySequenceHighWater, "replaySequenceHighWater"),
|
|
49
|
+
revocationHighWater: nonNegativeInteger(value.revocationHighWater, "revocationHighWater"),
|
|
50
|
+
budgetReservationHighWater: nonNegativeInteger(value.budgetReservationHighWater, "budgetReservationHighWater"),
|
|
51
|
+
fencingToken: identifier(value.fencingToken, "fencingToken"),
|
|
52
|
+
};
|
|
53
|
+
return Object.freeze({
|
|
54
|
+
...result,
|
|
55
|
+
recoveryDigest: await digest(result),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
export async function validatePlanningRecoveryStateV1(input) {
|
|
59
|
+
const value = exactObject(input, [
|
|
60
|
+
"schemaVersion",
|
|
61
|
+
"assignmentEpochHighWater",
|
|
62
|
+
"replaySequenceHighWater",
|
|
63
|
+
"revocationHighWater",
|
|
64
|
+
"budgetReservationHighWater",
|
|
65
|
+
"fencingToken",
|
|
66
|
+
"recoveryDigest",
|
|
67
|
+
], "recovery_state");
|
|
68
|
+
const rebuilt = await createPlanningRecoveryStateV1({
|
|
69
|
+
schemaVersion: value.schemaVersion,
|
|
70
|
+
assignmentEpochHighWater: value.assignmentEpochHighWater,
|
|
71
|
+
replaySequenceHighWater: value.replaySequenceHighWater,
|
|
72
|
+
revocationHighWater: value.revocationHighWater,
|
|
73
|
+
budgetReservationHighWater: value.budgetReservationHighWater,
|
|
74
|
+
fencingToken: value.fencingToken,
|
|
75
|
+
});
|
|
76
|
+
if (value.recoveryDigest !== rebuilt.recoveryDigest)
|
|
77
|
+
throw new TypeError("planning_recovery_recovery_digest_invalid");
|
|
78
|
+
return rebuilt;
|
|
79
|
+
}
|
|
80
|
+
export async function validatePlanningRecoveryDurableStateV1(input) {
|
|
81
|
+
const value = exactObject(input, [
|
|
82
|
+
"schemaVersion",
|
|
83
|
+
"scope",
|
|
84
|
+
"generation",
|
|
85
|
+
"planningSnapshot",
|
|
86
|
+
"recovery",
|
|
87
|
+
"highWaters",
|
|
88
|
+
"stateDigest",
|
|
89
|
+
], "state");
|
|
90
|
+
if (value.schemaVersion !== 1)
|
|
91
|
+
throw new TypeError("planning_recovery_schema_version_invalid");
|
|
92
|
+
const rebuilt = await createPlanningRecoveryDurableStateV1({
|
|
93
|
+
scope: value.scope,
|
|
94
|
+
generation: value.generation,
|
|
95
|
+
planningSnapshot: value.planningSnapshot,
|
|
96
|
+
recovery: value.recovery,
|
|
97
|
+
});
|
|
98
|
+
if (!sameJson(value.highWaters, rebuilt.highWaters) ||
|
|
99
|
+
value.stateDigest !== rebuilt.stateDigest)
|
|
100
|
+
throw new TypeError("planning_recovery_state_digest_invalid");
|
|
101
|
+
return rebuilt;
|
|
102
|
+
}
|
|
103
|
+
export async function createPlanningRecoveryDurableEventV1(input) {
|
|
104
|
+
const value = exactObject(input, [
|
|
105
|
+
"scope",
|
|
106
|
+
"sequence",
|
|
107
|
+
"previousDigest",
|
|
108
|
+
"stateGeneration",
|
|
109
|
+
"stateDigest",
|
|
110
|
+
"event",
|
|
111
|
+
"occurredAt",
|
|
112
|
+
], "event_input");
|
|
113
|
+
const scope = normalizeScope(value.scope);
|
|
114
|
+
const event = normalizeEvent(value.event);
|
|
115
|
+
const result = {
|
|
116
|
+
schemaVersion: 1,
|
|
117
|
+
scope,
|
|
118
|
+
sequence: positiveInteger(value.sequence, "sequence"),
|
|
119
|
+
previousDigest: digestString(value.previousDigest, "previousDigest"),
|
|
120
|
+
stateGeneration: positiveInteger(value.stateGeneration, "stateGeneration"),
|
|
121
|
+
stateDigest: digestString(value.stateDigest, "stateDigest"),
|
|
122
|
+
...event,
|
|
123
|
+
occurredAt: timestamp(value.occurredAt),
|
|
124
|
+
};
|
|
125
|
+
return Object.freeze({
|
|
126
|
+
...result,
|
|
127
|
+
digest: await digest(result),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
export async function verifyPlanningRecoveryDurableJournalV1(input) {
|
|
131
|
+
const value = exactObject(input, [
|
|
132
|
+
"entries",
|
|
133
|
+
"anchorSequence",
|
|
134
|
+
"anchorDigest",
|
|
135
|
+
"expectedHeadSequence",
|
|
136
|
+
"expectedHeadDigest",
|
|
137
|
+
], "journal_input", [
|
|
138
|
+
"anchorSequence",
|
|
139
|
+
"anchorDigest",
|
|
140
|
+
"expectedHeadSequence",
|
|
141
|
+
"expectedHeadDigest",
|
|
142
|
+
]);
|
|
143
|
+
if (!Array.isArray(value.entries) ||
|
|
144
|
+
(value.anchorSequence === undefined) !== (value.anchorDigest === undefined))
|
|
145
|
+
throw new TypeError("planning_recovery_journal_invalid");
|
|
146
|
+
let sequence = value.anchorSequence === undefined
|
|
147
|
+
? 0
|
|
148
|
+
: nonNegativeInteger(value.anchorSequence, "anchorSequence");
|
|
149
|
+
let head = value.anchorDigest === undefined
|
|
150
|
+
? PLANNING_RECOVERY_DURABILITY_GENESIS_DIGEST
|
|
151
|
+
: digestString(value.anchorDigest, "anchorDigest");
|
|
152
|
+
let scope;
|
|
153
|
+
let stateGeneration = 0;
|
|
154
|
+
for (const raw of value.entries) {
|
|
155
|
+
const entry = exactObject(raw, [
|
|
156
|
+
"schemaVersion",
|
|
157
|
+
"scope",
|
|
158
|
+
"sequence",
|
|
159
|
+
"previousDigest",
|
|
160
|
+
"digest",
|
|
161
|
+
"stateGeneration",
|
|
162
|
+
"stateDigest",
|
|
163
|
+
"eventId",
|
|
164
|
+
"kind",
|
|
165
|
+
"logicalTimeMs",
|
|
166
|
+
"payload",
|
|
167
|
+
"occurredAt",
|
|
168
|
+
], "journal_entry");
|
|
169
|
+
const entryScope = normalizeScope(entry.scope);
|
|
170
|
+
const entryGeneration = positiveInteger(entry.stateGeneration, "stateGeneration");
|
|
171
|
+
if (entry.sequence !== sequence + 1 ||
|
|
172
|
+
entry.previousDigest !== head ||
|
|
173
|
+
(scope !== undefined && !sameJson(scope, entryScope)) ||
|
|
174
|
+
entryGeneration <= stateGeneration)
|
|
175
|
+
return false;
|
|
176
|
+
const rebuilt = await createPlanningRecoveryDurableEventV1({
|
|
177
|
+
scope: entry.scope,
|
|
178
|
+
sequence: entry.sequence,
|
|
179
|
+
previousDigest: entry.previousDigest,
|
|
180
|
+
stateGeneration: entry.stateGeneration,
|
|
181
|
+
stateDigest: entry.stateDigest,
|
|
182
|
+
event: {
|
|
183
|
+
eventId: entry.eventId,
|
|
184
|
+
kind: entry.kind,
|
|
185
|
+
logicalTimeMs: entry.logicalTimeMs,
|
|
186
|
+
payload: entry.payload,
|
|
187
|
+
},
|
|
188
|
+
occurredAt: entry.occurredAt,
|
|
189
|
+
});
|
|
190
|
+
if (entry.schemaVersion !== 1 || entry.digest !== rebuilt.digest)
|
|
191
|
+
return false;
|
|
192
|
+
scope = entryScope;
|
|
193
|
+
stateGeneration = rebuilt.stateGeneration;
|
|
194
|
+
sequence = rebuilt.sequence;
|
|
195
|
+
head = rebuilt.digest;
|
|
196
|
+
}
|
|
197
|
+
return ((value.expectedHeadSequence === undefined ||
|
|
198
|
+
value.expectedHeadSequence === sequence) &&
|
|
199
|
+
(value.expectedHeadDigest === undefined ||
|
|
200
|
+
value.expectedHeadDigest === head));
|
|
201
|
+
}
|
|
202
|
+
/** In-memory conformance layer. It deliberately shares all validation with PostgreSQL. */
|
|
203
|
+
export class InMemoryPlanningRecoveryDurableRepositoryV1 {
|
|
204
|
+
#snapshot;
|
|
205
|
+
#events = [];
|
|
206
|
+
scope;
|
|
207
|
+
constructor(scope) {
|
|
208
|
+
this.scope = normalizeScope(scope);
|
|
209
|
+
}
|
|
210
|
+
async initialize(input) {
|
|
211
|
+
const state = await scopedState(this.scope, input);
|
|
212
|
+
if (!this.#snapshot) {
|
|
213
|
+
this.#snapshot = await makeSnapshot(state, 0, PLANNING_RECOVERY_DURABILITY_GENESIS_DIGEST);
|
|
214
|
+
return "initialized";
|
|
215
|
+
}
|
|
216
|
+
if (this.#snapshot.state.stateDigest !== state.stateDigest)
|
|
217
|
+
throw new Error("planning_recovery_state_conflict");
|
|
218
|
+
return "existing";
|
|
219
|
+
}
|
|
220
|
+
async read() {
|
|
221
|
+
if (!this.#snapshot)
|
|
222
|
+
throw new Error("planning_recovery_state_missing");
|
|
223
|
+
return clone(this.#snapshot);
|
|
224
|
+
}
|
|
225
|
+
async commit(input) {
|
|
226
|
+
const normalized = await normalizeCommitInput(input);
|
|
227
|
+
const current = await this.read();
|
|
228
|
+
const next = await scopedState(this.scope, normalized.nextState);
|
|
229
|
+
if (normalized.expectedGeneration !== current.state.generation ||
|
|
230
|
+
normalized.expectedStateDigest !== current.state.stateDigest)
|
|
231
|
+
return Object.freeze({ committed: false, code: "state_conflict" });
|
|
232
|
+
if (!canAdvance(current.state, next, normalized.event))
|
|
233
|
+
return Object.freeze({ committed: false, code: "rollback_rejected" });
|
|
234
|
+
const event = await createPlanningRecoveryDurableEventV1({
|
|
235
|
+
scope: this.scope,
|
|
236
|
+
sequence: current.eventHeadSequence + 1,
|
|
237
|
+
previousDigest: current.eventHeadDigest,
|
|
238
|
+
stateGeneration: next.generation,
|
|
239
|
+
stateDigest: next.stateDigest,
|
|
240
|
+
event: normalized.event,
|
|
241
|
+
occurredAt: new Date(0).toISOString(),
|
|
242
|
+
});
|
|
243
|
+
this.#events.push(event);
|
|
244
|
+
this.#snapshot = await makeSnapshot(next, event.sequence, event.digest);
|
|
245
|
+
return Object.freeze({ committed: true, state: next, event });
|
|
246
|
+
}
|
|
247
|
+
async restore(input) {
|
|
248
|
+
const snapshot = await validateSnapshot(input);
|
|
249
|
+
assertSameScope(this.scope, snapshot.state.scope);
|
|
250
|
+
if (!this.#snapshot) {
|
|
251
|
+
this.#snapshot = snapshot;
|
|
252
|
+
this.#events = [];
|
|
253
|
+
return Object.freeze({ restored: true, snapshot: clone(snapshot) });
|
|
254
|
+
}
|
|
255
|
+
if (this.#snapshot.snapshotDigest === snapshot.snapshotDigest)
|
|
256
|
+
return Object.freeze({ restored: false, code: "existing" });
|
|
257
|
+
return Object.freeze({ restored: false, code: "rollback_rejected" });
|
|
258
|
+
}
|
|
259
|
+
async inspectEvents(input = {}) {
|
|
260
|
+
const after = nonNegativeInteger(input.afterSequence ?? 0, "afterSequence");
|
|
261
|
+
const limit = positiveInteger(input.limit ?? 100, "limit", 1000);
|
|
262
|
+
return Object.freeze(this.#events
|
|
263
|
+
.filter((event) => event.sequence > after)
|
|
264
|
+
.slice(0, limit)
|
|
265
|
+
.map(clone));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
export class PostgresPlanningRecoveryDurableRepositoryV1 {
|
|
269
|
+
pool;
|
|
270
|
+
#scope;
|
|
271
|
+
#prefix;
|
|
272
|
+
constructor(pool, options) {
|
|
273
|
+
this.pool = pool;
|
|
274
|
+
if (!pool || typeof pool.connect !== "function")
|
|
275
|
+
throw new TypeError("A PostgreSQL pool is required");
|
|
276
|
+
const values = exactObject(options, [
|
|
277
|
+
"schema",
|
|
278
|
+
"tenantId",
|
|
279
|
+
"policyDomainId",
|
|
280
|
+
"missionId",
|
|
281
|
+
"intentRevision",
|
|
282
|
+
"intentDigest",
|
|
283
|
+
"policyDigest",
|
|
284
|
+
"peerId",
|
|
285
|
+
"peerInstanceId",
|
|
286
|
+
], "postgres_options", ["schema"]);
|
|
287
|
+
this.#scope = normalizeScope({
|
|
288
|
+
tenantId: values.tenantId,
|
|
289
|
+
policyDomainId: values.policyDomainId,
|
|
290
|
+
missionId: values.missionId,
|
|
291
|
+
intentRevision: values.intentRevision,
|
|
292
|
+
intentDigest: values.intentDigest,
|
|
293
|
+
policyDigest: values.policyDigest,
|
|
294
|
+
peerId: values.peerId,
|
|
295
|
+
peerInstanceId: values.peerInstanceId,
|
|
296
|
+
});
|
|
297
|
+
this.#prefix = `${quotePostgresIdentifier(normalizePostgresIdentifier(values.schema ?? defaultPostgresSchema, "schema"))}.`;
|
|
298
|
+
}
|
|
299
|
+
async initialize(input) {
|
|
300
|
+
const state = await scopedState(this.#scope, input);
|
|
301
|
+
return this.#transaction(async (client) => {
|
|
302
|
+
const inserted = await client.query(`INSERT INTO ${this.#prefix}mesh_planning_recovery_states (tenant_id,policy_domain_id,mission_id,intent_revision,intent_digest,policy_digest,peer_id,peer_instance_id,generation,high_water_logical_ms,plan_revision_high_water,fragment_revision_high_water,budget_reservation_high_water,replay_sequence_high_water,assignment_epoch_high_water,revocation_high_water,state_digest,planning_snapshot,recovery_state,event_head_sequence,event_head_digest) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18::jsonb,$19::jsonb,$20,$21) ON CONFLICT DO NOTHING`, rowValues(state, 0, PLANNING_RECOVERY_DURABILITY_GENESIS_DIGEST));
|
|
303
|
+
if ((inserted.rowCount ?? 0) === 1)
|
|
304
|
+
return "initialized";
|
|
305
|
+
if ((await this.#readWith(client)).state.stateDigest !== state.stateDigest)
|
|
306
|
+
throw new Error("planning_recovery_state_conflict");
|
|
307
|
+
return "existing";
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
async read() {
|
|
311
|
+
return this.#readWith(this.pool);
|
|
312
|
+
}
|
|
313
|
+
async commit(input) {
|
|
314
|
+
const normalized = await normalizeCommitInput(input);
|
|
315
|
+
const next = await scopedState(this.#scope, normalized.nextState);
|
|
316
|
+
return this.#transaction(async (client) => {
|
|
317
|
+
const current = await this.#readWith(client, true);
|
|
318
|
+
if (normalized.expectedGeneration !== current.state.generation ||
|
|
319
|
+
normalized.expectedStateDigest !== current.state.stateDigest)
|
|
320
|
+
return Object.freeze({
|
|
321
|
+
committed: false,
|
|
322
|
+
code: "state_conflict",
|
|
323
|
+
});
|
|
324
|
+
if (!canAdvance(current.state, next, normalized.event))
|
|
325
|
+
return Object.freeze({
|
|
326
|
+
committed: false,
|
|
327
|
+
code: "rollback_rejected",
|
|
328
|
+
});
|
|
329
|
+
const event = await createPlanningRecoveryDurableEventV1({
|
|
330
|
+
scope: this.#scope,
|
|
331
|
+
sequence: current.eventHeadSequence + 1,
|
|
332
|
+
previousDigest: current.eventHeadDigest,
|
|
333
|
+
stateGeneration: next.generation,
|
|
334
|
+
stateDigest: next.stateDigest,
|
|
335
|
+
event: normalized.event,
|
|
336
|
+
occurredAt: new Date().toISOString(),
|
|
337
|
+
});
|
|
338
|
+
const updated = await client.query(`UPDATE ${this.#prefix}mesh_planning_recovery_states SET generation=$9,high_water_logical_ms=$10,plan_revision_high_water=$11,fragment_revision_high_water=$12,budget_reservation_high_water=$13,replay_sequence_high_water=$14,assignment_epoch_high_water=$15,revocation_high_water=$16,state_digest=$17,planning_snapshot=$18::jsonb,recovery_state=$19::jsonb,event_head_sequence=$20,event_head_digest=$21,updated_at=transaction_timestamp() WHERE tenant_id=$1 AND policy_domain_id=$2 AND mission_id=$3 AND intent_revision=$4 AND intent_digest=$5 AND policy_digest=$6 AND peer_id=$7 AND peer_instance_id=$8 AND generation=$22 AND state_digest=$23 AND high_water_logical_ms <= $10`, [
|
|
339
|
+
...scopeValues(this.#scope),
|
|
340
|
+
...stateValues(next),
|
|
341
|
+
event.sequence,
|
|
342
|
+
event.digest,
|
|
343
|
+
normalized.expectedGeneration,
|
|
344
|
+
normalized.expectedStateDigest,
|
|
345
|
+
]);
|
|
346
|
+
if ((updated.rowCount ?? 0) !== 1)
|
|
347
|
+
return Object.freeze({
|
|
348
|
+
committed: false,
|
|
349
|
+
code: "state_conflict",
|
|
350
|
+
});
|
|
351
|
+
await client.query(`INSERT INTO ${this.#prefix}mesh_planning_recovery_events (tenant_id,policy_domain_id,mission_id,intent_revision,intent_digest,policy_digest,peer_id,peer_instance_id,sequence,event_id,previous_digest,digest,state_generation,state_digest,logical_time_ms,kind,payload,occurred_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17::jsonb,$18)`, [
|
|
352
|
+
...scopeValues(this.#scope),
|
|
353
|
+
event.sequence,
|
|
354
|
+
event.eventId,
|
|
355
|
+
event.previousDigest,
|
|
356
|
+
event.digest,
|
|
357
|
+
event.stateGeneration,
|
|
358
|
+
event.stateDigest,
|
|
359
|
+
event.logicalTimeMs,
|
|
360
|
+
event.kind,
|
|
361
|
+
JSON.stringify(event.payload),
|
|
362
|
+
event.occurredAt,
|
|
363
|
+
]);
|
|
364
|
+
return Object.freeze({ committed: true, state: next, event });
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
async restore(input) {
|
|
368
|
+
const snapshot = await validateSnapshot(input);
|
|
369
|
+
assertSameScope(this.#scope, snapshot.state.scope);
|
|
370
|
+
return this.#transaction(async (client) => {
|
|
371
|
+
const inserted = await client.query(`INSERT INTO ${this.#prefix}mesh_planning_recovery_states (tenant_id,policy_domain_id,mission_id,intent_revision,intent_digest,policy_digest,peer_id,peer_instance_id,generation,high_water_logical_ms,plan_revision_high_water,fragment_revision_high_water,budget_reservation_high_water,replay_sequence_high_water,assignment_epoch_high_water,revocation_high_water,state_digest,planning_snapshot,recovery_state,event_head_sequence,event_head_digest) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18::jsonb,$19::jsonb,$20,$21) ON CONFLICT DO NOTHING`, rowValues(snapshot.state, snapshot.eventHeadSequence, snapshot.eventHeadDigest));
|
|
372
|
+
if ((inserted.rowCount ?? 0) === 1)
|
|
373
|
+
return Object.freeze({ restored: true, snapshot });
|
|
374
|
+
const current = await this.#readWith(client, true);
|
|
375
|
+
if (current.snapshotDigest === snapshot.snapshotDigest)
|
|
376
|
+
return Object.freeze({ restored: false, code: "existing" });
|
|
377
|
+
return Object.freeze({
|
|
378
|
+
restored: false,
|
|
379
|
+
code: "rollback_rejected",
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
async inspectEvents(input = {}) {
|
|
384
|
+
const after = nonNegativeInteger(input.afterSequence ?? 0, "afterSequence");
|
|
385
|
+
const limit = positiveInteger(input.limit ?? 100, "limit", 1000);
|
|
386
|
+
const result = await this.pool.query(`SELECT * FROM ${this.#prefix}mesh_planning_recovery_events WHERE tenant_id=$1 AND policy_domain_id=$2 AND mission_id=$3 AND intent_revision=$4 AND intent_digest=$5 AND policy_digest=$6 AND peer_id=$7 AND peer_instance_id=$8 AND sequence > $9 ORDER BY sequence LIMIT $10`, [...scopeValues(this.#scope), after, limit]);
|
|
387
|
+
return Object.freeze(await Promise.all(result.rows.map((row) => mapEvent(row, this.#scope))));
|
|
388
|
+
}
|
|
389
|
+
async #readWith(queryable, locked = false) {
|
|
390
|
+
const result = await queryable.query(`SELECT * FROM ${this.#prefix}mesh_planning_recovery_states WHERE tenant_id=$1 AND policy_domain_id=$2 AND mission_id=$3 AND intent_revision=$4 AND intent_digest=$5 AND policy_digest=$6 AND peer_id=$7 AND peer_instance_id=$8${locked ? " FOR UPDATE" : ""}`, scopeValues(this.#scope));
|
|
391
|
+
if (!result.rows[0])
|
|
392
|
+
throw new Error("planning_recovery_state_missing");
|
|
393
|
+
return mapSnapshot(result.rows[0], this.#scope);
|
|
394
|
+
}
|
|
395
|
+
async #transaction(work) {
|
|
396
|
+
const client = await this.pool.connect();
|
|
397
|
+
try {
|
|
398
|
+
await client.query("BEGIN");
|
|
399
|
+
const result = await work(client);
|
|
400
|
+
await client.query("COMMIT");
|
|
401
|
+
return result;
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
await client.query("ROLLBACK").catch(() => undefined);
|
|
405
|
+
throw error;
|
|
406
|
+
}
|
|
407
|
+
finally {
|
|
408
|
+
client.release();
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
async function mapSnapshot(row, scope) {
|
|
413
|
+
const state = await createPlanningRecoveryDurableStateV1({
|
|
414
|
+
scope,
|
|
415
|
+
generation: numberValue(row.generation, "generation"),
|
|
416
|
+
planningSnapshot: row.planning_snapshot,
|
|
417
|
+
recovery: row.recovery_state,
|
|
418
|
+
});
|
|
419
|
+
if (row.state_digest !== state.stateDigest ||
|
|
420
|
+
!sameJson(rowHighWaters(row), state.highWaters))
|
|
421
|
+
throw new Error("planning_recovery_state_corrupt");
|
|
422
|
+
return makeSnapshot(state, nonNegativeInteger(numberValue(row.event_head_sequence, "eventHeadSequence"), "eventHeadSequence"), digestString(row.event_head_digest, "eventHeadDigest"));
|
|
423
|
+
}
|
|
424
|
+
async function mapEvent(row, scope) {
|
|
425
|
+
const event = await createPlanningRecoveryDurableEventV1({
|
|
426
|
+
scope,
|
|
427
|
+
sequence: numberValue(row.sequence, "sequence"),
|
|
428
|
+
previousDigest: String(row.previous_digest),
|
|
429
|
+
stateGeneration: numberValue(row.state_generation, "stateGeneration"),
|
|
430
|
+
stateDigest: String(row.state_digest),
|
|
431
|
+
event: {
|
|
432
|
+
eventId: String(row.event_id),
|
|
433
|
+
kind: String(row.kind),
|
|
434
|
+
logicalTimeMs: numberValue(row.logical_time_ms, "logicalTimeMs"),
|
|
435
|
+
payload: row.payload,
|
|
436
|
+
},
|
|
437
|
+
occurredAt: new Date(String(row.occurred_at)).toISOString(),
|
|
438
|
+
});
|
|
439
|
+
if (event.digest !== row.digest)
|
|
440
|
+
throw new Error("planning_recovery_event_corrupt");
|
|
441
|
+
return event;
|
|
442
|
+
}
|
|
443
|
+
async function makeSnapshot(state, eventHeadSequence, eventHeadDigest) {
|
|
444
|
+
const snapshot = {
|
|
445
|
+
schemaVersion: 1,
|
|
446
|
+
state,
|
|
447
|
+
eventHeadSequence: nonNegativeInteger(eventHeadSequence, "eventHeadSequence"),
|
|
448
|
+
eventHeadDigest: digestString(eventHeadDigest, "eventHeadDigest"),
|
|
449
|
+
};
|
|
450
|
+
return Object.freeze({
|
|
451
|
+
...snapshot,
|
|
452
|
+
snapshotDigest: await digest(snapshot),
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
async function validateSnapshot(input) {
|
|
456
|
+
const value = exactObject(input, [
|
|
457
|
+
"schemaVersion",
|
|
458
|
+
"state",
|
|
459
|
+
"eventHeadSequence",
|
|
460
|
+
"eventHeadDigest",
|
|
461
|
+
"snapshotDigest",
|
|
462
|
+
], "snapshot");
|
|
463
|
+
if (value.schemaVersion !== 1)
|
|
464
|
+
throw new TypeError("planning_recovery_snapshot_invalid");
|
|
465
|
+
const snapshot = await makeSnapshot(await validatePlanningRecoveryDurableStateV1(value.state), value.eventHeadSequence, value.eventHeadDigest);
|
|
466
|
+
if (value.snapshotDigest !== snapshot.snapshotDigest)
|
|
467
|
+
throw new TypeError("planning_recovery_snapshot_digest_invalid");
|
|
468
|
+
return snapshot;
|
|
469
|
+
}
|
|
470
|
+
async function scopedState(scope, input) {
|
|
471
|
+
const state = await validatePlanningRecoveryDurableStateV1(input);
|
|
472
|
+
assertSameScope(scope, state.scope);
|
|
473
|
+
return state;
|
|
474
|
+
}
|
|
475
|
+
async function normalizeCommitInput(input) {
|
|
476
|
+
const value = exactObject(input, ["expectedGeneration", "expectedStateDigest", "nextState", "event"], "commit_input");
|
|
477
|
+
return Object.freeze({
|
|
478
|
+
expectedGeneration: positiveInteger(value.expectedGeneration, "expectedGeneration"),
|
|
479
|
+
expectedStateDigest: digestString(value.expectedStateDigest, "expectedStateDigest"),
|
|
480
|
+
nextState: await validatePlanningRecoveryDurableStateV1(value.nextState),
|
|
481
|
+
event: normalizeEvent(value.event),
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
function canAdvance(previous, next, event) {
|
|
485
|
+
if (next.generation !== previous.generation + 1 ||
|
|
486
|
+
event.logicalTimeMs < previous.highWaters.logicalTimeMs)
|
|
487
|
+
return false;
|
|
488
|
+
try {
|
|
489
|
+
restorePlanningReducerSnapshotV1(previous.planningSnapshot.state, next.planningSnapshot);
|
|
490
|
+
assertSnapshotHighWatersNotLoweredV1(toCollective(previous.planningSnapshot), toCollective(next.planningSnapshot));
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
return (highWatersNotLowered(previous.highWaters, next.highWaters) &&
|
|
496
|
+
recoveryCanAdvance(previous.recovery, next.recovery));
|
|
497
|
+
}
|
|
498
|
+
function toCollective(snapshot) {
|
|
499
|
+
const state = snapshot.state;
|
|
500
|
+
return createCollectivePlanningSnapshotV1({
|
|
501
|
+
format: "agentplat.collective-planning.snapshot",
|
|
502
|
+
formatVersion: 1,
|
|
503
|
+
schemaVersion: 1,
|
|
504
|
+
snapshotId: snapshot.snapshotId,
|
|
505
|
+
tenantId: state.tenantId,
|
|
506
|
+
policyDomainId: state.policyDomainId,
|
|
507
|
+
peerId: state.peerId,
|
|
508
|
+
peerInstanceId: state.peerInstanceId,
|
|
509
|
+
missionIntent: state.missionIntent,
|
|
510
|
+
selectionPolicy: state.selectionPolicy,
|
|
511
|
+
planView: state.planView,
|
|
512
|
+
domainHighWaters: state.recordHighWaters,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
function highWatersNotLowered(a, b) {
|
|
516
|
+
return Object.keys(a).every((key) => b[key] >= a[key]);
|
|
517
|
+
}
|
|
518
|
+
function recoveryCanAdvance(previous, next) {
|
|
519
|
+
if (next.assignmentEpochHighWater === previous.assignmentEpochHighWater)
|
|
520
|
+
return next.fencingToken === previous.fencingToken;
|
|
521
|
+
return (next.assignmentEpochHighWater === previous.assignmentEpochHighWater + 1 &&
|
|
522
|
+
next.fencingToken !== previous.fencingToken);
|
|
523
|
+
}
|
|
524
|
+
function deriveHighWaters(snapshot, recovery) {
|
|
525
|
+
const state = snapshot.state;
|
|
526
|
+
const fragments = state.planView.fragments;
|
|
527
|
+
const budget = state.planView.budgetReservations.reduce((sum, item) => sum + item.units, 0);
|
|
528
|
+
if (recovery.budgetReservationHighWater < budget)
|
|
529
|
+
throw new TypeError("planning_recovery_budget_high_water_invalid");
|
|
530
|
+
return Object.freeze({
|
|
531
|
+
logicalTimeMs: state.planView.logicalTimeHighWaterMs,
|
|
532
|
+
intentRevision: state.missionIntent.revision,
|
|
533
|
+
planRevision: state.planView.revision,
|
|
534
|
+
fragmentRevision: fragments.reduce((max, item) => Math.max(max, item.fragmentRevision), 0),
|
|
535
|
+
budgetReservationUnits: recovery.budgetReservationHighWater,
|
|
536
|
+
replaySequence: recovery.replaySequenceHighWater,
|
|
537
|
+
assignmentEpoch: recovery.assignmentEpochHighWater,
|
|
538
|
+
revocation: recovery.revocationHighWater,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
function assertSnapshotScope(scope, snapshot) {
|
|
542
|
+
const state = snapshot.state;
|
|
543
|
+
if (scope.tenantId !== state.tenantId ||
|
|
544
|
+
scope.policyDomainId !== state.policyDomainId ||
|
|
545
|
+
scope.missionId !== state.missionIntent.missionIntentId ||
|
|
546
|
+
scope.intentRevision !== state.missionIntent.revision ||
|
|
547
|
+
scope.intentDigest !== state.missionIntent.intentDigest ||
|
|
548
|
+
scope.policyDigest !== state.selectionPolicy.policyDigest ||
|
|
549
|
+
scope.peerId !== state.peerId ||
|
|
550
|
+
scope.peerInstanceId !== state.peerInstanceId)
|
|
551
|
+
throw new Error("planning_recovery_scope_mismatch");
|
|
552
|
+
}
|
|
553
|
+
function normalizeScope(input) {
|
|
554
|
+
const value = exactObject(input, [
|
|
555
|
+
"tenantId",
|
|
556
|
+
"policyDomainId",
|
|
557
|
+
"missionId",
|
|
558
|
+
"intentRevision",
|
|
559
|
+
"intentDigest",
|
|
560
|
+
"policyDigest",
|
|
561
|
+
"peerId",
|
|
562
|
+
"peerInstanceId",
|
|
563
|
+
], "scope");
|
|
564
|
+
return Object.freeze({
|
|
565
|
+
tenantId: identifier(value.tenantId, "tenantId"),
|
|
566
|
+
policyDomainId: identifier(value.policyDomainId, "policyDomainId"),
|
|
567
|
+
missionId: identifier(value.missionId, "missionId"),
|
|
568
|
+
intentRevision: positiveInteger(value.intentRevision, "intentRevision"),
|
|
569
|
+
intentDigest: planningDigest(value.intentDigest, "intentDigest"),
|
|
570
|
+
policyDigest: planningDigest(value.policyDigest, "policyDigest"),
|
|
571
|
+
peerId: identifier(value.peerId, "peerId"),
|
|
572
|
+
peerInstanceId: identifier(value.peerInstanceId, "peerInstanceId"),
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
function normalizeEvent(input) {
|
|
576
|
+
const value = exactObject(input, ["eventId", "kind", "logicalTimeMs", "payload"], "event");
|
|
577
|
+
if (!canonicalizeMeshJsonBytes(value.payload).ok)
|
|
578
|
+
throw new TypeError("planning_recovery_payload_invalid");
|
|
579
|
+
return Object.freeze({
|
|
580
|
+
eventId: identifier(value.eventId, "eventId"),
|
|
581
|
+
kind: identifier(value.kind, "kind"),
|
|
582
|
+
logicalTimeMs: nonNegativeInteger(value.logicalTimeMs, "logicalTimeMs"),
|
|
583
|
+
payload: clone(value.payload),
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
function exactObject(input, keys, name, optional = []) {
|
|
587
|
+
if (!input ||
|
|
588
|
+
typeof input !== "object" ||
|
|
589
|
+
Object.getPrototypeOf(input) !== Object.prototype ||
|
|
590
|
+
Object.getOwnPropertySymbols(input).length)
|
|
591
|
+
throw new TypeError(`planning_recovery_${name}_invalid`);
|
|
592
|
+
const names = Object.getOwnPropertyNames(input);
|
|
593
|
+
if (names.some((key) => !keys.includes(key)) ||
|
|
594
|
+
keys.some((key) => !optional.includes(key) && !names.includes(key)))
|
|
595
|
+
throw new TypeError(`planning_recovery_${name}_keys_invalid`);
|
|
596
|
+
const result = {};
|
|
597
|
+
for (const key of names) {
|
|
598
|
+
const descriptor = Object.getOwnPropertyDescriptor(input, key);
|
|
599
|
+
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable)
|
|
600
|
+
throw new TypeError(`planning_recovery_${name}_accessor_invalid`);
|
|
601
|
+
result[key] = descriptor.value;
|
|
602
|
+
}
|
|
603
|
+
return result;
|
|
604
|
+
}
|
|
605
|
+
function assertSameScope(a, b) {
|
|
606
|
+
if (!sameJson(a, b))
|
|
607
|
+
throw new Error("planning_recovery_scope_mismatch");
|
|
608
|
+
}
|
|
609
|
+
function scopeValues(scope) {
|
|
610
|
+
return [
|
|
611
|
+
scope.tenantId,
|
|
612
|
+
scope.policyDomainId,
|
|
613
|
+
scope.missionId,
|
|
614
|
+
scope.intentRevision,
|
|
615
|
+
scope.intentDigest,
|
|
616
|
+
scope.policyDigest,
|
|
617
|
+
scope.peerId,
|
|
618
|
+
scope.peerInstanceId,
|
|
619
|
+
];
|
|
620
|
+
}
|
|
621
|
+
function stateValues(state) {
|
|
622
|
+
const h = state.highWaters;
|
|
623
|
+
return [
|
|
624
|
+
state.generation,
|
|
625
|
+
h.logicalTimeMs,
|
|
626
|
+
h.planRevision,
|
|
627
|
+
h.fragmentRevision,
|
|
628
|
+
h.budgetReservationUnits,
|
|
629
|
+
h.replaySequence,
|
|
630
|
+
h.assignmentEpoch,
|
|
631
|
+
h.revocation,
|
|
632
|
+
state.stateDigest,
|
|
633
|
+
JSON.stringify(state.planningSnapshot),
|
|
634
|
+
JSON.stringify(state.recovery),
|
|
635
|
+
];
|
|
636
|
+
}
|
|
637
|
+
function rowValues(state, sequence, digestValue) {
|
|
638
|
+
return [
|
|
639
|
+
...scopeValues(state.scope),
|
|
640
|
+
...stateValues(state),
|
|
641
|
+
sequence,
|
|
642
|
+
digestValue,
|
|
643
|
+
];
|
|
644
|
+
}
|
|
645
|
+
function rowHighWaters(row) {
|
|
646
|
+
return {
|
|
647
|
+
logicalTimeMs: numberValue(row.high_water_logical_ms, "logicalTimeMs"),
|
|
648
|
+
intentRevision: numberValue(row.intent_revision, "intentRevision"),
|
|
649
|
+
planRevision: numberValue(row.plan_revision_high_water, "planRevision"),
|
|
650
|
+
fragmentRevision: numberValue(row.fragment_revision_high_water, "fragmentRevision"),
|
|
651
|
+
budgetReservationUnits: numberValue(row.budget_reservation_high_water, "budgetReservationUnits"),
|
|
652
|
+
replaySequence: numberValue(row.replay_sequence_high_water, "replaySequence"),
|
|
653
|
+
assignmentEpoch: numberValue(row.assignment_epoch_high_water, "assignmentEpoch"),
|
|
654
|
+
revocation: numberValue(row.revocation_high_water, "revocation"),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
async function digest(value) {
|
|
658
|
+
return computeMeshDurableValueDigest(value);
|
|
659
|
+
}
|
|
660
|
+
function clone(value) {
|
|
661
|
+
return JSON.parse(JSON.stringify(value));
|
|
662
|
+
}
|
|
663
|
+
function sameJson(a, b) {
|
|
664
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
665
|
+
}
|
|
666
|
+
function identifier(value, name) {
|
|
667
|
+
if (typeof value !== "string" ||
|
|
668
|
+
value.length < 1 ||
|
|
669
|
+
value.length > 256 ||
|
|
670
|
+
/[\u0000-\u001f\u007f]/u.test(value))
|
|
671
|
+
throw new TypeError(`planning_recovery_${name}_invalid`);
|
|
672
|
+
return value;
|
|
673
|
+
}
|
|
674
|
+
function digestString(value, name) {
|
|
675
|
+
if (typeof value !== "string" || !/^sha256:[A-Za-z0-9_-]{43}$/u.test(value))
|
|
676
|
+
throw new TypeError(`planning_recovery_${name}_invalid`);
|
|
677
|
+
return value;
|
|
678
|
+
}
|
|
679
|
+
function planningDigest(value, name) {
|
|
680
|
+
if (typeof value !== "string" || !/^sha256:[0-9a-f]{64}$/u.test(value))
|
|
681
|
+
throw new TypeError(`planning_recovery_${name}_invalid`);
|
|
682
|
+
return value;
|
|
683
|
+
}
|
|
684
|
+
function positiveInteger(value, name, max = Number.MAX_SAFE_INTEGER) {
|
|
685
|
+
if (!Number.isSafeInteger(value) ||
|
|
686
|
+
value < 1 ||
|
|
687
|
+
value > max)
|
|
688
|
+
throw new RangeError(`planning_recovery_${name}_invalid`);
|
|
689
|
+
return value;
|
|
690
|
+
}
|
|
691
|
+
function nonNegativeInteger(value, name, max = Number.MAX_SAFE_INTEGER) {
|
|
692
|
+
if (!Number.isSafeInteger(value) ||
|
|
693
|
+
value < 0 ||
|
|
694
|
+
value > max)
|
|
695
|
+
throw new RangeError(`planning_recovery_${name}_invalid`);
|
|
696
|
+
return value;
|
|
697
|
+
}
|
|
698
|
+
function numberValue(value, name) {
|
|
699
|
+
return nonNegativeInteger(typeof value === "number" ? value : Number(value), name);
|
|
700
|
+
}
|
|
701
|
+
function timestamp(value) {
|
|
702
|
+
if (typeof value !== "string" || !Number.isFinite(Date.parse(value)))
|
|
703
|
+
throw new TypeError("planning_recovery_timestamp_invalid");
|
|
704
|
+
return new Date(value).toISOString();
|
|
705
|
+
}
|
|
706
|
+
//# sourceMappingURL=planning-recovery.js.map
|