@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
|
@@ -2421,6 +2421,29 @@ var init_migrations = __esm(() => {
|
|
|
2421
2421
|
${sqliteTodosProjectRegistrationSchemaSql()}
|
|
2422
2422
|
INSERT OR IGNORE INTO _migrations (id) VALUES (69);
|
|
2423
2423
|
COMMIT;
|
|
2424
|
+
`,
|
|
2425
|
+
`BEGIN;
|
|
2426
|
+
CREATE TABLE IF NOT EXISTS plan_project_link_receipts (
|
|
2427
|
+
receipt_id TEXT PRIMARY KEY,
|
|
2428
|
+
idempotency_key TEXT NOT NULL UNIQUE,
|
|
2429
|
+
plan_id TEXT NOT NULL,
|
|
2430
|
+
project_id TEXT NOT NULL,
|
|
2431
|
+
payload_hash TEXT NOT NULL,
|
|
2432
|
+
payload TEXT NOT NULL,
|
|
2433
|
+
created_at TEXT NOT NULL
|
|
2434
|
+
);
|
|
2435
|
+
CREATE INDEX IF NOT EXISTS idx_plan_project_link_receipts_plan
|
|
2436
|
+
ON plan_project_link_receipts(plan_id, created_at);
|
|
2437
|
+
|
|
2438
|
+
CREATE TABLE IF NOT EXISTS plan_project_link_rollback_receipts (
|
|
2439
|
+
rollback_receipt_id TEXT PRIMARY KEY,
|
|
2440
|
+
accepted_receipt_id TEXT NOT NULL UNIQUE
|
|
2441
|
+
REFERENCES plan_project_link_receipts(receipt_id) ON DELETE RESTRICT,
|
|
2442
|
+
payload TEXT NOT NULL,
|
|
2443
|
+
created_at TEXT NOT NULL
|
|
2444
|
+
);
|
|
2445
|
+
INSERT OR IGNORE INTO _migrations (id) VALUES (70);
|
|
2446
|
+
COMMIT;
|
|
2424
2447
|
`
|
|
2425
2448
|
];
|
|
2426
2449
|
});
|
|
@@ -5801,7 +5824,7 @@ var init_runner_sandbox = __esm(() => {
|
|
|
5801
5824
|
});
|
|
5802
5825
|
|
|
5803
5826
|
// src/lib/event-hooks.ts
|
|
5804
|
-
import { createHash as
|
|
5827
|
+
import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
|
|
5805
5828
|
import { appendFileSync, mkdirSync as mkdirSync3 } from "fs";
|
|
5806
5829
|
import { dirname as dirname3, resolve as resolve6 } from "path";
|
|
5807
5830
|
import { createConnection } from "net";
|
|
@@ -5868,7 +5891,7 @@ function buildEnvelope(type, payload, timestamp2 = new Date().toISOString()) {
|
|
|
5868
5891
|
payload: redactValue(payload ?? {}),
|
|
5869
5892
|
source: { package: "@hasna/todos", local_only: true }
|
|
5870
5893
|
};
|
|
5871
|
-
const digest =
|
|
5894
|
+
const digest = createHash3("sha256").update(canonicalEvent(base)).digest("hex");
|
|
5872
5895
|
return { ...base, integrity: { algorithm: "sha256", digest } };
|
|
5873
5896
|
}
|
|
5874
5897
|
function summarize(value) {
|
|
@@ -6755,6 +6778,40 @@ function deleteTaskList(id, db) {
|
|
|
6755
6778
|
return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
|
|
6756
6779
|
})();
|
|
6757
6780
|
}
|
|
6781
|
+
function deleteTaskListIfUnchangedAndUnused(id, expected, db) {
|
|
6782
|
+
const d = db || getDatabase();
|
|
6783
|
+
return d.transaction(() => {
|
|
6784
|
+
const current = getTaskList(id, d);
|
|
6785
|
+
if (!current) {
|
|
6786
|
+
return { status: "not_found", task_dependents: 0, plan_dependents: 0 };
|
|
6787
|
+
}
|
|
6788
|
+
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);
|
|
6789
|
+
if (changed) {
|
|
6790
|
+
return { status: "changed", task_dependents: 0, plan_dependents: 0 };
|
|
6791
|
+
}
|
|
6792
|
+
const taskDependents = Number(d.query("SELECT COUNT(*) AS count FROM tasks WHERE task_list_id = ?").get(id).count);
|
|
6793
|
+
const planDependents = Number(d.query("SELECT COUNT(*) AS count FROM plans WHERE task_list_id = ?").get(id).count);
|
|
6794
|
+
if (taskDependents > 0 || planDependents > 0) {
|
|
6795
|
+
return {
|
|
6796
|
+
status: "has_dependents",
|
|
6797
|
+
task_dependents: taskDependents,
|
|
6798
|
+
plan_dependents: planDependents
|
|
6799
|
+
};
|
|
6800
|
+
}
|
|
6801
|
+
recordStorageTombstone({
|
|
6802
|
+
object_type: "task_lists",
|
|
6803
|
+
object_id: id,
|
|
6804
|
+
payload: current
|
|
6805
|
+
}, d);
|
|
6806
|
+
releaseCanonicalSlugClaims("task_list", id, d);
|
|
6807
|
+
const deleted = d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
|
|
6808
|
+
return {
|
|
6809
|
+
status: deleted ? "deleted" : "not_found",
|
|
6810
|
+
task_dependents: 0,
|
|
6811
|
+
plan_dependents: 0
|
|
6812
|
+
};
|
|
6813
|
+
})();
|
|
6814
|
+
}
|
|
6758
6815
|
function ensureTaskList(name, slug, projectId, db) {
|
|
6759
6816
|
const d = db || getDatabase();
|
|
6760
6817
|
const existing = getTaskListBySlug(slug, projectId, d);
|
|
@@ -7782,6 +7839,14 @@ var init_checklists = __esm(() => {
|
|
|
7782
7839
|
init_database();
|
|
7783
7840
|
});
|
|
7784
7841
|
|
|
7842
|
+
// src/db/plan-row-serialization.ts
|
|
7843
|
+
function guardPlanRowsSqlite(planIds, db) {
|
|
7844
|
+
const ids = [...new Set(planIds.filter((id) => Boolean(id)))].sort();
|
|
7845
|
+
for (const id of ids) {
|
|
7846
|
+
db.run("/* todos:sqlite-plan-row-guard */ UPDATE plans SET id = id WHERE id = ?", [id]);
|
|
7847
|
+
}
|
|
7848
|
+
}
|
|
7849
|
+
|
|
7785
7850
|
// src/lib/recurrence.ts
|
|
7786
7851
|
function parseRecurrenceRule(rule) {
|
|
7787
7852
|
const normalized = rule.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 createTask2(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 createTask2(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 createTask2(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 listTasks2(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 updateTask2(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 updateTask2(id, input, db) {
|
|
|
9681
9788
|
emitSharedTaskEventQuiet({ type: "task.updated", task: updatedTask, databasePath });
|
|
9682
9789
|
return updatedTask;
|
|
9683
9790
|
}
|
|
9791
|
+
function updateTask2(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 updatePlan2(id, input, db) {
|
|
|
10391
10513
|
});
|
|
10392
10514
|
return updated;
|
|
10393
10515
|
}
|
|
10516
|
+
function updatePlan2(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);
|
|
@@ -10815,7 +10944,7 @@ var init_boards = __esm(() => {
|
|
|
10815
10944
|
});
|
|
10816
10945
|
|
|
10817
10946
|
// src/lib/artifact-store.ts
|
|
10818
|
-
import { createHash as
|
|
10947
|
+
import { createHash as createHash4 } from "crypto";
|
|
10819
10948
|
import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync3, rmSync, statSync as statSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
10820
10949
|
import { basename, dirname as dirname4, join as join5, resolve as resolve7 } from "path";
|
|
10821
10950
|
import { tmpdir as tmpdir2 } from "os";
|
|
@@ -10840,7 +10969,7 @@ function artifactStorePath(relativePath) {
|
|
|
10840
10969
|
return join5(artifactStoreRoot(), normalized);
|
|
10841
10970
|
}
|
|
10842
10971
|
function sha256(buffer) {
|
|
10843
|
-
return
|
|
10972
|
+
return createHash4("sha256").update(buffer).digest("hex");
|
|
10844
10973
|
}
|
|
10845
10974
|
function isTextLike(buffer, path) {
|
|
10846
10975
|
if (buffer.includes(0))
|
|
@@ -12249,11 +12378,11 @@ var init_tasks = __esm(() => {
|
|
|
12249
12378
|
});
|
|
12250
12379
|
|
|
12251
12380
|
// src/project-registration/authority.ts
|
|
12252
|
-
import { createHash as
|
|
12381
|
+
import { createHash as createHash5 } from "crypto";
|
|
12253
12382
|
// package.json
|
|
12254
12383
|
var package_default = {
|
|
12255
12384
|
name: "@hasna/todos",
|
|
12256
|
-
version: "0.15.
|
|
12385
|
+
version: "0.15.10",
|
|
12257
12386
|
description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
|
|
12258
12387
|
type: "module",
|
|
12259
12388
|
main: "dist/index.js",
|
|
@@ -12295,6 +12424,10 @@ var package_default = {
|
|
|
12295
12424
|
"./project-registration": {
|
|
12296
12425
|
types: "./dist/project-registration.d.ts",
|
|
12297
12426
|
import: "./dist/project-registration.js"
|
|
12427
|
+
},
|
|
12428
|
+
"./task-manifest": {
|
|
12429
|
+
types: "./dist/task-manifest.d.ts",
|
|
12430
|
+
import: "./dist/task-manifest.js"
|
|
12298
12431
|
}
|
|
12299
12432
|
},
|
|
12300
12433
|
workspaces: [
|
|
@@ -12307,8 +12440,8 @@ var package_default = {
|
|
|
12307
12440
|
"README.md"
|
|
12308
12441
|
],
|
|
12309
12442
|
scripts: {
|
|
12310
|
-
build: "rm -rf dist dashboard/dist && cd dashboard && bun install --frozen-lockfile && bun run build && cd .. && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts src/project-registration.ts --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && tsc --emitDeclarationOnly --outDir dist",
|
|
12311
|
-
"build:server": "rm -rf dist && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts src/project-registration.ts --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*'",
|
|
12443
|
+
build: "rm -rf dist dashboard/dist && cd dashboard && bun install --frozen-lockfile && bun run build && cd .. && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts src/project-registration.ts src/task-manifest.ts --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && tsc --emitDeclarationOnly --outDir dist",
|
|
12444
|
+
"build:server": "rm -rf dist && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts src/project-registration.ts src/task-manifest.ts --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*'",
|
|
12312
12445
|
migrate: "bun run src/server/index.ts migrate",
|
|
12313
12446
|
"backfill:comment-redaction": "bun run src/server/index.ts redact-comments",
|
|
12314
12447
|
"generate:sdk": "bun run scripts/generate-sdk.ts",
|
|
@@ -12391,6 +12524,61 @@ function normalizeAgentNameInput(name) {
|
|
|
12391
12524
|
// src/storage/postgres-adapter.ts
|
|
12392
12525
|
init_creator_identity();
|
|
12393
12526
|
|
|
12527
|
+
// src/lib/plan-project-link-contract.ts
|
|
12528
|
+
import { createHash as createHash2 } from "crypto";
|
|
12529
|
+
var PLAN_PROJECT_LINK_SCHEMA_VERSION = "todos.plan-project-link.v1";
|
|
12530
|
+
|
|
12531
|
+
class PlanProjectLinkError extends Error {
|
|
12532
|
+
code;
|
|
12533
|
+
details;
|
|
12534
|
+
constructor(code, message, details = {}) {
|
|
12535
|
+
super(message);
|
|
12536
|
+
this.code = code;
|
|
12537
|
+
this.details = details;
|
|
12538
|
+
this.name = "PlanProjectLinkError";
|
|
12539
|
+
}
|
|
12540
|
+
}
|
|
12541
|
+
function canonicalPlanProjectLinkJson(value) {
|
|
12542
|
+
if (value === null || typeof value !== "object")
|
|
12543
|
+
return JSON.stringify(value);
|
|
12544
|
+
if (Array.isArray(value))
|
|
12545
|
+
return `[${value.map(canonicalPlanProjectLinkJson).join(",")}]`;
|
|
12546
|
+
return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalPlanProjectLinkJson(item)}`).join(",")}}`;
|
|
12547
|
+
}
|
|
12548
|
+
function planProjectLinkDigest(value) {
|
|
12549
|
+
return createHash2("sha256").update(canonicalPlanProjectLinkJson(value)).digest("hex");
|
|
12550
|
+
}
|
|
12551
|
+
function normalizePlanProjectLinkIdempotencyKey(value) {
|
|
12552
|
+
const key = value?.trim() ?? "";
|
|
12553
|
+
if (key.length < 8 || key.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(key)) {
|
|
12554
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_KEY_INVALID", "idempotency_key must be 8-128 ASCII letters, digits, dots, underscores, colons, or hyphens");
|
|
12555
|
+
}
|
|
12556
|
+
return key;
|
|
12557
|
+
}
|
|
12558
|
+
function planProjectLinkReceiptId(idempotencyKey) {
|
|
12559
|
+
return `pplr_${planProjectLinkDigest({ idempotency_key: idempotencyKey }).slice(0, 48)}`;
|
|
12560
|
+
}
|
|
12561
|
+
function planProjectLinkRollbackReceiptId(receiptId) {
|
|
12562
|
+
return `pplr_inverse_${planProjectLinkDigest({ accepted_receipt_id: receiptId }).slice(0, 38)}`;
|
|
12563
|
+
}
|
|
12564
|
+
function planProjectLinkRequestHash(planId, projectId) {
|
|
12565
|
+
return planProjectLinkDigest({ plan_id: planId, project_id: projectId });
|
|
12566
|
+
}
|
|
12567
|
+
function planProjectLinkResultDigest(plan, tasks) {
|
|
12568
|
+
return planProjectLinkDigest({
|
|
12569
|
+
plan_id: plan.id,
|
|
12570
|
+
plan_project_id: plan.project_id,
|
|
12571
|
+
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))
|
|
12572
|
+
});
|
|
12573
|
+
}
|
|
12574
|
+
function assertPlanProjectLinkReceipt(value) {
|
|
12575
|
+
const receipt = value;
|
|
12576
|
+
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") {
|
|
12577
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "Stored plan-project-link receipt is invalid");
|
|
12578
|
+
}
|
|
12579
|
+
return receipt;
|
|
12580
|
+
}
|
|
12581
|
+
|
|
12394
12582
|
// src/storage/postgres-sync.ts
|
|
12395
12583
|
var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records";
|
|
12396
12584
|
var DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors";
|
|
@@ -12419,6 +12607,21 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
|
|
|
12419
12607
|
RETURNS text
|
|
12420
12608
|
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
|
|
12421
12609
|
AS $$ SELECT unaccent('unaccent', $1) $$`,
|
|
12610
|
+
`CREATE OR REPLACE FUNCTION todos_try_timestamptz(text)
|
|
12611
|
+
RETURNS timestamptz
|
|
12612
|
+
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
|
|
12613
|
+
SET DateStyle TO 'ISO, YMD'
|
|
12614
|
+
AS $$
|
|
12615
|
+
BEGIN
|
|
12616
|
+
RETURN CASE
|
|
12617
|
+
WHEN $1 ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN $1::timestamptz
|
|
12618
|
+
ELSE ($1::timestamp AT TIME ZONE 'UTC')
|
|
12619
|
+
END;
|
|
12620
|
+
EXCEPTION WHEN others THEN RETURN NULL; END $$`,
|
|
12621
|
+
`DROP INDEX IF EXISTS ${tableName}_task_updated_at_idx`,
|
|
12622
|
+
`CREATE INDEX IF NOT EXISTS ${tableName}_task_updated_at_utc_idx
|
|
12623
|
+
ON ${tableName} (todos_try_timestamptz(payload->>'updated_at'))
|
|
12624
|
+
WHERE object_type = 'tasks' AND deleted_at IS NULL`,
|
|
12422
12625
|
`ALTER TABLE ${tableName}
|
|
12423
12626
|
ADD COLUMN IF NOT EXISTS task_search_tsv tsvector
|
|
12424
12627
|
GENERATED ALWAYS AS (
|
|
@@ -13085,6 +13288,12 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
13085
13288
|
update: (id, input) => updatePlan(id, input, store),
|
|
13086
13289
|
delete: (id, context) => store.deletePlan(id, context)
|
|
13087
13290
|
},
|
|
13291
|
+
planProjectLinks: {
|
|
13292
|
+
apply: (input, context) => store.applyPlanProjectLink(input, context),
|
|
13293
|
+
rollback: (input, context) => store.rollbackPlanProjectLink(input, context),
|
|
13294
|
+
getReceipt: (receiptId) => store.getPlanProjectLinkReceipt(receiptId),
|
|
13295
|
+
getReceiptByIdempotencyKey: (key) => store.getPlanProjectLinkReceiptByIdempotencyKey(key)
|
|
13296
|
+
},
|
|
13088
13297
|
agents: {
|
|
13089
13298
|
register: (input, context) => registerAgent(input, store, context),
|
|
13090
13299
|
get: (id) => store.get("agents", id),
|
|
@@ -13267,6 +13476,9 @@ class PostgresJsonRecordStore {
|
|
|
13267
13476
|
conds.push(`LOWER(payload->>'created_by') = LOWER(${p(filter.created_by)})`);
|
|
13268
13477
|
if (filter.not_created_by !== undefined)
|
|
13269
13478
|
conds.push(`(payload->>'created_by' IS NULL OR LOWER(payload->>'created_by') <> LOWER(${p(filter.not_created_by)}))`);
|
|
13479
|
+
if (filter.updated_after !== undefined) {
|
|
13480
|
+
conds.push(`(todos_try_timestamptz(payload->>'updated_at') IS NULL ` + `OR todos_try_timestamptz(payload->>'updated_at') > ${p(filter.updated_after)}::timestamptz)`);
|
|
13481
|
+
}
|
|
13270
13482
|
if (filter.session_id !== undefined)
|
|
13271
13483
|
conds.push(`payload->>'session_id' = ${p(filter.session_id)}`);
|
|
13272
13484
|
if (filter.tags?.length) {
|
|
@@ -13451,6 +13663,115 @@ class PostgresJsonRecordStore {
|
|
|
13451
13663
|
}
|
|
13452
13664
|
return value;
|
|
13453
13665
|
}
|
|
13666
|
+
async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
|
|
13667
|
+
const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
|
|
13668
|
+
if (planIds.length === 0)
|
|
13669
|
+
return this.upsert("tasks", value, context);
|
|
13670
|
+
await this.ensureSchema();
|
|
13671
|
+
const updatedAt = value.updated_at;
|
|
13672
|
+
const targetPlanId = value.plan_id;
|
|
13673
|
+
const result = await this.options.client.query(`/* todos:task-plan-membership-guard */ WITH
|
|
13674
|
+
locked_plans AS MATERIALIZED (
|
|
13675
|
+
SELECT object_id, payload FROM ${this.tableName}
|
|
13676
|
+
WHERE service = $1 AND object_type = 'plans' AND deleted_at IS NULL
|
|
13677
|
+
AND object_id IN (SELECT value FROM jsonb_array_elements_text($7::jsonb))
|
|
13678
|
+
ORDER BY object_id
|
|
13679
|
+
FOR UPDATE
|
|
13680
|
+
), validation AS (
|
|
13681
|
+
SELECT
|
|
13682
|
+
(SELECT count(*) FROM locked_plans) = jsonb_array_length($7::jsonb) AS all_plans_found,
|
|
13683
|
+
($8::text IS NULL OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
|
|
13684
|
+
(SELECT payload->>'project_id' FROM locked_plans WHERE object_id = $8) AS target_project_id
|
|
13685
|
+
), guarded AS (
|
|
13686
|
+
SELECT
|
|
13687
|
+
validation.*,
|
|
13688
|
+
($9::boolean AND validation.target_project_id IS NOT NULL
|
|
13689
|
+
AND ($3::jsonb->>'project_id') IS DISTINCT FROM validation.target_project_id) AS project_conflict,
|
|
13690
|
+
CASE
|
|
13691
|
+
WHEN validation.target_project_id IS NULL THEN $3::jsonb
|
|
13692
|
+
ELSE jsonb_set($3::jsonb, '{project_id}', to_jsonb(validation.target_project_id), true)
|
|
13693
|
+
END AS payload
|
|
13694
|
+
FROM validation
|
|
13695
|
+
), stored AS (
|
|
13696
|
+
INSERT INTO ${this.tableName} (
|
|
13697
|
+
service, object_type, object_id, payload, updated_at,
|
|
13698
|
+
deleted_at, source_machine_id, version
|
|
13699
|
+
)
|
|
13700
|
+
SELECT $1, 'tasks', $2, guarded.payload, $4::timestamptz, NULL, $5, $6
|
|
13701
|
+
FROM guarded
|
|
13702
|
+
WHERE guarded.all_plans_found AND guarded.target_plan_found AND NOT guarded.project_conflict
|
|
13703
|
+
ON CONFLICT (service, object_type, object_id) DO UPDATE SET
|
|
13704
|
+
payload = EXCLUDED.payload,
|
|
13705
|
+
updated_at = EXCLUDED.updated_at,
|
|
13706
|
+
deleted_at = NULL,
|
|
13707
|
+
source_machine_id = EXCLUDED.source_machine_id,
|
|
13708
|
+
version = EXCLUDED.version
|
|
13709
|
+
WHERE ${this.tableName}.updated_at IS NULL
|
|
13710
|
+
OR ${this.tableName}.updated_at < EXCLUDED.updated_at
|
|
13711
|
+
OR (${this.tableName}.updated_at = EXCLUDED.updated_at
|
|
13712
|
+
AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
|
|
13713
|
+
RETURNING payload
|
|
13714
|
+
)
|
|
13715
|
+
SELECT guarded.all_plans_found, guarded.target_plan_found, guarded.project_conflict,
|
|
13716
|
+
(SELECT payload FROM stored) AS payload
|
|
13717
|
+
FROM guarded`, [
|
|
13718
|
+
this.service,
|
|
13719
|
+
value.id,
|
|
13720
|
+
jsonbParam(value),
|
|
13721
|
+
updatedAt,
|
|
13722
|
+
context.requestId ?? this.sourceMachineId ?? null,
|
|
13723
|
+
numberValue2(value.version),
|
|
13724
|
+
jsonbParam(planIds),
|
|
13725
|
+
targetPlanId,
|
|
13726
|
+
explicitProject
|
|
13727
|
+
]);
|
|
13728
|
+
const row = result.rows[0];
|
|
13729
|
+
if (!row?.all_plans_found || !row.target_plan_found) {
|
|
13730
|
+
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 });
|
|
13731
|
+
}
|
|
13732
|
+
if (row.project_conflict) {
|
|
13733
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${targetPlanId}`);
|
|
13734
|
+
}
|
|
13735
|
+
if (!row.payload) {
|
|
13736
|
+
return await requireRecord("tasks", value.id, this);
|
|
13737
|
+
}
|
|
13738
|
+
return payloadRecord2(row.payload);
|
|
13739
|
+
}
|
|
13740
|
+
async updatePlanWithProjectLinkGuard(value, context = {}) {
|
|
13741
|
+
await this.ensureSchema();
|
|
13742
|
+
const result = await this.options.client.query(`/* todos:plan-update-project-link-guard */ WITH locked_plan AS MATERIALIZED (
|
|
13743
|
+
SELECT payload FROM ${this.tableName}
|
|
13744
|
+
WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
|
|
13745
|
+
FOR UPDATE
|
|
13746
|
+
), stored AS (
|
|
13747
|
+
UPDATE ${this.tableName} r SET
|
|
13748
|
+
payload = jsonb_set(
|
|
13749
|
+
$3::jsonb,
|
|
13750
|
+
'{project_id}',
|
|
13751
|
+
COALESCE((SELECT payload->'project_id' FROM locked_plan), 'null'::jsonb),
|
|
13752
|
+
true
|
|
13753
|
+
),
|
|
13754
|
+
updated_at = $4::timestamptz,
|
|
13755
|
+
deleted_at = NULL,
|
|
13756
|
+
source_machine_id = COALESCE($5, r.source_machine_id),
|
|
13757
|
+
version = COALESCE(r.version, 0) + 1
|
|
13758
|
+
FROM locked_plan
|
|
13759
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
13760
|
+
RETURNING r.payload
|
|
13761
|
+
)
|
|
13762
|
+
SELECT EXISTS (SELECT 1 FROM locked_plan) AS plan_found,
|
|
13763
|
+
(SELECT payload FROM stored) AS payload`, [
|
|
13764
|
+
this.service,
|
|
13765
|
+
value.id,
|
|
13766
|
+
jsonbParam(value),
|
|
13767
|
+
value.updated_at,
|
|
13768
|
+
context.requestId ?? this.sourceMachineId ?? null
|
|
13769
|
+
]);
|
|
13770
|
+
const row = result.rows[0];
|
|
13771
|
+
if (!row?.plan_found || !row.payload)
|
|
13772
|
+
throw new PlanNotFoundError(value.id);
|
|
13773
|
+
return payloadRecord2(row.payload);
|
|
13774
|
+
}
|
|
13454
13775
|
async createTemplateWithTasks(template, tasks, context = {}) {
|
|
13455
13776
|
await this.ensureSchema();
|
|
13456
13777
|
const records = [
|
|
@@ -13689,6 +14010,333 @@ class PostgresJsonRecordStore {
|
|
|
13689
14010
|
version: numberValue2(existing["version"])
|
|
13690
14011
|
}, context);
|
|
13691
14012
|
}
|
|
14013
|
+
async getPlanProjectLinkReceipt(receiptId) {
|
|
14014
|
+
const value = await this.get("plan_project_link_receipts", receiptId);
|
|
14015
|
+
return value ? assertPlanProjectLinkReceipt(value) : null;
|
|
14016
|
+
}
|
|
14017
|
+
async getPlanProjectLinkReceiptByIdempotencyKey(idempotencyKey) {
|
|
14018
|
+
await this.ensureSchema();
|
|
14019
|
+
const result = await this.options.client.query(`/* todos:plan-project-link-receipt-by-key */ SELECT payload FROM ${this.tableName}
|
|
14020
|
+
WHERE service = $1 AND object_type = 'plan_project_link_receipts' AND deleted_at IS NULL
|
|
14021
|
+
AND payload->>'idempotency_key' = $2
|
|
14022
|
+
LIMIT 2`, [this.service, idempotencyKey]);
|
|
14023
|
+
if (result.rows.length > 1) {
|
|
14024
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "More than one immutable receipt carries this idempotency key", { idempotency_key: idempotencyKey });
|
|
14025
|
+
}
|
|
14026
|
+
return result.rows[0] ? assertPlanProjectLinkReceipt(result.rows[0].payload) : null;
|
|
14027
|
+
}
|
|
14028
|
+
async currentPlanProjectLinkResult(receipt, action) {
|
|
14029
|
+
const [plan, project, tasks] = await Promise.all([
|
|
14030
|
+
this.get("plans", receipt.plan_id),
|
|
14031
|
+
this.get("projects", receipt.project_id),
|
|
14032
|
+
this.listTasks({ plan_id: receipt.plan_id, include_subtasks: true })
|
|
14033
|
+
]);
|
|
14034
|
+
const sortedTasks = tasks.sort((left, right) => left.id.localeCompare(right.id));
|
|
14035
|
+
if (!plan || !project || planProjectLinkResultDigest(plan, sortedTasks) !== receipt.result_digest) {
|
|
14036
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "The accepted plan-project-link result has drifted", { receipt_id: receipt.receipt_id });
|
|
14037
|
+
}
|
|
14038
|
+
return { mode: "apply", action, plan, project, tasks: sortedTasks, receipt };
|
|
14039
|
+
}
|
|
14040
|
+
async applyPlanProjectLink(input, context = {}) {
|
|
14041
|
+
await this.ensureSchema();
|
|
14042
|
+
const existing = await this.getPlanProjectLinkReceipt(input.receipt_id);
|
|
14043
|
+
if (existing) {
|
|
14044
|
+
if (existing.plan_id !== input.plan_id || existing.project_id !== input.project_id) {
|
|
14045
|
+
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 });
|
|
14046
|
+
}
|
|
14047
|
+
const rolledBack = await this.get("plan_project_link_rollback_receipts", planProjectLinkRollbackReceiptId(input.receipt_id));
|
|
14048
|
+
if (rolledBack) {
|
|
14049
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
|
|
14050
|
+
}
|
|
14051
|
+
return this.currentPlanProjectLinkResult(existing, "already_linked");
|
|
14052
|
+
}
|
|
14053
|
+
const [plan, project, tasks, scopedPlans] = await Promise.all([
|
|
14054
|
+
this.get("plans", input.plan_id),
|
|
14055
|
+
this.get("projects", input.project_id),
|
|
14056
|
+
this.listTasks({ plan_id: input.plan_id, include_subtasks: true }),
|
|
14057
|
+
this.list("plans")
|
|
14058
|
+
]);
|
|
14059
|
+
if (!plan)
|
|
14060
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
|
|
14061
|
+
if (!project)
|
|
14062
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${input.project_id}`);
|
|
14063
|
+
if (plan.updated_at !== input.expected_plan_revision) {
|
|
14064
|
+
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 });
|
|
14065
|
+
}
|
|
14066
|
+
if (project.updated_at !== input.expected_project_revision) {
|
|
14067
|
+
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 });
|
|
14068
|
+
}
|
|
14069
|
+
const collision = scopedPlans.find((candidate) => candidate.id !== plan.id && candidate.project_id === project.id && candidate.slug !== null && candidate.slug === plan.slug);
|
|
14070
|
+
if (collision) {
|
|
14071
|
+
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 });
|
|
14072
|
+
}
|
|
14073
|
+
const sortedTasks = tasks.sort((left, right) => left.id.localeCompare(right.id));
|
|
14074
|
+
const priorTaskProjectIds = Object.fromEntries(sortedTasks.map((task) => [task.id, task.project_id]));
|
|
14075
|
+
const projectedPlan = { ...plan, project_id: project.id, updated_at: input.created_at };
|
|
14076
|
+
const projectedTasks = sortedTasks.map((task) => task.project_id === project.id ? task : { ...task, project_id: project.id, updated_at: input.created_at, version: task.version + 1 });
|
|
14077
|
+
const alreadyLinked = plan.project_id === project.id && sortedTasks.every((task) => task.project_id === project.id);
|
|
14078
|
+
const receipt = {
|
|
14079
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
14080
|
+
receipt_id: input.receipt_id,
|
|
14081
|
+
idempotency_key: input.idempotency_key,
|
|
14082
|
+
plan_id: plan.id,
|
|
14083
|
+
project_id: project.id,
|
|
14084
|
+
prior_plan_project_id: plan.project_id,
|
|
14085
|
+
prior_task_project_ids: priorTaskProjectIds,
|
|
14086
|
+
task_ids: sortedTasks.map((task) => task.id),
|
|
14087
|
+
task_count: sortedTasks.length,
|
|
14088
|
+
result_plan_revision: projectedPlan.updated_at,
|
|
14089
|
+
result_digest: planProjectLinkResultDigest(projectedPlan, projectedTasks),
|
|
14090
|
+
rollback_supported: true,
|
|
14091
|
+
created_at: input.created_at
|
|
14092
|
+
};
|
|
14093
|
+
let mutation;
|
|
14094
|
+
try {
|
|
14095
|
+
mutation = await this.options.client.query(`/* todos:plan-project-link-atomic */ WITH
|
|
14096
|
+
target_plan AS MATERIALIZED (
|
|
14097
|
+
SELECT payload FROM ${this.tableName}
|
|
14098
|
+
WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
|
|
14099
|
+
FOR UPDATE
|
|
14100
|
+
), target_project AS (
|
|
14101
|
+
SELECT payload FROM ${this.tableName}
|
|
14102
|
+
WHERE service = $1 AND object_type = 'projects' AND object_id = $3 AND deleted_at IS NULL
|
|
14103
|
+
FOR UPDATE
|
|
14104
|
+
), member_tasks AS MATERIALIZED (
|
|
14105
|
+
SELECT object_id, payload FROM ${this.tableName}
|
|
14106
|
+
WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
|
|
14107
|
+
AND payload->>'plan_id' = $2
|
|
14108
|
+
AND EXISTS (SELECT 1 FROM target_plan)
|
|
14109
|
+
FOR UPDATE
|
|
14110
|
+
), existing AS (
|
|
14111
|
+
SELECT payload FROM ${this.tableName}
|
|
14112
|
+
WHERE service = $1 AND object_type = 'plan_project_link_receipts'
|
|
14113
|
+
AND object_id = $6 AND deleted_at IS NULL
|
|
14114
|
+
FOR UPDATE
|
|
14115
|
+
), collision AS (
|
|
14116
|
+
SELECT 1 FROM ${this.tableName} r, target_plan p
|
|
14117
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.deleted_at IS NULL
|
|
14118
|
+
AND r.object_id <> $2 AND r.payload->>'project_id' = $3
|
|
14119
|
+
AND r.payload->>'slug' IS NOT DISTINCT FROM p.payload->>'slug'
|
|
14120
|
+
AND p.payload->>'slug' IS NOT NULL
|
|
14121
|
+
LIMIT 1
|
|
14122
|
+
), checks AS (
|
|
14123
|
+
SELECT
|
|
14124
|
+
EXISTS (SELECT 1 FROM target_plan) AS plan_found,
|
|
14125
|
+
EXISTS (SELECT 1 FROM target_project) AS project_found,
|
|
14126
|
+
COALESCE((SELECT payload->>'updated_at' = $4 FROM target_plan), false) AS plan_revision_ok,
|
|
14127
|
+
COALESCE((SELECT payload->>'updated_at' = $5 FROM target_project), false) AS project_revision_ok,
|
|
14128
|
+
COALESCE((SELECT jsonb_object_agg(object_id, COALESCE(payload->'project_id', 'null'::jsonb) ORDER BY object_id) FROM member_tasks), '{}'::jsonb) = $8::jsonb
|
|
14129
|
+
AND COALESCE((SELECT jsonb_agg(object_id ORDER BY object_id) FROM member_tasks), '[]'::jsonb) = $9::jsonb AS membership_ok,
|
|
14130
|
+
EXISTS (SELECT 1 FROM collision) AS collision,
|
|
14131
|
+
EXISTS (SELECT 1 FROM existing) AS has_existing
|
|
14132
|
+
), updated_plan AS (
|
|
14133
|
+
UPDATE ${this.tableName} r SET
|
|
14134
|
+
payload = r.payload || jsonb_build_object('project_id', $3::text, 'updated_at', $10::text),
|
|
14135
|
+
updated_at = $10::timestamptz,
|
|
14136
|
+
version = COALESCE(r.version, 0) + 1,
|
|
14137
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
14138
|
+
FROM checks
|
|
14139
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
14140
|
+
AND checks.plan_found AND checks.project_found AND checks.plan_revision_ok
|
|
14141
|
+
AND checks.project_revision_ok AND checks.membership_ok AND NOT checks.collision AND NOT checks.has_existing
|
|
14142
|
+
RETURNING r.payload
|
|
14143
|
+
), updated_tasks AS (
|
|
14144
|
+
UPDATE ${this.tableName} r SET
|
|
14145
|
+
payload = r.payload || jsonb_build_object(
|
|
14146
|
+
'project_id', $3::text,
|
|
14147
|
+
'updated_at', $10::text,
|
|
14148
|
+
'version', COALESCE((r.payload->>'version')::int, 0) + 1
|
|
14149
|
+
),
|
|
14150
|
+
updated_at = $10::timestamptz,
|
|
14151
|
+
version = COALESCE(r.version, 0) + 1,
|
|
14152
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
14153
|
+
WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
|
|
14154
|
+
AND r.payload->>'plan_id' = $2
|
|
14155
|
+
AND r.payload->>'project_id' IS DISTINCT FROM $3
|
|
14156
|
+
AND EXISTS (SELECT 1 FROM updated_plan)
|
|
14157
|
+
RETURNING 1
|
|
14158
|
+
), task_gate AS (
|
|
14159
|
+
SELECT count(*) AS count FROM updated_tasks
|
|
14160
|
+
), inserted AS (
|
|
14161
|
+
INSERT INTO ${this.tableName}
|
|
14162
|
+
(service, object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version)
|
|
14163
|
+
SELECT $1, 'plan_project_link_receipts', $6, $7::jsonb, $10::timestamptz, NULL, $11, 1
|
|
14164
|
+
FROM checks, task_gate
|
|
14165
|
+
WHERE NOT checks.has_existing AND EXISTS (SELECT 1 FROM updated_plan)
|
|
14166
|
+
RETURNING payload
|
|
14167
|
+
) SELECT
|
|
14168
|
+
checks.plan_found,
|
|
14169
|
+
checks.project_found,
|
|
14170
|
+
checks.plan_revision_ok,
|
|
14171
|
+
checks.project_revision_ok,
|
|
14172
|
+
checks.membership_ok,
|
|
14173
|
+
checks.collision,
|
|
14174
|
+
(SELECT payload FROM existing) AS existing_receipt,
|
|
14175
|
+
(SELECT payload FROM inserted) AS inserted_receipt
|
|
14176
|
+
FROM checks`, [
|
|
14177
|
+
this.service,
|
|
14178
|
+
plan.id,
|
|
14179
|
+
project.id,
|
|
14180
|
+
input.expected_plan_revision,
|
|
14181
|
+
input.expected_project_revision,
|
|
14182
|
+
receipt.receipt_id,
|
|
14183
|
+
jsonbParam(receipt),
|
|
14184
|
+
jsonbParam(priorTaskProjectIds),
|
|
14185
|
+
jsonbParam(receipt.task_ids),
|
|
14186
|
+
input.created_at,
|
|
14187
|
+
this.machineId(context)
|
|
14188
|
+
]);
|
|
14189
|
+
} catch (error) {
|
|
14190
|
+
if (isPostgresUniqueViolation(error)) {
|
|
14191
|
+
const raced = await this.getPlanProjectLinkReceipt(input.receipt_id);
|
|
14192
|
+
if (raced && raced.plan_id === input.plan_id && raced.project_id === input.project_id) {
|
|
14193
|
+
return this.currentPlanProjectLinkResult(raced, "already_linked");
|
|
14194
|
+
}
|
|
14195
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key raced with a different plan-project link", { idempotency_key: input.idempotency_key });
|
|
14196
|
+
}
|
|
14197
|
+
throw error;
|
|
14198
|
+
}
|
|
14199
|
+
const row = mutation.rows[0];
|
|
14200
|
+
if (!row?.plan_found)
|
|
14201
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${plan.id}`);
|
|
14202
|
+
if (!row.project_found)
|
|
14203
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${project.id}`);
|
|
14204
|
+
if (!row.plan_revision_ok)
|
|
14205
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed during the atomic link");
|
|
14206
|
+
if (!row.project_revision_ok)
|
|
14207
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_REVISION_CONFLICT", "Project changed during the atomic link");
|
|
14208
|
+
if (!row.membership_ok)
|
|
14209
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "Plan membership changed during the atomic link");
|
|
14210
|
+
if (row.collision)
|
|
14211
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_SCOPE_COLLISION", "Another plan owns this slug in the destination project");
|
|
14212
|
+
const accepted = assertPlanProjectLinkReceipt(row.existing_receipt ?? row.inserted_receipt);
|
|
14213
|
+
if (accepted.plan_id !== plan.id || accepted.project_id !== project.id) {
|
|
14214
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key was accepted for a different target");
|
|
14215
|
+
}
|
|
14216
|
+
return this.currentPlanProjectLinkResult(accepted, alreadyLinked ? "already_linked" : "linked");
|
|
14217
|
+
}
|
|
14218
|
+
async rollbackPlanProjectLink(input, context = {}) {
|
|
14219
|
+
await this.ensureSchema();
|
|
14220
|
+
const existingRollback = await this.get("plan_project_link_rollback_receipts", input.rollback_receipt_id);
|
|
14221
|
+
if (existingRollback)
|
|
14222
|
+
return existingRollback;
|
|
14223
|
+
const receipt = await this.getPlanProjectLinkReceipt(input.receipt_id);
|
|
14224
|
+
if (!receipt || receipt.plan_id !== input.plan_id || receipt.project_id !== input.project_id) {
|
|
14225
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "No exact plan-project-link receipt matches this rollback request", { receipt_id: input.receipt_id });
|
|
14226
|
+
}
|
|
14227
|
+
const [plan, tasks] = await Promise.all([
|
|
14228
|
+
this.get("plans", input.plan_id),
|
|
14229
|
+
this.listTasks({ plan_id: input.plan_id, include_subtasks: true })
|
|
14230
|
+
]);
|
|
14231
|
+
const sortedTasks = tasks.sort((left, right) => left.id.localeCompare(right.id));
|
|
14232
|
+
if (!plan || plan.updated_at !== input.expected_plan_revision) {
|
|
14233
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed after the accepted link; fetch an exact readback before rollback");
|
|
14234
|
+
}
|
|
14235
|
+
if (planProjectLinkResultDigest(plan, sortedTasks) !== receipt.result_digest) {
|
|
14236
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership or project linkage drifted; refusing conditional rollback");
|
|
14237
|
+
}
|
|
14238
|
+
const projectedPlan = { ...plan, project_id: receipt.prior_plan_project_id, updated_at: input.restored_at };
|
|
14239
|
+
const projectedTasks = sortedTasks.map((task) => ({
|
|
14240
|
+
...task,
|
|
14241
|
+
project_id: receipt.prior_task_project_ids[task.id] ?? null,
|
|
14242
|
+
updated_at: input.restored_at,
|
|
14243
|
+
version: task.version + 1
|
|
14244
|
+
}));
|
|
14245
|
+
const rollback = {
|
|
14246
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
14247
|
+
action: "restored",
|
|
14248
|
+
plan: projectedPlan,
|
|
14249
|
+
tasks: projectedTasks,
|
|
14250
|
+
accepted_receipt_id: receipt.receipt_id,
|
|
14251
|
+
rollback_receipt_id: input.rollback_receipt_id,
|
|
14252
|
+
restored_at: input.restored_at
|
|
14253
|
+
};
|
|
14254
|
+
const currentTaskProjects = Object.fromEntries(sortedTasks.map((task) => [task.id, task.project_id]));
|
|
14255
|
+
const result = await this.options.client.query(`/* todos:plan-project-link-rollback-atomic */ WITH
|
|
14256
|
+
target_plan AS MATERIALIZED (
|
|
14257
|
+
SELECT payload FROM ${this.tableName}
|
|
14258
|
+
WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
|
|
14259
|
+
FOR UPDATE
|
|
14260
|
+
), member_tasks AS MATERIALIZED (
|
|
14261
|
+
SELECT object_id, payload FROM ${this.tableName}
|
|
14262
|
+
WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
|
|
14263
|
+
AND payload->>'plan_id' = $2
|
|
14264
|
+
AND EXISTS (SELECT 1 FROM target_plan)
|
|
14265
|
+
FOR UPDATE
|
|
14266
|
+
), existing AS (
|
|
14267
|
+
SELECT payload FROM ${this.tableName}
|
|
14268
|
+
WHERE service = $1 AND object_type = 'plan_project_link_rollback_receipts'
|
|
14269
|
+
AND object_id = $5 AND deleted_at IS NULL
|
|
14270
|
+
FOR UPDATE
|
|
14271
|
+
), checks AS (
|
|
14272
|
+
SELECT
|
|
14273
|
+
EXISTS (SELECT 1 FROM target_plan) AS plan_found,
|
|
14274
|
+
COALESCE((SELECT payload->>'updated_at' = $3 FROM target_plan), false) AS plan_revision_ok,
|
|
14275
|
+
COALESCE((SELECT jsonb_object_agg(object_id, COALESCE(payload->'project_id', 'null'::jsonb) ORDER BY object_id) FROM member_tasks), '{}'::jsonb) = $7::jsonb
|
|
14276
|
+
AND COALESCE((SELECT jsonb_agg(object_id ORDER BY object_id) FROM member_tasks), '[]'::jsonb) = $8::jsonb AS membership_ok,
|
|
14277
|
+
EXISTS (SELECT 1 FROM existing) AS has_existing
|
|
14278
|
+
), updated_plan AS (
|
|
14279
|
+
UPDATE ${this.tableName} r SET
|
|
14280
|
+
payload = r.payload || jsonb_build_object('project_id', $9::jsonb, 'updated_at', $10::text),
|
|
14281
|
+
updated_at = $10::timestamptz,
|
|
14282
|
+
version = COALESCE(r.version, 0) + 1,
|
|
14283
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
14284
|
+
FROM checks
|
|
14285
|
+
WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
14286
|
+
AND checks.plan_found AND checks.plan_revision_ok AND checks.membership_ok AND NOT checks.has_existing
|
|
14287
|
+
RETURNING 1
|
|
14288
|
+
), updated_tasks AS (
|
|
14289
|
+
UPDATE ${this.tableName} r SET
|
|
14290
|
+
payload = r.payload || jsonb_build_object(
|
|
14291
|
+
'project_id', COALESCE($12::jsonb -> r.object_id, 'null'::jsonb),
|
|
14292
|
+
'updated_at', $10::text,
|
|
14293
|
+
'version', COALESCE((r.payload->>'version')::int, 0) + 1
|
|
14294
|
+
),
|
|
14295
|
+
updated_at = $10::timestamptz,
|
|
14296
|
+
version = COALESCE(r.version, 0) + 1,
|
|
14297
|
+
source_machine_id = COALESCE($11, r.source_machine_id)
|
|
14298
|
+
WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
|
|
14299
|
+
AND r.payload->>'plan_id' = $2 AND EXISTS (SELECT 1 FROM updated_plan)
|
|
14300
|
+
RETURNING 1
|
|
14301
|
+
), task_gate AS (SELECT count(*) AS count FROM updated_tasks), inserted AS (
|
|
14302
|
+
INSERT INTO ${this.tableName}
|
|
14303
|
+
(service, object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version)
|
|
14304
|
+
SELECT $1, 'plan_project_link_rollback_receipts', $5, $6::jsonb, $10::timestamptz, NULL, $11, 1
|
|
14305
|
+
FROM checks, task_gate
|
|
14306
|
+
WHERE NOT checks.has_existing AND EXISTS (SELECT 1 FROM updated_plan)
|
|
14307
|
+
RETURNING payload
|
|
14308
|
+
) SELECT
|
|
14309
|
+
checks.plan_found,
|
|
14310
|
+
checks.plan_revision_ok,
|
|
14311
|
+
checks.membership_ok,
|
|
14312
|
+
(SELECT payload FROM existing) AS existing_rollback,
|
|
14313
|
+
(SELECT payload FROM inserted) AS inserted_rollback
|
|
14314
|
+
FROM checks`, [
|
|
14315
|
+
this.service,
|
|
14316
|
+
input.plan_id,
|
|
14317
|
+
input.expected_plan_revision,
|
|
14318
|
+
input.receipt_id,
|
|
14319
|
+
input.rollback_receipt_id,
|
|
14320
|
+
jsonbParam(rollback),
|
|
14321
|
+
jsonbParam(currentTaskProjects),
|
|
14322
|
+
jsonbParam(receipt.task_ids),
|
|
14323
|
+
jsonbParam(receipt.prior_plan_project_id),
|
|
14324
|
+
input.restored_at,
|
|
14325
|
+
this.machineId(context),
|
|
14326
|
+
jsonbParam(receipt.prior_task_project_ids)
|
|
14327
|
+
]);
|
|
14328
|
+
const row = result.rows[0];
|
|
14329
|
+
if (!row?.plan_found)
|
|
14330
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
|
|
14331
|
+
if (!row.plan_revision_ok)
|
|
14332
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed during rollback");
|
|
14333
|
+
if (!row.membership_ok)
|
|
14334
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership changed during rollback");
|
|
14335
|
+
const accepted = row.existing_rollback ?? row.inserted_rollback;
|
|
14336
|
+
if (!accepted)
|
|
14337
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback did not produce an immutable receipt");
|
|
14338
|
+
return accepted;
|
|
14339
|
+
}
|
|
13692
14340
|
async deletePlan(id, context = {}) {
|
|
13693
14341
|
await this.ensureSchema();
|
|
13694
14342
|
const timestamp = new Date().toISOString();
|
|
@@ -13783,11 +14431,17 @@ class PostgresJsonRecordStore {
|
|
|
13783
14431
|
}
|
|
13784
14432
|
async function createTask(input, store, context) {
|
|
13785
14433
|
const timestamp = new Date().toISOString();
|
|
13786
|
-
const
|
|
14434
|
+
const linkedPlan = input.plan_id ? await store.get("plans", input.plan_id) : null;
|
|
14435
|
+
const requestedProjectId = input.project_id ?? context?.projectId ?? null;
|
|
14436
|
+
if (linkedPlan?.project_id && requestedProjectId && requestedProjectId !== linkedPlan.project_id) {
|
|
14437
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${input.plan_id}: expected ${linkedPlan.project_id}`);
|
|
14438
|
+
}
|
|
14439
|
+
const effectiveProjectId = linkedPlan?.project_id ?? requestedProjectId;
|
|
14440
|
+
const shortId = effectiveProjectId ? await nextTaskShortId(effectiveProjectId, store, context) : null;
|
|
13787
14441
|
const task = {
|
|
13788
14442
|
id: randomUUID(),
|
|
13789
14443
|
short_id: shortId,
|
|
13790
|
-
project_id:
|
|
14444
|
+
project_id: effectiveProjectId,
|
|
13791
14445
|
parent_id: input.parent_id ?? null,
|
|
13792
14446
|
plan_id: input.plan_id ?? null,
|
|
13793
14447
|
task_list_id: input.task_list_id ?? context?.taskListId ?? null,
|
|
@@ -13841,15 +14495,27 @@ async function createTask(input, store, context) {
|
|
|
13841
14495
|
synced_at: null,
|
|
13842
14496
|
archived_at: null
|
|
13843
14497
|
};
|
|
13844
|
-
await store.
|
|
13845
|
-
await logTaskChange(
|
|
13846
|
-
return
|
|
14498
|
+
const storedTask = await store.upsertTaskWithPlanMembershipGuard(task, task.plan_id ? [task.plan_id] : [], input.project_id !== undefined || context?.projectId !== undefined, context);
|
|
14499
|
+
await logTaskChange(storedTask.id, "created", "status", null, storedTask.status, storedTask.assigned_by ?? storedTask.agent_id, store, context);
|
|
14500
|
+
return storedTask;
|
|
13847
14501
|
}
|
|
13848
14502
|
async function updateTask(id, input, store) {
|
|
13849
14503
|
const existing = await requireRecord("tasks", id, store);
|
|
13850
14504
|
if (existing.version !== input.version) {
|
|
13851
14505
|
throw new Error(`Task ${id} version conflict: expected ${existing.version}, got ${input.version}`);
|
|
13852
14506
|
}
|
|
14507
|
+
const effectivePlanId = input.plan_id !== undefined ? input.plan_id : existing.plan_id;
|
|
14508
|
+
const linkedPlan = effectivePlanId ? await store.get("plans", effectivePlanId) : null;
|
|
14509
|
+
if (linkedPlan?.project_id) {
|
|
14510
|
+
const effectiveProjectId = input.project_id !== undefined ? input.project_id : existing.project_id;
|
|
14511
|
+
if (effectiveProjectId !== linkedPlan.project_id) {
|
|
14512
|
+
if (input.project_id === undefined && (input.plan_id !== undefined || existing.project_id === null)) {
|
|
14513
|
+
input = { ...input, project_id: linkedPlan.project_id };
|
|
14514
|
+
} else {
|
|
14515
|
+
throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${effectivePlanId}: expected ${linkedPlan.project_id}`);
|
|
14516
|
+
}
|
|
14517
|
+
}
|
|
14518
|
+
}
|
|
13853
14519
|
const reopened = existing.status === "completed" && input.status !== undefined && input.status !== "completed" && input.completed_at === undefined;
|
|
13854
14520
|
const terminalNow = input.status !== undefined && isTerminalStatus(input.status);
|
|
13855
14521
|
const task = {
|
|
@@ -13865,8 +14531,7 @@ async function updateTask(id, input, store) {
|
|
|
13865
14531
|
created_by: existing.created_by,
|
|
13866
14532
|
completed_at: reopened ? null : input.completed_at !== undefined ? input.completed_at : existing.completed_at
|
|
13867
14533
|
};
|
|
13868
|
-
|
|
13869
|
-
return task;
|
|
14534
|
+
return store.upsertTaskWithPlanMembershipGuard(task, [existing.plan_id, effectivePlanId].filter((planId) => Boolean(planId)), input.project_id !== undefined);
|
|
13870
14535
|
}
|
|
13871
14536
|
async function startTask(id, agentId, store) {
|
|
13872
14537
|
const task = await requireRecord("tasks", id, store);
|
|
@@ -13930,8 +14595,7 @@ async function patchTask(task, patch, store) {
|
|
|
13930
14595
|
version: task.version + 1,
|
|
13931
14596
|
updated_at: new Date().toISOString()
|
|
13932
14597
|
};
|
|
13933
|
-
|
|
13934
|
-
return updated;
|
|
14598
|
+
return store.upsertTaskWithPlanMembershipGuard(updated, [task.plan_id, updated.plan_id].filter((planId) => Boolean(planId)), Object.prototype.hasOwnProperty.call(patch, "project_id"));
|
|
13935
14599
|
}
|
|
13936
14600
|
var CLOUD_LOCK_EXPIRY_MINUTES = 30;
|
|
13937
14601
|
function sameCloudLockHolder(stored, incoming) {
|
|
@@ -14196,7 +14860,12 @@ async function updatePlan(id, input, store) {
|
|
|
14196
14860
|
excludeId: id
|
|
14197
14861
|
});
|
|
14198
14862
|
}
|
|
14199
|
-
return store.
|
|
14863
|
+
return store.updatePlanWithProjectLinkGuard({
|
|
14864
|
+
...plan,
|
|
14865
|
+
...patch,
|
|
14866
|
+
project_id: plan.project_id,
|
|
14867
|
+
updated_at: new Date().toISOString()
|
|
14868
|
+
});
|
|
14200
14869
|
}
|
|
14201
14870
|
function matchAgentByName(agents, name, options) {
|
|
14202
14871
|
const target = normalizeAgentNameInput(name);
|
|
@@ -14975,6 +15644,10 @@ class PostgresTodosProjectRegistrationBackend {
|
|
|
14975
15644
|
}
|
|
14976
15645
|
}
|
|
14977
15646
|
|
|
15647
|
+
// src/project-registration/sqlite.ts
|
|
15648
|
+
init_database();
|
|
15649
|
+
init_storage_tombstones();
|
|
15650
|
+
|
|
14978
15651
|
// src/storage/local-sqlite.ts
|
|
14979
15652
|
init_types();
|
|
14980
15653
|
|
|
@@ -15812,6 +16485,184 @@ function scanSqliteIntegrity(db = getDatabase()) {
|
|
|
15812
16485
|
return buildIntegrityReport(conditions, now());
|
|
15813
16486
|
}
|
|
15814
16487
|
|
|
16488
|
+
// src/db/plan-project-links.ts
|
|
16489
|
+
init_database();
|
|
16490
|
+
init_plans();
|
|
16491
|
+
init_projects();
|
|
16492
|
+
init_tasks();
|
|
16493
|
+
function getPlanProjectLinkReceipt(receiptId, db) {
|
|
16494
|
+
const d = db || getDatabase();
|
|
16495
|
+
const row = d.query("SELECT payload FROM plan_project_link_receipts WHERE receipt_id = ?").get(receiptId);
|
|
16496
|
+
return row ? assertPlanProjectLinkReceipt(JSON.parse(row.payload)) : null;
|
|
16497
|
+
}
|
|
16498
|
+
function getPlanProjectLinkReceiptByIdempotencyKey(idempotencyKey, db) {
|
|
16499
|
+
const d = db || getDatabase();
|
|
16500
|
+
const row = d.query("SELECT payload FROM plan_project_link_receipts WHERE idempotency_key = ?").get(idempotencyKey);
|
|
16501
|
+
return row ? assertPlanProjectLinkReceipt(JSON.parse(row.payload)) : null;
|
|
16502
|
+
}
|
|
16503
|
+
function exactTasks(planId, db) {
|
|
16504
|
+
return listTasks2({ plan_id: planId, include_subtasks: true, include_archived: true }, db).sort((left, right) => left.id.localeCompare(right.id));
|
|
16505
|
+
}
|
|
16506
|
+
function currentResult(planId, projectId, receipt, db, action) {
|
|
16507
|
+
const plan = getPlan(planId, db);
|
|
16508
|
+
const project = getProject(projectId, db);
|
|
16509
|
+
if (!plan || !project) {
|
|
16510
|
+
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 });
|
|
16511
|
+
}
|
|
16512
|
+
const tasks = exactTasks(planId, db);
|
|
16513
|
+
if (planProjectLinkResultDigest(plan, tasks) !== receipt.result_digest) {
|
|
16514
|
+
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 });
|
|
16515
|
+
}
|
|
16516
|
+
return { mode: "apply", action, plan, project, tasks, receipt };
|
|
16517
|
+
}
|
|
16518
|
+
function applyPlanProjectLinkSqlite(input, db) {
|
|
16519
|
+
const d = db || getDatabase();
|
|
16520
|
+
const mutate = d.transaction(() => {
|
|
16521
|
+
guardPlanRowsSqlite([input.plan_id], d);
|
|
16522
|
+
const requestHash = planProjectLinkRequestHash(input.plan_id, input.project_id);
|
|
16523
|
+
const existingRow = d.query("SELECT payload_hash, payload FROM plan_project_link_receipts WHERE idempotency_key = ?").get(input.idempotency_key);
|
|
16524
|
+
if (existingRow) {
|
|
16525
|
+
if (existingRow.payload_hash !== requestHash) {
|
|
16526
|
+
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 });
|
|
16527
|
+
}
|
|
16528
|
+
const rolledBack = d.query("SELECT rollback_receipt_id FROM plan_project_link_rollback_receipts WHERE accepted_receipt_id = ?").get(input.receipt_id);
|
|
16529
|
+
if (rolledBack) {
|
|
16530
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
|
|
16531
|
+
}
|
|
16532
|
+
return currentResult(input.plan_id, input.project_id, assertPlanProjectLinkReceipt(JSON.parse(existingRow.payload)), d, "already_linked");
|
|
16533
|
+
}
|
|
16534
|
+
const plan = getPlan(input.plan_id, d);
|
|
16535
|
+
if (!plan) {
|
|
16536
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
|
|
16537
|
+
}
|
|
16538
|
+
const project = getProject(input.project_id, d);
|
|
16539
|
+
if (!project) {
|
|
16540
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${input.project_id}`);
|
|
16541
|
+
}
|
|
16542
|
+
if (plan.updated_at !== input.expected_plan_revision) {
|
|
16543
|
+
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 });
|
|
16544
|
+
}
|
|
16545
|
+
if (project.updated_at !== input.expected_project_revision) {
|
|
16546
|
+
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 });
|
|
16547
|
+
}
|
|
16548
|
+
if (plan.slug) {
|
|
16549
|
+
const collision = d.query("SELECT id FROM plans WHERE project_id = ? AND slug = ? AND id <> ? LIMIT 1").get(project.id, plan.slug, plan.id);
|
|
16550
|
+
if (collision) {
|
|
16551
|
+
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 });
|
|
16552
|
+
}
|
|
16553
|
+
}
|
|
16554
|
+
const beforeTasks = exactTasks(plan.id, d);
|
|
16555
|
+
const alreadyLinked = plan.project_id === project.id && beforeTasks.every((task) => task.project_id === project.id);
|
|
16556
|
+
const priorTaskProjectIds = Object.fromEntries(beforeTasks.map((task) => [task.id, task.project_id]));
|
|
16557
|
+
const timestamp2 = input.created_at;
|
|
16558
|
+
if (plan.project_id !== project.id) {
|
|
16559
|
+
d.run("UPDATE plans SET project_id = ?, updated_at = ? WHERE id = ? AND updated_at = ?", [
|
|
16560
|
+
project.id,
|
|
16561
|
+
timestamp2,
|
|
16562
|
+
plan.id,
|
|
16563
|
+
input.expected_plan_revision
|
|
16564
|
+
]);
|
|
16565
|
+
}
|
|
16566
|
+
for (const task of beforeTasks) {
|
|
16567
|
+
if (task.project_id === project.id)
|
|
16568
|
+
continue;
|
|
16569
|
+
d.run("UPDATE tasks SET project_id = ?, updated_at = ?, version = version + 1 WHERE id = ? AND plan_id = ?", [project.id, timestamp2, task.id, plan.id]);
|
|
16570
|
+
}
|
|
16571
|
+
const linkedPlan = getPlan(plan.id, d);
|
|
16572
|
+
const linkedTasks = exactTasks(plan.id, d);
|
|
16573
|
+
if (linkedPlan.project_id !== project.id || linkedTasks.length !== beforeTasks.length || linkedTasks.some((task) => task.project_id !== project.id)) {
|
|
16574
|
+
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 });
|
|
16575
|
+
}
|
|
16576
|
+
const receipt = {
|
|
16577
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
16578
|
+
receipt_id: input.receipt_id,
|
|
16579
|
+
idempotency_key: input.idempotency_key,
|
|
16580
|
+
plan_id: plan.id,
|
|
16581
|
+
project_id: project.id,
|
|
16582
|
+
prior_plan_project_id: plan.project_id,
|
|
16583
|
+
prior_task_project_ids: priorTaskProjectIds,
|
|
16584
|
+
task_ids: beforeTasks.map((task) => task.id),
|
|
16585
|
+
task_count: beforeTasks.length,
|
|
16586
|
+
result_plan_revision: linkedPlan.updated_at,
|
|
16587
|
+
result_digest: planProjectLinkResultDigest(linkedPlan, linkedTasks),
|
|
16588
|
+
rollback_supported: true,
|
|
16589
|
+
created_at: input.created_at
|
|
16590
|
+
};
|
|
16591
|
+
d.run(`INSERT INTO plan_project_link_receipts
|
|
16592
|
+
(receipt_id, idempotency_key, plan_id, project_id, payload_hash, payload, created_at)
|
|
16593
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`, [
|
|
16594
|
+
receipt.receipt_id,
|
|
16595
|
+
receipt.idempotency_key,
|
|
16596
|
+
receipt.plan_id,
|
|
16597
|
+
receipt.project_id,
|
|
16598
|
+
requestHash,
|
|
16599
|
+
JSON.stringify(receipt),
|
|
16600
|
+
receipt.created_at
|
|
16601
|
+
]);
|
|
16602
|
+
return {
|
|
16603
|
+
mode: "apply",
|
|
16604
|
+
action: alreadyLinked ? "already_linked" : "linked",
|
|
16605
|
+
plan: linkedPlan,
|
|
16606
|
+
project,
|
|
16607
|
+
tasks: linkedTasks,
|
|
16608
|
+
receipt
|
|
16609
|
+
};
|
|
16610
|
+
});
|
|
16611
|
+
return mutate();
|
|
16612
|
+
}
|
|
16613
|
+
function rollbackPlanProjectLinkSqlite(input, db) {
|
|
16614
|
+
const d = db || getDatabase();
|
|
16615
|
+
return d.transaction(() => {
|
|
16616
|
+
guardPlanRowsSqlite([input.plan_id], d);
|
|
16617
|
+
const priorRollback = d.query("SELECT payload FROM plan_project_link_rollback_receipts WHERE accepted_receipt_id = ?").get(input.receipt_id);
|
|
16618
|
+
if (priorRollback)
|
|
16619
|
+
return JSON.parse(priorRollback.payload);
|
|
16620
|
+
const receipt = getPlanProjectLinkReceipt(input.receipt_id, d);
|
|
16621
|
+
if (!receipt || receipt.plan_id !== input.plan_id || receipt.project_id !== input.project_id) {
|
|
16622
|
+
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 });
|
|
16623
|
+
}
|
|
16624
|
+
const plan = getPlan(input.plan_id, d);
|
|
16625
|
+
if (!plan || plan.updated_at !== input.expected_plan_revision) {
|
|
16626
|
+
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 });
|
|
16627
|
+
}
|
|
16628
|
+
const tasks = exactTasks(plan.id, d);
|
|
16629
|
+
if (planProjectLinkResultDigest(plan, tasks) !== receipt.result_digest || tasks.length !== receipt.task_ids.length || tasks.some((task, index) => task.id !== receipt.task_ids[index])) {
|
|
16630
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership or project linkage drifted; refusing conditional rollback", { receipt_id: receipt.receipt_id });
|
|
16631
|
+
}
|
|
16632
|
+
d.run("UPDATE plans SET project_id = ?, updated_at = ? WHERE id = ? AND updated_at = ?", [
|
|
16633
|
+
receipt.prior_plan_project_id,
|
|
16634
|
+
input.restored_at,
|
|
16635
|
+
plan.id,
|
|
16636
|
+
input.expected_plan_revision
|
|
16637
|
+
]);
|
|
16638
|
+
for (const task of tasks) {
|
|
16639
|
+
const priorProjectId = receipt.prior_task_project_ids[task.id];
|
|
16640
|
+
if (priorProjectId === undefined && !(task.id in receipt.prior_task_project_ids)) {
|
|
16641
|
+
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 });
|
|
16642
|
+
}
|
|
16643
|
+
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]);
|
|
16644
|
+
}
|
|
16645
|
+
const restoredPlan = getPlan(plan.id, d);
|
|
16646
|
+
const restoredTasks = exactTasks(plan.id, d);
|
|
16647
|
+
if (restoredPlan.project_id !== receipt.prior_plan_project_id || restoredTasks.some((task) => task.project_id !== receipt.prior_task_project_ids[task.id])) {
|
|
16648
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback readback did not restore every exact prior project id", { receipt_id: receipt.receipt_id });
|
|
16649
|
+
}
|
|
16650
|
+
const result = {
|
|
16651
|
+
schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
|
|
16652
|
+
action: "restored",
|
|
16653
|
+
plan: restoredPlan,
|
|
16654
|
+
tasks: restoredTasks,
|
|
16655
|
+
accepted_receipt_id: receipt.receipt_id,
|
|
16656
|
+
rollback_receipt_id: input.rollback_receipt_id,
|
|
16657
|
+
restored_at: input.restored_at
|
|
16658
|
+
};
|
|
16659
|
+
d.run(`INSERT INTO plan_project_link_rollback_receipts
|
|
16660
|
+
(rollback_receipt_id, accepted_receipt_id, payload, created_at)
|
|
16661
|
+
VALUES (?, ?, ?, ?)`, [result.rollback_receipt_id, result.accepted_receipt_id, JSON.stringify(result), result.restored_at]);
|
|
16662
|
+
return result;
|
|
16663
|
+
})();
|
|
16664
|
+
}
|
|
16665
|
+
|
|
15815
16666
|
// src/storage/sqlite-snapshot.ts
|
|
15816
16667
|
init_database();
|
|
15817
16668
|
init_audit();
|
|
@@ -16315,6 +17166,12 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
16315
17166
|
update: (id, input) => updatePlan2(id, input, database()),
|
|
16316
17167
|
delete: (id) => deletePlan(id, database())
|
|
16317
17168
|
},
|
|
17169
|
+
planProjectLinks: {
|
|
17170
|
+
apply: (input) => applyPlanProjectLinkSqlite(input, database()),
|
|
17171
|
+
rollback: (input) => rollbackPlanProjectLinkSqlite(input, database()),
|
|
17172
|
+
getReceipt: (receiptId) => getPlanProjectLinkReceipt(receiptId, database()),
|
|
17173
|
+
getReceiptByIdempotencyKey: (key) => getPlanProjectLinkReceiptByIdempotencyKey(key, database())
|
|
17174
|
+
},
|
|
16318
17175
|
agents: {
|
|
16319
17176
|
register: (input) => registerAgent2(input, database()),
|
|
16320
17177
|
get: (id) => getAgent(id, database()),
|
|
@@ -16328,7 +17185,8 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
16328
17185
|
getBySlug: (slug, projectId) => getTaskListBySlug(slug, projectId, database()),
|
|
16329
17186
|
list: (projectId) => listTaskLists(projectId, database()),
|
|
16330
17187
|
update: (id, input) => updateTaskList2(id, input, database()),
|
|
16331
|
-
delete: (id) => deleteTaskList(id, database())
|
|
17188
|
+
delete: (id) => deleteTaskList(id, database()),
|
|
17189
|
+
deleteIfUnchangedAndUnused: (id, expected) => deleteTaskListIfUnchangedAndUnused(id, expected, database())
|
|
16332
17190
|
},
|
|
16333
17191
|
templates: {
|
|
16334
17192
|
create: (input) => createTemplate2(input, database()),
|
|
@@ -16384,9 +17242,75 @@ var PROJECT_REFERENCE_COLUMNS = new Set([
|
|
|
16384
17242
|
"external_project_id"
|
|
16385
17243
|
]);
|
|
16386
17244
|
var TASK_LIST_REFERENCE_COLUMNS = new Set(["task_list_id"]);
|
|
17245
|
+
var SQLITE_TRANSACTION_RETRY_LIMIT = 8;
|
|
17246
|
+
|
|
17247
|
+
class SqliteRegistrationOptimisticConflict extends Error {
|
|
17248
|
+
constructor(message, options = {}) {
|
|
17249
|
+
super(message, options);
|
|
17250
|
+
this.name = "SqliteRegistrationOptimisticConflict";
|
|
17251
|
+
}
|
|
17252
|
+
}
|
|
17253
|
+
function sameSqliteValue(left, right) {
|
|
17254
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
17255
|
+
}
|
|
17256
|
+
function taskListFromRow(row) {
|
|
17257
|
+
return {
|
|
17258
|
+
...row,
|
|
17259
|
+
metadata: JSON.parse(row.metadata || "{}")
|
|
17260
|
+
};
|
|
17261
|
+
}
|
|
17262
|
+
function selectProject(db, id) {
|
|
17263
|
+
return db.query("SELECT * FROM projects WHERE id = ? LIMIT 1").get(id);
|
|
17264
|
+
}
|
|
17265
|
+
function selectTaskList(db, id) {
|
|
17266
|
+
const row = db.query("SELECT * FROM task_lists WHERE id = ? LIMIT 1").get(id);
|
|
17267
|
+
return row ? taskListFromRow(row) : null;
|
|
17268
|
+
}
|
|
17269
|
+
function selectProjectConflict(db, path, taskListSlug) {
|
|
17270
|
+
return db.query(`
|
|
17271
|
+
SELECT * FROM projects
|
|
17272
|
+
WHERE path = ? OR task_list_id = ?
|
|
17273
|
+
ORDER BY created_at ASC, id ASC
|
|
17274
|
+
LIMIT 1
|
|
17275
|
+
`).get(path, taskListSlug);
|
|
17276
|
+
}
|
|
17277
|
+
function selectTaskListConflict(db, projectId, slug) {
|
|
17278
|
+
const row = db.query(`
|
|
17279
|
+
SELECT * FROM task_lists
|
|
17280
|
+
WHERE project_id = ? AND slug = ?
|
|
17281
|
+
LIMIT 1
|
|
17282
|
+
`).get(projectId, slug);
|
|
17283
|
+
return row ? taskListFromRow(row) : null;
|
|
17284
|
+
}
|
|
16387
17285
|
function quoteSqliteIdentifier(value) {
|
|
16388
17286
|
return `"${value.replaceAll('"', '""')}"`;
|
|
16389
17287
|
}
|
|
17288
|
+
function hasSqliteDependents(db, resourceKind, targetId) {
|
|
17289
|
+
const targetTable = resourceKind === "project" ? "projects" : "task_lists";
|
|
17290
|
+
const semanticColumns = resourceKind === "project" ? PROJECT_REFERENCE_COLUMNS : TASK_LIST_REFERENCE_COLUMNS;
|
|
17291
|
+
const tables = db.query(`
|
|
17292
|
+
SELECT name FROM sqlite_schema
|
|
17293
|
+
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
|
|
17294
|
+
ORDER BY name
|
|
17295
|
+
`).all();
|
|
17296
|
+
for (const { name: tableName } of tables) {
|
|
17297
|
+
const quotedTable = quoteSqliteIdentifier(tableName);
|
|
17298
|
+
const columns = db.query(`PRAGMA table_info(${quotedTable})`).all();
|
|
17299
|
+
const foreignKeys = db.query(`PRAGMA foreign_key_list(${quotedTable})`).all();
|
|
17300
|
+
const referenceColumns = columns.map((column) => column.name).filter((columnName) => semanticColumns.has(columnName) || foreignKeys.some((foreignKey) => foreignKey.from === columnName && foreignKey.table === targetTable));
|
|
17301
|
+
for (const columnName of referenceColumns) {
|
|
17302
|
+
const row = db.query(`
|
|
17303
|
+
SELECT 1 AS found
|
|
17304
|
+
FROM ${quotedTable}
|
|
17305
|
+
WHERE ${quoteSqliteIdentifier(columnName)} = ?
|
|
17306
|
+
LIMIT 1
|
|
17307
|
+
`).get(targetId);
|
|
17308
|
+
if (row)
|
|
17309
|
+
return true;
|
|
17310
|
+
}
|
|
17311
|
+
}
|
|
17312
|
+
return false;
|
|
17313
|
+
}
|
|
16390
17314
|
function receiptFromRow2(row) {
|
|
16391
17315
|
return {
|
|
16392
17316
|
...row,
|
|
@@ -16529,30 +17453,7 @@ class SqliteTodosProjectRegistrationTransaction {
|
|
|
16529
17453
|
}
|
|
16530
17454
|
async lockCompensationWrites() {}
|
|
16531
17455
|
async hasDependents(resourceKind, targetId) {
|
|
16532
|
-
|
|
16533
|
-
const semanticColumns = resourceKind === "project" ? PROJECT_REFERENCE_COLUMNS : TASK_LIST_REFERENCE_COLUMNS;
|
|
16534
|
-
const tables = this.db.query(`
|
|
16535
|
-
SELECT name FROM sqlite_schema
|
|
16536
|
-
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
|
|
16537
|
-
ORDER BY name
|
|
16538
|
-
`).all();
|
|
16539
|
-
for (const { name: tableName } of tables) {
|
|
16540
|
-
const quotedTable = quoteSqliteIdentifier(tableName);
|
|
16541
|
-
const columns = this.db.query(`PRAGMA table_info(${quotedTable})`).all();
|
|
16542
|
-
const foreignKeys = this.db.query(`PRAGMA foreign_key_list(${quotedTable})`).all();
|
|
16543
|
-
const referenceColumns = columns.map((column) => column.name).filter((columnName) => semanticColumns.has(columnName) || foreignKeys.some((foreignKey) => foreignKey.from === columnName && foreignKey.table === targetTable));
|
|
16544
|
-
for (const columnName of referenceColumns) {
|
|
16545
|
-
const row = this.db.query(`
|
|
16546
|
-
SELECT 1 AS found
|
|
16547
|
-
FROM ${quotedTable}
|
|
16548
|
-
WHERE ${quoteSqliteIdentifier(columnName)} = ?
|
|
16549
|
-
LIMIT 1
|
|
16550
|
-
`).get(targetId);
|
|
16551
|
-
if (row)
|
|
16552
|
-
return true;
|
|
16553
|
-
}
|
|
16554
|
-
}
|
|
16555
|
-
return false;
|
|
17456
|
+
return hasSqliteDependents(this.db, resourceKind, targetId);
|
|
16556
17457
|
}
|
|
16557
17458
|
async deleteProject(id) {
|
|
16558
17459
|
return await this.storage.projects.delete(id);
|
|
@@ -16562,6 +17463,376 @@ class SqliteTodosProjectRegistrationTransaction {
|
|
|
16562
17463
|
}
|
|
16563
17464
|
}
|
|
16564
17465
|
|
|
17466
|
+
class StagedSqliteTodosProjectRegistrationTransaction {
|
|
17467
|
+
db;
|
|
17468
|
+
direct;
|
|
17469
|
+
validators = [];
|
|
17470
|
+
mutations = [];
|
|
17471
|
+
receipts = new Map;
|
|
17472
|
+
bindings = new Map;
|
|
17473
|
+
projects = new Map;
|
|
17474
|
+
taskLists = new Map;
|
|
17475
|
+
constructor(db) {
|
|
17476
|
+
this.db = db;
|
|
17477
|
+
this.direct = new SqliteTodosProjectRegistrationTransaction(db);
|
|
17478
|
+
}
|
|
17479
|
+
commit() {
|
|
17480
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
17481
|
+
try {
|
|
17482
|
+
for (const validate of this.validators) {
|
|
17483
|
+
if (!validate()) {
|
|
17484
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration input changed before SQLite commit");
|
|
17485
|
+
}
|
|
17486
|
+
}
|
|
17487
|
+
for (const mutate of this.mutations)
|
|
17488
|
+
mutate();
|
|
17489
|
+
this.db.exec("COMMIT");
|
|
17490
|
+
} catch (error) {
|
|
17491
|
+
try {
|
|
17492
|
+
this.db.exec("ROLLBACK");
|
|
17493
|
+
} catch {}
|
|
17494
|
+
throw error;
|
|
17495
|
+
}
|
|
17496
|
+
}
|
|
17497
|
+
async lockStep(_identity) {}
|
|
17498
|
+
async getReceiptForLookup(identity) {
|
|
17499
|
+
const staged = [...this.receipts.values()].filter((receipt) => receipt.authority_id === identity.authority_id && receipt.tenant_id === identity.tenant_id && receipt.corpus_id === identity.corpus_id && receipt.operation_id === identity.operation_id && receipt.step_id === identity.step_id && receipt.resource_kind === identity.resource_kind && receipt.direction === identity.direction && receipt.idempotency_key === identity.idempotency_key && receipt.target_selector === identity.target_selector);
|
|
17500
|
+
const stored = await this.direct.getReceiptForLookup(identity);
|
|
17501
|
+
if (stored)
|
|
17502
|
+
staged.push(stored);
|
|
17503
|
+
const outcomeRank = (receipt) => receipt.outcome === "terminal_nonacceptance" ? 0 : receipt.outcome === "duplicate_of_accepted" ? 1 : 2;
|
|
17504
|
+
return staged.sort((left, right) => outcomeRank(left) - outcomeRank(right) || right.created_at.localeCompare(left.created_at) || right.receipt_id.localeCompare(left.receipt_id))[0] ?? null;
|
|
17505
|
+
}
|
|
17506
|
+
async getReceiptById(receiptId) {
|
|
17507
|
+
return this.receipts.get(receiptId) ?? this.direct.getReceiptById(receiptId);
|
|
17508
|
+
}
|
|
17509
|
+
async getAcceptedReceiptForStep(identity) {
|
|
17510
|
+
const staged = [...this.receipts.values()].filter((receipt) => receipt.authority_id === identity.authority_id && receipt.tenant_id === identity.tenant_id && receipt.corpus_id === identity.corpus_id && receipt.operation_id === identity.operation_id && receipt.step_id === identity.step_id && receipt.resource_kind === identity.resource_kind && receipt.direction === identity.direction && receipt.outcome === "accepted");
|
|
17511
|
+
const stored = await this.direct.getAcceptedReceiptForStep(identity);
|
|
17512
|
+
if (stored)
|
|
17513
|
+
staged.push(stored);
|
|
17514
|
+
return staged.sort((left, right) => left.created_at.localeCompare(right.created_at) || left.receipt_id.localeCompare(right.receipt_id))[0] ?? null;
|
|
17515
|
+
}
|
|
17516
|
+
async insertReceipt(receipt) {
|
|
17517
|
+
if (this.receipts.has(receipt.receipt_id))
|
|
17518
|
+
return false;
|
|
17519
|
+
if (await this.direct.getReceiptById(receipt.receipt_id))
|
|
17520
|
+
return false;
|
|
17521
|
+
const planned = { ...receipt };
|
|
17522
|
+
this.receipts.set(planned.receipt_id, planned);
|
|
17523
|
+
this.mutations.push(() => {
|
|
17524
|
+
try {
|
|
17525
|
+
const result = this.db.query(`
|
|
17526
|
+
INSERT OR IGNORE INTO todos_project_registration_receipts (
|
|
17527
|
+
receipt_id, authority, route, package_version, authority_id, tenant_id,
|
|
17528
|
+
corpus_id, operation_id, step_id, resource_kind, direction,
|
|
17529
|
+
target_selector, idempotency_key, request_digest, precondition_digest,
|
|
17530
|
+
normalized_call_digest, outcome, reason, target_id, result_revision,
|
|
17531
|
+
result_digest, duplicate_of_receipt_id, accepted_receipt_id,
|
|
17532
|
+
created_by_operation, created_at
|
|
17533
|
+
) VALUES (
|
|
17534
|
+
?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?
|
|
17535
|
+
)
|
|
17536
|
+
`).run(planned.receipt_id, planned.authority, planned.route, planned.package_version, planned.authority_id, planned.tenant_id, planned.corpus_id, planned.operation_id, planned.step_id, planned.resource_kind, planned.direction, planned.target_selector, planned.idempotency_key, planned.request_digest, planned.precondition_digest, planned.normalized_call_digest, planned.outcome, planned.reason, planned.target_id, planned.result_revision, planned.result_digest, planned.duplicate_of_receipt_id, planned.accepted_receipt_id, planned.created_by_operation ? 1 : 0, planned.created_at);
|
|
17537
|
+
if (result.changes !== 1) {
|
|
17538
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration receipt changed before SQLite commit");
|
|
17539
|
+
}
|
|
17540
|
+
} catch (error) {
|
|
17541
|
+
if (error instanceof SqliteRegistrationOptimisticConflict)
|
|
17542
|
+
throw error;
|
|
17543
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration receipt conflicted at SQLite commit", { cause: error });
|
|
17544
|
+
}
|
|
17545
|
+
});
|
|
17546
|
+
return true;
|
|
17547
|
+
}
|
|
17548
|
+
async getBinding(scope, resourceKind, targetSelector) {
|
|
17549
|
+
const key = this.bindingKey(scope, resourceKind, targetSelector);
|
|
17550
|
+
return this.bindings.get(key) ?? this.direct.getBinding(scope, resourceKind, targetSelector);
|
|
17551
|
+
}
|
|
17552
|
+
async claimBinding(binding) {
|
|
17553
|
+
const key = this.bindingKey(binding, binding.resource_kind, binding.target_selector);
|
|
17554
|
+
if (this.bindings.has(key))
|
|
17555
|
+
return false;
|
|
17556
|
+
if (await this.direct.getBinding(binding, binding.resource_kind, binding.target_selector)) {
|
|
17557
|
+
return false;
|
|
17558
|
+
}
|
|
17559
|
+
const planned = { ...binding };
|
|
17560
|
+
this.bindings.set(key, planned);
|
|
17561
|
+
this.mutations.push(() => {
|
|
17562
|
+
try {
|
|
17563
|
+
const result = this.db.query(`
|
|
17564
|
+
INSERT OR IGNORE INTO todos_project_registration_bindings (
|
|
17565
|
+
authority_id, tenant_id, corpus_id, resource_kind, target_selector,
|
|
17566
|
+
operation_id, step_id, direction, idempotency_key, request_digest,
|
|
17567
|
+
precondition_digest, normalized_call_digest, state, target_id,
|
|
17568
|
+
accepted_receipt_id, result_revision, result_digest, removed_receipt_id,
|
|
17569
|
+
created_at, updated_at
|
|
17570
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
17571
|
+
`).run(planned.authority_id, planned.tenant_id, planned.corpus_id, planned.resource_kind, planned.target_selector, planned.operation_id, planned.step_id, planned.direction, planned.idempotency_key, planned.request_digest, planned.precondition_digest, planned.normalized_call_digest, planned.state, planned.target_id, planned.accepted_receipt_id, planned.result_revision, planned.result_digest, planned.removed_receipt_id, planned.created_at, planned.updated_at);
|
|
17572
|
+
if (result.changes !== 1) {
|
|
17573
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration binding changed before SQLite commit");
|
|
17574
|
+
}
|
|
17575
|
+
} catch (error) {
|
|
17576
|
+
if (error instanceof SqliteRegistrationOptimisticConflict)
|
|
17577
|
+
throw error;
|
|
17578
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration binding conflicted at SQLite commit", { cause: error });
|
|
17579
|
+
}
|
|
17580
|
+
});
|
|
17581
|
+
return true;
|
|
17582
|
+
}
|
|
17583
|
+
async setBindingAccepted(scope, resourceKind, targetSelector, update) {
|
|
17584
|
+
const binding = await this.requireBinding(scope, resourceKind, targetSelector, "pending");
|
|
17585
|
+
this.bindings.set(this.bindingKey(scope, resourceKind, targetSelector), {
|
|
17586
|
+
...binding,
|
|
17587
|
+
state: "accepted",
|
|
17588
|
+
target_id: update.target_id,
|
|
17589
|
+
accepted_receipt_id: update.accepted_receipt_id,
|
|
17590
|
+
result_revision: update.result_revision,
|
|
17591
|
+
result_digest: update.result_digest,
|
|
17592
|
+
updated_at: update.updated_at
|
|
17593
|
+
});
|
|
17594
|
+
this.mutations.push(() => {
|
|
17595
|
+
const result = this.db.query(`
|
|
17596
|
+
UPDATE todos_project_registration_bindings
|
|
17597
|
+
SET state = 'accepted', target_id = ?, accepted_receipt_id = ?,
|
|
17598
|
+
result_revision = ?, result_digest = ?, updated_at = ?
|
|
17599
|
+
WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
|
|
17600
|
+
AND resource_kind = ? AND target_selector = ? AND state = 'pending'
|
|
17601
|
+
`).run(update.target_id, update.accepted_receipt_id, update.result_revision, update.result_digest, update.updated_at, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
|
|
17602
|
+
if (result.changes !== 1) {
|
|
17603
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration binding was no longer pending at SQLite commit");
|
|
17604
|
+
}
|
|
17605
|
+
});
|
|
17606
|
+
}
|
|
17607
|
+
async setBindingTerminal(scope, resourceKind, targetSelector, updatedAt) {
|
|
17608
|
+
const binding = await this.requireBinding(scope, resourceKind, targetSelector, "pending");
|
|
17609
|
+
this.bindings.set(this.bindingKey(scope, resourceKind, targetSelector), {
|
|
17610
|
+
...binding,
|
|
17611
|
+
state: "terminal_nonacceptance",
|
|
17612
|
+
updated_at: updatedAt
|
|
17613
|
+
});
|
|
17614
|
+
this.mutations.push(() => {
|
|
17615
|
+
const result = this.db.query(`
|
|
17616
|
+
UPDATE todos_project_registration_bindings
|
|
17617
|
+
SET state = 'terminal_nonacceptance', updated_at = ?
|
|
17618
|
+
WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
|
|
17619
|
+
AND resource_kind = ? AND target_selector = ? AND state = 'pending'
|
|
17620
|
+
`).run(updatedAt, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
|
|
17621
|
+
if (result.changes !== 1) {
|
|
17622
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration binding was no longer pending at SQLite commit");
|
|
17623
|
+
}
|
|
17624
|
+
});
|
|
17625
|
+
}
|
|
17626
|
+
async setBindingRemoved(scope, resourceKind, targetSelector, removedReceiptId, updatedAt) {
|
|
17627
|
+
const binding = await this.requireBinding(scope, resourceKind, targetSelector, "accepted");
|
|
17628
|
+
this.bindings.set(this.bindingKey(scope, resourceKind, targetSelector), {
|
|
17629
|
+
...binding,
|
|
17630
|
+
state: "removed",
|
|
17631
|
+
removed_receipt_id: removedReceiptId,
|
|
17632
|
+
updated_at: updatedAt
|
|
17633
|
+
});
|
|
17634
|
+
this.mutations.push(() => {
|
|
17635
|
+
const result = this.db.query(`
|
|
17636
|
+
UPDATE todos_project_registration_bindings
|
|
17637
|
+
SET state = 'removed', removed_receipt_id = ?, updated_at = ?
|
|
17638
|
+
WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
|
|
17639
|
+
AND resource_kind = ? AND target_selector = ? AND state = 'accepted'
|
|
17640
|
+
`).run(removedReceiptId, updatedAt, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
|
|
17641
|
+
if (result.changes !== 1) {
|
|
17642
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration binding was no longer accepted at SQLite commit");
|
|
17643
|
+
}
|
|
17644
|
+
});
|
|
17645
|
+
}
|
|
17646
|
+
async findProjectConflict(path, taskListSlug) {
|
|
17647
|
+
const planned = [...this.projects.values()].find((project) => project?.path === path || project?.task_list_id === taskListSlug);
|
|
17648
|
+
if (planned)
|
|
17649
|
+
return planned;
|
|
17650
|
+
const observed = selectProjectConflict(this.db, path, taskListSlug);
|
|
17651
|
+
this.validators.push(() => sameSqliteValue(selectProjectConflict(this.db, path, taskListSlug), observed));
|
|
17652
|
+
return observed;
|
|
17653
|
+
}
|
|
17654
|
+
async findTaskListConflict(projectId, slug) {
|
|
17655
|
+
const planned = [...this.taskLists.values()].find((taskList) => taskList?.project_id === projectId && taskList.slug === slug);
|
|
17656
|
+
if (planned)
|
|
17657
|
+
return planned;
|
|
17658
|
+
const observed = selectTaskListConflict(this.db, projectId, slug);
|
|
17659
|
+
this.validators.push(() => sameSqliteValue(selectTaskListConflict(this.db, projectId, slug), observed));
|
|
17660
|
+
return observed;
|
|
17661
|
+
}
|
|
17662
|
+
async createProject(input) {
|
|
17663
|
+
const derivedSlug = normalizeSlug(input.name);
|
|
17664
|
+
const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : normalizeSlug(input.task_list_id);
|
|
17665
|
+
if (!derivedSlug || !taskListId) {
|
|
17666
|
+
throw new Error("Project name and task-list slug must be non-empty");
|
|
17667
|
+
}
|
|
17668
|
+
const project = {
|
|
17669
|
+
id: uuid(),
|
|
17670
|
+
name: input.name,
|
|
17671
|
+
path: input.path,
|
|
17672
|
+
description: input.description || null,
|
|
17673
|
+
task_list_id: taskListId,
|
|
17674
|
+
task_prefix: input.task_prefix ?? this.availableProjectPrefix(input.name),
|
|
17675
|
+
task_counter: 0,
|
|
17676
|
+
created_at: now(),
|
|
17677
|
+
updated_at: now(),
|
|
17678
|
+
machine_id: currentStorageMachineId(this.db)
|
|
17679
|
+
};
|
|
17680
|
+
project.updated_at = project.created_at;
|
|
17681
|
+
this.projects.set(project.id, project);
|
|
17682
|
+
this.mutations.push(() => {
|
|
17683
|
+
try {
|
|
17684
|
+
const result = this.db.run(`INSERT INTO projects (
|
|
17685
|
+
id, name, path, description, task_list_id, task_prefix,
|
|
17686
|
+
task_counter, created_at, updated_at, machine_id
|
|
17687
|
+
) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [
|
|
17688
|
+
project.id,
|
|
17689
|
+
project.name,
|
|
17690
|
+
project.path,
|
|
17691
|
+
project.description,
|
|
17692
|
+
project.task_list_id,
|
|
17693
|
+
project.task_prefix,
|
|
17694
|
+
project.created_at,
|
|
17695
|
+
project.updated_at,
|
|
17696
|
+
project.machine_id ?? null
|
|
17697
|
+
]);
|
|
17698
|
+
if (result.changes < 1) {
|
|
17699
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project changed before SQLite registration commit");
|
|
17700
|
+
}
|
|
17701
|
+
} catch (error) {
|
|
17702
|
+
if (error instanceof SqliteRegistrationOptimisticConflict)
|
|
17703
|
+
throw error;
|
|
17704
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project conflicted at SQLite registration commit", { cause: error });
|
|
17705
|
+
}
|
|
17706
|
+
});
|
|
17707
|
+
return project;
|
|
17708
|
+
}
|
|
17709
|
+
async createTaskList(input) {
|
|
17710
|
+
const slug = normalizeSlug(input.slug === undefined ? input.name : input.slug);
|
|
17711
|
+
if (!slug)
|
|
17712
|
+
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
17713
|
+
const taskList = {
|
|
17714
|
+
id: uuid(),
|
|
17715
|
+
project_id: input.project_id || null,
|
|
17716
|
+
slug,
|
|
17717
|
+
name: input.name,
|
|
17718
|
+
description: input.description || null,
|
|
17719
|
+
metadata: input.metadata ?? {},
|
|
17720
|
+
created_at: now(),
|
|
17721
|
+
updated_at: now(),
|
|
17722
|
+
machine_id: currentStorageMachineId(this.db)
|
|
17723
|
+
};
|
|
17724
|
+
taskList.updated_at = taskList.created_at;
|
|
17725
|
+
this.taskLists.set(taskList.id, taskList);
|
|
17726
|
+
this.mutations.push(() => {
|
|
17727
|
+
try {
|
|
17728
|
+
const result = this.db.run(`INSERT INTO task_lists (
|
|
17729
|
+
id, project_id, slug, name, description, metadata,
|
|
17730
|
+
created_at, updated_at, machine_id
|
|
17731
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
17732
|
+
taskList.id,
|
|
17733
|
+
taskList.project_id,
|
|
17734
|
+
taskList.slug,
|
|
17735
|
+
taskList.name,
|
|
17736
|
+
taskList.description,
|
|
17737
|
+
JSON.stringify(taskList.metadata),
|
|
17738
|
+
taskList.created_at,
|
|
17739
|
+
taskList.updated_at,
|
|
17740
|
+
taskList.machine_id ?? null
|
|
17741
|
+
]);
|
|
17742
|
+
if (result.changes < 1) {
|
|
17743
|
+
throw new SqliteRegistrationOptimisticConflict("Todos task list changed before SQLite registration commit");
|
|
17744
|
+
}
|
|
17745
|
+
} catch (error) {
|
|
17746
|
+
if (error instanceof SqliteRegistrationOptimisticConflict)
|
|
17747
|
+
throw error;
|
|
17748
|
+
throw new SqliteRegistrationOptimisticConflict("Todos task list conflicted at SQLite registration commit", { cause: error });
|
|
17749
|
+
}
|
|
17750
|
+
});
|
|
17751
|
+
return taskList;
|
|
17752
|
+
}
|
|
17753
|
+
async getProject(id) {
|
|
17754
|
+
if (this.projects.has(id))
|
|
17755
|
+
return this.projects.get(id) ?? null;
|
|
17756
|
+
const observed = selectProject(this.db, id);
|
|
17757
|
+
this.validators.push(() => sameSqliteValue(selectProject(this.db, id), observed));
|
|
17758
|
+
return observed;
|
|
17759
|
+
}
|
|
17760
|
+
async getTaskList(id) {
|
|
17761
|
+
if (this.taskLists.has(id))
|
|
17762
|
+
return this.taskLists.get(id) ?? null;
|
|
17763
|
+
const observed = selectTaskList(this.db, id);
|
|
17764
|
+
this.validators.push(() => sameSqliteValue(selectTaskList(this.db, id), observed));
|
|
17765
|
+
return observed;
|
|
17766
|
+
}
|
|
17767
|
+
async lockCompensationWrites() {}
|
|
17768
|
+
async hasDependents(resourceKind, targetId) {
|
|
17769
|
+
const observed = hasSqliteDependents(this.db, resourceKind, targetId);
|
|
17770
|
+
this.validators.push(() => hasSqliteDependents(this.db, resourceKind, targetId) === observed);
|
|
17771
|
+
return observed;
|
|
17772
|
+
}
|
|
17773
|
+
async deleteProject(id) {
|
|
17774
|
+
const project = await this.getProject(id);
|
|
17775
|
+
if (!project)
|
|
17776
|
+
return false;
|
|
17777
|
+
this.projects.set(id, null);
|
|
17778
|
+
this.mutations.push(() => {
|
|
17779
|
+
recordStorageTombstone({
|
|
17780
|
+
object_type: "projects",
|
|
17781
|
+
object_id: id,
|
|
17782
|
+
payload: project
|
|
17783
|
+
}, this.db);
|
|
17784
|
+
if (this.db.run("DELETE FROM projects WHERE id = ?", [id]).changes < 1) {
|
|
17785
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project changed before SQLite compensation commit");
|
|
17786
|
+
}
|
|
17787
|
+
});
|
|
17788
|
+
return true;
|
|
17789
|
+
}
|
|
17790
|
+
async deleteTaskList(id) {
|
|
17791
|
+
const taskList = await this.getTaskList(id);
|
|
17792
|
+
if (!taskList)
|
|
17793
|
+
return false;
|
|
17794
|
+
this.taskLists.set(id, null);
|
|
17795
|
+
this.mutations.push(() => {
|
|
17796
|
+
recordStorageTombstone({
|
|
17797
|
+
object_type: "task_lists",
|
|
17798
|
+
object_id: id,
|
|
17799
|
+
payload: taskList
|
|
17800
|
+
}, this.db);
|
|
17801
|
+
if (this.db.run("DELETE FROM task_lists WHERE id = ?", [id]).changes < 1) {
|
|
17802
|
+
throw new SqliteRegistrationOptimisticConflict("Todos task list changed before SQLite compensation commit");
|
|
17803
|
+
}
|
|
17804
|
+
});
|
|
17805
|
+
return true;
|
|
17806
|
+
}
|
|
17807
|
+
bindingKey(scope, resourceKind, targetSelector) {
|
|
17808
|
+
return JSON.stringify([
|
|
17809
|
+
scope.authority_id,
|
|
17810
|
+
scope.tenant_id,
|
|
17811
|
+
scope.corpus_id,
|
|
17812
|
+
resourceKind,
|
|
17813
|
+
targetSelector
|
|
17814
|
+
]);
|
|
17815
|
+
}
|
|
17816
|
+
async requireBinding(scope, resourceKind, targetSelector, state) {
|
|
17817
|
+
const binding = await this.getBinding(scope, resourceKind, targetSelector);
|
|
17818
|
+
if (!binding || binding.state !== state) {
|
|
17819
|
+
throw new Error(`Todos project registration binding was not ${state}`);
|
|
17820
|
+
}
|
|
17821
|
+
return binding;
|
|
17822
|
+
}
|
|
17823
|
+
availableProjectPrefix(name) {
|
|
17824
|
+
const words = name.replace(/[^a-zA-Z0-9\s]/g, "").trim().split(/\s+/);
|
|
17825
|
+
const prefix = words.length >= 3 ? words.slice(0, 3).map((word) => word[0].toUpperCase()).join("") : words.length === 2 ? (words[0].slice(0, 2) + words[1][0]).toUpperCase() : words[0].slice(0, 3).toUpperCase();
|
|
17826
|
+
let candidate = prefix;
|
|
17827
|
+
let suffix = 1;
|
|
17828
|
+
while (this.db.query("SELECT id FROM projects WHERE task_prefix = ? LIMIT 1").get(candidate) || [...this.projects.values()].some((project) => project?.task_prefix === candidate)) {
|
|
17829
|
+
suffix += 1;
|
|
17830
|
+
candidate = `${prefix}${suffix}`;
|
|
17831
|
+
}
|
|
17832
|
+
return candidate;
|
|
17833
|
+
}
|
|
17834
|
+
}
|
|
17835
|
+
|
|
16565
17836
|
class SqliteTodosProjectRegistrationBackend {
|
|
16566
17837
|
db;
|
|
16567
17838
|
kind = "sqlite";
|
|
@@ -16580,15 +17851,19 @@ class SqliteTodosProjectRegistrationBackend {
|
|
|
16580
17851
|
sqliteTransactionTails.set(this.db, current);
|
|
16581
17852
|
await previous;
|
|
16582
17853
|
try {
|
|
16583
|
-
|
|
16584
|
-
|
|
16585
|
-
|
|
16586
|
-
|
|
16587
|
-
|
|
16588
|
-
|
|
16589
|
-
|
|
16590
|
-
|
|
16591
|
-
|
|
17854
|
+
for (let attempt = 0;attempt < SQLITE_TRANSACTION_RETRY_LIMIT; attempt += 1) {
|
|
17855
|
+
const transaction = new StagedSqliteTodosProjectRegistrationTransaction(this.db);
|
|
17856
|
+
const result = await fn(transaction);
|
|
17857
|
+
try {
|
|
17858
|
+
transaction.commit();
|
|
17859
|
+
return result;
|
|
17860
|
+
} catch (error) {
|
|
17861
|
+
if (!(error instanceof SqliteRegistrationOptimisticConflict) || attempt === SQLITE_TRANSACTION_RETRY_LIMIT - 1) {
|
|
17862
|
+
throw error;
|
|
17863
|
+
}
|
|
17864
|
+
}
|
|
17865
|
+
}
|
|
17866
|
+
throw new SqliteRegistrationOptimisticConflict("Todos project registration exhausted SQLite optimistic retries");
|
|
16592
17867
|
} finally {
|
|
16593
17868
|
release();
|
|
16594
17869
|
if (sqliteTransactionTails.get(this.db) === current) {
|
|
@@ -16647,7 +17922,7 @@ function canonicalize2(value) {
|
|
|
16647
17922
|
return out;
|
|
16648
17923
|
}
|
|
16649
17924
|
function digestProjectRegistrationValue(value) {
|
|
16650
|
-
return
|
|
17925
|
+
return createHash5("sha256").update(canonicalProjectRegistrationJson(value)).digest("hex");
|
|
16651
17926
|
}
|
|
16652
17927
|
function deriveTodosProjectRegistrationIdempotencyKey(input) {
|
|
16653
17928
|
return `prk_${digestProjectRegistrationValue({
|