@hasna/todos 0.15.14 → 0.15.16
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/dist/cli/cloud-router.d.ts.map +1 -1
- package/dist/cli/commands/mcp-hooks-commands.d.ts.map +1 -1
- package/dist/cli/commands/task-commands.d.ts.map +1 -1
- package/dist/cli/index.js +12470 -10747
- package/dist/contracts.js +1 -1
- package/dist/index.js +358 -47
- package/dist/lib/agent-adapter-docs.d.ts +10 -0
- package/dist/lib/agent-adapter-docs.d.ts.map +1 -1
- package/dist/mcp/index.js +2290 -803
- package/dist/mcp.js +1 -1
- package/dist/project-registration.js +1 -1
- package/dist/registry.js +1 -1
- package/dist/release-provenance.json +5 -5
- package/dist/sdk/index.d.ts +1 -1
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +7 -0
- package/dist/sdk/v1.generated.d.ts +23 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +3 -0
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +19806 -18319
- package/dist/server/openapi.d.ts +134 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/v1.d.ts +2 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/task-manifest/authority.d.ts +3 -1
- package/dist/task-manifest/authority.d.ts.map +1 -1
- package/dist/task-manifest/backend.d.ts +18 -1
- package/dist/task-manifest/backend.d.ts.map +1 -1
- package/dist/task-manifest/http.d.ts +2 -1
- package/dist/task-manifest/http.d.ts.map +1 -1
- package/dist/task-manifest/index.d.ts +2 -2
- package/dist/task-manifest/index.d.ts.map +1 -1
- package/dist/task-manifest/postgres.d.ts +3 -1
- package/dist/task-manifest/postgres.d.ts.map +1 -1
- package/dist/task-manifest/schema-sql.d.ts +3 -1
- package/dist/task-manifest/schema-sql.d.ts.map +1 -1
- package/dist/task-manifest/schema.d.ts +2 -1
- package/dist/task-manifest/schema.d.ts.map +1 -1
- package/dist/task-manifest/sqlite.d.ts +4 -2
- package/dist/task-manifest/sqlite.d.ts.map +1 -1
- package/dist/task-manifest/types.d.ts +24 -1
- package/dist/task-manifest/types.d.ts.map +1 -1
- package/dist/task-manifest.js +357 -46
- package/package.json +1 -1
package/dist/task-manifest.js
CHANGED
|
@@ -4835,6 +4835,9 @@ init_types();
|
|
|
4835
4835
|
// src/task-manifest/types.ts
|
|
4836
4836
|
var TODOS_TASK_MANIFEST_ROUTE = "todos.task-manifest.v1";
|
|
4837
4837
|
var TODOS_TASK_MANIFEST_SCHEMA_VERSION = 1;
|
|
4838
|
+
function supportsIdempotentOutboxDelivery(capability) {
|
|
4839
|
+
return capability !== null && typeof capability === "object" && capability["idempotent_outbox_delivery"] === true;
|
|
4840
|
+
}
|
|
4838
4841
|
|
|
4839
4842
|
class TodosTaskManifestError extends Error {
|
|
4840
4843
|
code;
|
|
@@ -4922,6 +4925,14 @@ var compensationSchema = exports_external.object({
|
|
|
4922
4925
|
idempotency_key: identifier,
|
|
4923
4926
|
if_binding_version: exports_external.number().int().min(1).max(Number.MAX_SAFE_INTEGER)
|
|
4924
4927
|
}).strict();
|
|
4928
|
+
var bindingLookupSchema = exports_external.object({
|
|
4929
|
+
authority: exports_external.string().min(1).max(64),
|
|
4930
|
+
route: exports_external.string().min(1).max(128),
|
|
4931
|
+
schema_version: exports_external.number().int().min(1).max(Number.MAX_SAFE_INTEGER),
|
|
4932
|
+
tenant_id: identifier,
|
|
4933
|
+
plan_id: uuid,
|
|
4934
|
+
max_items: exports_external.number().int().min(1).max(Number.MAX_SAFE_INTEGER)
|
|
4935
|
+
}).strict();
|
|
4925
4936
|
function parseTodosTaskManifest(input) {
|
|
4926
4937
|
const parsed = schema.safeParse(input);
|
|
4927
4938
|
if (!parsed.success) {
|
|
@@ -4987,6 +4998,36 @@ function parseTodosTaskManifestCompensation(input) {
|
|
|
4987
4998
|
}
|
|
4988
4999
|
return parsed.data;
|
|
4989
5000
|
}
|
|
5001
|
+
function parseTodosTaskManifestBindingLookup(input) {
|
|
5002
|
+
const parsed = bindingLookupSchema.safeParse(input);
|
|
5003
|
+
if (!parsed.success) {
|
|
5004
|
+
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_INVALID_INPUT", `Invalid task-manifest binding lookup: ${parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`, { issues: parsed.error.issues });
|
|
5005
|
+
}
|
|
5006
|
+
return parsed.data;
|
|
5007
|
+
}
|
|
5008
|
+
|
|
5009
|
+
// src/task-manifest/backend.ts
|
|
5010
|
+
var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
5011
|
+
function validateTaskManifestBindingLookupRows(rows, tenantId, planId) {
|
|
5012
|
+
if (rows.length === 0) {
|
|
5013
|
+
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_BINDING_NOT_FOUND", `Managed task-manifest binding not found for plan: ${planId}`, { plan_id: planId });
|
|
5014
|
+
}
|
|
5015
|
+
if (rows.length !== 1) {
|
|
5016
|
+
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_LOOKUP_CONFLICT", "Task-manifest plan lookup matched more than one binding", { plan_id: planId, matched_items: rows.length, max_items: 1 });
|
|
5017
|
+
}
|
|
5018
|
+
const row = rows[0];
|
|
5019
|
+
const bindingVersion = Number(row.binding_version);
|
|
5020
|
+
const state = row.state;
|
|
5021
|
+
if (row.binding_tenant_id !== tenantId || row.receipt_tenant_id !== tenantId || row.binding_plan_id !== planId || row.receipt_plan_id !== planId || row.receipt_authority !== "todos" || row.receipt_route !== "todos.task-manifest.v1" || Number(row.receipt_schema_version) !== 1 || row.receipt_kind !== "apply" || row.binding_operation_id !== row.receipt_operation_id || typeof row.apply_receipt_id !== "string" || !UUID_PATTERN.test(row.apply_receipt_id) || !Number.isSafeInteger(bindingVersion) || bindingVersion < 1 || state !== "applied" && state !== "compensated") {
|
|
5022
|
+
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_LOOKUP_CONFLICT", "Task-manifest binding and immutable apply receipt disagree", { plan_id: planId });
|
|
5023
|
+
}
|
|
5024
|
+
return {
|
|
5025
|
+
plan_id: planId,
|
|
5026
|
+
apply_receipt_id: row.apply_receipt_id,
|
|
5027
|
+
binding_version: bindingVersion,
|
|
5028
|
+
state
|
|
5029
|
+
};
|
|
5030
|
+
}
|
|
4990
5031
|
|
|
4991
5032
|
// src/task-manifest/reference-guard.ts
|
|
4992
5033
|
function quoteSqliteIdentifier(value) {
|
|
@@ -5076,10 +5117,14 @@ function postgresTaskManifestForeignReferenceSql(tableName) {
|
|
|
5076
5117
|
}
|
|
5077
5118
|
|
|
5078
5119
|
// src/task-manifest/schema-sql.ts
|
|
5120
|
+
function sqlString(value) {
|
|
5121
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
5122
|
+
}
|
|
5079
5123
|
function sqliteTodosTaskManifestSchemaSql() {
|
|
5080
5124
|
return `
|
|
5081
5125
|
CREATE TABLE IF NOT EXISTS todos_task_manifest_receipts (
|
|
5082
5126
|
receipt_id TEXT PRIMARY KEY,
|
|
5127
|
+
tenant_id TEXT NOT NULL,
|
|
5083
5128
|
authority TEXT NOT NULL CHECK(authority = 'todos'),
|
|
5084
5129
|
route TEXT NOT NULL,
|
|
5085
5130
|
schema_version INTEGER NOT NULL CHECK(schema_version = 1),
|
|
@@ -5097,6 +5142,7 @@ function sqliteTodosTaskManifestSchemaSql() {
|
|
|
5097
5142
|
);
|
|
5098
5143
|
CREATE TABLE IF NOT EXISTS todos_task_manifest_bindings (
|
|
5099
5144
|
operation_id TEXT PRIMARY KEY,
|
|
5145
|
+
tenant_id TEXT NOT NULL,
|
|
5100
5146
|
idempotency_key TEXT NOT NULL UNIQUE,
|
|
5101
5147
|
request_digest TEXT NOT NULL,
|
|
5102
5148
|
result_digest TEXT NOT NULL,
|
|
@@ -5132,10 +5178,37 @@ function sqliteTodosTaskManifestSchemaSql() {
|
|
|
5132
5178
|
END;
|
|
5133
5179
|
`;
|
|
5134
5180
|
}
|
|
5135
|
-
function
|
|
5181
|
+
function sqliteTableHasColumn(db, tableName, columnName) {
|
|
5182
|
+
const columns = db.query(`PRAGMA table_info("${tableName}")`).all();
|
|
5183
|
+
return columns.some((column) => column.name === columnName);
|
|
5184
|
+
}
|
|
5185
|
+
function ensureSqliteTodosTaskManifestSchema(db, tenantId) {
|
|
5186
|
+
db.exec(sqliteTodosTaskManifestSchemaSql());
|
|
5187
|
+
const tenantDefault = sqlString(tenantId);
|
|
5188
|
+
for (const tableName of [
|
|
5189
|
+
"todos_task_manifest_receipts",
|
|
5190
|
+
"todos_task_manifest_bindings"
|
|
5191
|
+
]) {
|
|
5192
|
+
if (!sqliteTableHasColumn(db, tableName, "tenant_id")) {
|
|
5193
|
+
db.exec(`ALTER TABLE "${tableName}" ADD COLUMN tenant_id TEXT NOT NULL DEFAULT ${tenantDefault}`);
|
|
5194
|
+
}
|
|
5195
|
+
}
|
|
5196
|
+
db.exec(`
|
|
5197
|
+
CREATE INDEX IF NOT EXISTS idx_todos_task_manifest_receipts_tenant
|
|
5198
|
+
ON todos_task_manifest_receipts(tenant_id, receipt_id, kind);
|
|
5199
|
+
CREATE INDEX IF NOT EXISTS idx_todos_task_manifest_bindings_tenant_plan
|
|
5200
|
+
ON todos_task_manifest_bindings(
|
|
5201
|
+
tenant_id,
|
|
5202
|
+
json_extract(result_json, '$.graph.plan_id')
|
|
5203
|
+
);
|
|
5204
|
+
`);
|
|
5205
|
+
}
|
|
5206
|
+
function postgresTodosTaskManifestSchemaSql(tenantId = "default") {
|
|
5207
|
+
const tenantDefault = sqlString(tenantId);
|
|
5136
5208
|
return [
|
|
5137
5209
|
`CREATE TABLE IF NOT EXISTS todos_task_manifest_receipts (
|
|
5138
5210
|
receipt_id text PRIMARY KEY,
|
|
5211
|
+
tenant_id text NOT NULL,
|
|
5139
5212
|
authority text NOT NULL CHECK(authority = 'todos'),
|
|
5140
5213
|
route text NOT NULL,
|
|
5141
5214
|
schema_version integer NOT NULL CHECK(schema_version = 1),
|
|
@@ -5151,8 +5224,13 @@ function postgresTodosTaskManifestSchemaSql() {
|
|
|
5151
5224
|
created_at timestamptz NOT NULL,
|
|
5152
5225
|
UNIQUE(kind, idempotency_key)
|
|
5153
5226
|
)`,
|
|
5227
|
+
`ALTER TABLE todos_task_manifest_receipts
|
|
5228
|
+
ADD COLUMN IF NOT EXISTS tenant_id text NOT NULL DEFAULT ${tenantDefault}`,
|
|
5229
|
+
`ALTER TABLE todos_task_manifest_receipts
|
|
5230
|
+
ALTER COLUMN tenant_id DROP DEFAULT`,
|
|
5154
5231
|
`CREATE TABLE IF NOT EXISTS todos_task_manifest_bindings (
|
|
5155
5232
|
operation_id text PRIMARY KEY,
|
|
5233
|
+
tenant_id text NOT NULL,
|
|
5156
5234
|
idempotency_key text NOT NULL UNIQUE,
|
|
5157
5235
|
request_digest text NOT NULL,
|
|
5158
5236
|
result_digest text NOT NULL,
|
|
@@ -5165,6 +5243,10 @@ function postgresTodosTaskManifestSchemaSql() {
|
|
|
5165
5243
|
created_at timestamptz NOT NULL,
|
|
5166
5244
|
updated_at timestamptz NOT NULL
|
|
5167
5245
|
)`,
|
|
5246
|
+
`ALTER TABLE todos_task_manifest_bindings
|
|
5247
|
+
ADD COLUMN IF NOT EXISTS tenant_id text NOT NULL DEFAULT ${tenantDefault}`,
|
|
5248
|
+
`ALTER TABLE todos_task_manifest_bindings
|
|
5249
|
+
ALTER COLUMN tenant_id DROP DEFAULT`,
|
|
5168
5250
|
`CREATE TABLE IF NOT EXISTS todos_task_manifest_outbox (
|
|
5169
5251
|
id text PRIMARY KEY,
|
|
5170
5252
|
apply_receipt_id text NOT NULL REFERENCES todos_task_manifest_receipts(receipt_id),
|
|
@@ -5178,6 +5260,13 @@ function postgresTodosTaskManifestSchemaSql() {
|
|
|
5178
5260
|
)`,
|
|
5179
5261
|
`CREATE INDEX IF NOT EXISTS todos_task_manifest_outbox_receipt_idx
|
|
5180
5262
|
ON todos_task_manifest_outbox(apply_receipt_id, status)`,
|
|
5263
|
+
`CREATE INDEX IF NOT EXISTS todos_task_manifest_receipts_tenant_idx
|
|
5264
|
+
ON todos_task_manifest_receipts(tenant_id, receipt_id, kind)`,
|
|
5265
|
+
`CREATE INDEX IF NOT EXISTS todos_task_manifest_bindings_tenant_plan_idx
|
|
5266
|
+
ON todos_task_manifest_bindings(
|
|
5267
|
+
tenant_id,
|
|
5268
|
+
((result_json #>> '{graph,plan_id}'))
|
|
5269
|
+
)`,
|
|
5181
5270
|
`CREATE OR REPLACE FUNCTION todos_task_manifest_receipts_immutable()
|
|
5182
5271
|
RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
|
5183
5272
|
RAISE EXCEPTION 'todos task manifest receipts are immutable';
|
|
@@ -5201,10 +5290,12 @@ function parseApplyResult(value, duplicate) {
|
|
|
5201
5290
|
|
|
5202
5291
|
class SqliteTodosTaskManifestBackend {
|
|
5203
5292
|
db;
|
|
5293
|
+
tenantId;
|
|
5204
5294
|
kind = "sqlite";
|
|
5205
|
-
constructor(db) {
|
|
5295
|
+
constructor(db, tenantId = "default") {
|
|
5206
5296
|
this.db = db;
|
|
5207
|
-
|
|
5297
|
+
this.tenantId = tenantId;
|
|
5298
|
+
ensureSqliteTodosTaskManifestSchema(db, tenantId);
|
|
5208
5299
|
}
|
|
5209
5300
|
async serialized(run) {
|
|
5210
5301
|
const previous = sqliteTails.get(this.db) ?? Promise.resolve();
|
|
@@ -5234,7 +5325,7 @@ class SqliteTodosTaskManifestBackend {
|
|
|
5234
5325
|
async apply(input, faults) {
|
|
5235
5326
|
return this.serialized(() => {
|
|
5236
5327
|
const { manifest } = input;
|
|
5237
|
-
const binding = this.db.query("SELECT * FROM todos_task_manifest_bindings WHERE operation_id = ? LIMIT 1").get(manifest.operation_id);
|
|
5328
|
+
const binding = this.db.query("SELECT * FROM todos_task_manifest_bindings WHERE tenant_id = ? AND operation_id = ? LIMIT 1").get(this.tenantId, manifest.operation_id);
|
|
5238
5329
|
if (binding) {
|
|
5239
5330
|
if (binding["idempotency_key"] !== manifest.idempotency_key || binding["request_digest"] !== input.request_digest) {
|
|
5240
5331
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Operation is already bound to a different request");
|
|
@@ -5244,7 +5335,7 @@ class SqliteTodosTaskManifestBackend {
|
|
|
5244
5335
|
}
|
|
5245
5336
|
return parseApplyResult(String(binding["result_json"]), true);
|
|
5246
5337
|
}
|
|
5247
|
-
const idempotency = this.db.query("SELECT operation_id, request_digest FROM todos_task_manifest_bindings WHERE idempotency_key = ? LIMIT 1").get(manifest.idempotency_key);
|
|
5338
|
+
const idempotency = this.db.query("SELECT operation_id, request_digest FROM todos_task_manifest_bindings WHERE tenant_id = ? AND idempotency_key = ? LIMIT 1").get(this.tenantId, manifest.idempotency_key);
|
|
5248
5339
|
if (idempotency)
|
|
5249
5340
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Idempotency key is already used by another operation");
|
|
5250
5341
|
if (manifest.if_binding_version !== undefined && manifest.if_binding_version !== 0) {
|
|
@@ -5334,9 +5425,9 @@ class SqliteTodosTaskManifestBackend {
|
|
|
5334
5425
|
const resultJson = canonicalJson(result);
|
|
5335
5426
|
const manifestJson = canonicalJson(manifest);
|
|
5336
5427
|
this.db.query(`INSERT INTO todos_task_manifest_receipts (
|
|
5337
|
-
receipt_id, authority, route, schema_version, kind, operation_id, idempotency_key,
|
|
5428
|
+
receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, idempotency_key,
|
|
5338
5429
|
request_digest, result_digest, binding_version, apply_receipt_id, manifest_json, result_json, created_at
|
|
5339
|
-
) VALUES (?, 'todos', 'todos.task-manifest.v1', 1, 'apply', ?, ?, ?, ?, 1, NULL, ?, ?, ?)`).run(input.receipt_id, manifest.operation_id, manifest.idempotency_key, input.request_digest, input.result_digest, manifestJson, resultJson, input.now);
|
|
5430
|
+
) VALUES (?, ?, 'todos', 'todos.task-manifest.v1', 1, 'apply', ?, ?, ?, ?, 1, NULL, ?, ?, ?)`).run(input.receipt_id, this.tenantId, manifest.operation_id, manifest.idempotency_key, input.request_digest, input.result_digest, manifestJson, resultJson, input.now);
|
|
5340
5431
|
for (const entry of input.outbox) {
|
|
5341
5432
|
this.db.query(`INSERT INTO todos_task_manifest_outbox (
|
|
5342
5433
|
id, apply_receipt_id, topic, payload, payload_digest, status, created_at
|
|
@@ -5344,49 +5435,102 @@ class SqliteTodosTaskManifestBackend {
|
|
|
5344
5435
|
}
|
|
5345
5436
|
fault(faults, "after_outbox_write");
|
|
5346
5437
|
this.db.query(`INSERT INTO todos_task_manifest_bindings (
|
|
5347
|
-
operation_id, idempotency_key, request_digest, result_digest, apply_receipt_id,
|
|
5438
|
+
operation_id, tenant_id, idempotency_key, request_digest, result_digest, apply_receipt_id,
|
|
5348
5439
|
manifest_json, result_json, state, version, created_at, updated_at
|
|
5349
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, 'applied', 1, ?, ?)`).run(manifest.operation_id, manifest.idempotency_key, input.request_digest, input.result_digest, input.receipt_id, manifestJson, resultJson, input.now, input.now);
|
|
5440
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'applied', 1, ?, ?)`).run(manifest.operation_id, this.tenantId, manifest.idempotency_key, input.request_digest, input.result_digest, input.receipt_id, manifestJson, resultJson, input.now, input.now);
|
|
5350
5441
|
fault(faults, "after_receipt_write");
|
|
5351
5442
|
return result;
|
|
5352
5443
|
});
|
|
5353
5444
|
}
|
|
5354
5445
|
async readExact(receiptId) {
|
|
5355
|
-
const row = this.db.query("SELECT result_json FROM todos_task_manifest_receipts WHERE receipt_id = ? AND kind = 'apply' LIMIT 1").get(receiptId);
|
|
5446
|
+
const row = this.db.query("SELECT result_json FROM todos_task_manifest_receipts WHERE tenant_id = ? AND receipt_id = ? AND kind = 'apply' LIMIT 1").get(this.tenantId, receiptId);
|
|
5356
5447
|
if (!row)
|
|
5357
5448
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_RECEIPT_NOT_FOUND", `Apply receipt not found: ${receiptId}`);
|
|
5358
5449
|
return parseApplyResult(row.result_json, false);
|
|
5359
5450
|
}
|
|
5451
|
+
async lookupBindingByPlanId(planId) {
|
|
5452
|
+
const rows = this.db.query(`
|
|
5453
|
+
SELECT
|
|
5454
|
+
b.apply_receipt_id AS apply_receipt_id,
|
|
5455
|
+
b.state AS state,
|
|
5456
|
+
b.version AS binding_version,
|
|
5457
|
+
b.tenant_id AS binding_tenant_id,
|
|
5458
|
+
b.operation_id AS binding_operation_id,
|
|
5459
|
+
json_extract(b.result_json, '$.graph.plan_id') AS binding_plan_id,
|
|
5460
|
+
r.tenant_id AS receipt_tenant_id,
|
|
5461
|
+
r.authority AS receipt_authority,
|
|
5462
|
+
r.route AS receipt_route,
|
|
5463
|
+
r.schema_version AS receipt_schema_version,
|
|
5464
|
+
r.kind AS receipt_kind,
|
|
5465
|
+
r.operation_id AS receipt_operation_id,
|
|
5466
|
+
json_extract(r.result_json, '$.graph.plan_id') AS receipt_plan_id
|
|
5467
|
+
FROM todos_task_manifest_bindings b
|
|
5468
|
+
LEFT JOIN todos_task_manifest_receipts r
|
|
5469
|
+
ON r.receipt_id = b.apply_receipt_id
|
|
5470
|
+
AND r.tenant_id = b.tenant_id
|
|
5471
|
+
WHERE b.tenant_id = ?
|
|
5472
|
+
AND json_extract(b.result_json, '$.graph.plan_id') = ?
|
|
5473
|
+
LIMIT 2
|
|
5474
|
+
`).all(this.tenantId, planId);
|
|
5475
|
+
return validateTaskManifestBindingLookupRows(rows, this.tenantId, planId);
|
|
5476
|
+
}
|
|
5360
5477
|
async markOutboxDelivered(outboxId, deliveredAt) {
|
|
5361
5478
|
await this.serialized(() => {
|
|
5362
5479
|
const result = this.db.query(`UPDATE todos_task_manifest_outbox
|
|
5363
5480
|
SET status = 'delivered', delivered_at = ?, attempts = attempts + 1
|
|
5364
|
-
WHERE id = ? AND status = 'pending'
|
|
5365
|
-
|
|
5366
|
-
|
|
5367
|
-
|
|
5481
|
+
WHERE id = ? AND status = 'pending'
|
|
5482
|
+
AND EXISTS (
|
|
5483
|
+
SELECT 1 FROM todos_task_manifest_receipts r
|
|
5484
|
+
WHERE r.receipt_id = todos_task_manifest_outbox.apply_receipt_id
|
|
5485
|
+
AND r.tenant_id = ?
|
|
5486
|
+
AND r.authority = 'todos'
|
|
5487
|
+
AND r.route = 'todos.task-manifest.v1'
|
|
5488
|
+
AND r.schema_version = 1
|
|
5489
|
+
AND r.kind = 'apply'
|
|
5490
|
+
)`).run(deliveredAt, outboxId, this.tenantId);
|
|
5491
|
+
if (result.changes === 1)
|
|
5492
|
+
return;
|
|
5493
|
+
const existing = this.db.query(`SELECT o.status
|
|
5494
|
+
FROM todos_task_manifest_outbox o
|
|
5495
|
+
JOIN todos_task_manifest_receipts r
|
|
5496
|
+
ON r.receipt_id = o.apply_receipt_id
|
|
5497
|
+
WHERE r.tenant_id = ?
|
|
5498
|
+
AND r.authority = 'todos'
|
|
5499
|
+
AND r.route = 'todos.task-manifest.v1'
|
|
5500
|
+
AND r.schema_version = 1
|
|
5501
|
+
AND r.kind = 'apply'
|
|
5502
|
+
AND o.id = ?
|
|
5503
|
+
LIMIT 1`).get(this.tenantId, outboxId);
|
|
5504
|
+
if (existing?.status === "delivered")
|
|
5505
|
+
return;
|
|
5506
|
+
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_GRAPH_CONFLICT", `Pending outbox row not found: ${outboxId}`);
|
|
5368
5507
|
});
|
|
5369
5508
|
}
|
|
5370
5509
|
async compensate(input, receipt, compensationReceiptId, requestDigest, now) {
|
|
5371
5510
|
return this.serialized(() => {
|
|
5372
5511
|
const existing = this.db.query(`SELECT apply_receipt_id, request_digest, result_json
|
|
5373
|
-
FROM todos_task_manifest_receipts
|
|
5512
|
+
FROM todos_task_manifest_receipts
|
|
5513
|
+
WHERE tenant_id = ? AND kind = 'compensate' AND idempotency_key = ?
|
|
5514
|
+
LIMIT 1`).get(this.tenantId, input.idempotency_key);
|
|
5374
5515
|
if (existing) {
|
|
5375
5516
|
if (existing.apply_receipt_id !== input.receipt_id || existing.request_digest !== requestDigest) {
|
|
5376
5517
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Compensation idempotency key is already used");
|
|
5377
5518
|
}
|
|
5378
5519
|
return { ...JSON.parse(existing.result_json), duplicate: true };
|
|
5379
5520
|
}
|
|
5380
|
-
const row = this.db.query("SELECT * FROM todos_task_manifest_receipts WHERE receipt_id = ? AND kind = 'apply' LIMIT 1").get(input.receipt_id);
|
|
5521
|
+
const row = this.db.query("SELECT * FROM todos_task_manifest_receipts WHERE tenant_id = ? AND receipt_id = ? AND kind = 'apply' LIMIT 1").get(this.tenantId, input.receipt_id);
|
|
5381
5522
|
if (!row)
|
|
5382
5523
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_RECEIPT_NOT_FOUND", "Apply receipt not found");
|
|
5383
|
-
const binding = this.db.query("SELECT * FROM todos_task_manifest_bindings WHERE operation_id = ? LIMIT 1").get(String(row["operation_id"]));
|
|
5524
|
+
const binding = this.db.query("SELECT * FROM todos_task_manifest_bindings WHERE tenant_id = ? AND operation_id = ? LIMIT 1").get(this.tenantId, String(row["operation_id"]));
|
|
5384
5525
|
if (!binding || Number(binding["version"]) !== input.if_binding_version) {
|
|
5385
5526
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_CAS_CONFLICT", "Binding version changed before compensation");
|
|
5386
5527
|
}
|
|
5387
5528
|
if (binding["state"] !== "applied")
|
|
5388
5529
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Graph is not in applied state");
|
|
5389
|
-
const delivered = this.db.query(
|
|
5530
|
+
const delivered = this.db.query(`SELECT o.id FROM todos_task_manifest_outbox o
|
|
5531
|
+
JOIN todos_task_manifest_receipts r ON r.receipt_id = o.apply_receipt_id
|
|
5532
|
+
WHERE r.tenant_id = ? AND o.apply_receipt_id = ? AND o.status = 'delivered'
|
|
5533
|
+
LIMIT 1`).get(this.tenantId, input.receipt_id);
|
|
5390
5534
|
if (delivered)
|
|
5391
5535
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: delivered outbox row exists");
|
|
5392
5536
|
const applyResult = parseApplyResult(String(row["result_json"]), false);
|
|
@@ -5399,7 +5543,14 @@ class SqliteTodosTaskManifestBackend {
|
|
|
5399
5543
|
...(manifest.effects ?? []).map((effect2) => ({ topic: effect2.topic, payload: effect2.payload }))
|
|
5400
5544
|
];
|
|
5401
5545
|
const storedOutbox = this.db.query(`SELECT id, topic, payload, payload_digest, status, attempts, delivered_at
|
|
5402
|
-
FROM todos_task_manifest_outbox
|
|
5546
|
+
FROM todos_task_manifest_outbox
|
|
5547
|
+
WHERE apply_receipt_id = ?
|
|
5548
|
+
AND EXISTS (
|
|
5549
|
+
SELECT 1 FROM todos_task_manifest_receipts r
|
|
5550
|
+
WHERE r.receipt_id = todos_task_manifest_outbox.apply_receipt_id
|
|
5551
|
+
AND r.tenant_id = ?
|
|
5552
|
+
)
|
|
5553
|
+
ORDER BY id`).all(input.receipt_id, this.tenantId);
|
|
5403
5554
|
if (storedOutbox.length !== expectedEffects.length) {
|
|
5404
5555
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: outbox changed since apply");
|
|
5405
5556
|
}
|
|
@@ -5487,7 +5638,14 @@ class SqliteTodosTaskManifestBackend {
|
|
|
5487
5638
|
}
|
|
5488
5639
|
expectedVerificationIndex += 1;
|
|
5489
5640
|
}
|
|
5490
|
-
this.db.query(
|
|
5641
|
+
this.db.query(`UPDATE todos_task_manifest_outbox
|
|
5642
|
+
SET status = 'cancelled'
|
|
5643
|
+
WHERE apply_receipt_id = ? AND status = 'pending'
|
|
5644
|
+
AND EXISTS (
|
|
5645
|
+
SELECT 1 FROM todos_task_manifest_receipts r
|
|
5646
|
+
WHERE r.receipt_id = todos_task_manifest_outbox.apply_receipt_id
|
|
5647
|
+
AND r.tenant_id = ?
|
|
5648
|
+
)`).run(input.receipt_id, this.tenantId);
|
|
5491
5649
|
for (const table of ["task_tags", "task_dependencies", "task_comments", "task_verifications"]) {
|
|
5492
5650
|
const column = table === "task_dependencies" ? "task_id" : "task_id";
|
|
5493
5651
|
this.db.query(`DELETE FROM ${table} WHERE ${column} IN (${placeholders2})`).run(...taskIds);
|
|
@@ -5498,11 +5656,11 @@ class SqliteTodosTaskManifestBackend {
|
|
|
5498
5656
|
const result = { duplicate: false, receipt, absent: true, readback };
|
|
5499
5657
|
const resultJson = canonicalJson(result);
|
|
5500
5658
|
this.db.query(`INSERT INTO todos_task_manifest_receipts (
|
|
5501
|
-
receipt_id, authority, route, schema_version, kind, operation_id, idempotency_key,
|
|
5659
|
+
receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, idempotency_key,
|
|
5502
5660
|
request_digest, result_digest, binding_version, apply_receipt_id, manifest_json, result_json, created_at
|
|
5503
|
-
) VALUES (?, 'todos', 'todos.task-manifest.v1', 1, 'compensate', ?, ?, ?, ?, ?, ?, NULL, ?, ?)`).run(compensationReceiptId, receipt.operation_id, input.idempotency_key, requestDigest, receipt.result_digest, receipt.binding_version, input.receipt_id, resultJson, now);
|
|
5661
|
+
) VALUES (?, ?, 'todos', 'todos.task-manifest.v1', 1, 'compensate', ?, ?, ?, ?, ?, ?, NULL, ?, ?)`).run(compensationReceiptId, this.tenantId, receipt.operation_id, input.idempotency_key, requestDigest, receipt.result_digest, receipt.binding_version, input.receipt_id, resultJson, now);
|
|
5504
5662
|
const updated = this.db.query(`UPDATE todos_task_manifest_bindings SET state = 'compensated', version = ?, compensation_receipt_id = ?, updated_at = ?
|
|
5505
|
-
WHERE operation_id = ? AND state = 'applied' AND version = ?`).run(receipt.binding_version, compensationReceiptId, now, receipt.operation_id, input.if_binding_version);
|
|
5663
|
+
WHERE tenant_id = ? AND operation_id = ? AND state = 'applied' AND version = ?`).run(receipt.binding_version, compensationReceiptId, now, this.tenantId, receipt.operation_id, input.if_binding_version);
|
|
5506
5664
|
if (updated.changes !== 1) {
|
|
5507
5665
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_CAS_CONFLICT", "Binding changed during compensation");
|
|
5508
5666
|
}
|
|
@@ -6045,17 +6203,19 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6045
6203
|
kind = "postgresql";
|
|
6046
6204
|
service;
|
|
6047
6205
|
tableName;
|
|
6206
|
+
tenantId;
|
|
6048
6207
|
schemaReady = null;
|
|
6049
6208
|
constructor(client, options = {}) {
|
|
6050
6209
|
this.client = client;
|
|
6051
6210
|
this.service = options.service ?? "todos";
|
|
6052
6211
|
this.tableName = safeIdentifier(options.tableName ?? DEFAULT_TODOS_POSTGRES_SYNC_TABLE, "tableName");
|
|
6212
|
+
this.tenantId = options.tenantId ?? "default";
|
|
6053
6213
|
}
|
|
6054
6214
|
async ensureSchema() {
|
|
6055
6215
|
this.schemaReady ??= (async () => {
|
|
6056
6216
|
for (const sql of postgresTodosSyncSchemaSql(this.tableName))
|
|
6057
6217
|
await this.client.query(sql);
|
|
6058
|
-
for (const sql of postgresTodosTaskManifestSchemaSql())
|
|
6218
|
+
for (const sql of postgresTodosTaskManifestSchemaSql(this.tenantId))
|
|
6059
6219
|
await this.client.query(sql);
|
|
6060
6220
|
})();
|
|
6061
6221
|
await this.schemaReady;
|
|
@@ -6077,7 +6237,7 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6077
6237
|
const { manifest } = input;
|
|
6078
6238
|
await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${this.service}\x1F${manifest.operation_id}`]);
|
|
6079
6239
|
await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${this.service}\x1Fidempotency\x1F${manifest.idempotency_key}`]);
|
|
6080
|
-
const existing = await tx.query("SELECT * FROM todos_task_manifest_bindings WHERE
|
|
6240
|
+
const existing = await tx.query("SELECT * FROM todos_task_manifest_bindings WHERE tenant_id = $1 AND operation_id = $2 LIMIT 1 FOR UPDATE", [this.tenantId, manifest.operation_id]);
|
|
6081
6241
|
if (existing.rows[0]) {
|
|
6082
6242
|
const binding = existing.rows[0];
|
|
6083
6243
|
if (binding["idempotency_key"] !== manifest.idempotency_key || binding["request_digest"] !== input.request_digest) {
|
|
@@ -6088,7 +6248,7 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6088
6248
|
}
|
|
6089
6249
|
return { ...parseJson(binding["result_json"]), duplicate: true };
|
|
6090
6250
|
}
|
|
6091
|
-
const reused = await tx.query("SELECT operation_id FROM todos_task_manifest_bindings WHERE
|
|
6251
|
+
const reused = await tx.query("SELECT operation_id FROM todos_task_manifest_bindings WHERE tenant_id = $1 AND idempotency_key = $2 LIMIT 1", [this.tenantId, manifest.idempotency_key]);
|
|
6092
6252
|
if (reused.rows[0])
|
|
6093
6253
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Idempotency key is already used");
|
|
6094
6254
|
if (manifest.if_binding_version !== undefined && manifest.if_binding_version !== 0) {
|
|
@@ -6198,10 +6358,11 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6198
6358
|
const manifestJson = canonicalJson(manifest);
|
|
6199
6359
|
const resultJson = canonicalJson(result);
|
|
6200
6360
|
await tx.query(`INSERT INTO todos_task_manifest_receipts (
|
|
6201
|
-
receipt_id, authority, route, schema_version, kind, operation_id, idempotency_key,
|
|
6361
|
+
receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, idempotency_key,
|
|
6202
6362
|
request_digest, result_digest, binding_version, apply_receipt_id, manifest_json, result_json, created_at
|
|
6203
|
-
) VALUES ($1, 'todos', 'todos.task-manifest.v1', 1, 'apply', $
|
|
6363
|
+
) VALUES ($1, $2, 'todos', 'todos.task-manifest.v1', 1, 'apply', $3, $4, $5, $6, 1, NULL, $7::jsonb, $8::jsonb, $9)`, [
|
|
6204
6364
|
input.receipt_id,
|
|
6365
|
+
this.tenantId,
|
|
6205
6366
|
manifest.operation_id,
|
|
6206
6367
|
manifest.idempotency_key,
|
|
6207
6368
|
input.request_digest,
|
|
@@ -6224,10 +6385,11 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6224
6385
|
}
|
|
6225
6386
|
fault2(faults, "after_outbox_write");
|
|
6226
6387
|
await tx.query(`INSERT INTO todos_task_manifest_bindings (
|
|
6227
|
-
operation_id, idempotency_key, request_digest, result_digest, apply_receipt_id,
|
|
6388
|
+
operation_id, tenant_id, idempotency_key, request_digest, result_digest, apply_receipt_id,
|
|
6228
6389
|
manifest_json, result_json, state, version, created_at, updated_at
|
|
6229
|
-
) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $
|
|
6390
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, 'applied', 1, $9, $9)`, [
|
|
6230
6391
|
manifest.operation_id,
|
|
6392
|
+
this.tenantId,
|
|
6231
6393
|
manifest.idempotency_key,
|
|
6232
6394
|
input.request_digest,
|
|
6233
6395
|
input.result_digest,
|
|
@@ -6242,19 +6404,86 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6242
6404
|
}
|
|
6243
6405
|
async readExact(receiptId) {
|
|
6244
6406
|
await this.ensureSchema();
|
|
6245
|
-
const result = await this.client.query("SELECT result_json FROM todos_task_manifest_receipts WHERE
|
|
6407
|
+
const result = await this.client.query("SELECT result_json FROM todos_task_manifest_receipts WHERE tenant_id = $1 AND receipt_id = $2 AND kind = 'apply' LIMIT 1", [this.tenantId, receiptId]);
|
|
6246
6408
|
if (!result.rows[0])
|
|
6247
6409
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_RECEIPT_NOT_FOUND", `Apply receipt not found: ${receiptId}`);
|
|
6248
6410
|
return { ...parseJson(result.rows[0]["result_json"]), duplicate: false };
|
|
6249
6411
|
}
|
|
6412
|
+
async lookupBindingByPlanId(planId) {
|
|
6413
|
+
await this.ensureSchema();
|
|
6414
|
+
const result = await this.client.query(`
|
|
6415
|
+
SELECT
|
|
6416
|
+
b.apply_receipt_id AS apply_receipt_id,
|
|
6417
|
+
b.state AS state,
|
|
6418
|
+
b.version AS binding_version,
|
|
6419
|
+
b.tenant_id AS binding_tenant_id,
|
|
6420
|
+
b.operation_id AS binding_operation_id,
|
|
6421
|
+
b.result_json #>> '{graph,plan_id}' AS binding_plan_id,
|
|
6422
|
+
r.tenant_id AS receipt_tenant_id,
|
|
6423
|
+
r.authority AS receipt_authority,
|
|
6424
|
+
r.route AS receipt_route,
|
|
6425
|
+
r.schema_version AS receipt_schema_version,
|
|
6426
|
+
r.kind AS receipt_kind,
|
|
6427
|
+
r.operation_id AS receipt_operation_id,
|
|
6428
|
+
r.result_json #>> '{graph,plan_id}' AS receipt_plan_id
|
|
6429
|
+
FROM todos_task_manifest_bindings b
|
|
6430
|
+
LEFT JOIN todos_task_manifest_receipts r
|
|
6431
|
+
ON r.receipt_id = b.apply_receipt_id
|
|
6432
|
+
AND r.tenant_id = b.tenant_id
|
|
6433
|
+
WHERE b.tenant_id = $1
|
|
6434
|
+
AND b.result_json #>> '{graph,plan_id}' = $2
|
|
6435
|
+
LIMIT 2
|
|
6436
|
+
`, [this.tenantId, planId]);
|
|
6437
|
+
return validateTaskManifestBindingLookupRows(result.rows, this.tenantId, planId);
|
|
6438
|
+
}
|
|
6250
6439
|
async markOutboxDelivered(outboxId, deliveredAt) {
|
|
6251
6440
|
await this.ensureSchema();
|
|
6252
6441
|
await this.client.transaction(async (tx) => {
|
|
6442
|
+
const owned = await tx.query(`SELECT r.operation_id
|
|
6443
|
+
FROM todos_task_manifest_outbox o
|
|
6444
|
+
JOIN todos_task_manifest_receipts r
|
|
6445
|
+
ON r.receipt_id = o.apply_receipt_id
|
|
6446
|
+
WHERE r.tenant_id = $1
|
|
6447
|
+
AND r.authority = 'todos'
|
|
6448
|
+
AND r.route = 'todos.task-manifest.v1'
|
|
6449
|
+
AND r.schema_version = 1
|
|
6450
|
+
AND r.kind = 'apply'
|
|
6451
|
+
AND o.id = $2
|
|
6452
|
+
LIMIT 1`, [this.tenantId, outboxId]);
|
|
6453
|
+
const operationId = owned.rows[0]?.operation_id;
|
|
6454
|
+
if (operationId == null) {
|
|
6455
|
+
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_GRAPH_CONFLICT", `Pending outbox row not found: ${outboxId}`);
|
|
6456
|
+
}
|
|
6457
|
+
await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${this.service}\x1F${String(operationId)}`]);
|
|
6253
6458
|
const result = await tx.query(`UPDATE todos_task_manifest_outbox
|
|
6254
6459
|
SET status = 'delivered', delivered_at = $1, attempts = attempts + 1
|
|
6255
|
-
WHERE id = $2 AND status = 'pending'
|
|
6256
|
-
|
|
6257
|
-
|
|
6460
|
+
WHERE id = $2 AND status = 'pending'
|
|
6461
|
+
AND EXISTS (
|
|
6462
|
+
SELECT 1 FROM todos_task_manifest_receipts r
|
|
6463
|
+
WHERE r.receipt_id = todos_task_manifest_outbox.apply_receipt_id
|
|
6464
|
+
AND r.tenant_id = $3
|
|
6465
|
+
AND r.authority = 'todos'
|
|
6466
|
+
AND r.route = 'todos.task-manifest.v1'
|
|
6467
|
+
AND r.schema_version = 1
|
|
6468
|
+
AND r.kind = 'apply'
|
|
6469
|
+
)
|
|
6470
|
+
RETURNING id`, [deliveredAt, outboxId, this.tenantId]);
|
|
6471
|
+
if (result.rows[0])
|
|
6472
|
+
return;
|
|
6473
|
+
const existing = await tx.query(`SELECT o.status
|
|
6474
|
+
FROM todos_task_manifest_outbox o
|
|
6475
|
+
JOIN todos_task_manifest_receipts r
|
|
6476
|
+
ON r.receipt_id = o.apply_receipt_id
|
|
6477
|
+
WHERE r.tenant_id = $1
|
|
6478
|
+
AND r.authority = 'todos'
|
|
6479
|
+
AND r.route = 'todos.task-manifest.v1'
|
|
6480
|
+
AND r.schema_version = 1
|
|
6481
|
+
AND r.kind = 'apply'
|
|
6482
|
+
AND o.id = $2
|
|
6483
|
+
LIMIT 1`, [this.tenantId, outboxId]);
|
|
6484
|
+
if (existing.rows[0]?.status === "delivered")
|
|
6485
|
+
return;
|
|
6486
|
+
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_GRAPH_CONFLICT", `Pending outbox row not found: ${outboxId}`);
|
|
6258
6487
|
});
|
|
6259
6488
|
}
|
|
6260
6489
|
async compensate(input, receipt, compensationReceiptId, requestDigest, now) {
|
|
@@ -6262,7 +6491,10 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6262
6491
|
return this.client.transaction(async (tx) => {
|
|
6263
6492
|
await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${this.service}\x1F${receipt.operation_id}`]);
|
|
6264
6493
|
await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${this.service}\x1Fcompensation-idempotency\x1F${input.idempotency_key}`]);
|
|
6265
|
-
const previous = await tx.query(
|
|
6494
|
+
const previous = await tx.query(`SELECT apply_receipt_id, request_digest, result_json
|
|
6495
|
+
FROM todos_task_manifest_receipts
|
|
6496
|
+
WHERE tenant_id = $1 AND kind = 'compensate' AND idempotency_key = $2
|
|
6497
|
+
LIMIT 1`, [this.tenantId, input.idempotency_key]);
|
|
6266
6498
|
if (previous.rows[0]) {
|
|
6267
6499
|
const row = previous.rows[0];
|
|
6268
6500
|
if (row["apply_receipt_id"] !== input.receipt_id || row["request_digest"] !== requestDigest) {
|
|
@@ -6270,18 +6502,27 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6270
6502
|
}
|
|
6271
6503
|
return { ...parseJson(row["result_json"]), duplicate: true };
|
|
6272
6504
|
}
|
|
6273
|
-
const applyRows = await tx.query("SELECT * FROM todos_task_manifest_receipts WHERE
|
|
6505
|
+
const applyRows = await tx.query("SELECT * FROM todos_task_manifest_receipts WHERE tenant_id = $1 AND receipt_id = $2 AND kind = 'apply' LIMIT 1", [this.tenantId, input.receipt_id]);
|
|
6274
6506
|
const applyRow = applyRows.rows[0];
|
|
6275
6507
|
if (!applyRow)
|
|
6276
6508
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_RECEIPT_NOT_FOUND", "Apply receipt not found");
|
|
6277
|
-
const bindingRows = await tx.query("SELECT * FROM todos_task_manifest_bindings WHERE
|
|
6509
|
+
const bindingRows = await tx.query("SELECT * FROM todos_task_manifest_bindings WHERE tenant_id = $1 AND operation_id = $2 LIMIT 1 FOR UPDATE", [this.tenantId, receipt.operation_id]);
|
|
6278
6510
|
const binding = bindingRows.rows[0];
|
|
6279
6511
|
if (!binding || Number(binding["version"]) !== input.if_binding_version) {
|
|
6280
6512
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_CAS_CONFLICT", "Binding version changed before compensation");
|
|
6281
6513
|
}
|
|
6282
6514
|
if (binding["state"] !== "applied")
|
|
6283
6515
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Graph is not applied");
|
|
6284
|
-
const delivered = await tx.query(
|
|
6516
|
+
const delivered = await tx.query(`SELECT o.id FROM todos_task_manifest_outbox o
|
|
6517
|
+
JOIN todos_task_manifest_receipts r ON r.receipt_id = o.apply_receipt_id
|
|
6518
|
+
WHERE r.tenant_id = $1
|
|
6519
|
+
AND r.authority = 'todos'
|
|
6520
|
+
AND r.route = 'todos.task-manifest.v1'
|
|
6521
|
+
AND r.schema_version = 1
|
|
6522
|
+
AND r.kind = 'apply'
|
|
6523
|
+
AND o.apply_receipt_id = $2
|
|
6524
|
+
AND o.status = 'delivered'
|
|
6525
|
+
LIMIT 1`, [this.tenantId, input.receipt_id]);
|
|
6285
6526
|
if (delivered.rows[0])
|
|
6286
6527
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: delivered outbox row exists");
|
|
6287
6528
|
const applyResult = parseJson(applyRow["result_json"]);
|
|
@@ -6293,8 +6534,20 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6293
6534
|
},
|
|
6294
6535
|
...(manifest.effects ?? []).map((effect2) => ({ topic: effect2.topic, payload: effect2.payload }))
|
|
6295
6536
|
];
|
|
6296
|
-
const outboxRows = await tx.query(`SELECT id, topic, payload, payload_digest, status, attempts, delivered_at
|
|
6297
|
-
FROM todos_task_manifest_outbox
|
|
6537
|
+
const outboxRows = await tx.query(`SELECT o.id, o.topic, o.payload, o.payload_digest, o.status, o.attempts, o.delivered_at
|
|
6538
|
+
FROM todos_task_manifest_outbox o
|
|
6539
|
+
WHERE o.apply_receipt_id = $1
|
|
6540
|
+
AND EXISTS (
|
|
6541
|
+
SELECT 1 FROM todos_task_manifest_receipts r
|
|
6542
|
+
WHERE r.receipt_id = o.apply_receipt_id
|
|
6543
|
+
AND r.tenant_id = $2
|
|
6544
|
+
AND r.authority = 'todos'
|
|
6545
|
+
AND r.route = 'todos.task-manifest.v1'
|
|
6546
|
+
AND r.schema_version = 1
|
|
6547
|
+
AND r.kind = 'apply'
|
|
6548
|
+
)
|
|
6549
|
+
ORDER BY o.id
|
|
6550
|
+
FOR UPDATE OF o`, [input.receipt_id, this.tenantId]);
|
|
6298
6551
|
if (outboxRows.rows.length !== expectedEffects.length) {
|
|
6299
6552
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: outbox changed since apply");
|
|
6300
6553
|
}
|
|
@@ -6394,7 +6647,23 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6394
6647
|
}
|
|
6395
6648
|
if (stored.rows.length !== managedIds.length)
|
|
6396
6649
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: managed graph is incomplete");
|
|
6397
|
-
await tx.query(
|
|
6650
|
+
const cancelled = await tx.query(`UPDATE todos_task_manifest_outbox
|
|
6651
|
+
SET status = 'cancelled'
|
|
6652
|
+
WHERE apply_receipt_id = $1 AND status = 'pending'
|
|
6653
|
+
AND EXISTS (
|
|
6654
|
+
SELECT 1 FROM todos_task_manifest_receipts r
|
|
6655
|
+
WHERE r.receipt_id = todos_task_manifest_outbox.apply_receipt_id
|
|
6656
|
+
AND r.tenant_id = $2
|
|
6657
|
+
AND r.authority = 'todos'
|
|
6658
|
+
AND r.route = 'todos.task-manifest.v1'
|
|
6659
|
+
AND r.schema_version = 1
|
|
6660
|
+
AND r.kind = 'apply'
|
|
6661
|
+
)
|
|
6662
|
+
RETURNING id`, [input.receipt_id, this.tenantId]);
|
|
6663
|
+
const cancelledIds = new Set(cancelled.rows.map((row) => String(row.id)));
|
|
6664
|
+
if (cancelledIds.size !== applyResult.outbox_ids.length || applyResult.outbox_ids.some((id) => !cancelledIds.has(id))) {
|
|
6665
|
+
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: failed to cancel every expected outbox row");
|
|
6666
|
+
}
|
|
6398
6667
|
const typedIds = [
|
|
6399
6668
|
["dependencies", applyResult.graph.dependency_ids],
|
|
6400
6669
|
["comments", applyResult.graph.comment_ids],
|
|
@@ -6411,10 +6680,11 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6411
6680
|
const readback = await this.readback(tx, applyResult.graph);
|
|
6412
6681
|
const result = { duplicate: false, receipt, absent: true, readback };
|
|
6413
6682
|
await tx.query(`INSERT INTO todos_task_manifest_receipts (
|
|
6414
|
-
receipt_id, authority, route, schema_version, kind, operation_id, idempotency_key,
|
|
6683
|
+
receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, idempotency_key,
|
|
6415
6684
|
request_digest, result_digest, binding_version, apply_receipt_id, manifest_json, result_json, created_at
|
|
6416
|
-
) VALUES ($1, 'todos', 'todos.task-manifest.v1', 1, 'compensate', $
|
|
6685
|
+
) VALUES ($1, $2, 'todos', 'todos.task-manifest.v1', 1, 'compensate', $3, $4, $5, $6, $7, $8, NULL, $9::jsonb, $10)`, [
|
|
6417
6686
|
compensationReceiptId,
|
|
6687
|
+
this.tenantId,
|
|
6418
6688
|
receipt.operation_id,
|
|
6419
6689
|
input.idempotency_key,
|
|
6420
6690
|
requestDigest,
|
|
@@ -6426,10 +6696,11 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6426
6696
|
]);
|
|
6427
6697
|
const updated = await tx.query(`UPDATE todos_task_manifest_bindings
|
|
6428
6698
|
SET state = 'compensated', version = $1, compensation_receipt_id = $2, updated_at = $3
|
|
6429
|
-
WHERE
|
|
6699
|
+
WHERE tenant_id = $4 AND operation_id = $5 AND state = 'applied' AND version = $6 RETURNING operation_id`, [
|
|
6430
6700
|
receipt.binding_version,
|
|
6431
6701
|
compensationReceiptId,
|
|
6432
6702
|
now,
|
|
6703
|
+
this.tenantId,
|
|
6433
6704
|
receipt.operation_id,
|
|
6434
6705
|
input.if_binding_version
|
|
6435
6706
|
]);
|
|
@@ -6469,6 +6740,13 @@ var FAULT_POINTS = [
|
|
|
6469
6740
|
"after_outbox_write",
|
|
6470
6741
|
"after_receipt_write"
|
|
6471
6742
|
];
|
|
6743
|
+
function resolveTenantId(value) {
|
|
6744
|
+
const tenantId = value ?? "default";
|
|
6745
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(tenantId)) {
|
|
6746
|
+
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_INVALID_INPUT", "tenantId must be a bounded exact authority identifier");
|
|
6747
|
+
}
|
|
6748
|
+
return tenantId;
|
|
6749
|
+
}
|
|
6472
6750
|
function normalize(input, now) {
|
|
6473
6751
|
const parsed = parseTodosTaskManifest(input);
|
|
6474
6752
|
const requestBytes = Buffer.byteLength(canonicalJson(parsed), "utf8");
|
|
@@ -6556,19 +6834,23 @@ function sanitizeManifest(manifest) {
|
|
|
6556
6834
|
class PackageOwnedTodosTaskManifestAuthority {
|
|
6557
6835
|
backend;
|
|
6558
6836
|
options;
|
|
6837
|
+
tenantId;
|
|
6559
6838
|
constructor(backend, options = {}) {
|
|
6560
6839
|
this.backend = backend;
|
|
6561
6840
|
this.options = options;
|
|
6841
|
+
this.tenantId = resolveTenantId(options.tenantId);
|
|
6562
6842
|
}
|
|
6563
6843
|
async capability() {
|
|
6564
6844
|
return {
|
|
6565
6845
|
authority: "todos",
|
|
6566
6846
|
route: TODOS_TASK_MANIFEST_ROUTE,
|
|
6567
6847
|
schema_version: TODOS_TASK_MANIFEST_SCHEMA_VERSION,
|
|
6848
|
+
tenant_id: this.tenantId,
|
|
6568
6849
|
backend: this.backend.kind,
|
|
6569
6850
|
deterministic_ids: true,
|
|
6570
6851
|
immutable_receipts: true,
|
|
6571
6852
|
transactional_outbox: true,
|
|
6853
|
+
idempotent_outbox_delivery: true,
|
|
6572
6854
|
exact_bounded_readback: true,
|
|
6573
6855
|
conditional_compensation: true,
|
|
6574
6856
|
transcript_safe: false,
|
|
@@ -6603,6 +6885,22 @@ class PackageOwnedTodosTaskManifestAuthority {
|
|
|
6603
6885
|
}
|
|
6604
6886
|
return this.backend.readExact(receiptId).then((result) => this.bounded(result));
|
|
6605
6887
|
}
|
|
6888
|
+
async lookupBinding(input) {
|
|
6889
|
+
const request = parseTodosTaskManifestBindingLookup(input);
|
|
6890
|
+
if (request.max_items !== 1) {
|
|
6891
|
+
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_BOUNDS_EXCEEDED", "max_items must be exactly 1 for task-manifest binding lookup", { max_items: request.max_items, max_items_limit: 1 });
|
|
6892
|
+
}
|
|
6893
|
+
if (request.authority !== "todos" || request.route !== TODOS_TASK_MANIFEST_ROUTE || request.schema_version !== TODOS_TASK_MANIFEST_SCHEMA_VERSION || request.tenant_id !== this.tenantId) {
|
|
6894
|
+
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_CAPABILITY_MISMATCH", "Task-manifest binding lookup does not match this authority identity");
|
|
6895
|
+
}
|
|
6896
|
+
return this.bounded({
|
|
6897
|
+
authority: "todos",
|
|
6898
|
+
route: TODOS_TASK_MANIFEST_ROUTE,
|
|
6899
|
+
schema_version: TODOS_TASK_MANIFEST_SCHEMA_VERSION,
|
|
6900
|
+
tenant_id: this.tenantId,
|
|
6901
|
+
...await this.backend.lookupBindingByPlanId(request.plan_id)
|
|
6902
|
+
});
|
|
6903
|
+
}
|
|
6606
6904
|
markOutboxDelivered(outboxId) {
|
|
6607
6905
|
if (!outboxId || outboxId.length > 200) {
|
|
6608
6906
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_INVALID_INPUT", "outboxId must be a bounded exact identifier");
|
|
@@ -6642,13 +6940,15 @@ function createSqliteTodosTaskManifestAuthority(options) {
|
|
|
6642
6940
|
if (!options?.database) {
|
|
6643
6941
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_ATOMICITY_UNAVAILABLE", "An explicit SQLite Database is required");
|
|
6644
6942
|
}
|
|
6645
|
-
|
|
6943
|
+
const tenantId = resolveTenantId(options.tenantId);
|
|
6944
|
+
return new PackageOwnedTodosTaskManifestAuthority(new SqliteTodosTaskManifestBackend(options.database, tenantId), { ...options, tenantId });
|
|
6646
6945
|
}
|
|
6647
6946
|
function createPostgresTodosTaskManifestAuthority(client, options = {}) {
|
|
6648
6947
|
if (!client || typeof client.transaction !== "function") {
|
|
6649
6948
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_ATOMICITY_UNAVAILABLE", "An authoritative PostgreSQL transaction(callback) client is required");
|
|
6650
6949
|
}
|
|
6651
|
-
|
|
6950
|
+
const tenantId = resolveTenantId(options.tenantId);
|
|
6951
|
+
return new PackageOwnedTodosTaskManifestAuthority(new PostgresTodosTaskManifestBackend(client, { ...options, tenantId }), { ...options, tenantId });
|
|
6652
6952
|
}
|
|
6653
6953
|
// src/task-manifest/http.ts
|
|
6654
6954
|
var JSON_HEADERS = { "Content-Type": "application/json" };
|
|
@@ -6662,6 +6962,7 @@ function status(error) {
|
|
|
6662
6962
|
case "TODOS_TASK_MANIFEST_FOREIGN_REFERENCE":
|
|
6663
6963
|
return 400;
|
|
6664
6964
|
case "TODOS_TASK_MANIFEST_RECEIPT_NOT_FOUND":
|
|
6965
|
+
case "TODOS_TASK_MANIFEST_BINDING_NOT_FOUND":
|
|
6665
6966
|
return 404;
|
|
6666
6967
|
case "TODOS_TASK_MANIFEST_ATOMICITY_UNAVAILABLE":
|
|
6667
6968
|
return 503;
|
|
@@ -6724,6 +7025,11 @@ async function handleTodosTaskManifestHttpRequest(request, url, authority, baseP
|
|
|
6724
7025
|
}
|
|
6725
7026
|
return json({ result: await authority.readExact(input.receipt_id) });
|
|
6726
7027
|
}
|
|
7028
|
+
if (action === "bindings/lookup") {
|
|
7029
|
+
return json({
|
|
7030
|
+
result: await authority.lookupBinding(await body(request))
|
|
7031
|
+
});
|
|
7032
|
+
}
|
|
6727
7033
|
if (action === "compensate") {
|
|
6728
7034
|
return json({ result: await authority.compensate(await body(request)) }, 201);
|
|
6729
7035
|
}
|
|
@@ -6783,6 +7089,9 @@ class TodosTaskManifestHttpClient {
|
|
|
6783
7089
|
async readExact(receiptId) {
|
|
6784
7090
|
return (await this.request("/read-exact", { method: "POST", body: JSON.stringify({ receipt_id: receiptId }) })).result;
|
|
6785
7091
|
}
|
|
7092
|
+
async lookupBinding(input) {
|
|
7093
|
+
return (await this.request("/bindings/lookup", { method: "POST", body: JSON.stringify(input) })).result;
|
|
7094
|
+
}
|
|
6786
7095
|
async markOutboxDelivered(outboxId) {
|
|
6787
7096
|
await this.request("/outbox/delivered", { method: "POST", body: JSON.stringify({ outbox_id: outboxId }) });
|
|
6788
7097
|
}
|
|
@@ -6794,9 +7103,11 @@ function createTodosTaskManifestHttpClient(options) {
|
|
|
6794
7103
|
return new TodosTaskManifestHttpClient(options);
|
|
6795
7104
|
}
|
|
6796
7105
|
export {
|
|
7106
|
+
supportsIdempotentOutboxDelivery,
|
|
6797
7107
|
sqliteTodosTaskManifestSchemaSql,
|
|
6798
7108
|
postgresTodosTaskManifestSchemaSql,
|
|
6799
7109
|
parseTodosTaskManifestCompensation,
|
|
7110
|
+
parseTodosTaskManifestBindingLookup,
|
|
6800
7111
|
parseTodosTaskManifest,
|
|
6801
7112
|
handleTodosTaskManifestHttpRequest,
|
|
6802
7113
|
canonicalDigest as digestTodosTaskManifest,
|