@hasna/todos 0.15.7 → 0.15.10
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 +26 -1
- package/dist/cli/cloud-router.d.ts.map +1 -1
- package/dist/cli/commands/plan-template-commands.d.ts.map +1 -1
- package/dist/cli/commands/project-commands.d.ts.map +1 -1
- package/dist/cli/commands/task-commands.d.ts.map +1 -1
- package/dist/cli/index.js +3836 -1465
- package/dist/contracts.js +143 -10
- package/dist/db/migrations.d.ts.map +1 -1
- package/dist/db/plan-project-links.d.ts +8 -0
- package/dist/db/plan-project-links.d.ts.map +1 -0
- package/dist/db/plan-row-serialization.d.ts +8 -0
- package/dist/db/plan-row-serialization.d.ts.map +1 -0
- package/dist/db/plans.d.ts.map +1 -1
- package/dist/db/task-crud.d.ts.map +1 -1
- package/dist/db/task-lists.d.ts +5 -0
- package/dist/db/task-lists.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8037 -1112
- package/dist/lib/assignee-validation.d.ts +44 -0
- package/dist/lib/assignee-validation.d.ts.map +1 -1
- package/dist/lib/plan-project-link-contract.d.ts +17 -0
- package/dist/lib/plan-project-link-contract.d.ts.map +1 -0
- package/dist/lib/plan-project-link.d.ts +14 -0
- package/dist/lib/plan-project-link.d.ts.map +1 -0
- package/dist/lib/project-task-list-ensure.d.ts +19 -0
- package/dist/lib/project-task-list-ensure.d.ts.map +1 -0
- package/dist/mcp/index.js +2234 -31
- package/dist/mcp.js +7 -3
- package/dist/project-registration/sqlite.d.ts.map +1 -1
- package/dist/project-registration.js +1335 -60
- package/dist/registry.d.ts +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +150 -10
- 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 +42 -0
- package/dist/sdk/v1.generated.d.ts +96 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/index.js +2419 -216
- package/dist/server/openapi.d.ts +705 -24
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage/interfaces.d.ts +37 -1
- package/dist/storage/interfaces.d.ts.map +1 -1
- package/dist/storage/local-sqlite.d.ts.map +1 -1
- package/dist/storage/postgres-adapter.d.ts.map +1 -1
- package/dist/storage/postgres-sync.d.ts.map +1 -1
- package/dist/storage.js +870 -20
- package/dist/task-manifest/authority.d.ts +19 -0
- package/dist/task-manifest/authority.d.ts.map +1 -0
- package/dist/task-manifest/backend.d.ts +27 -0
- package/dist/task-manifest/backend.d.ts.map +1 -0
- package/dist/task-manifest/canonical.d.ts +4 -0
- package/dist/task-manifest/canonical.d.ts.map +1 -0
- package/dist/task-manifest/http.d.ts +16 -0
- package/dist/task-manifest/http.d.ts.map +1 -0
- package/dist/task-manifest/index.d.ts +10 -0
- package/dist/task-manifest/index.d.ts.map +1 -0
- package/dist/task-manifest/postgres.d.ts +18 -0
- package/dist/task-manifest/postgres.d.ts.map +1 -0
- package/dist/task-manifest/reference-guard.d.ts +27 -0
- package/dist/task-manifest/reference-guard.d.ts.map +1 -0
- package/dist/task-manifest/schema-sql.d.ts +3 -0
- package/dist/task-manifest/schema-sql.d.ts.map +1 -0
- package/dist/task-manifest/schema.d.ts +15 -0
- package/dist/task-manifest/schema.d.ts.map +1 -0
- package/dist/task-manifest/sqlite.d.ts +15 -0
- package/dist/task-manifest/sqlite.d.ts.map +1 -0
- package/dist/task-manifest/types.d.ts +166 -0
- package/dist/task-manifest/types.d.ts.map +1 -0
- package/dist/task-manifest.d.ts +2 -0
- package/dist/task-manifest.d.ts.map +1 -0
- package/dist/task-manifest.js +6816 -0
- package/dist/types/index.d.ts +89 -2
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +7 -3
package/dist/storage.js
CHANGED
|
@@ -2269,6 +2269,29 @@ var init_migrations = __esm(() => {
|
|
|
2269
2269
|
${sqliteTodosProjectRegistrationSchemaSql()}
|
|
2270
2270
|
INSERT OR IGNORE INTO _migrations (id) VALUES (69);
|
|
2271
2271
|
COMMIT;
|
|
2272
|
+
`,
|
|
2273
|
+
`BEGIN;
|
|
2274
|
+
CREATE TABLE IF NOT EXISTS plan_project_link_receipts (
|
|
2275
|
+
receipt_id TEXT PRIMARY KEY,
|
|
2276
|
+
idempotency_key TEXT NOT NULL UNIQUE,
|
|
2277
|
+
plan_id TEXT NOT NULL,
|
|
2278
|
+
project_id TEXT NOT NULL,
|
|
2279
|
+
payload_hash TEXT NOT NULL,
|
|
2280
|
+
payload TEXT NOT NULL,
|
|
2281
|
+
created_at TEXT NOT NULL
|
|
2282
|
+
);
|
|
2283
|
+
CREATE INDEX IF NOT EXISTS idx_plan_project_link_receipts_plan
|
|
2284
|
+
ON plan_project_link_receipts(plan_id, created_at);
|
|
2285
|
+
|
|
2286
|
+
CREATE TABLE IF NOT EXISTS plan_project_link_rollback_receipts (
|
|
2287
|
+
rollback_receipt_id TEXT PRIMARY KEY,
|
|
2288
|
+
accepted_receipt_id TEXT NOT NULL UNIQUE
|
|
2289
|
+
REFERENCES plan_project_link_receipts(receipt_id) ON DELETE RESTRICT,
|
|
2290
|
+
payload TEXT NOT NULL,
|
|
2291
|
+
created_at TEXT NOT NULL
|
|
2292
|
+
);
|
|
2293
|
+
INSERT OR IGNORE INTO _migrations (id) VALUES (70);
|
|
2294
|
+
COMMIT;
|
|
2272
2295
|
`
|
|
2273
2296
|
];
|
|
2274
2297
|
});
|
|
@@ -6747,6 +6770,40 @@ function deleteTaskList(id, db) {
|
|
|
6747
6770
|
return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
|
|
6748
6771
|
})();
|
|
6749
6772
|
}
|
|
6773
|
+
function deleteTaskListIfUnchangedAndUnused(id, expected, db) {
|
|
6774
|
+
const d = db || getDatabase();
|
|
6775
|
+
return d.transaction(() => {
|
|
6776
|
+
const current = getTaskList(id, d);
|
|
6777
|
+
if (!current) {
|
|
6778
|
+
return { status: "not_found", task_dependents: 0, plan_dependents: 0 };
|
|
6779
|
+
}
|
|
6780
|
+
const changed = current.project_id !== expected.project_id || current.slug !== expected.slug || current.name !== expected.name || current.description !== expected.description || current.updated_at !== expected.updated_at || JSON.stringify(current.metadata) !== JSON.stringify(expected.metadata);
|
|
6781
|
+
if (changed) {
|
|
6782
|
+
return { status: "changed", task_dependents: 0, plan_dependents: 0 };
|
|
6783
|
+
}
|
|
6784
|
+
const taskDependents = Number(d.query("SELECT COUNT(*) AS count FROM tasks WHERE task_list_id = ?").get(id).count);
|
|
6785
|
+
const planDependents = Number(d.query("SELECT COUNT(*) AS count FROM plans WHERE task_list_id = ?").get(id).count);
|
|
6786
|
+
if (taskDependents > 0 || planDependents > 0) {
|
|
6787
|
+
return {
|
|
6788
|
+
status: "has_dependents",
|
|
6789
|
+
task_dependents: taskDependents,
|
|
6790
|
+
plan_dependents: planDependents
|
|
6791
|
+
};
|
|
6792
|
+
}
|
|
6793
|
+
recordStorageTombstone({
|
|
6794
|
+
object_type: "task_lists",
|
|
6795
|
+
object_id: id,
|
|
6796
|
+
payload: current
|
|
6797
|
+
}, d);
|
|
6798
|
+
releaseCanonicalSlugClaims("task_list", id, d);
|
|
6799
|
+
const deleted = d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
|
|
6800
|
+
return {
|
|
6801
|
+
status: deleted ? "deleted" : "not_found",
|
|
6802
|
+
task_dependents: 0,
|
|
6803
|
+
plan_dependents: 0
|
|
6804
|
+
};
|
|
6805
|
+
})();
|
|
6806
|
+
}
|
|
6750
6807
|
function ensureTaskList(name, slug, projectId, db) {
|
|
6751
6808
|
const d = db || getDatabase();
|
|
6752
6809
|
const existing = getTaskListBySlug(slug, projectId, d);
|
|
@@ -7774,6 +7831,14 @@ var init_checklists = __esm(() => {
|
|
|
7774
7831
|
init_database();
|
|
7775
7832
|
});
|
|
7776
7833
|
|
|
7834
|
+
// src/db/plan-row-serialization.ts
|
|
7835
|
+
function guardPlanRowsSqlite(planIds, db) {
|
|
7836
|
+
const ids = [...new Set(planIds.filter((id) => Boolean(id)))].sort();
|
|
7837
|
+
for (const id of ids) {
|
|
7838
|
+
db.run("/* todos:sqlite-plan-row-guard */ UPDATE plans SET id = id WHERE id = ?", [id]);
|
|
7839
|
+
}
|
|
7840
|
+
}
|
|
7841
|
+
|
|
7777
7842
|
// src/lib/creator-identity.ts
|
|
7778
7843
|
function canonicalAgentRef(value) {
|
|
7779
7844
|
return value.trim().toLowerCase();
|
|
@@ -9100,9 +9165,23 @@ function sanitizeUpdateTaskInput(input) {
|
|
|
9100
9165
|
metadata: input.metadata !== undefined ? sanitizePreWriteValue(input.metadata, "task.metadata") : undefined
|
|
9101
9166
|
};
|
|
9102
9167
|
}
|
|
9103
|
-
function
|
|
9104
|
-
|
|
9105
|
-
|
|
9168
|
+
function linkedPlanProjectId(planId, db) {
|
|
9169
|
+
if (!planId)
|
|
9170
|
+
return null;
|
|
9171
|
+
const row = db.query("SELECT project_id FROM plans WHERE id = ?").get(planId);
|
|
9172
|
+
return row?.project_id ?? null;
|
|
9173
|
+
}
|
|
9174
|
+
function resolveCreateProjectForPlan(input, db) {
|
|
9175
|
+
const linkedProjectId = linkedPlanProjectId(input.plan_id, db);
|
|
9176
|
+
if (!linkedProjectId)
|
|
9177
|
+
return input.project_id || null;
|
|
9178
|
+
if (input.project_id !== undefined && input.project_id !== linkedProjectId) {
|
|
9179
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${input.plan_id}: expected ${linkedProjectId}`);
|
|
9180
|
+
}
|
|
9181
|
+
return linkedProjectId;
|
|
9182
|
+
}
|
|
9183
|
+
function createTaskStored(input, d) {
|
|
9184
|
+
const effectiveProjectId = resolveCreateProjectForPlan(input, d);
|
|
9106
9185
|
const timestamp2 = now();
|
|
9107
9186
|
const tags = input.tags || [];
|
|
9108
9187
|
const machineId = currentStorageMachineId(d);
|
|
@@ -9116,7 +9195,7 @@ function createTask(input, db) {
|
|
|
9116
9195
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
9117
9196
|
id,
|
|
9118
9197
|
null,
|
|
9119
|
-
|
|
9198
|
+
effectiveProjectId,
|
|
9120
9199
|
input.parent_id || null,
|
|
9121
9200
|
input.plan_id || null,
|
|
9122
9201
|
input.task_list_id || null,
|
|
@@ -9166,7 +9245,15 @@ function createTask(input, db) {
|
|
|
9166
9245
|
if (tags.length > 0) {
|
|
9167
9246
|
insertTaskTags(id, tags, d);
|
|
9168
9247
|
}
|
|
9169
|
-
|
|
9248
|
+
return getTask(id, d);
|
|
9249
|
+
}
|
|
9250
|
+
function createTask(input, db) {
|
|
9251
|
+
input = sanitizeCreateTaskInput(input);
|
|
9252
|
+
const d = db || getDatabase();
|
|
9253
|
+
const task = input.plan_id ? d.transaction(() => {
|
|
9254
|
+
guardPlanRowsSqlite([input.plan_id], d);
|
|
9255
|
+
return createTaskStored(input, d);
|
|
9256
|
+
})() : createTaskStored(input, d);
|
|
9170
9257
|
const payload = taskEventData(task);
|
|
9171
9258
|
const databasePath = databasePathFromDatabase(d);
|
|
9172
9259
|
dispatchWebhook2("task.created", payload, d).catch(() => {});
|
|
@@ -9266,6 +9353,10 @@ function listTasks(filter = {}, db) {
|
|
|
9266
9353
|
conditions.push("(created_by IS NULL OR LOWER(created_by) != LOWER(?))");
|
|
9267
9354
|
params.push(filter.not_created_by);
|
|
9268
9355
|
}
|
|
9356
|
+
if (filter.updated_after) {
|
|
9357
|
+
conditions.push("(julianday(updated_at) IS NULL OR julianday(updated_at) > julianday(?))");
|
|
9358
|
+
params.push(filter.updated_after);
|
|
9359
|
+
}
|
|
9269
9360
|
if (filter.session_id) {
|
|
9270
9361
|
conditions.push("session_id = ?");
|
|
9271
9362
|
params.push(filter.session_id);
|
|
@@ -9426,6 +9517,10 @@ function countTasks(filter = {}, db) {
|
|
|
9426
9517
|
conditions.push("(created_by IS NULL OR LOWER(created_by) != LOWER(?))");
|
|
9427
9518
|
params.push(filter.not_created_by);
|
|
9428
9519
|
}
|
|
9520
|
+
if (filter.updated_after) {
|
|
9521
|
+
conditions.push("(julianday(updated_at) IS NULL OR julianday(updated_at) > julianday(?))");
|
|
9522
|
+
params.push(filter.updated_after);
|
|
9523
|
+
}
|
|
9429
9524
|
if (filter.session_id) {
|
|
9430
9525
|
conditions.push("session_id = ?");
|
|
9431
9526
|
params.push(filter.session_id);
|
|
@@ -9465,7 +9560,7 @@ function countTasks(filter = {}, db) {
|
|
|
9465
9560
|
const row = d.query(`SELECT COUNT(*) as count FROM tasks ${where}`).get(...params);
|
|
9466
9561
|
return row.count;
|
|
9467
9562
|
}
|
|
9468
|
-
function
|
|
9563
|
+
function updateTaskStored(id, input, db) {
|
|
9469
9564
|
const d = db || getDatabase();
|
|
9470
9565
|
const task = getTask(id, d);
|
|
9471
9566
|
if (!task)
|
|
@@ -9474,6 +9569,18 @@ function updateTask(id, input, db) {
|
|
|
9474
9569
|
throw new VersionConflictError(id, input.version, task.version);
|
|
9475
9570
|
}
|
|
9476
9571
|
input = sanitizeUpdateTaskInput(input);
|
|
9572
|
+
const effectivePlanId = input.plan_id !== undefined ? input.plan_id : task.plan_id;
|
|
9573
|
+
const linkedProjectId = linkedPlanProjectId(effectivePlanId, d);
|
|
9574
|
+
if (linkedProjectId) {
|
|
9575
|
+
const effectiveProjectId = input.project_id !== undefined ? input.project_id : task.project_id;
|
|
9576
|
+
if (effectiveProjectId !== linkedProjectId) {
|
|
9577
|
+
if (input.project_id === undefined && (input.plan_id !== undefined || task.project_id === null)) {
|
|
9578
|
+
input = { ...input, project_id: linkedProjectId };
|
|
9579
|
+
} else {
|
|
9580
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${effectivePlanId}: expected ${linkedProjectId}`);
|
|
9581
|
+
}
|
|
9582
|
+
}
|
|
9583
|
+
}
|
|
9477
9584
|
const timestamp2 = now();
|
|
9478
9585
|
const completionTimestamp = input.completed_at ?? timestamp2;
|
|
9479
9586
|
const sets = ["version = version + 1", "updated_at = ?"];
|
|
@@ -9681,6 +9788,21 @@ function updateTask(id, input, db) {
|
|
|
9681
9788
|
emitSharedTaskEventQuiet({ type: "task.updated", task: updatedTask, databasePath });
|
|
9682
9789
|
return updatedTask;
|
|
9683
9790
|
}
|
|
9791
|
+
function updateTask(id, input, db) {
|
|
9792
|
+
const d = db || getDatabase();
|
|
9793
|
+
const before = getTask(id, d);
|
|
9794
|
+
if (!before)
|
|
9795
|
+
throw new TaskNotFoundError(id);
|
|
9796
|
+
const guardedPlanIds = [before.plan_id, input.plan_id];
|
|
9797
|
+
if (!guardedPlanIds.some(Boolean))
|
|
9798
|
+
return updateTaskStored(id, input, d);
|
|
9799
|
+
return d.transaction(() => {
|
|
9800
|
+
guardPlanRowsSqlite(guardedPlanIds, d);
|
|
9801
|
+
const current = getTask(id, d);
|
|
9802
|
+
guardPlanRowsSqlite([current?.plan_id, input.plan_id], d);
|
|
9803
|
+
return updateTaskStored(id, input, d);
|
|
9804
|
+
})();
|
|
9805
|
+
}
|
|
9684
9806
|
function deleteTask(id, db) {
|
|
9685
9807
|
const d = db || getDatabase();
|
|
9686
9808
|
const row = d.query("SELECT * FROM tasks WHERE id = ?").get(id);
|
|
@@ -10346,7 +10468,7 @@ function listPlans(projectId, db) {
|
|
|
10346
10468
|
}
|
|
10347
10469
|
return d.query("SELECT * FROM plans ORDER BY created_at DESC").all();
|
|
10348
10470
|
}
|
|
10349
|
-
function
|
|
10471
|
+
function updatePlanStored(id, input, db) {
|
|
10350
10472
|
const d = db || getDatabase();
|
|
10351
10473
|
const plan = getPlan(id, d);
|
|
10352
10474
|
if (!plan)
|
|
@@ -10391,6 +10513,13 @@ function updatePlan(id, input, db) {
|
|
|
10391
10513
|
});
|
|
10392
10514
|
return updated;
|
|
10393
10515
|
}
|
|
10516
|
+
function updatePlan(id, input, db) {
|
|
10517
|
+
const d = db || getDatabase();
|
|
10518
|
+
return d.transaction(() => {
|
|
10519
|
+
guardPlanRowsSqlite([id], d);
|
|
10520
|
+
return updatePlanStored(id, input, d);
|
|
10521
|
+
})();
|
|
10522
|
+
}
|
|
10394
10523
|
function deletePlan(id, db) {
|
|
10395
10524
|
const d = db || getDatabase();
|
|
10396
10525
|
const plan = getPlan(id, d);
|
|
@@ -13332,6 +13461,239 @@ function scanSqliteIntegrity(db = getDatabase()) {
|
|
|
13332
13461
|
return buildIntegrityReport(conditions, now());
|
|
13333
13462
|
}
|
|
13334
13463
|
|
|
13464
|
+
// src/lib/plan-project-link-contract.ts
|
|
13465
|
+
import { createHash as createHash4 } from "crypto";
|
|
13466
|
+
var PLAN_PROJECT_LINK_SCHEMA_VERSION = "todos.plan-project-link.v1";
|
|
13467
|
+
|
|
13468
|
+
class PlanProjectLinkError extends Error {
|
|
13469
|
+
code;
|
|
13470
|
+
details;
|
|
13471
|
+
constructor(code, message, details = {}) {
|
|
13472
|
+
super(message);
|
|
13473
|
+
this.code = code;
|
|
13474
|
+
this.details = details;
|
|
13475
|
+
this.name = "PlanProjectLinkError";
|
|
13476
|
+
}
|
|
13477
|
+
}
|
|
13478
|
+
function canonicalPlanProjectLinkJson(value) {
|
|
13479
|
+
if (value === null || typeof value !== "object")
|
|
13480
|
+
return JSON.stringify(value);
|
|
13481
|
+
if (Array.isArray(value))
|
|
13482
|
+
return `[${value.map(canonicalPlanProjectLinkJson).join(",")}]`;
|
|
13483
|
+
return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalPlanProjectLinkJson(item)}`).join(",")}}`;
|
|
13484
|
+
}
|
|
13485
|
+
function planProjectLinkDigest(value) {
|
|
13486
|
+
return createHash4("sha256").update(canonicalPlanProjectLinkJson(value)).digest("hex");
|
|
13487
|
+
}
|
|
13488
|
+
function normalizePlanProjectLinkIdempotencyKey(value) {
|
|
13489
|
+
const key = value?.trim() ?? "";
|
|
13490
|
+
if (key.length < 8 || key.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(key)) {
|
|
13491
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_KEY_INVALID", "idempotency_key must be 8-128 ASCII letters, digits, dots, underscores, colons, or hyphens");
|
|
13492
|
+
}
|
|
13493
|
+
return key;
|
|
13494
|
+
}
|
|
13495
|
+
function planProjectLinkReceiptId(idempotencyKey) {
|
|
13496
|
+
return `pplr_${planProjectLinkDigest({ idempotency_key: idempotencyKey }).slice(0, 48)}`;
|
|
13497
|
+
}
|
|
13498
|
+
function planProjectLinkRollbackReceiptId(receiptId) {
|
|
13499
|
+
return `pplr_inverse_${planProjectLinkDigest({ accepted_receipt_id: receiptId }).slice(0, 38)}`;
|
|
13500
|
+
}
|
|
13501
|
+
function planProjectLinkRequestHash(planId, projectId) {
|
|
13502
|
+
return planProjectLinkDigest({ plan_id: planId, project_id: projectId });
|
|
13503
|
+
}
|
|
13504
|
+
function planProjectLinkResultDigest(plan, tasks) {
|
|
13505
|
+
return planProjectLinkDigest({
|
|
13506
|
+
plan_id: plan.id,
|
|
13507
|
+
plan_project_id: plan.project_id,
|
|
13508
|
+
tasks: tasks.map((task) => ({ id: task.id, plan_id: task.plan_id, project_id: task.project_id })).sort((left, right) => left.id.localeCompare(right.id))
|
|
13509
|
+
});
|
|
13510
|
+
}
|
|
13511
|
+
function assertPlanProjectLinkReceipt(value) {
|
|
13512
|
+
const receipt = value;
|
|
13513
|
+
if (!receipt || typeof receipt !== "object" || receipt.schema_version !== PLAN_PROJECT_LINK_SCHEMA_VERSION || typeof receipt.receipt_id !== "string" || typeof receipt.idempotency_key !== "string" || typeof receipt.plan_id !== "string" || typeof receipt.project_id !== "string" || !Array.isArray(receipt.task_ids) || receipt.task_ids.some((id) => typeof id !== "string") || !receipt.prior_task_project_ids || typeof receipt.prior_task_project_ids !== "object" || typeof receipt.result_digest !== "string") {
|
|
13514
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "Stored plan-project-link receipt is invalid");
|
|
13515
|
+
}
|
|
13516
|
+
return receipt;
|
|
13517
|
+
}
|
|
13518
|
+
|
|
13519
|
+
// src/db/plan-project-links.ts
|
|
13520
|
+
init_database();
|
|
13521
|
+
init_plans();
|
|
13522
|
+
init_projects();
|
|
13523
|
+
init_tasks();
|
|
13524
|
+
function getPlanProjectLinkReceipt(receiptId, db) {
|
|
13525
|
+
const d = db || getDatabase();
|
|
13526
|
+
const row = d.query("SELECT payload FROM plan_project_link_receipts WHERE receipt_id = ?").get(receiptId);
|
|
13527
|
+
return row ? assertPlanProjectLinkReceipt(JSON.parse(row.payload)) : null;
|
|
13528
|
+
}
|
|
13529
|
+
function getPlanProjectLinkReceiptByIdempotencyKey(idempotencyKey, db) {
|
|
13530
|
+
const d = db || getDatabase();
|
|
13531
|
+
const row = d.query("SELECT payload FROM plan_project_link_receipts WHERE idempotency_key = ?").get(idempotencyKey);
|
|
13532
|
+
return row ? assertPlanProjectLinkReceipt(JSON.parse(row.payload)) : null;
|
|
13533
|
+
}
|
|
13534
|
+
function exactTasks(planId, db) {
|
|
13535
|
+
return listTasks({ plan_id: planId, include_subtasks: true, include_archived: true }, db).sort((left, right) => left.id.localeCompare(right.id));
|
|
13536
|
+
}
|
|
13537
|
+
function currentResult(planId, projectId, receipt, db, action) {
|
|
13538
|
+
const plan = getPlan(planId, db);
|
|
13539
|
+
const project = getProject(projectId, db);
|
|
13540
|
+
if (!plan || !project) {
|
|
13541
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "An accepted plan-project-link target no longer resolves", { plan_id: planId, project_id: projectId, receipt_id: receipt.receipt_id });
|
|
13542
|
+
}
|
|
13543
|
+
const tasks = exactTasks(planId, db);
|
|
13544
|
+
if (planProjectLinkResultDigest(plan, tasks) !== receipt.result_digest) {
|
|
13545
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "The accepted plan-project-link result has drifted", { plan_id: planId, project_id: projectId, receipt_id: receipt.receipt_id });
|
|
13546
|
+
}
|
|
13547
|
+
return { mode: "apply", action, plan, project, tasks, receipt };
|
|
13548
|
+
}
|
|
13549
|
+
function applyPlanProjectLinkSqlite(input, db) {
|
|
13550
|
+
const d = db || getDatabase();
|
|
13551
|
+
const mutate = d.transaction(() => {
|
|
13552
|
+
guardPlanRowsSqlite([input.plan_id], d);
|
|
13553
|
+
const requestHash = planProjectLinkRequestHash(input.plan_id, input.project_id);
|
|
13554
|
+
const existingRow = d.query("SELECT payload_hash, payload FROM plan_project_link_receipts WHERE idempotency_key = ?").get(input.idempotency_key);
|
|
13555
|
+
if (existingRow) {
|
|
13556
|
+
if (existingRow.payload_hash !== requestHash) {
|
|
13557
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key was already accepted for a different plan-project link", { idempotency_key: input.idempotency_key });
|
|
13558
|
+
}
|
|
13559
|
+
const rolledBack = d.query("SELECT rollback_receipt_id FROM plan_project_link_rollback_receipts WHERE accepted_receipt_id = ?").get(input.receipt_id);
|
|
13560
|
+
if (rolledBack) {
|
|
13561
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
|
|
13562
|
+
}
|
|
13563
|
+
return currentResult(input.plan_id, input.project_id, assertPlanProjectLinkReceipt(JSON.parse(existingRow.payload)), d, "already_linked");
|
|
13564
|
+
}
|
|
13565
|
+
const plan = getPlan(input.plan_id, d);
|
|
13566
|
+
if (!plan) {
|
|
13567
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
|
|
13568
|
+
}
|
|
13569
|
+
const project = getProject(input.project_id, d);
|
|
13570
|
+
if (!project) {
|
|
13571
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${input.project_id}`);
|
|
13572
|
+
}
|
|
13573
|
+
if (plan.updated_at !== input.expected_plan_revision) {
|
|
13574
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed after the link plan; fetch a fresh plan before applying", { expected_plan_revision: input.expected_plan_revision, current_plan_revision: plan.updated_at });
|
|
13575
|
+
}
|
|
13576
|
+
if (project.updated_at !== input.expected_project_revision) {
|
|
13577
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_REVISION_CONFLICT", "Destination project changed after the link plan; fetch a fresh plan before applying", { expected_project_revision: input.expected_project_revision, current_project_revision: project.updated_at });
|
|
13578
|
+
}
|
|
13579
|
+
if (plan.slug) {
|
|
13580
|
+
const collision = d.query("SELECT id FROM plans WHERE project_id = ? AND slug = ? AND id <> ? LIMIT 1").get(project.id, plan.slug, plan.id);
|
|
13581
|
+
if (collision) {
|
|
13582
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_SCOPE_COLLISION", "Another plan already owns this slug in the destination project", { conflicting_plan_id: collision.id, slug: plan.slug });
|
|
13583
|
+
}
|
|
13584
|
+
}
|
|
13585
|
+
const beforeTasks = exactTasks(plan.id, d);
|
|
13586
|
+
const alreadyLinked = plan.project_id === project.id && beforeTasks.every((task) => task.project_id === project.id);
|
|
13587
|
+
const priorTaskProjectIds = Object.fromEntries(beforeTasks.map((task) => [task.id, task.project_id]));
|
|
13588
|
+
const timestamp2 = input.created_at;
|
|
13589
|
+
if (plan.project_id !== project.id) {
|
|
13590
|
+
d.run("UPDATE plans SET project_id = ?, updated_at = ? WHERE id = ? AND updated_at = ?", [
|
|
13591
|
+
project.id,
|
|
13592
|
+
timestamp2,
|
|
13593
|
+
plan.id,
|
|
13594
|
+
input.expected_plan_revision
|
|
13595
|
+
]);
|
|
13596
|
+
}
|
|
13597
|
+
for (const task of beforeTasks) {
|
|
13598
|
+
if (task.project_id === project.id)
|
|
13599
|
+
continue;
|
|
13600
|
+
d.run("UPDATE tasks SET project_id = ?, updated_at = ?, version = version + 1 WHERE id = ? AND plan_id = ?", [project.id, timestamp2, task.id, plan.id]);
|
|
13601
|
+
}
|
|
13602
|
+
const linkedPlan = getPlan(plan.id, d);
|
|
13603
|
+
const linkedTasks = exactTasks(plan.id, d);
|
|
13604
|
+
if (linkedPlan.project_id !== project.id || linkedTasks.length !== beforeTasks.length || linkedTasks.some((task) => task.project_id !== project.id)) {
|
|
13605
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "Atomic plan-project link readback did not preserve the exact plan membership", { plan_id: plan.id, project_id: project.id });
|
|
13606
|
+
}
|
|
13607
|
+
const receipt = {
|
|
13608
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
13609
|
+
receipt_id: input.receipt_id,
|
|
13610
|
+
idempotency_key: input.idempotency_key,
|
|
13611
|
+
plan_id: plan.id,
|
|
13612
|
+
project_id: project.id,
|
|
13613
|
+
prior_plan_project_id: plan.project_id,
|
|
13614
|
+
prior_task_project_ids: priorTaskProjectIds,
|
|
13615
|
+
task_ids: beforeTasks.map((task) => task.id),
|
|
13616
|
+
task_count: beforeTasks.length,
|
|
13617
|
+
result_plan_revision: linkedPlan.updated_at,
|
|
13618
|
+
result_digest: planProjectLinkResultDigest(linkedPlan, linkedTasks),
|
|
13619
|
+
rollback_supported: true,
|
|
13620
|
+
created_at: input.created_at
|
|
13621
|
+
};
|
|
13622
|
+
d.run(`INSERT INTO plan_project_link_receipts
|
|
13623
|
+
(receipt_id, idempotency_key, plan_id, project_id, payload_hash, payload, created_at)
|
|
13624
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`, [
|
|
13625
|
+
receipt.receipt_id,
|
|
13626
|
+
receipt.idempotency_key,
|
|
13627
|
+
receipt.plan_id,
|
|
13628
|
+
receipt.project_id,
|
|
13629
|
+
requestHash,
|
|
13630
|
+
JSON.stringify(receipt),
|
|
13631
|
+
receipt.created_at
|
|
13632
|
+
]);
|
|
13633
|
+
return {
|
|
13634
|
+
mode: "apply",
|
|
13635
|
+
action: alreadyLinked ? "already_linked" : "linked",
|
|
13636
|
+
plan: linkedPlan,
|
|
13637
|
+
project,
|
|
13638
|
+
tasks: linkedTasks,
|
|
13639
|
+
receipt
|
|
13640
|
+
};
|
|
13641
|
+
});
|
|
13642
|
+
return mutate();
|
|
13643
|
+
}
|
|
13644
|
+
function rollbackPlanProjectLinkSqlite(input, db) {
|
|
13645
|
+
const d = db || getDatabase();
|
|
13646
|
+
return d.transaction(() => {
|
|
13647
|
+
guardPlanRowsSqlite([input.plan_id], d);
|
|
13648
|
+
const priorRollback = d.query("SELECT payload FROM plan_project_link_rollback_receipts WHERE accepted_receipt_id = ?").get(input.receipt_id);
|
|
13649
|
+
if (priorRollback)
|
|
13650
|
+
return JSON.parse(priorRollback.payload);
|
|
13651
|
+
const receipt = getPlanProjectLinkReceipt(input.receipt_id, d);
|
|
13652
|
+
if (!receipt || receipt.plan_id !== input.plan_id || receipt.project_id !== input.project_id) {
|
|
13653
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "No exact plan-project-link receipt matches this rollback request", { receipt_id: input.receipt_id, plan_id: input.plan_id, project_id: input.project_id });
|
|
13654
|
+
}
|
|
13655
|
+
const plan = getPlan(input.plan_id, d);
|
|
13656
|
+
if (!plan || plan.updated_at !== input.expected_plan_revision) {
|
|
13657
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed after the accepted link; fetch an exact readback before rollback", { expected_plan_revision: input.expected_plan_revision, current_plan_revision: plan?.updated_at ?? null });
|
|
13658
|
+
}
|
|
13659
|
+
const tasks = exactTasks(plan.id, d);
|
|
13660
|
+
if (planProjectLinkResultDigest(plan, tasks) !== receipt.result_digest || tasks.length !== receipt.task_ids.length || tasks.some((task, index) => task.id !== receipt.task_ids[index])) {
|
|
13661
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership or project linkage drifted; refusing conditional rollback", { receipt_id: receipt.receipt_id });
|
|
13662
|
+
}
|
|
13663
|
+
d.run("UPDATE plans SET project_id = ?, updated_at = ? WHERE id = ? AND updated_at = ?", [
|
|
13664
|
+
receipt.prior_plan_project_id,
|
|
13665
|
+
input.restored_at,
|
|
13666
|
+
plan.id,
|
|
13667
|
+
input.expected_plan_revision
|
|
13668
|
+
]);
|
|
13669
|
+
for (const task of tasks) {
|
|
13670
|
+
const priorProjectId = receipt.prior_task_project_ids[task.id];
|
|
13671
|
+
if (priorProjectId === undefined && !(task.id in receipt.prior_task_project_ids)) {
|
|
13672
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Receipt does not contain the exact prior project for every member task", { task_id: task.id, receipt_id: receipt.receipt_id });
|
|
13673
|
+
}
|
|
13674
|
+
d.run("UPDATE tasks SET project_id = ?, updated_at = ?, version = version + 1 WHERE id = ? AND plan_id = ?", [priorProjectId, input.restored_at, task.id, plan.id]);
|
|
13675
|
+
}
|
|
13676
|
+
const restoredPlan = getPlan(plan.id, d);
|
|
13677
|
+
const restoredTasks = exactTasks(plan.id, d);
|
|
13678
|
+
if (restoredPlan.project_id !== receipt.prior_plan_project_id || restoredTasks.some((task) => task.project_id !== receipt.prior_task_project_ids[task.id])) {
|
|
13679
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback readback did not restore every exact prior project id", { receipt_id: receipt.receipt_id });
|
|
13680
|
+
}
|
|
13681
|
+
const result = {
|
|
13682
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
13683
|
+
action: "restored",
|
|
13684
|
+
plan: restoredPlan,
|
|
13685
|
+
tasks: restoredTasks,
|
|
13686
|
+
accepted_receipt_id: receipt.receipt_id,
|
|
13687
|
+
rollback_receipt_id: input.rollback_receipt_id,
|
|
13688
|
+
restored_at: input.restored_at
|
|
13689
|
+
};
|
|
13690
|
+
d.run(`INSERT INTO plan_project_link_rollback_receipts
|
|
13691
|
+
(rollback_receipt_id, accepted_receipt_id, payload, created_at)
|
|
13692
|
+
VALUES (?, ?, ?, ?)`, [result.rollback_receipt_id, result.accepted_receipt_id, JSON.stringify(result), result.restored_at]);
|
|
13693
|
+
return result;
|
|
13694
|
+
})();
|
|
13695
|
+
}
|
|
13696
|
+
|
|
13335
13697
|
// src/storage/sqlite-snapshot.ts
|
|
13336
13698
|
init_database();
|
|
13337
13699
|
init_audit();
|
|
@@ -13835,6 +14197,12 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
13835
14197
|
update: (id, input) => updatePlan(id, input, database()),
|
|
13836
14198
|
delete: (id) => deletePlan(id, database())
|
|
13837
14199
|
},
|
|
14200
|
+
planProjectLinks: {
|
|
14201
|
+
apply: (input) => applyPlanProjectLinkSqlite(input, database()),
|
|
14202
|
+
rollback: (input) => rollbackPlanProjectLinkSqlite(input, database()),
|
|
14203
|
+
getReceipt: (receiptId) => getPlanProjectLinkReceipt(receiptId, database()),
|
|
14204
|
+
getReceiptByIdempotencyKey: (key) => getPlanProjectLinkReceiptByIdempotencyKey(key, database())
|
|
14205
|
+
},
|
|
13838
14206
|
agents: {
|
|
13839
14207
|
register: (input) => registerAgent(input, database()),
|
|
13840
14208
|
get: (id) => getAgent(id, database()),
|
|
@@ -13848,7 +14216,8 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
13848
14216
|
getBySlug: (slug, projectId) => getTaskListBySlug(slug, projectId, database()),
|
|
13849
14217
|
list: (projectId) => listTaskLists(projectId, database()),
|
|
13850
14218
|
update: (id, input) => updateTaskList(id, input, database()),
|
|
13851
|
-
delete: (id) => deleteTaskList(id, database())
|
|
14219
|
+
delete: (id) => deleteTaskList(id, database()),
|
|
14220
|
+
deleteIfUnchangedAndUnused: (id, expected) => deleteTaskListIfUnchangedAndUnused(id, expected, database())
|
|
13852
14221
|
},
|
|
13853
14222
|
templates: {
|
|
13854
14223
|
create: (input) => createTemplate(input, database()),
|
|
@@ -13928,6 +14297,21 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
|
|
|
13928
14297
|
RETURNS text
|
|
13929
14298
|
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
|
|
13930
14299
|
AS $$ SELECT unaccent('unaccent', $1) $$`,
|
|
14300
|
+
`CREATE OR REPLACE FUNCTION todos_try_timestamptz(text)
|
|
14301
|
+
RETURNS timestamptz
|
|
14302
|
+
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
|
|
14303
|
+
SET DateStyle TO 'ISO, YMD'
|
|
14304
|
+
AS $$
|
|
14305
|
+
BEGIN
|
|
14306
|
+
RETURN CASE
|
|
14307
|
+
WHEN $1 ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN $1::timestamptz
|
|
14308
|
+
ELSE ($1::timestamp AT TIME ZONE 'UTC')
|
|
14309
|
+
END;
|
|
14310
|
+
EXCEPTION WHEN others THEN RETURN NULL; END $$`,
|
|
14311
|
+
`DROP INDEX IF EXISTS ${tableName}_task_updated_at_idx`,
|
|
14312
|
+
`CREATE INDEX IF NOT EXISTS ${tableName}_task_updated_at_utc_idx
|
|
14313
|
+
ON ${tableName} (todos_try_timestamptz(payload->>'updated_at'))
|
|
14314
|
+
WHERE object_type = 'tasks' AND deleted_at IS NULL`,
|
|
13931
14315
|
`ALTER TABLE ${tableName}
|
|
13932
14316
|
ADD COLUMN IF NOT EXISTS task_search_tsv tsvector
|
|
13933
14317
|
GENERATED ALWAYS AS (
|
|
@@ -14357,6 +14741,12 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
14357
14741
|
update: (id, input) => updatePlan2(id, input, store),
|
|
14358
14742
|
delete: (id, context) => store.deletePlan(id, context)
|
|
14359
14743
|
},
|
|
14744
|
+
planProjectLinks: {
|
|
14745
|
+
apply: (input, context) => store.applyPlanProjectLink(input, context),
|
|
14746
|
+
rollback: (input, context) => store.rollbackPlanProjectLink(input, context),
|
|
14747
|
+
getReceipt: (receiptId) => store.getPlanProjectLinkReceipt(receiptId),
|
|
14748
|
+
getReceiptByIdempotencyKey: (key) => store.getPlanProjectLinkReceiptByIdempotencyKey(key)
|
|
14749
|
+
},
|
|
14360
14750
|
agents: {
|
|
14361
14751
|
register: (input, context) => registerAgent2(input, store, context),
|
|
14362
14752
|
get: (id) => store.get("agents", id),
|
|
@@ -14539,6 +14929,9 @@ class PostgresJsonRecordStore {
|
|
|
14539
14929
|
conds.push(`LOWER(payload->>'created_by') = LOWER(${p(filter.created_by)})`);
|
|
14540
14930
|
if (filter.not_created_by !== undefined)
|
|
14541
14931
|
conds.push(`(payload->>'created_by' IS NULL OR LOWER(payload->>'created_by') <> LOWER(${p(filter.not_created_by)}))`);
|
|
14932
|
+
if (filter.updated_after !== undefined) {
|
|
14933
|
+
conds.push(`(todos_try_timestamptz(payload->>'updated_at') IS NULL ` + `OR todos_try_timestamptz(payload->>'updated_at') > ${p(filter.updated_after)}::timestamptz)`);
|
|
14934
|
+
}
|
|
14542
14935
|
if (filter.session_id !== undefined)
|
|
14543
14936
|
conds.push(`payload->>'session_id' = ${p(filter.session_id)}`);
|
|
14544
14937
|
if (filter.tags?.length) {
|
|
@@ -14723,6 +15116,115 @@ class PostgresJsonRecordStore {
|
|
|
14723
15116
|
}
|
|
14724
15117
|
return value;
|
|
14725
15118
|
}
|
|
15119
|
+
async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
|
|
15120
|
+
const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
|
|
15121
|
+
if (planIds.length === 0)
|
|
15122
|
+
return this.upsert("tasks", value, context);
|
|
15123
|
+
await this.ensureSchema();
|
|
15124
|
+
const updatedAt = value.updated_at;
|
|
15125
|
+
const targetPlanId = value.plan_id;
|
|
15126
|
+
const result = await this.options.client.query(`/* todos:task-plan-membership-guard */ WITH
|
|
15127
|
+
locked_plans AS MATERIALIZED (
|
|
15128
|
+
SELECT object_id, payload FROM ${this.tableName}
|
|
15129
|
+
WHERE service = $1 AND object_type = 'plans' AND deleted_at IS NULL
|
|
15130
|
+
AND object_id IN (SELECT value FROM jsonb_array_elements_text($7::jsonb))
|
|
15131
|
+
ORDER BY object_id
|
|
15132
|
+
FOR UPDATE
|
|
15133
|
+
), validation AS (
|
|
15134
|
+
SELECT
|
|
15135
|
+
(SELECT count(*) FROM locked_plans) = jsonb_array_length($7::jsonb) AS all_plans_found,
|
|
15136
|
+
($8::text IS NULL OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
|
|
15137
|
+
(SELECT payload->>'project_id' FROM locked_plans WHERE object_id = $8) AS target_project_id
|
|
15138
|
+
), guarded AS (
|
|
15139
|
+
SELECT
|
|
15140
|
+
validation.*,
|
|
15141
|
+
($9::boolean AND validation.target_project_id IS NOT NULL
|
|
15142
|
+
AND ($3::jsonb->>'project_id') IS DISTINCT FROM validation.target_project_id) AS project_conflict,
|
|
15143
|
+
CASE
|
|
15144
|
+
WHEN validation.target_project_id IS NULL THEN $3::jsonb
|
|
15145
|
+
ELSE jsonb_set($3::jsonb, '{project_id}', to_jsonb(validation.target_project_id), true)
|
|
15146
|
+
END AS payload
|
|
15147
|
+
FROM validation
|
|
15148
|
+
), stored AS (
|
|
15149
|
+
INSERT INTO ${this.tableName} (
|
|
15150
|
+
service, object_type, object_id, payload, updated_at,
|
|
15151
|
+
deleted_at, source_machine_id, version
|
|
15152
|
+
)
|
|
15153
|
+
SELECT $1, 'tasks', $2, guarded.payload, $4::timestamptz, NULL, $5, $6
|
|
15154
|
+
FROM guarded
|
|
15155
|
+
WHERE guarded.all_plans_found AND guarded.target_plan_found AND NOT guarded.project_conflict
|
|
15156
|
+
ON CONFLICT (service, object_type, object_id) DO UPDATE SET
|
|
15157
|
+
payload = EXCLUDED.payload,
|
|
15158
|
+
updated_at = EXCLUDED.updated_at,
|
|
15159
|
+
deleted_at = NULL,
|
|
15160
|
+
source_machine_id = EXCLUDED.source_machine_id,
|
|
15161
|
+
version = EXCLUDED.version
|
|
15162
|
+
WHERE ${this.tableName}.updated_at IS NULL
|
|
15163
|
+
OR ${this.tableName}.updated_at < EXCLUDED.updated_at
|
|
15164
|
+
OR (${this.tableName}.updated_at = EXCLUDED.updated_at
|
|
15165
|
+
AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
|
|
15166
|
+
RETURNING payload
|
|
15167
|
+
)
|
|
15168
|
+
SELECT guarded.all_plans_found, guarded.target_plan_found, guarded.project_conflict,
|
|
15169
|
+
(SELECT payload FROM stored) AS payload
|
|
15170
|
+
FROM guarded`, [
|
|
15171
|
+
this.service,
|
|
15172
|
+
value.id,
|
|
15173
|
+
jsonbParam(value),
|
|
15174
|
+
updatedAt,
|
|
15175
|
+
context.requestId ?? this.sourceMachineId ?? null,
|
|
15176
|
+
numberValue2(value.version),
|
|
15177
|
+
jsonbParam(planIds),
|
|
15178
|
+
targetPlanId,
|
|
15179
|
+
explicitProject
|
|
15180
|
+
]);
|
|
15181
|
+
const row = result.rows[0];
|
|
15182
|
+
if (!row?.all_plans_found || !row.target_plan_found) {
|
|
15183
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan membership changed through a missing plan: ${targetPlanId ?? planIds.join(", ")}`, { plan_ids: planIds, target_plan_id: targetPlanId });
|
|
15184
|
+
}
|
|
15185
|
+
if (row.project_conflict) {
|
|
15186
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${targetPlanId}`);
|
|
15187
|
+
}
|
|
15188
|
+
if (!row.payload) {
|
|
15189
|
+
return await requireRecord("tasks", value.id, this);
|
|
15190
|
+
}
|
|
15191
|
+
return payloadRecord2(row.payload);
|
|
15192
|
+
}
|
|
15193
|
+
async updatePlanWithProjectLinkGuard(value, context = {}) {
|
|
15194
|
+
await this.ensureSchema();
|
|
15195
|
+
const result = await this.options.client.query(`/* todos:plan-update-project-link-guard */ WITH locked_plan AS MATERIALIZED (
|
|
15196
|
+
SELECT payload FROM ${this.tableName}
|
|
15197
|
+
WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
|
|
15198
|
+
FOR UPDATE
|
|
15199
|
+
), stored AS (
|
|
15200
|
+
UPDATE ${this.tableName} r SET
|
|
15201
|
+
payload = jsonb_set(
|
|
15202
|
+
$3::jsonb,
|
|
15203
|
+
'{project_id}',
|
|
15204
|
+
COALESCE((SELECT payload->'project_id' FROM locked_plan), 'null'::jsonb),
|
|
15205
|
+
true
|
|
15206
|
+
),
|
|
15207
|
+
updated_at = $4::timestamptz,
|
|
15208
|
+
deleted_at = NULL,
|
|
15209
|
+
source_machine_id = COALESCE($5, r.source_machine_id),
|
|
15210
|
+
version = COALESCE(r.version, 0) + 1
|
|
15211
|
+
FROM locked_plan
|
|
15212
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
15213
|
+
RETURNING r.payload
|
|
15214
|
+
)
|
|
15215
|
+
SELECT EXISTS (SELECT 1 FROM locked_plan) AS plan_found,
|
|
15216
|
+
(SELECT payload FROM stored) AS payload`, [
|
|
15217
|
+
this.service,
|
|
15218
|
+
value.id,
|
|
15219
|
+
jsonbParam(value),
|
|
15220
|
+
value.updated_at,
|
|
15221
|
+
context.requestId ?? this.sourceMachineId ?? null
|
|
15222
|
+
]);
|
|
15223
|
+
const row = result.rows[0];
|
|
15224
|
+
if (!row?.plan_found || !row.payload)
|
|
15225
|
+
throw new PlanNotFoundError(value.id);
|
|
15226
|
+
return payloadRecord2(row.payload);
|
|
15227
|
+
}
|
|
14726
15228
|
async createTemplateWithTasks(template, tasks, context = {}) {
|
|
14727
15229
|
await this.ensureSchema();
|
|
14728
15230
|
const records = [
|
|
@@ -14961,6 +15463,333 @@ class PostgresJsonRecordStore {
|
|
|
14961
15463
|
version: numberValue2(existing["version"])
|
|
14962
15464
|
}, context);
|
|
14963
15465
|
}
|
|
15466
|
+
async getPlanProjectLinkReceipt(receiptId) {
|
|
15467
|
+
const value = await this.get("plan_project_link_receipts", receiptId);
|
|
15468
|
+
return value ? assertPlanProjectLinkReceipt(value) : null;
|
|
15469
|
+
}
|
|
15470
|
+
async getPlanProjectLinkReceiptByIdempotencyKey(idempotencyKey) {
|
|
15471
|
+
await this.ensureSchema();
|
|
15472
|
+
const result = await this.options.client.query(`/* todos:plan-project-link-receipt-by-key */ SELECT payload FROM ${this.tableName}
|
|
15473
|
+
WHERE service = $1 AND object_type = 'plan_project_link_receipts' AND deleted_at IS NULL
|
|
15474
|
+
AND payload->>'idempotency_key' = $2
|
|
15475
|
+
LIMIT 2`, [this.service, idempotencyKey]);
|
|
15476
|
+
if (result.rows.length > 1) {
|
|
15477
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "More than one immutable receipt carries this idempotency key", { idempotency_key: idempotencyKey });
|
|
15478
|
+
}
|
|
15479
|
+
return result.rows[0] ? assertPlanProjectLinkReceipt(result.rows[0].payload) : null;
|
|
15480
|
+
}
|
|
15481
|
+
async currentPlanProjectLinkResult(receipt, action) {
|
|
15482
|
+
const [plan, project, tasks] = await Promise.all([
|
|
15483
|
+
this.get("plans", receipt.plan_id),
|
|
15484
|
+
this.get("projects", receipt.project_id),
|
|
15485
|
+
this.listTasks({ plan_id: receipt.plan_id, include_subtasks: true })
|
|
15486
|
+
]);
|
|
15487
|
+
const sortedTasks2 = tasks.sort((left, right) => left.id.localeCompare(right.id));
|
|
15488
|
+
if (!plan || !project || planProjectLinkResultDigest(plan, sortedTasks2) !== receipt.result_digest) {
|
|
15489
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "The accepted plan-project-link result has drifted", { receipt_id: receipt.receipt_id });
|
|
15490
|
+
}
|
|
15491
|
+
return { mode: "apply", action, plan, project, tasks: sortedTasks2, receipt };
|
|
15492
|
+
}
|
|
15493
|
+
async applyPlanProjectLink(input, context = {}) {
|
|
15494
|
+
await this.ensureSchema();
|
|
15495
|
+
const existing = await this.getPlanProjectLinkReceipt(input.receipt_id);
|
|
15496
|
+
if (existing) {
|
|
15497
|
+
if (existing.plan_id !== input.plan_id || existing.project_id !== input.project_id) {
|
|
15498
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key was already accepted for a different plan-project link", { idempotency_key: input.idempotency_key, receipt_id: existing.receipt_id });
|
|
15499
|
+
}
|
|
15500
|
+
const rolledBack = await this.get("plan_project_link_rollback_receipts", planProjectLinkRollbackReceiptId(input.receipt_id));
|
|
15501
|
+
if (rolledBack) {
|
|
15502
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
|
|
15503
|
+
}
|
|
15504
|
+
return this.currentPlanProjectLinkResult(existing, "already_linked");
|
|
15505
|
+
}
|
|
15506
|
+
const [plan, project, tasks, scopedPlans] = await Promise.all([
|
|
15507
|
+
this.get("plans", input.plan_id),
|
|
15508
|
+
this.get("projects", input.project_id),
|
|
15509
|
+
this.listTasks({ plan_id: input.plan_id, include_subtasks: true }),
|
|
15510
|
+
this.list("plans")
|
|
15511
|
+
]);
|
|
15512
|
+
if (!plan)
|
|
15513
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
|
|
15514
|
+
if (!project)
|
|
15515
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${input.project_id}`);
|
|
15516
|
+
if (plan.updated_at !== input.expected_plan_revision) {
|
|
15517
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed after the link plan; fetch a fresh plan before applying", { expected_plan_revision: input.expected_plan_revision, current_plan_revision: plan.updated_at });
|
|
15518
|
+
}
|
|
15519
|
+
if (project.updated_at !== input.expected_project_revision) {
|
|
15520
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_REVISION_CONFLICT", "Destination project changed after the link plan; fetch a fresh plan before applying", { expected_project_revision: input.expected_project_revision, current_project_revision: project.updated_at });
|
|
15521
|
+
}
|
|
15522
|
+
const collision = scopedPlans.find((candidate) => candidate.id !== plan.id && candidate.project_id === project.id && candidate.slug !== null && candidate.slug === plan.slug);
|
|
15523
|
+
if (collision) {
|
|
15524
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_SCOPE_COLLISION", "Another plan already owns this slug in the destination project", { conflicting_plan_id: collision.id, slug: plan.slug });
|
|
15525
|
+
}
|
|
15526
|
+
const sortedTasks2 = tasks.sort((left, right) => left.id.localeCompare(right.id));
|
|
15527
|
+
const priorTaskProjectIds = Object.fromEntries(sortedTasks2.map((task) => [task.id, task.project_id]));
|
|
15528
|
+
const projectedPlan = { ...plan, project_id: project.id, updated_at: input.created_at };
|
|
15529
|
+
const projectedTasks = sortedTasks2.map((task) => task.project_id === project.id ? task : { ...task, project_id: project.id, updated_at: input.created_at, version: task.version + 1 });
|
|
15530
|
+
const alreadyLinked = plan.project_id === project.id && sortedTasks2.every((task) => task.project_id === project.id);
|
|
15531
|
+
const receipt = {
|
|
15532
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
15533
|
+
receipt_id: input.receipt_id,
|
|
15534
|
+
idempotency_key: input.idempotency_key,
|
|
15535
|
+
plan_id: plan.id,
|
|
15536
|
+
project_id: project.id,
|
|
15537
|
+
prior_plan_project_id: plan.project_id,
|
|
15538
|
+
prior_task_project_ids: priorTaskProjectIds,
|
|
15539
|
+
task_ids: sortedTasks2.map((task) => task.id),
|
|
15540
|
+
task_count: sortedTasks2.length,
|
|
15541
|
+
result_plan_revision: projectedPlan.updated_at,
|
|
15542
|
+
result_digest: planProjectLinkResultDigest(projectedPlan, projectedTasks),
|
|
15543
|
+
rollback_supported: true,
|
|
15544
|
+
created_at: input.created_at
|
|
15545
|
+
};
|
|
15546
|
+
let mutation;
|
|
15547
|
+
try {
|
|
15548
|
+
mutation = await this.options.client.query(`/* todos:plan-project-link-atomic */ WITH
|
|
15549
|
+
target_plan AS MATERIALIZED (
|
|
15550
|
+
SELECT payload FROM ${this.tableName}
|
|
15551
|
+
WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
|
|
15552
|
+
FOR UPDATE
|
|
15553
|
+
), target_project AS (
|
|
15554
|
+
SELECT payload FROM ${this.tableName}
|
|
15555
|
+
WHERE service = $1 AND object_type = 'projects' AND object_id = $3 AND deleted_at IS NULL
|
|
15556
|
+
FOR UPDATE
|
|
15557
|
+
), member_tasks AS MATERIALIZED (
|
|
15558
|
+
SELECT object_id, payload FROM ${this.tableName}
|
|
15559
|
+
WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
|
|
15560
|
+
AND payload->>'plan_id' = $2
|
|
15561
|
+
AND EXISTS (SELECT 1 FROM target_plan)
|
|
15562
|
+
FOR UPDATE
|
|
15563
|
+
), existing AS (
|
|
15564
|
+
SELECT payload FROM ${this.tableName}
|
|
15565
|
+
WHERE service = $1 AND object_type = 'plan_project_link_receipts'
|
|
15566
|
+
AND object_id = $6 AND deleted_at IS NULL
|
|
15567
|
+
FOR UPDATE
|
|
15568
|
+
), collision AS (
|
|
15569
|
+
SELECT 1 FROM ${this.tableName} r, target_plan p
|
|
15570
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.deleted_at IS NULL
|
|
15571
|
+
AND r.object_id <> $2 AND r.payload->>'project_id' = $3
|
|
15572
|
+
AND r.payload->>'slug' IS NOT DISTINCT FROM p.payload->>'slug'
|
|
15573
|
+
AND p.payload->>'slug' IS NOT NULL
|
|
15574
|
+
LIMIT 1
|
|
15575
|
+
), checks AS (
|
|
15576
|
+
SELECT
|
|
15577
|
+
EXISTS (SELECT 1 FROM target_plan) AS plan_found,
|
|
15578
|
+
EXISTS (SELECT 1 FROM target_project) AS project_found,
|
|
15579
|
+
COALESCE((SELECT payload->>'updated_at' = $4 FROM target_plan), false) AS plan_revision_ok,
|
|
15580
|
+
COALESCE((SELECT payload->>'updated_at' = $5 FROM target_project), false) AS project_revision_ok,
|
|
15581
|
+
COALESCE((SELECT jsonb_object_agg(object_id, COALESCE(payload->'project_id', 'null'::jsonb) ORDER BY object_id) FROM member_tasks), '{}'::jsonb) = $8::jsonb
|
|
15582
|
+
AND COALESCE((SELECT jsonb_agg(object_id ORDER BY object_id) FROM member_tasks), '[]'::jsonb) = $9::jsonb AS membership_ok,
|
|
15583
|
+
EXISTS (SELECT 1 FROM collision) AS collision,
|
|
15584
|
+
EXISTS (SELECT 1 FROM existing) AS has_existing
|
|
15585
|
+
), updated_plan AS (
|
|
15586
|
+
UPDATE ${this.tableName} r SET
|
|
15587
|
+
payload = r.payload || jsonb_build_object('project_id', $3::text, 'updated_at', $10::text),
|
|
15588
|
+
updated_at = $10::timestamptz,
|
|
15589
|
+
version = COALESCE(r.version, 0) + 1,
|
|
15590
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
15591
|
+
FROM checks
|
|
15592
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
15593
|
+
AND checks.plan_found AND checks.project_found AND checks.plan_revision_ok
|
|
15594
|
+
AND checks.project_revision_ok AND checks.membership_ok AND NOT checks.collision AND NOT checks.has_existing
|
|
15595
|
+
RETURNING r.payload
|
|
15596
|
+
), updated_tasks AS (
|
|
15597
|
+
UPDATE ${this.tableName} r SET
|
|
15598
|
+
payload = r.payload || jsonb_build_object(
|
|
15599
|
+
'project_id', $3::text,
|
|
15600
|
+
'updated_at', $10::text,
|
|
15601
|
+
'version', COALESCE((r.payload->>'version')::int, 0) + 1
|
|
15602
|
+
),
|
|
15603
|
+
updated_at = $10::timestamptz,
|
|
15604
|
+
version = COALESCE(r.version, 0) + 1,
|
|
15605
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
15606
|
+
WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
|
|
15607
|
+
AND r.payload->>'plan_id' = $2
|
|
15608
|
+
AND r.payload->>'project_id' IS DISTINCT FROM $3
|
|
15609
|
+
AND EXISTS (SELECT 1 FROM updated_plan)
|
|
15610
|
+
RETURNING 1
|
|
15611
|
+
), task_gate AS (
|
|
15612
|
+
SELECT count(*) AS count FROM updated_tasks
|
|
15613
|
+
), inserted AS (
|
|
15614
|
+
INSERT INTO ${this.tableName}
|
|
15615
|
+
(service, object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version)
|
|
15616
|
+
SELECT $1, 'plan_project_link_receipts', $6, $7::jsonb, $10::timestamptz, NULL, $11, 1
|
|
15617
|
+
FROM checks, task_gate
|
|
15618
|
+
WHERE NOT checks.has_existing AND EXISTS (SELECT 1 FROM updated_plan)
|
|
15619
|
+
RETURNING payload
|
|
15620
|
+
) SELECT
|
|
15621
|
+
checks.plan_found,
|
|
15622
|
+
checks.project_found,
|
|
15623
|
+
checks.plan_revision_ok,
|
|
15624
|
+
checks.project_revision_ok,
|
|
15625
|
+
checks.membership_ok,
|
|
15626
|
+
checks.collision,
|
|
15627
|
+
(SELECT payload FROM existing) AS existing_receipt,
|
|
15628
|
+
(SELECT payload FROM inserted) AS inserted_receipt
|
|
15629
|
+
FROM checks`, [
|
|
15630
|
+
this.service,
|
|
15631
|
+
plan.id,
|
|
15632
|
+
project.id,
|
|
15633
|
+
input.expected_plan_revision,
|
|
15634
|
+
input.expected_project_revision,
|
|
15635
|
+
receipt.receipt_id,
|
|
15636
|
+
jsonbParam(receipt),
|
|
15637
|
+
jsonbParam(priorTaskProjectIds),
|
|
15638
|
+
jsonbParam(receipt.task_ids),
|
|
15639
|
+
input.created_at,
|
|
15640
|
+
this.machineId(context)
|
|
15641
|
+
]);
|
|
15642
|
+
} catch (error) {
|
|
15643
|
+
if (isPostgresUniqueViolation(error)) {
|
|
15644
|
+
const raced = await this.getPlanProjectLinkReceipt(input.receipt_id);
|
|
15645
|
+
if (raced && raced.plan_id === input.plan_id && raced.project_id === input.project_id) {
|
|
15646
|
+
return this.currentPlanProjectLinkResult(raced, "already_linked");
|
|
15647
|
+
}
|
|
15648
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key raced with a different plan-project link", { idempotency_key: input.idempotency_key });
|
|
15649
|
+
}
|
|
15650
|
+
throw error;
|
|
15651
|
+
}
|
|
15652
|
+
const row = mutation.rows[0];
|
|
15653
|
+
if (!row?.plan_found)
|
|
15654
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${plan.id}`);
|
|
15655
|
+
if (!row.project_found)
|
|
15656
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${project.id}`);
|
|
15657
|
+
if (!row.plan_revision_ok)
|
|
15658
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed during the atomic link");
|
|
15659
|
+
if (!row.project_revision_ok)
|
|
15660
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_REVISION_CONFLICT", "Project changed during the atomic link");
|
|
15661
|
+
if (!row.membership_ok)
|
|
15662
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "Plan membership changed during the atomic link");
|
|
15663
|
+
if (row.collision)
|
|
15664
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_SCOPE_COLLISION", "Another plan owns this slug in the destination project");
|
|
15665
|
+
const accepted = assertPlanProjectLinkReceipt(row.existing_receipt ?? row.inserted_receipt);
|
|
15666
|
+
if (accepted.plan_id !== plan.id || accepted.project_id !== project.id) {
|
|
15667
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key was accepted for a different target");
|
|
15668
|
+
}
|
|
15669
|
+
return this.currentPlanProjectLinkResult(accepted, alreadyLinked ? "already_linked" : "linked");
|
|
15670
|
+
}
|
|
15671
|
+
async rollbackPlanProjectLink(input, context = {}) {
|
|
15672
|
+
await this.ensureSchema();
|
|
15673
|
+
const existingRollback = await this.get("plan_project_link_rollback_receipts", input.rollback_receipt_id);
|
|
15674
|
+
if (existingRollback)
|
|
15675
|
+
return existingRollback;
|
|
15676
|
+
const receipt = await this.getPlanProjectLinkReceipt(input.receipt_id);
|
|
15677
|
+
if (!receipt || receipt.plan_id !== input.plan_id || receipt.project_id !== input.project_id) {
|
|
15678
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "No exact plan-project-link receipt matches this rollback request", { receipt_id: input.receipt_id });
|
|
15679
|
+
}
|
|
15680
|
+
const [plan, tasks] = await Promise.all([
|
|
15681
|
+
this.get("plans", input.plan_id),
|
|
15682
|
+
this.listTasks({ plan_id: input.plan_id, include_subtasks: true })
|
|
15683
|
+
]);
|
|
15684
|
+
const sortedTasks2 = tasks.sort((left, right) => left.id.localeCompare(right.id));
|
|
15685
|
+
if (!plan || plan.updated_at !== input.expected_plan_revision) {
|
|
15686
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed after the accepted link; fetch an exact readback before rollback");
|
|
15687
|
+
}
|
|
15688
|
+
if (planProjectLinkResultDigest(plan, sortedTasks2) !== receipt.result_digest) {
|
|
15689
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership or project linkage drifted; refusing conditional rollback");
|
|
15690
|
+
}
|
|
15691
|
+
const projectedPlan = { ...plan, project_id: receipt.prior_plan_project_id, updated_at: input.restored_at };
|
|
15692
|
+
const projectedTasks = sortedTasks2.map((task) => ({
|
|
15693
|
+
...task,
|
|
15694
|
+
project_id: receipt.prior_task_project_ids[task.id] ?? null,
|
|
15695
|
+
updated_at: input.restored_at,
|
|
15696
|
+
version: task.version + 1
|
|
15697
|
+
}));
|
|
15698
|
+
const rollback = {
|
|
15699
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
15700
|
+
action: "restored",
|
|
15701
|
+
plan: projectedPlan,
|
|
15702
|
+
tasks: projectedTasks,
|
|
15703
|
+
accepted_receipt_id: receipt.receipt_id,
|
|
15704
|
+
rollback_receipt_id: input.rollback_receipt_id,
|
|
15705
|
+
restored_at: input.restored_at
|
|
15706
|
+
};
|
|
15707
|
+
const currentTaskProjects = Object.fromEntries(sortedTasks2.map((task) => [task.id, task.project_id]));
|
|
15708
|
+
const result = await this.options.client.query(`/* todos:plan-project-link-rollback-atomic */ WITH
|
|
15709
|
+
target_plan AS MATERIALIZED (
|
|
15710
|
+
SELECT payload FROM ${this.tableName}
|
|
15711
|
+
WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
|
|
15712
|
+
FOR UPDATE
|
|
15713
|
+
), member_tasks AS MATERIALIZED (
|
|
15714
|
+
SELECT object_id, payload FROM ${this.tableName}
|
|
15715
|
+
WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
|
|
15716
|
+
AND payload->>'plan_id' = $2
|
|
15717
|
+
AND EXISTS (SELECT 1 FROM target_plan)
|
|
15718
|
+
FOR UPDATE
|
|
15719
|
+
), existing AS (
|
|
15720
|
+
SELECT payload FROM ${this.tableName}
|
|
15721
|
+
WHERE service = $1 AND object_type = 'plan_project_link_rollback_receipts'
|
|
15722
|
+
AND object_id = $5 AND deleted_at IS NULL
|
|
15723
|
+
FOR UPDATE
|
|
15724
|
+
), checks AS (
|
|
15725
|
+
SELECT
|
|
15726
|
+
EXISTS (SELECT 1 FROM target_plan) AS plan_found,
|
|
15727
|
+
COALESCE((SELECT payload->>'updated_at' = $3 FROM target_plan), false) AS plan_revision_ok,
|
|
15728
|
+
COALESCE((SELECT jsonb_object_agg(object_id, COALESCE(payload->'project_id', 'null'::jsonb) ORDER BY object_id) FROM member_tasks), '{}'::jsonb) = $7::jsonb
|
|
15729
|
+
AND COALESCE((SELECT jsonb_agg(object_id ORDER BY object_id) FROM member_tasks), '[]'::jsonb) = $8::jsonb AS membership_ok,
|
|
15730
|
+
EXISTS (SELECT 1 FROM existing) AS has_existing
|
|
15731
|
+
), updated_plan AS (
|
|
15732
|
+
UPDATE ${this.tableName} r SET
|
|
15733
|
+
payload = r.payload || jsonb_build_object('project_id', $9::jsonb, 'updated_at', $10::text),
|
|
15734
|
+
updated_at = $10::timestamptz,
|
|
15735
|
+
version = COALESCE(r.version, 0) + 1,
|
|
15736
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
15737
|
+
FROM checks
|
|
15738
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
15739
|
+
AND checks.plan_found AND checks.plan_revision_ok AND checks.membership_ok AND NOT checks.has_existing
|
|
15740
|
+
RETURNING 1
|
|
15741
|
+
), updated_tasks AS (
|
|
15742
|
+
UPDATE ${this.tableName} r SET
|
|
15743
|
+
payload = r.payload || jsonb_build_object(
|
|
15744
|
+
'project_id', COALESCE($12::jsonb -> r.object_id, 'null'::jsonb),
|
|
15745
|
+
'updated_at', $10::text,
|
|
15746
|
+
'version', COALESCE((r.payload->>'version')::int, 0) + 1
|
|
15747
|
+
),
|
|
15748
|
+
updated_at = $10::timestamptz,
|
|
15749
|
+
version = COALESCE(r.version, 0) + 1,
|
|
15750
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
15751
|
+
WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
|
|
15752
|
+
AND r.payload->>'plan_id' = $2 AND EXISTS (SELECT 1 FROM updated_plan)
|
|
15753
|
+
RETURNING 1
|
|
15754
|
+
), task_gate AS (SELECT count(*) AS count FROM updated_tasks), inserted AS (
|
|
15755
|
+
INSERT INTO ${this.tableName}
|
|
15756
|
+
(service, object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version)
|
|
15757
|
+
SELECT $1, 'plan_project_link_rollback_receipts', $5, $6::jsonb, $10::timestamptz, NULL, $11, 1
|
|
15758
|
+
FROM checks, task_gate
|
|
15759
|
+
WHERE NOT checks.has_existing AND EXISTS (SELECT 1 FROM updated_plan)
|
|
15760
|
+
RETURNING payload
|
|
15761
|
+
) SELECT
|
|
15762
|
+
checks.plan_found,
|
|
15763
|
+
checks.plan_revision_ok,
|
|
15764
|
+
checks.membership_ok,
|
|
15765
|
+
(SELECT payload FROM existing) AS existing_rollback,
|
|
15766
|
+
(SELECT payload FROM inserted) AS inserted_rollback
|
|
15767
|
+
FROM checks`, [
|
|
15768
|
+
this.service,
|
|
15769
|
+
input.plan_id,
|
|
15770
|
+
input.expected_plan_revision,
|
|
15771
|
+
input.receipt_id,
|
|
15772
|
+
input.rollback_receipt_id,
|
|
15773
|
+
jsonbParam(rollback),
|
|
15774
|
+
jsonbParam(currentTaskProjects),
|
|
15775
|
+
jsonbParam(receipt.task_ids),
|
|
15776
|
+
jsonbParam(receipt.prior_plan_project_id),
|
|
15777
|
+
input.restored_at,
|
|
15778
|
+
this.machineId(context),
|
|
15779
|
+
jsonbParam(receipt.prior_task_project_ids)
|
|
15780
|
+
]);
|
|
15781
|
+
const row = result.rows[0];
|
|
15782
|
+
if (!row?.plan_found)
|
|
15783
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
|
|
15784
|
+
if (!row.plan_revision_ok)
|
|
15785
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed during rollback");
|
|
15786
|
+
if (!row.membership_ok)
|
|
15787
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership changed during rollback");
|
|
15788
|
+
const accepted = row.existing_rollback ?? row.inserted_rollback;
|
|
15789
|
+
if (!accepted)
|
|
15790
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback did not produce an immutable receipt");
|
|
15791
|
+
return accepted;
|
|
15792
|
+
}
|
|
14964
15793
|
async deletePlan(id, context = {}) {
|
|
14965
15794
|
await this.ensureSchema();
|
|
14966
15795
|
const timestamp2 = new Date().toISOString();
|
|
@@ -15055,11 +15884,17 @@ class PostgresJsonRecordStore {
|
|
|
15055
15884
|
}
|
|
15056
15885
|
async function createTask2(input, store, context) {
|
|
15057
15886
|
const timestamp2 = new Date().toISOString();
|
|
15058
|
-
const
|
|
15887
|
+
const linkedPlan = input.plan_id ? await store.get("plans", input.plan_id) : null;
|
|
15888
|
+
const requestedProjectId = input.project_id ?? context?.projectId ?? null;
|
|
15889
|
+
if (linkedPlan?.project_id && requestedProjectId && requestedProjectId !== linkedPlan.project_id) {
|
|
15890
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${input.plan_id}: expected ${linkedPlan.project_id}`);
|
|
15891
|
+
}
|
|
15892
|
+
const effectiveProjectId = linkedPlan?.project_id ?? requestedProjectId;
|
|
15893
|
+
const shortId = effectiveProjectId ? await nextTaskShortId2(effectiveProjectId, store, context) : null;
|
|
15059
15894
|
const task = {
|
|
15060
15895
|
id: randomUUID3(),
|
|
15061
15896
|
short_id: shortId,
|
|
15062
|
-
project_id:
|
|
15897
|
+
project_id: effectiveProjectId,
|
|
15063
15898
|
parent_id: input.parent_id ?? null,
|
|
15064
15899
|
plan_id: input.plan_id ?? null,
|
|
15065
15900
|
task_list_id: input.task_list_id ?? context?.taskListId ?? null,
|
|
@@ -15113,15 +15948,27 @@ async function createTask2(input, store, context) {
|
|
|
15113
15948
|
synced_at: null,
|
|
15114
15949
|
archived_at: null
|
|
15115
15950
|
};
|
|
15116
|
-
await store.
|
|
15117
|
-
await logTaskChange2(
|
|
15118
|
-
return
|
|
15951
|
+
const storedTask = await store.upsertTaskWithPlanMembershipGuard(task, task.plan_id ? [task.plan_id] : [], input.project_id !== undefined || context?.projectId !== undefined, context);
|
|
15952
|
+
await logTaskChange2(storedTask.id, "created", "status", null, storedTask.status, storedTask.assigned_by ?? storedTask.agent_id, store, context);
|
|
15953
|
+
return storedTask;
|
|
15119
15954
|
}
|
|
15120
15955
|
async function updateTask2(id, input, store) {
|
|
15121
15956
|
const existing = await requireRecord("tasks", id, store);
|
|
15122
15957
|
if (existing.version !== input.version) {
|
|
15123
15958
|
throw new Error(`Task ${id} version conflict: expected ${existing.version}, got ${input.version}`);
|
|
15124
15959
|
}
|
|
15960
|
+
const effectivePlanId = input.plan_id !== undefined ? input.plan_id : existing.plan_id;
|
|
15961
|
+
const linkedPlan = effectivePlanId ? await store.get("plans", effectivePlanId) : null;
|
|
15962
|
+
if (linkedPlan?.project_id) {
|
|
15963
|
+
const effectiveProjectId = input.project_id !== undefined ? input.project_id : existing.project_id;
|
|
15964
|
+
if (effectiveProjectId !== linkedPlan.project_id) {
|
|
15965
|
+
if (input.project_id === undefined && (input.plan_id !== undefined || existing.project_id === null)) {
|
|
15966
|
+
input = { ...input, project_id: linkedPlan.project_id };
|
|
15967
|
+
} else {
|
|
15968
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${effectivePlanId}: expected ${linkedPlan.project_id}`);
|
|
15969
|
+
}
|
|
15970
|
+
}
|
|
15971
|
+
}
|
|
15125
15972
|
const reopened = existing.status === "completed" && input.status !== undefined && input.status !== "completed" && input.completed_at === undefined;
|
|
15126
15973
|
const terminalNow = input.status !== undefined && isTerminalStatus(input.status);
|
|
15127
15974
|
const task = {
|
|
@@ -15137,8 +15984,7 @@ async function updateTask2(id, input, store) {
|
|
|
15137
15984
|
created_by: existing.created_by,
|
|
15138
15985
|
completed_at: reopened ? null : input.completed_at !== undefined ? input.completed_at : existing.completed_at
|
|
15139
15986
|
};
|
|
15140
|
-
|
|
15141
|
-
return task;
|
|
15987
|
+
return store.upsertTaskWithPlanMembershipGuard(task, [existing.plan_id, effectivePlanId].filter((planId) => Boolean(planId)), input.project_id !== undefined);
|
|
15142
15988
|
}
|
|
15143
15989
|
async function startTask2(id, agentId, store) {
|
|
15144
15990
|
const task = await requireRecord("tasks", id, store);
|
|
@@ -15202,8 +16048,7 @@ async function patchTask(task, patch, store) {
|
|
|
15202
16048
|
version: task.version + 1,
|
|
15203
16049
|
updated_at: new Date().toISOString()
|
|
15204
16050
|
};
|
|
15205
|
-
|
|
15206
|
-
return updated;
|
|
16051
|
+
return store.upsertTaskWithPlanMembershipGuard(updated, [task.plan_id, updated.plan_id].filter((planId) => Boolean(planId)), Object.prototype.hasOwnProperty.call(patch, "project_id"));
|
|
15207
16052
|
}
|
|
15208
16053
|
var CLOUD_LOCK_EXPIRY_MINUTES = 30;
|
|
15209
16054
|
function sameCloudLockHolder(stored, incoming) {
|
|
@@ -15468,7 +16313,12 @@ async function updatePlan2(id, input, store) {
|
|
|
15468
16313
|
excludeId: id
|
|
15469
16314
|
});
|
|
15470
16315
|
}
|
|
15471
|
-
return store.
|
|
16316
|
+
return store.updatePlanWithProjectLinkGuard({
|
|
16317
|
+
...plan,
|
|
16318
|
+
...patch,
|
|
16319
|
+
project_id: plan.project_id,
|
|
16320
|
+
updated_at: new Date().toISOString()
|
|
16321
|
+
});
|
|
15472
16322
|
}
|
|
15473
16323
|
function matchAgentByName(agents, name, options) {
|
|
15474
16324
|
const target = normalizeAgentNameInput(name);
|
|
@@ -16800,7 +17650,7 @@ function isCommentRedactionBackfillComplete(result) {
|
|
|
16800
17650
|
return !result.dry_run && result.conflicts === 0 && result.remaining_candidates === 0;
|
|
16801
17651
|
}
|
|
16802
17652
|
// src/storage/s3-artifacts.ts
|
|
16803
|
-
import { createHash as
|
|
17653
|
+
import { createHash as createHash5, createHmac as createHmac2 } from "crypto";
|
|
16804
17654
|
function createTodosS3ArtifactStore(options) {
|
|
16805
17655
|
const requestFetch = options.fetch ?? fetch;
|
|
16806
17656
|
const now3 = options.now ?? (() => new Date);
|
|
@@ -16972,7 +17822,7 @@ function toAmzDate(date) {
|
|
|
16972
17822
|
return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
16973
17823
|
}
|
|
16974
17824
|
function sha256Hex(value) {
|
|
16975
|
-
return
|
|
17825
|
+
return createHash5("sha256").update(value).digest("hex");
|
|
16976
17826
|
}
|
|
16977
17827
|
function hmac(key, value) {
|
|
16978
17828
|
return createHmac2("sha256", key).update(value).digest();
|