@hasna/todos 0.15.14 → 0.15.15
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/index.js +12103 -10728
- package/dist/contracts.js +1 -1
- package/dist/index.js +276 -41
- package/dist/mcp/index.js +2170 -798
- 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 +19650 -18278
- 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 +1 -1
- 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 +22 -1
- package/dist/task-manifest/types.d.ts.map +1 -1
- package/dist/task-manifest.js +275 -40
- package/package.json +1 -1
package/dist/task-manifest.js
CHANGED
|
@@ -4922,6 +4922,14 @@ var compensationSchema = exports_external.object({
|
|
|
4922
4922
|
idempotency_key: identifier,
|
|
4923
4923
|
if_binding_version: exports_external.number().int().min(1).max(Number.MAX_SAFE_INTEGER)
|
|
4924
4924
|
}).strict();
|
|
4925
|
+
var bindingLookupSchema = exports_external.object({
|
|
4926
|
+
authority: exports_external.string().min(1).max(64),
|
|
4927
|
+
route: exports_external.string().min(1).max(128),
|
|
4928
|
+
schema_version: exports_external.number().int().min(1).max(Number.MAX_SAFE_INTEGER),
|
|
4929
|
+
tenant_id: identifier,
|
|
4930
|
+
plan_id: uuid,
|
|
4931
|
+
max_items: exports_external.number().int().min(1).max(Number.MAX_SAFE_INTEGER)
|
|
4932
|
+
}).strict();
|
|
4925
4933
|
function parseTodosTaskManifest(input) {
|
|
4926
4934
|
const parsed = schema.safeParse(input);
|
|
4927
4935
|
if (!parsed.success) {
|
|
@@ -4987,6 +4995,36 @@ function parseTodosTaskManifestCompensation(input) {
|
|
|
4987
4995
|
}
|
|
4988
4996
|
return parsed.data;
|
|
4989
4997
|
}
|
|
4998
|
+
function parseTodosTaskManifestBindingLookup(input) {
|
|
4999
|
+
const parsed = bindingLookupSchema.safeParse(input);
|
|
5000
|
+
if (!parsed.success) {
|
|
5001
|
+
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 });
|
|
5002
|
+
}
|
|
5003
|
+
return parsed.data;
|
|
5004
|
+
}
|
|
5005
|
+
|
|
5006
|
+
// src/task-manifest/backend.ts
|
|
5007
|
+
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;
|
|
5008
|
+
function validateTaskManifestBindingLookupRows(rows, tenantId, planId) {
|
|
5009
|
+
if (rows.length === 0) {
|
|
5010
|
+
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_BINDING_NOT_FOUND", `Managed task-manifest binding not found for plan: ${planId}`, { plan_id: planId });
|
|
5011
|
+
}
|
|
5012
|
+
if (rows.length !== 1) {
|
|
5013
|
+
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 });
|
|
5014
|
+
}
|
|
5015
|
+
const row = rows[0];
|
|
5016
|
+
const bindingVersion = Number(row.binding_version);
|
|
5017
|
+
const state = row.state;
|
|
5018
|
+
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") {
|
|
5019
|
+
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_LOOKUP_CONFLICT", "Task-manifest binding and immutable apply receipt disagree", { plan_id: planId });
|
|
5020
|
+
}
|
|
5021
|
+
return {
|
|
5022
|
+
plan_id: planId,
|
|
5023
|
+
apply_receipt_id: row.apply_receipt_id,
|
|
5024
|
+
binding_version: bindingVersion,
|
|
5025
|
+
state
|
|
5026
|
+
};
|
|
5027
|
+
}
|
|
4990
5028
|
|
|
4991
5029
|
// src/task-manifest/reference-guard.ts
|
|
4992
5030
|
function quoteSqliteIdentifier(value) {
|
|
@@ -5076,10 +5114,14 @@ function postgresTaskManifestForeignReferenceSql(tableName) {
|
|
|
5076
5114
|
}
|
|
5077
5115
|
|
|
5078
5116
|
// src/task-manifest/schema-sql.ts
|
|
5117
|
+
function sqlString(value) {
|
|
5118
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
5119
|
+
}
|
|
5079
5120
|
function sqliteTodosTaskManifestSchemaSql() {
|
|
5080
5121
|
return `
|
|
5081
5122
|
CREATE TABLE IF NOT EXISTS todos_task_manifest_receipts (
|
|
5082
5123
|
receipt_id TEXT PRIMARY KEY,
|
|
5124
|
+
tenant_id TEXT NOT NULL,
|
|
5083
5125
|
authority TEXT NOT NULL CHECK(authority = 'todos'),
|
|
5084
5126
|
route TEXT NOT NULL,
|
|
5085
5127
|
schema_version INTEGER NOT NULL CHECK(schema_version = 1),
|
|
@@ -5097,6 +5139,7 @@ function sqliteTodosTaskManifestSchemaSql() {
|
|
|
5097
5139
|
);
|
|
5098
5140
|
CREATE TABLE IF NOT EXISTS todos_task_manifest_bindings (
|
|
5099
5141
|
operation_id TEXT PRIMARY KEY,
|
|
5142
|
+
tenant_id TEXT NOT NULL,
|
|
5100
5143
|
idempotency_key TEXT NOT NULL UNIQUE,
|
|
5101
5144
|
request_digest TEXT NOT NULL,
|
|
5102
5145
|
result_digest TEXT NOT NULL,
|
|
@@ -5132,10 +5175,37 @@ function sqliteTodosTaskManifestSchemaSql() {
|
|
|
5132
5175
|
END;
|
|
5133
5176
|
`;
|
|
5134
5177
|
}
|
|
5135
|
-
function
|
|
5178
|
+
function sqliteTableHasColumn(db, tableName, columnName) {
|
|
5179
|
+
const columns = db.query(`PRAGMA table_info("${tableName}")`).all();
|
|
5180
|
+
return columns.some((column) => column.name === columnName);
|
|
5181
|
+
}
|
|
5182
|
+
function ensureSqliteTodosTaskManifestSchema(db, tenantId) {
|
|
5183
|
+
db.exec(sqliteTodosTaskManifestSchemaSql());
|
|
5184
|
+
const tenantDefault = sqlString(tenantId);
|
|
5185
|
+
for (const tableName of [
|
|
5186
|
+
"todos_task_manifest_receipts",
|
|
5187
|
+
"todos_task_manifest_bindings"
|
|
5188
|
+
]) {
|
|
5189
|
+
if (!sqliteTableHasColumn(db, tableName, "tenant_id")) {
|
|
5190
|
+
db.exec(`ALTER TABLE "${tableName}" ADD COLUMN tenant_id TEXT NOT NULL DEFAULT ${tenantDefault}`);
|
|
5191
|
+
}
|
|
5192
|
+
}
|
|
5193
|
+
db.exec(`
|
|
5194
|
+
CREATE INDEX IF NOT EXISTS idx_todos_task_manifest_receipts_tenant
|
|
5195
|
+
ON todos_task_manifest_receipts(tenant_id, receipt_id, kind);
|
|
5196
|
+
CREATE INDEX IF NOT EXISTS idx_todos_task_manifest_bindings_tenant_plan
|
|
5197
|
+
ON todos_task_manifest_bindings(
|
|
5198
|
+
tenant_id,
|
|
5199
|
+
json_extract(result_json, '$.graph.plan_id')
|
|
5200
|
+
);
|
|
5201
|
+
`);
|
|
5202
|
+
}
|
|
5203
|
+
function postgresTodosTaskManifestSchemaSql(tenantId = "default") {
|
|
5204
|
+
const tenantDefault = sqlString(tenantId);
|
|
5136
5205
|
return [
|
|
5137
5206
|
`CREATE TABLE IF NOT EXISTS todos_task_manifest_receipts (
|
|
5138
5207
|
receipt_id text PRIMARY KEY,
|
|
5208
|
+
tenant_id text NOT NULL,
|
|
5139
5209
|
authority text NOT NULL CHECK(authority = 'todos'),
|
|
5140
5210
|
route text NOT NULL,
|
|
5141
5211
|
schema_version integer NOT NULL CHECK(schema_version = 1),
|
|
@@ -5151,8 +5221,13 @@ function postgresTodosTaskManifestSchemaSql() {
|
|
|
5151
5221
|
created_at timestamptz NOT NULL,
|
|
5152
5222
|
UNIQUE(kind, idempotency_key)
|
|
5153
5223
|
)`,
|
|
5224
|
+
`ALTER TABLE todos_task_manifest_receipts
|
|
5225
|
+
ADD COLUMN IF NOT EXISTS tenant_id text NOT NULL DEFAULT ${tenantDefault}`,
|
|
5226
|
+
`ALTER TABLE todos_task_manifest_receipts
|
|
5227
|
+
ALTER COLUMN tenant_id DROP DEFAULT`,
|
|
5154
5228
|
`CREATE TABLE IF NOT EXISTS todos_task_manifest_bindings (
|
|
5155
5229
|
operation_id text PRIMARY KEY,
|
|
5230
|
+
tenant_id text NOT NULL,
|
|
5156
5231
|
idempotency_key text NOT NULL UNIQUE,
|
|
5157
5232
|
request_digest text NOT NULL,
|
|
5158
5233
|
result_digest text NOT NULL,
|
|
@@ -5165,6 +5240,10 @@ function postgresTodosTaskManifestSchemaSql() {
|
|
|
5165
5240
|
created_at timestamptz NOT NULL,
|
|
5166
5241
|
updated_at timestamptz NOT NULL
|
|
5167
5242
|
)`,
|
|
5243
|
+
`ALTER TABLE todos_task_manifest_bindings
|
|
5244
|
+
ADD COLUMN IF NOT EXISTS tenant_id text NOT NULL DEFAULT ${tenantDefault}`,
|
|
5245
|
+
`ALTER TABLE todos_task_manifest_bindings
|
|
5246
|
+
ALTER COLUMN tenant_id DROP DEFAULT`,
|
|
5168
5247
|
`CREATE TABLE IF NOT EXISTS todos_task_manifest_outbox (
|
|
5169
5248
|
id text PRIMARY KEY,
|
|
5170
5249
|
apply_receipt_id text NOT NULL REFERENCES todos_task_manifest_receipts(receipt_id),
|
|
@@ -5178,6 +5257,13 @@ function postgresTodosTaskManifestSchemaSql() {
|
|
|
5178
5257
|
)`,
|
|
5179
5258
|
`CREATE INDEX IF NOT EXISTS todos_task_manifest_outbox_receipt_idx
|
|
5180
5259
|
ON todos_task_manifest_outbox(apply_receipt_id, status)`,
|
|
5260
|
+
`CREATE INDEX IF NOT EXISTS todos_task_manifest_receipts_tenant_idx
|
|
5261
|
+
ON todos_task_manifest_receipts(tenant_id, receipt_id, kind)`,
|
|
5262
|
+
`CREATE INDEX IF NOT EXISTS todos_task_manifest_bindings_tenant_plan_idx
|
|
5263
|
+
ON todos_task_manifest_bindings(
|
|
5264
|
+
tenant_id,
|
|
5265
|
+
((result_json #>> '{graph,plan_id}'))
|
|
5266
|
+
)`,
|
|
5181
5267
|
`CREATE OR REPLACE FUNCTION todos_task_manifest_receipts_immutable()
|
|
5182
5268
|
RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
|
5183
5269
|
RAISE EXCEPTION 'todos task manifest receipts are immutable';
|
|
@@ -5201,10 +5287,12 @@ function parseApplyResult(value, duplicate) {
|
|
|
5201
5287
|
|
|
5202
5288
|
class SqliteTodosTaskManifestBackend {
|
|
5203
5289
|
db;
|
|
5290
|
+
tenantId;
|
|
5204
5291
|
kind = "sqlite";
|
|
5205
|
-
constructor(db) {
|
|
5292
|
+
constructor(db, tenantId = "default") {
|
|
5206
5293
|
this.db = db;
|
|
5207
|
-
|
|
5294
|
+
this.tenantId = tenantId;
|
|
5295
|
+
ensureSqliteTodosTaskManifestSchema(db, tenantId);
|
|
5208
5296
|
}
|
|
5209
5297
|
async serialized(run) {
|
|
5210
5298
|
const previous = sqliteTails.get(this.db) ?? Promise.resolve();
|
|
@@ -5234,7 +5322,7 @@ class SqliteTodosTaskManifestBackend {
|
|
|
5234
5322
|
async apply(input, faults) {
|
|
5235
5323
|
return this.serialized(() => {
|
|
5236
5324
|
const { manifest } = input;
|
|
5237
|
-
const binding = this.db.query("SELECT * FROM todos_task_manifest_bindings WHERE operation_id = ? LIMIT 1").get(manifest.operation_id);
|
|
5325
|
+
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
5326
|
if (binding) {
|
|
5239
5327
|
if (binding["idempotency_key"] !== manifest.idempotency_key || binding["request_digest"] !== input.request_digest) {
|
|
5240
5328
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Operation is already bound to a different request");
|
|
@@ -5244,7 +5332,7 @@ class SqliteTodosTaskManifestBackend {
|
|
|
5244
5332
|
}
|
|
5245
5333
|
return parseApplyResult(String(binding["result_json"]), true);
|
|
5246
5334
|
}
|
|
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);
|
|
5335
|
+
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
5336
|
if (idempotency)
|
|
5249
5337
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Idempotency key is already used by another operation");
|
|
5250
5338
|
if (manifest.if_binding_version !== undefined && manifest.if_binding_version !== 0) {
|
|
@@ -5334,9 +5422,9 @@ class SqliteTodosTaskManifestBackend {
|
|
|
5334
5422
|
const resultJson = canonicalJson(result);
|
|
5335
5423
|
const manifestJson = canonicalJson(manifest);
|
|
5336
5424
|
this.db.query(`INSERT INTO todos_task_manifest_receipts (
|
|
5337
|
-
receipt_id, authority, route, schema_version, kind, operation_id, idempotency_key,
|
|
5425
|
+
receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, idempotency_key,
|
|
5338
5426
|
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);
|
|
5427
|
+
) 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
5428
|
for (const entry of input.outbox) {
|
|
5341
5429
|
this.db.query(`INSERT INTO todos_task_manifest_outbox (
|
|
5342
5430
|
id, apply_receipt_id, topic, payload, payload_digest, status, created_at
|
|
@@ -5344,24 +5432,55 @@ class SqliteTodosTaskManifestBackend {
|
|
|
5344
5432
|
}
|
|
5345
5433
|
fault(faults, "after_outbox_write");
|
|
5346
5434
|
this.db.query(`INSERT INTO todos_task_manifest_bindings (
|
|
5347
|
-
operation_id, idempotency_key, request_digest, result_digest, apply_receipt_id,
|
|
5435
|
+
operation_id, tenant_id, idempotency_key, request_digest, result_digest, apply_receipt_id,
|
|
5348
5436
|
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);
|
|
5437
|
+
) 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
5438
|
fault(faults, "after_receipt_write");
|
|
5351
5439
|
return result;
|
|
5352
5440
|
});
|
|
5353
5441
|
}
|
|
5354
5442
|
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);
|
|
5443
|
+
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
5444
|
if (!row)
|
|
5357
5445
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_RECEIPT_NOT_FOUND", `Apply receipt not found: ${receiptId}`);
|
|
5358
5446
|
return parseApplyResult(row.result_json, false);
|
|
5359
5447
|
}
|
|
5448
|
+
async lookupBindingByPlanId(planId) {
|
|
5449
|
+
const rows = this.db.query(`
|
|
5450
|
+
SELECT
|
|
5451
|
+
b.apply_receipt_id AS apply_receipt_id,
|
|
5452
|
+
b.state AS state,
|
|
5453
|
+
b.version AS binding_version,
|
|
5454
|
+
b.tenant_id AS binding_tenant_id,
|
|
5455
|
+
b.operation_id AS binding_operation_id,
|
|
5456
|
+
json_extract(b.result_json, '$.graph.plan_id') AS binding_plan_id,
|
|
5457
|
+
r.tenant_id AS receipt_tenant_id,
|
|
5458
|
+
r.authority AS receipt_authority,
|
|
5459
|
+
r.route AS receipt_route,
|
|
5460
|
+
r.schema_version AS receipt_schema_version,
|
|
5461
|
+
r.kind AS receipt_kind,
|
|
5462
|
+
r.operation_id AS receipt_operation_id,
|
|
5463
|
+
json_extract(r.result_json, '$.graph.plan_id') AS receipt_plan_id
|
|
5464
|
+
FROM todos_task_manifest_bindings b
|
|
5465
|
+
LEFT JOIN todos_task_manifest_receipts r
|
|
5466
|
+
ON r.receipt_id = b.apply_receipt_id
|
|
5467
|
+
AND r.tenant_id = b.tenant_id
|
|
5468
|
+
WHERE b.tenant_id = ?
|
|
5469
|
+
AND json_extract(b.result_json, '$.graph.plan_id') = ?
|
|
5470
|
+
LIMIT 2
|
|
5471
|
+
`).all(this.tenantId, planId);
|
|
5472
|
+
return validateTaskManifestBindingLookupRows(rows, this.tenantId, planId);
|
|
5473
|
+
}
|
|
5360
5474
|
async markOutboxDelivered(outboxId, deliveredAt) {
|
|
5361
5475
|
await this.serialized(() => {
|
|
5362
5476
|
const result = this.db.query(`UPDATE todos_task_manifest_outbox
|
|
5363
5477
|
SET status = 'delivered', delivered_at = ?, attempts = attempts + 1
|
|
5364
|
-
WHERE id = ? AND status = 'pending'
|
|
5478
|
+
WHERE id = ? AND status = 'pending'
|
|
5479
|
+
AND EXISTS (
|
|
5480
|
+
SELECT 1 FROM todos_task_manifest_receipts r
|
|
5481
|
+
WHERE r.receipt_id = todos_task_manifest_outbox.apply_receipt_id
|
|
5482
|
+
AND r.tenant_id = ?
|
|
5483
|
+
)`).run(deliveredAt, outboxId, this.tenantId);
|
|
5365
5484
|
if (result.changes !== 1) {
|
|
5366
5485
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_GRAPH_CONFLICT", `Pending outbox row not found: ${outboxId}`);
|
|
5367
5486
|
}
|
|
@@ -5370,23 +5489,28 @@ class SqliteTodosTaskManifestBackend {
|
|
|
5370
5489
|
async compensate(input, receipt, compensationReceiptId, requestDigest, now) {
|
|
5371
5490
|
return this.serialized(() => {
|
|
5372
5491
|
const existing = this.db.query(`SELECT apply_receipt_id, request_digest, result_json
|
|
5373
|
-
FROM todos_task_manifest_receipts
|
|
5492
|
+
FROM todos_task_manifest_receipts
|
|
5493
|
+
WHERE tenant_id = ? AND kind = 'compensate' AND idempotency_key = ?
|
|
5494
|
+
LIMIT 1`).get(this.tenantId, input.idempotency_key);
|
|
5374
5495
|
if (existing) {
|
|
5375
5496
|
if (existing.apply_receipt_id !== input.receipt_id || existing.request_digest !== requestDigest) {
|
|
5376
5497
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Compensation idempotency key is already used");
|
|
5377
5498
|
}
|
|
5378
5499
|
return { ...JSON.parse(existing.result_json), duplicate: true };
|
|
5379
5500
|
}
|
|
5380
|
-
const row = this.db.query("SELECT * FROM todos_task_manifest_receipts WHERE receipt_id = ? AND kind = 'apply' LIMIT 1").get(input.receipt_id);
|
|
5501
|
+
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
5502
|
if (!row)
|
|
5382
5503
|
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"]));
|
|
5504
|
+
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
5505
|
if (!binding || Number(binding["version"]) !== input.if_binding_version) {
|
|
5385
5506
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_CAS_CONFLICT", "Binding version changed before compensation");
|
|
5386
5507
|
}
|
|
5387
5508
|
if (binding["state"] !== "applied")
|
|
5388
5509
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Graph is not in applied state");
|
|
5389
|
-
const delivered = this.db.query(
|
|
5510
|
+
const delivered = this.db.query(`SELECT o.id FROM todos_task_manifest_outbox o
|
|
5511
|
+
JOIN todos_task_manifest_receipts r ON r.receipt_id = o.apply_receipt_id
|
|
5512
|
+
WHERE r.tenant_id = ? AND o.apply_receipt_id = ? AND o.status = 'delivered'
|
|
5513
|
+
LIMIT 1`).get(this.tenantId, input.receipt_id);
|
|
5390
5514
|
if (delivered)
|
|
5391
5515
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: delivered outbox row exists");
|
|
5392
5516
|
const applyResult = parseApplyResult(String(row["result_json"]), false);
|
|
@@ -5399,7 +5523,14 @@ class SqliteTodosTaskManifestBackend {
|
|
|
5399
5523
|
...(manifest.effects ?? []).map((effect2) => ({ topic: effect2.topic, payload: effect2.payload }))
|
|
5400
5524
|
];
|
|
5401
5525
|
const storedOutbox = this.db.query(`SELECT id, topic, payload, payload_digest, status, attempts, delivered_at
|
|
5402
|
-
FROM todos_task_manifest_outbox
|
|
5526
|
+
FROM todos_task_manifest_outbox
|
|
5527
|
+
WHERE apply_receipt_id = ?
|
|
5528
|
+
AND EXISTS (
|
|
5529
|
+
SELECT 1 FROM todos_task_manifest_receipts r
|
|
5530
|
+
WHERE r.receipt_id = todos_task_manifest_outbox.apply_receipt_id
|
|
5531
|
+
AND r.tenant_id = ?
|
|
5532
|
+
)
|
|
5533
|
+
ORDER BY id`).all(input.receipt_id, this.tenantId);
|
|
5403
5534
|
if (storedOutbox.length !== expectedEffects.length) {
|
|
5404
5535
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: outbox changed since apply");
|
|
5405
5536
|
}
|
|
@@ -5487,7 +5618,14 @@ class SqliteTodosTaskManifestBackend {
|
|
|
5487
5618
|
}
|
|
5488
5619
|
expectedVerificationIndex += 1;
|
|
5489
5620
|
}
|
|
5490
|
-
this.db.query(
|
|
5621
|
+
this.db.query(`UPDATE todos_task_manifest_outbox
|
|
5622
|
+
SET status = 'cancelled'
|
|
5623
|
+
WHERE apply_receipt_id = ? AND status = 'pending'
|
|
5624
|
+
AND EXISTS (
|
|
5625
|
+
SELECT 1 FROM todos_task_manifest_receipts r
|
|
5626
|
+
WHERE r.receipt_id = todos_task_manifest_outbox.apply_receipt_id
|
|
5627
|
+
AND r.tenant_id = ?
|
|
5628
|
+
)`).run(input.receipt_id, this.tenantId);
|
|
5491
5629
|
for (const table of ["task_tags", "task_dependencies", "task_comments", "task_verifications"]) {
|
|
5492
5630
|
const column = table === "task_dependencies" ? "task_id" : "task_id";
|
|
5493
5631
|
this.db.query(`DELETE FROM ${table} WHERE ${column} IN (${placeholders2})`).run(...taskIds);
|
|
@@ -5498,11 +5636,11 @@ class SqliteTodosTaskManifestBackend {
|
|
|
5498
5636
|
const result = { duplicate: false, receipt, absent: true, readback };
|
|
5499
5637
|
const resultJson = canonicalJson(result);
|
|
5500
5638
|
this.db.query(`INSERT INTO todos_task_manifest_receipts (
|
|
5501
|
-
receipt_id, authority, route, schema_version, kind, operation_id, idempotency_key,
|
|
5639
|
+
receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, idempotency_key,
|
|
5502
5640
|
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);
|
|
5641
|
+
) 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
5642
|
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);
|
|
5643
|
+
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
5644
|
if (updated.changes !== 1) {
|
|
5507
5645
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_CAS_CONFLICT", "Binding changed during compensation");
|
|
5508
5646
|
}
|
|
@@ -6045,17 +6183,19 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6045
6183
|
kind = "postgresql";
|
|
6046
6184
|
service;
|
|
6047
6185
|
tableName;
|
|
6186
|
+
tenantId;
|
|
6048
6187
|
schemaReady = null;
|
|
6049
6188
|
constructor(client, options = {}) {
|
|
6050
6189
|
this.client = client;
|
|
6051
6190
|
this.service = options.service ?? "todos";
|
|
6052
6191
|
this.tableName = safeIdentifier(options.tableName ?? DEFAULT_TODOS_POSTGRES_SYNC_TABLE, "tableName");
|
|
6192
|
+
this.tenantId = options.tenantId ?? "default";
|
|
6053
6193
|
}
|
|
6054
6194
|
async ensureSchema() {
|
|
6055
6195
|
this.schemaReady ??= (async () => {
|
|
6056
6196
|
for (const sql of postgresTodosSyncSchemaSql(this.tableName))
|
|
6057
6197
|
await this.client.query(sql);
|
|
6058
|
-
for (const sql of postgresTodosTaskManifestSchemaSql())
|
|
6198
|
+
for (const sql of postgresTodosTaskManifestSchemaSql(this.tenantId))
|
|
6059
6199
|
await this.client.query(sql);
|
|
6060
6200
|
})();
|
|
6061
6201
|
await this.schemaReady;
|
|
@@ -6077,7 +6217,7 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6077
6217
|
const { manifest } = input;
|
|
6078
6218
|
await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${this.service}\x1F${manifest.operation_id}`]);
|
|
6079
6219
|
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
|
|
6220
|
+
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
6221
|
if (existing.rows[0]) {
|
|
6082
6222
|
const binding = existing.rows[0];
|
|
6083
6223
|
if (binding["idempotency_key"] !== manifest.idempotency_key || binding["request_digest"] !== input.request_digest) {
|
|
@@ -6088,7 +6228,7 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6088
6228
|
}
|
|
6089
6229
|
return { ...parseJson(binding["result_json"]), duplicate: true };
|
|
6090
6230
|
}
|
|
6091
|
-
const reused = await tx.query("SELECT operation_id FROM todos_task_manifest_bindings WHERE
|
|
6231
|
+
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
6232
|
if (reused.rows[0])
|
|
6093
6233
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Idempotency key is already used");
|
|
6094
6234
|
if (manifest.if_binding_version !== undefined && manifest.if_binding_version !== 0) {
|
|
@@ -6198,10 +6338,11 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6198
6338
|
const manifestJson = canonicalJson(manifest);
|
|
6199
6339
|
const resultJson = canonicalJson(result);
|
|
6200
6340
|
await tx.query(`INSERT INTO todos_task_manifest_receipts (
|
|
6201
|
-
receipt_id, authority, route, schema_version, kind, operation_id, idempotency_key,
|
|
6341
|
+
receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, idempotency_key,
|
|
6202
6342
|
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', $
|
|
6343
|
+
) VALUES ($1, $2, 'todos', 'todos.task-manifest.v1', 1, 'apply', $3, $4, $5, $6, 1, NULL, $7::jsonb, $8::jsonb, $9)`, [
|
|
6204
6344
|
input.receipt_id,
|
|
6345
|
+
this.tenantId,
|
|
6205
6346
|
manifest.operation_id,
|
|
6206
6347
|
manifest.idempotency_key,
|
|
6207
6348
|
input.request_digest,
|
|
@@ -6224,10 +6365,11 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6224
6365
|
}
|
|
6225
6366
|
fault2(faults, "after_outbox_write");
|
|
6226
6367
|
await tx.query(`INSERT INTO todos_task_manifest_bindings (
|
|
6227
|
-
operation_id, idempotency_key, request_digest, result_digest, apply_receipt_id,
|
|
6368
|
+
operation_id, tenant_id, idempotency_key, request_digest, result_digest, apply_receipt_id,
|
|
6228
6369
|
manifest_json, result_json, state, version, created_at, updated_at
|
|
6229
|
-
) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $
|
|
6370
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, 'applied', 1, $9, $9)`, [
|
|
6230
6371
|
manifest.operation_id,
|
|
6372
|
+
this.tenantId,
|
|
6231
6373
|
manifest.idempotency_key,
|
|
6232
6374
|
input.request_digest,
|
|
6233
6375
|
input.result_digest,
|
|
@@ -6242,17 +6384,50 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6242
6384
|
}
|
|
6243
6385
|
async readExact(receiptId) {
|
|
6244
6386
|
await this.ensureSchema();
|
|
6245
|
-
const result = await this.client.query("SELECT result_json FROM todos_task_manifest_receipts WHERE
|
|
6387
|
+
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
6388
|
if (!result.rows[0])
|
|
6247
6389
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_RECEIPT_NOT_FOUND", `Apply receipt not found: ${receiptId}`);
|
|
6248
6390
|
return { ...parseJson(result.rows[0]["result_json"]), duplicate: false };
|
|
6249
6391
|
}
|
|
6392
|
+
async lookupBindingByPlanId(planId) {
|
|
6393
|
+
await this.ensureSchema();
|
|
6394
|
+
const result = await this.client.query(`
|
|
6395
|
+
SELECT
|
|
6396
|
+
b.apply_receipt_id AS apply_receipt_id,
|
|
6397
|
+
b.state AS state,
|
|
6398
|
+
b.version AS binding_version,
|
|
6399
|
+
b.tenant_id AS binding_tenant_id,
|
|
6400
|
+
b.operation_id AS binding_operation_id,
|
|
6401
|
+
b.result_json #>> '{graph,plan_id}' AS binding_plan_id,
|
|
6402
|
+
r.tenant_id AS receipt_tenant_id,
|
|
6403
|
+
r.authority AS receipt_authority,
|
|
6404
|
+
r.route AS receipt_route,
|
|
6405
|
+
r.schema_version AS receipt_schema_version,
|
|
6406
|
+
r.kind AS receipt_kind,
|
|
6407
|
+
r.operation_id AS receipt_operation_id,
|
|
6408
|
+
r.result_json #>> '{graph,plan_id}' AS receipt_plan_id
|
|
6409
|
+
FROM todos_task_manifest_bindings b
|
|
6410
|
+
LEFT JOIN todos_task_manifest_receipts r
|
|
6411
|
+
ON r.receipt_id = b.apply_receipt_id
|
|
6412
|
+
AND r.tenant_id = b.tenant_id
|
|
6413
|
+
WHERE b.tenant_id = $1
|
|
6414
|
+
AND b.result_json #>> '{graph,plan_id}' = $2
|
|
6415
|
+
LIMIT 2
|
|
6416
|
+
`, [this.tenantId, planId]);
|
|
6417
|
+
return validateTaskManifestBindingLookupRows(result.rows, this.tenantId, planId);
|
|
6418
|
+
}
|
|
6250
6419
|
async markOutboxDelivered(outboxId, deliveredAt) {
|
|
6251
6420
|
await this.ensureSchema();
|
|
6252
6421
|
await this.client.transaction(async (tx) => {
|
|
6253
6422
|
const result = await tx.query(`UPDATE todos_task_manifest_outbox
|
|
6254
6423
|
SET status = 'delivered', delivered_at = $1, attempts = attempts + 1
|
|
6255
|
-
WHERE id = $2 AND status = 'pending'
|
|
6424
|
+
WHERE id = $2 AND status = 'pending'
|
|
6425
|
+
AND EXISTS (
|
|
6426
|
+
SELECT 1 FROM todos_task_manifest_receipts r
|
|
6427
|
+
WHERE r.receipt_id = todos_task_manifest_outbox.apply_receipt_id
|
|
6428
|
+
AND r.tenant_id = $3
|
|
6429
|
+
)
|
|
6430
|
+
RETURNING id`, [deliveredAt, outboxId, this.tenantId]);
|
|
6256
6431
|
if (!result.rows[0])
|
|
6257
6432
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_GRAPH_CONFLICT", `Pending outbox row not found: ${outboxId}`);
|
|
6258
6433
|
});
|
|
@@ -6262,7 +6437,10 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6262
6437
|
return this.client.transaction(async (tx) => {
|
|
6263
6438
|
await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${this.service}\x1F${receipt.operation_id}`]);
|
|
6264
6439
|
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(
|
|
6440
|
+
const previous = await tx.query(`SELECT apply_receipt_id, request_digest, result_json
|
|
6441
|
+
FROM todos_task_manifest_receipts
|
|
6442
|
+
WHERE tenant_id = $1 AND kind = 'compensate' AND idempotency_key = $2
|
|
6443
|
+
LIMIT 1`, [this.tenantId, input.idempotency_key]);
|
|
6266
6444
|
if (previous.rows[0]) {
|
|
6267
6445
|
const row = previous.rows[0];
|
|
6268
6446
|
if (row["apply_receipt_id"] !== input.receipt_id || row["request_digest"] !== requestDigest) {
|
|
@@ -6270,18 +6448,21 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6270
6448
|
}
|
|
6271
6449
|
return { ...parseJson(row["result_json"]), duplicate: true };
|
|
6272
6450
|
}
|
|
6273
|
-
const applyRows = await tx.query("SELECT * FROM todos_task_manifest_receipts WHERE
|
|
6451
|
+
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
6452
|
const applyRow = applyRows.rows[0];
|
|
6275
6453
|
if (!applyRow)
|
|
6276
6454
|
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
|
|
6455
|
+
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
6456
|
const binding = bindingRows.rows[0];
|
|
6279
6457
|
if (!binding || Number(binding["version"]) !== input.if_binding_version) {
|
|
6280
6458
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_CAS_CONFLICT", "Binding version changed before compensation");
|
|
6281
6459
|
}
|
|
6282
6460
|
if (binding["state"] !== "applied")
|
|
6283
6461
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Graph is not applied");
|
|
6284
|
-
const delivered = await tx.query(
|
|
6462
|
+
const delivered = await tx.query(`SELECT o.id FROM todos_task_manifest_outbox o
|
|
6463
|
+
JOIN todos_task_manifest_receipts r ON r.receipt_id = o.apply_receipt_id
|
|
6464
|
+
WHERE r.tenant_id = $1 AND o.apply_receipt_id = $2 AND o.status = 'delivered'
|
|
6465
|
+
LIMIT 1`, [this.tenantId, input.receipt_id]);
|
|
6285
6466
|
if (delivered.rows[0])
|
|
6286
6467
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: delivered outbox row exists");
|
|
6287
6468
|
const applyResult = parseJson(applyRow["result_json"]);
|
|
@@ -6294,7 +6475,14 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6294
6475
|
...(manifest.effects ?? []).map((effect2) => ({ topic: effect2.topic, payload: effect2.payload }))
|
|
6295
6476
|
];
|
|
6296
6477
|
const outboxRows = await tx.query(`SELECT id, topic, payload, payload_digest, status, attempts, delivered_at
|
|
6297
|
-
FROM todos_task_manifest_outbox
|
|
6478
|
+
FROM todos_task_manifest_outbox
|
|
6479
|
+
WHERE apply_receipt_id = $1
|
|
6480
|
+
AND EXISTS (
|
|
6481
|
+
SELECT 1 FROM todos_task_manifest_receipts r
|
|
6482
|
+
WHERE r.receipt_id = todos_task_manifest_outbox.apply_receipt_id
|
|
6483
|
+
AND r.tenant_id = $2
|
|
6484
|
+
)
|
|
6485
|
+
ORDER BY id`, [input.receipt_id, this.tenantId]);
|
|
6298
6486
|
if (outboxRows.rows.length !== expectedEffects.length) {
|
|
6299
6487
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: outbox changed since apply");
|
|
6300
6488
|
}
|
|
@@ -6394,7 +6582,14 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6394
6582
|
}
|
|
6395
6583
|
if (stored.rows.length !== managedIds.length)
|
|
6396
6584
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: managed graph is incomplete");
|
|
6397
|
-
await tx.query(
|
|
6585
|
+
await tx.query(`UPDATE todos_task_manifest_outbox
|
|
6586
|
+
SET status = 'cancelled'
|
|
6587
|
+
WHERE apply_receipt_id = $1 AND status = 'pending'
|
|
6588
|
+
AND EXISTS (
|
|
6589
|
+
SELECT 1 FROM todos_task_manifest_receipts r
|
|
6590
|
+
WHERE r.receipt_id = todos_task_manifest_outbox.apply_receipt_id
|
|
6591
|
+
AND r.tenant_id = $2
|
|
6592
|
+
)`, [input.receipt_id, this.tenantId]);
|
|
6398
6593
|
const typedIds = [
|
|
6399
6594
|
["dependencies", applyResult.graph.dependency_ids],
|
|
6400
6595
|
["comments", applyResult.graph.comment_ids],
|
|
@@ -6411,10 +6606,11 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6411
6606
|
const readback = await this.readback(tx, applyResult.graph);
|
|
6412
6607
|
const result = { duplicate: false, receipt, absent: true, readback };
|
|
6413
6608
|
await tx.query(`INSERT INTO todos_task_manifest_receipts (
|
|
6414
|
-
receipt_id, authority, route, schema_version, kind, operation_id, idempotency_key,
|
|
6609
|
+
receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, idempotency_key,
|
|
6415
6610
|
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', $
|
|
6611
|
+
) VALUES ($1, $2, 'todos', 'todos.task-manifest.v1', 1, 'compensate', $3, $4, $5, $6, $7, $8, NULL, $9::jsonb, $10)`, [
|
|
6417
6612
|
compensationReceiptId,
|
|
6613
|
+
this.tenantId,
|
|
6418
6614
|
receipt.operation_id,
|
|
6419
6615
|
input.idempotency_key,
|
|
6420
6616
|
requestDigest,
|
|
@@ -6426,10 +6622,11 @@ class PostgresTodosTaskManifestBackend {
|
|
|
6426
6622
|
]);
|
|
6427
6623
|
const updated = await tx.query(`UPDATE todos_task_manifest_bindings
|
|
6428
6624
|
SET state = 'compensated', version = $1, compensation_receipt_id = $2, updated_at = $3
|
|
6429
|
-
WHERE
|
|
6625
|
+
WHERE tenant_id = $4 AND operation_id = $5 AND state = 'applied' AND version = $6 RETURNING operation_id`, [
|
|
6430
6626
|
receipt.binding_version,
|
|
6431
6627
|
compensationReceiptId,
|
|
6432
6628
|
now,
|
|
6629
|
+
this.tenantId,
|
|
6433
6630
|
receipt.operation_id,
|
|
6434
6631
|
input.if_binding_version
|
|
6435
6632
|
]);
|
|
@@ -6469,6 +6666,13 @@ var FAULT_POINTS = [
|
|
|
6469
6666
|
"after_outbox_write",
|
|
6470
6667
|
"after_receipt_write"
|
|
6471
6668
|
];
|
|
6669
|
+
function resolveTenantId(value) {
|
|
6670
|
+
const tenantId = value ?? "default";
|
|
6671
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(tenantId)) {
|
|
6672
|
+
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_INVALID_INPUT", "tenantId must be a bounded exact authority identifier");
|
|
6673
|
+
}
|
|
6674
|
+
return tenantId;
|
|
6675
|
+
}
|
|
6472
6676
|
function normalize(input, now) {
|
|
6473
6677
|
const parsed = parseTodosTaskManifest(input);
|
|
6474
6678
|
const requestBytes = Buffer.byteLength(canonicalJson(parsed), "utf8");
|
|
@@ -6556,15 +6760,18 @@ function sanitizeManifest(manifest) {
|
|
|
6556
6760
|
class PackageOwnedTodosTaskManifestAuthority {
|
|
6557
6761
|
backend;
|
|
6558
6762
|
options;
|
|
6763
|
+
tenantId;
|
|
6559
6764
|
constructor(backend, options = {}) {
|
|
6560
6765
|
this.backend = backend;
|
|
6561
6766
|
this.options = options;
|
|
6767
|
+
this.tenantId = resolveTenantId(options.tenantId);
|
|
6562
6768
|
}
|
|
6563
6769
|
async capability() {
|
|
6564
6770
|
return {
|
|
6565
6771
|
authority: "todos",
|
|
6566
6772
|
route: TODOS_TASK_MANIFEST_ROUTE,
|
|
6567
6773
|
schema_version: TODOS_TASK_MANIFEST_SCHEMA_VERSION,
|
|
6774
|
+
tenant_id: this.tenantId,
|
|
6568
6775
|
backend: this.backend.kind,
|
|
6569
6776
|
deterministic_ids: true,
|
|
6570
6777
|
immutable_receipts: true,
|
|
@@ -6603,6 +6810,22 @@ class PackageOwnedTodosTaskManifestAuthority {
|
|
|
6603
6810
|
}
|
|
6604
6811
|
return this.backend.readExact(receiptId).then((result) => this.bounded(result));
|
|
6605
6812
|
}
|
|
6813
|
+
async lookupBinding(input) {
|
|
6814
|
+
const request = parseTodosTaskManifestBindingLookup(input);
|
|
6815
|
+
if (request.max_items !== 1) {
|
|
6816
|
+
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 });
|
|
6817
|
+
}
|
|
6818
|
+
if (request.authority !== "todos" || request.route !== TODOS_TASK_MANIFEST_ROUTE || request.schema_version !== TODOS_TASK_MANIFEST_SCHEMA_VERSION || request.tenant_id !== this.tenantId) {
|
|
6819
|
+
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_CAPABILITY_MISMATCH", "Task-manifest binding lookup does not match this authority identity");
|
|
6820
|
+
}
|
|
6821
|
+
return this.bounded({
|
|
6822
|
+
authority: "todos",
|
|
6823
|
+
route: TODOS_TASK_MANIFEST_ROUTE,
|
|
6824
|
+
schema_version: TODOS_TASK_MANIFEST_SCHEMA_VERSION,
|
|
6825
|
+
tenant_id: this.tenantId,
|
|
6826
|
+
...await this.backend.lookupBindingByPlanId(request.plan_id)
|
|
6827
|
+
});
|
|
6828
|
+
}
|
|
6606
6829
|
markOutboxDelivered(outboxId) {
|
|
6607
6830
|
if (!outboxId || outboxId.length > 200) {
|
|
6608
6831
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_INVALID_INPUT", "outboxId must be a bounded exact identifier");
|
|
@@ -6642,13 +6865,15 @@ function createSqliteTodosTaskManifestAuthority(options) {
|
|
|
6642
6865
|
if (!options?.database) {
|
|
6643
6866
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_ATOMICITY_UNAVAILABLE", "An explicit SQLite Database is required");
|
|
6644
6867
|
}
|
|
6645
|
-
|
|
6868
|
+
const tenantId = resolveTenantId(options.tenantId);
|
|
6869
|
+
return new PackageOwnedTodosTaskManifestAuthority(new SqliteTodosTaskManifestBackend(options.database, tenantId), { ...options, tenantId });
|
|
6646
6870
|
}
|
|
6647
6871
|
function createPostgresTodosTaskManifestAuthority(client, options = {}) {
|
|
6648
6872
|
if (!client || typeof client.transaction !== "function") {
|
|
6649
6873
|
throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_ATOMICITY_UNAVAILABLE", "An authoritative PostgreSQL transaction(callback) client is required");
|
|
6650
6874
|
}
|
|
6651
|
-
|
|
6875
|
+
const tenantId = resolveTenantId(options.tenantId);
|
|
6876
|
+
return new PackageOwnedTodosTaskManifestAuthority(new PostgresTodosTaskManifestBackend(client, { ...options, tenantId }), { ...options, tenantId });
|
|
6652
6877
|
}
|
|
6653
6878
|
// src/task-manifest/http.ts
|
|
6654
6879
|
var JSON_HEADERS = { "Content-Type": "application/json" };
|
|
@@ -6662,6 +6887,7 @@ function status(error) {
|
|
|
6662
6887
|
case "TODOS_TASK_MANIFEST_FOREIGN_REFERENCE":
|
|
6663
6888
|
return 400;
|
|
6664
6889
|
case "TODOS_TASK_MANIFEST_RECEIPT_NOT_FOUND":
|
|
6890
|
+
case "TODOS_TASK_MANIFEST_BINDING_NOT_FOUND":
|
|
6665
6891
|
return 404;
|
|
6666
6892
|
case "TODOS_TASK_MANIFEST_ATOMICITY_UNAVAILABLE":
|
|
6667
6893
|
return 503;
|
|
@@ -6724,6 +6950,11 @@ async function handleTodosTaskManifestHttpRequest(request, url, authority, baseP
|
|
|
6724
6950
|
}
|
|
6725
6951
|
return json({ result: await authority.readExact(input.receipt_id) });
|
|
6726
6952
|
}
|
|
6953
|
+
if (action === "bindings/lookup") {
|
|
6954
|
+
return json({
|
|
6955
|
+
result: await authority.lookupBinding(await body(request))
|
|
6956
|
+
});
|
|
6957
|
+
}
|
|
6727
6958
|
if (action === "compensate") {
|
|
6728
6959
|
return json({ result: await authority.compensate(await body(request)) }, 201);
|
|
6729
6960
|
}
|
|
@@ -6783,6 +7014,9 @@ class TodosTaskManifestHttpClient {
|
|
|
6783
7014
|
async readExact(receiptId) {
|
|
6784
7015
|
return (await this.request("/read-exact", { method: "POST", body: JSON.stringify({ receipt_id: receiptId }) })).result;
|
|
6785
7016
|
}
|
|
7017
|
+
async lookupBinding(input) {
|
|
7018
|
+
return (await this.request("/bindings/lookup", { method: "POST", body: JSON.stringify(input) })).result;
|
|
7019
|
+
}
|
|
6786
7020
|
async markOutboxDelivered(outboxId) {
|
|
6787
7021
|
await this.request("/outbox/delivered", { method: "POST", body: JSON.stringify({ outbox_id: outboxId }) });
|
|
6788
7022
|
}
|
|
@@ -6797,6 +7031,7 @@ export {
|
|
|
6797
7031
|
sqliteTodosTaskManifestSchemaSql,
|
|
6798
7032
|
postgresTodosTaskManifestSchemaSql,
|
|
6799
7033
|
parseTodosTaskManifestCompensation,
|
|
7034
|
+
parseTodosTaskManifestBindingLookup,
|
|
6800
7035
|
parseTodosTaskManifest,
|
|
6801
7036
|
handleTodosTaskManifestHttpRequest,
|
|
6802
7037
|
canonicalDigest as digestTodosTaskManifest,
|