@hasna/todos 0.11.88 → 0.11.89
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 +10 -2
- package/dist/cli/cloud-router.d.ts.map +1 -1
- package/dist/cli/commands/agent-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 +950 -118
- package/dist/contracts.js +301 -72
- package/dist/db/projects.d.ts +2 -2
- package/dist/db/projects.d.ts.map +1 -1
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/slug-claims.d.ts +11 -0
- package/dist/db/slug-claims.d.ts.map +1 -0
- package/dist/db/task-lists.d.ts.map +1 -1
- package/dist/index.js +641 -100
- package/dist/lib/slugs.d.ts +19 -0
- package/dist/lib/slugs.d.ts.map +1 -0
- package/dist/mcp/index.js +897 -102
- package/dist/registry.js +301 -72
- package/dist/release-provenance.json +3 -3
- package/dist/sdk/index.js +56 -0
- package/dist/sdk/v1.generated.d.ts +79 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +2 -0
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +906 -108
- package/dist/server/openapi.d.ts +464 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/interfaces.d.ts +3 -2
- 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 +30 -0
- package/dist/storage/postgres-sync.d.ts.map +1 -1
- package/dist/storage/shadow.d.ts.map +1 -1
- package/dist/storage/sqlite-snapshot.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +635 -88
- package/dist/types/index.d.ts +19 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/storage.js
CHANGED
|
@@ -247,7 +247,7 @@ var init_config = __esm(() => {
|
|
|
247
247
|
});
|
|
248
248
|
|
|
249
249
|
// src/types/index.ts
|
|
250
|
-
var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, PlanNotFoundError, LockError, AgentNotFoundError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
|
|
250
|
+
var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
|
|
251
251
|
var init_types = __esm(() => {
|
|
252
252
|
TASK_STATUSES = [
|
|
253
253
|
"pending",
|
|
@@ -297,6 +297,14 @@ var init_types = __esm(() => {
|
|
|
297
297
|
this.name = "ProjectNotFoundError";
|
|
298
298
|
}
|
|
299
299
|
};
|
|
300
|
+
ResourceConflictError = class ResourceConflictError extends Error {
|
|
301
|
+
code;
|
|
302
|
+
constructor(code, message) {
|
|
303
|
+
super(message);
|
|
304
|
+
this.code = code;
|
|
305
|
+
this.name = "ResourceConflictError";
|
|
306
|
+
}
|
|
307
|
+
};
|
|
300
308
|
PlanNotFoundError = class PlanNotFoundError extends Error {
|
|
301
309
|
planId;
|
|
302
310
|
static code = "PLAN_NOT_FOUND";
|
|
@@ -2085,6 +2093,95 @@ function ensureSchema(db) {
|
|
|
2085
2093
|
)`);
|
|
2086
2094
|
ensureIndex("CREATE INDEX IF NOT EXISTS idx_project_machine_paths_project ON project_machine_paths(project_id)");
|
|
2087
2095
|
ensureIndex("CREATE INDEX IF NOT EXISTS idx_project_machine_paths_machine ON project_machine_paths(machine_id)");
|
|
2096
|
+
ensureTable("canonical_slug_claims", `
|
|
2097
|
+
CREATE TABLE canonical_slug_claims (
|
|
2098
|
+
kind TEXT NOT NULL CHECK(kind IN ('project', 'task_list')),
|
|
2099
|
+
scope_key TEXT NOT NULL,
|
|
2100
|
+
slug TEXT NOT NULL,
|
|
2101
|
+
object_id TEXT NOT NULL,
|
|
2102
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
2103
|
+
PRIMARY KEY (kind, scope_key, slug)
|
|
2104
|
+
)`);
|
|
2105
|
+
ensureIndex("CREATE INDEX IF NOT EXISTS idx_canonical_slug_claims_object ON canonical_slug_claims(kind, object_id)");
|
|
2106
|
+
ensureColumn("projects", "task_list_id", "TEXT");
|
|
2107
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS claim_project_canonical_slug_insert
|
|
2108
|
+
BEFORE INSERT ON projects
|
|
2109
|
+
WHEN NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> ''
|
|
2110
|
+
BEGIN
|
|
2111
|
+
SELECT CASE WHEN EXISTS (
|
|
2112
|
+
SELECT 1 FROM projects WHERE id <> NEW.id AND task_list_id = NEW.task_list_id
|
|
2113
|
+
) THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
|
|
2114
|
+
INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
|
|
2115
|
+
VALUES ('project', 'global', NEW.task_list_id, NEW.id);
|
|
2116
|
+
SELECT CASE WHEN (
|
|
2117
|
+
SELECT object_id FROM canonical_slug_claims
|
|
2118
|
+
WHERE kind = 'project' AND scope_key = 'global' AND slug = NEW.task_list_id
|
|
2119
|
+
) <> NEW.id THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
|
|
2120
|
+
END`);
|
|
2121
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS claim_project_canonical_slug_update
|
|
2122
|
+
BEFORE UPDATE OF task_list_id ON projects
|
|
2123
|
+
WHEN NEW.task_list_id IS NOT OLD.task_list_id
|
|
2124
|
+
BEGIN
|
|
2125
|
+
DELETE FROM canonical_slug_claims WHERE kind = 'project' AND object_id = NEW.id;
|
|
2126
|
+
SELECT CASE WHEN EXISTS (
|
|
2127
|
+
SELECT 1 FROM projects WHERE id <> NEW.id AND task_list_id = NEW.task_list_id
|
|
2128
|
+
) AND NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> ''
|
|
2129
|
+
THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
|
|
2130
|
+
INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
|
|
2131
|
+
SELECT 'project', 'global', NEW.task_list_id, NEW.id
|
|
2132
|
+
WHERE NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> '';
|
|
2133
|
+
SELECT CASE WHEN NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> '' AND (
|
|
2134
|
+
SELECT object_id FROM canonical_slug_claims
|
|
2135
|
+
WHERE kind = 'project' AND scope_key = 'global' AND slug = NEW.task_list_id
|
|
2136
|
+
) <> NEW.id THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
|
|
2137
|
+
END`);
|
|
2138
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS release_project_canonical_slug_delete
|
|
2139
|
+
AFTER DELETE ON projects
|
|
2140
|
+
BEGIN
|
|
2141
|
+
DELETE FROM canonical_slug_claims WHERE kind = 'project' AND object_id = OLD.id;
|
|
2142
|
+
END`);
|
|
2143
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS claim_task_list_canonical_slug_insert
|
|
2144
|
+
BEFORE INSERT ON task_lists
|
|
2145
|
+
WHEN NEW.slug IS NOT NULL AND NEW.slug <> ''
|
|
2146
|
+
BEGIN
|
|
2147
|
+
SELECT CASE WHEN EXISTS (
|
|
2148
|
+
SELECT 1 FROM task_lists
|
|
2149
|
+
WHERE id <> NEW.id AND project_id IS NEW.project_id AND slug = NEW.slug
|
|
2150
|
+
) THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
|
|
2151
|
+
INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
|
|
2152
|
+
VALUES ('task_list', CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END, NEW.slug, NEW.id);
|
|
2153
|
+
SELECT CASE WHEN (
|
|
2154
|
+
SELECT object_id FROM canonical_slug_claims
|
|
2155
|
+
WHERE kind = 'task_list'
|
|
2156
|
+
AND scope_key = CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END
|
|
2157
|
+
AND slug = NEW.slug
|
|
2158
|
+
) <> NEW.id THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
|
|
2159
|
+
END`);
|
|
2160
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS claim_task_list_canonical_slug_update
|
|
2161
|
+
BEFORE UPDATE OF slug, project_id ON task_lists
|
|
2162
|
+
WHEN NEW.slug IS NOT OLD.slug OR NEW.project_id IS NOT OLD.project_id
|
|
2163
|
+
BEGIN
|
|
2164
|
+
DELETE FROM canonical_slug_claims WHERE kind = 'task_list' AND object_id = NEW.id;
|
|
2165
|
+
SELECT CASE WHEN EXISTS (
|
|
2166
|
+
SELECT 1 FROM task_lists
|
|
2167
|
+
WHERE id <> NEW.id AND project_id IS NEW.project_id AND slug = NEW.slug
|
|
2168
|
+
) AND NEW.slug IS NOT NULL AND NEW.slug <> ''
|
|
2169
|
+
THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
|
|
2170
|
+
INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
|
|
2171
|
+
SELECT 'task_list', CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END, NEW.slug, NEW.id
|
|
2172
|
+
WHERE NEW.slug IS NOT NULL AND NEW.slug <> '';
|
|
2173
|
+
SELECT CASE WHEN NEW.slug IS NOT NULL AND NEW.slug <> '' AND (
|
|
2174
|
+
SELECT object_id FROM canonical_slug_claims
|
|
2175
|
+
WHERE kind = 'task_list'
|
|
2176
|
+
AND scope_key = CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END
|
|
2177
|
+
AND slug = NEW.slug
|
|
2178
|
+
) <> NEW.id THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
|
|
2179
|
+
END`);
|
|
2180
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS release_task_list_canonical_slug_delete
|
|
2181
|
+
AFTER DELETE ON task_lists
|
|
2182
|
+
BEGIN
|
|
2183
|
+
DELETE FROM canonical_slug_claims WHERE kind = 'task_list' AND object_id = OLD.id;
|
|
2184
|
+
END`);
|
|
2088
2185
|
ensureTable("storage_tombstones", `
|
|
2089
2186
|
CREATE TABLE storage_tombstones (
|
|
2090
2187
|
id TEXT PRIMARY KEY,
|
|
@@ -3496,9 +3593,94 @@ var init_storage_tombstones = __esm(() => {
|
|
|
3496
3593
|
init_machines();
|
|
3497
3594
|
});
|
|
3498
3595
|
|
|
3596
|
+
// src/lib/slugs.ts
|
|
3597
|
+
function normalizeSlug(value) {
|
|
3598
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
3599
|
+
}
|
|
3600
|
+
function isCanonicalSlug(value) {
|
|
3601
|
+
return typeof value === "string" && value.length > 0 && normalizeSlug(value) === value;
|
|
3602
|
+
}
|
|
3603
|
+
function isValidTaskListProjectScope(value) {
|
|
3604
|
+
return value === undefined || value === null || typeof value === "string" && value.trim().length > 0;
|
|
3605
|
+
}
|
|
3606
|
+
function validateSnapshotRoutingRecords(projects, taskLists) {
|
|
3607
|
+
const errors = [];
|
|
3608
|
+
const projectSlugs = new Map;
|
|
3609
|
+
const taskListSlugs = new Map;
|
|
3610
|
+
for (const project of projects) {
|
|
3611
|
+
if (!isCanonicalSlug(project.task_list_id)) {
|
|
3612
|
+
errors.push(`project ${project.id}: task_list_id must be non-empty canonical kebab-case`);
|
|
3613
|
+
continue;
|
|
3614
|
+
}
|
|
3615
|
+
const existing = projectSlugs.get(project.task_list_id);
|
|
3616
|
+
if (projectSlugs.has(project.task_list_id)) {
|
|
3617
|
+
errors.push(`project ${project.id}: task_list_id duplicates project ${existing}: ${project.task_list_id}`);
|
|
3618
|
+
} else {
|
|
3619
|
+
projectSlugs.set(project.task_list_id, project.id);
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
for (const taskList of taskLists) {
|
|
3623
|
+
if (!isCanonicalSlug(taskList.slug)) {
|
|
3624
|
+
errors.push(`task list ${taskList.id}: slug must be non-empty canonical kebab-case`);
|
|
3625
|
+
continue;
|
|
3626
|
+
}
|
|
3627
|
+
if (!isValidTaskListProjectScope(taskList.project_id)) {
|
|
3628
|
+
errors.push(`task list ${taskList.id}: project_id must be null, missing, or a non-empty string`);
|
|
3629
|
+
continue;
|
|
3630
|
+
}
|
|
3631
|
+
const scope = taskList.project_id ?? null;
|
|
3632
|
+
const scopedSlugs = taskListSlugs.get(scope) ?? new Map;
|
|
3633
|
+
const existing = scopedSlugs.get(taskList.slug);
|
|
3634
|
+
if (scopedSlugs.has(taskList.slug)) {
|
|
3635
|
+
errors.push(`task list ${taskList.id}: slug duplicates task list ${existing} in the same scope: ${taskList.slug}`);
|
|
3636
|
+
} else {
|
|
3637
|
+
scopedSlugs.set(taskList.slug, taskList.id);
|
|
3638
|
+
taskListSlugs.set(scope, scopedSlugs);
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
3641
|
+
return errors;
|
|
3642
|
+
}
|
|
3643
|
+
function validateSnapshotRoutingDestinationConflicts(projects, taskLists, existingProjects, existingTaskLists) {
|
|
3644
|
+
const errors = [];
|
|
3645
|
+
for (const project of projects) {
|
|
3646
|
+
const current = existingProjects.find((candidate) => candidate.id === project.id);
|
|
3647
|
+
if (current?.task_list_id === project.task_list_id)
|
|
3648
|
+
continue;
|
|
3649
|
+
const conflict = existingProjects.find((candidate) => candidate.id !== project.id && candidate.task_list_id === project.task_list_id);
|
|
3650
|
+
if (conflict) {
|
|
3651
|
+
errors.push(`project ${project.id}: task_list_id conflicts with existing project ${conflict.id}: ${String(project.task_list_id)}`);
|
|
3652
|
+
}
|
|
3653
|
+
}
|
|
3654
|
+
for (const taskList of taskLists) {
|
|
3655
|
+
const projectId = taskList.project_id ?? null;
|
|
3656
|
+
const current = existingTaskLists.find((candidate) => candidate.id === taskList.id);
|
|
3657
|
+
if ((current?.project_id ?? null) === projectId && current?.slug === taskList.slug)
|
|
3658
|
+
continue;
|
|
3659
|
+
const conflict = existingTaskLists.find((candidate) => candidate.id !== taskList.id && (candidate.project_id ?? null) === projectId && candidate.slug === taskList.slug);
|
|
3660
|
+
if (conflict) {
|
|
3661
|
+
errors.push(`task list ${taskList.id}: slug conflicts with existing task list ${conflict.id} in the same scope: ${String(taskList.slug)}`);
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
return errors;
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3667
|
+
// src/db/slug-claims.ts
|
|
3668
|
+
function taskListSlugScopeKey(projectId) {
|
|
3669
|
+
return projectId ? `project:${projectId}` : "standalone:";
|
|
3670
|
+
}
|
|
3671
|
+
function claimCanonicalSlug(kind, scopeKey, slug, objectId, db) {
|
|
3672
|
+
db.run(`INSERT OR IGNORE INTO canonical_slug_claims (kind, scope_key, slug, object_id)
|
|
3673
|
+
VALUES (?, ?, ?, ?)`, [kind, scopeKey, slug, objectId]);
|
|
3674
|
+
const claim = db.query("SELECT object_id FROM canonical_slug_claims WHERE kind = ? AND scope_key = ? AND slug = ?").get(kind, scopeKey, slug);
|
|
3675
|
+
return claim?.object_id === objectId;
|
|
3676
|
+
}
|
|
3677
|
+
function releaseCanonicalSlugClaims(kind, objectId, db) {
|
|
3678
|
+
db.run("DELETE FROM canonical_slug_claims WHERE kind = ? AND object_id = ?", [kind, objectId]);
|
|
3679
|
+
}
|
|
3680
|
+
|
|
3499
3681
|
// src/db/projects.ts
|
|
3500
3682
|
function slugify(name) {
|
|
3501
|
-
return name
|
|
3683
|
+
return normalizeSlug(name);
|
|
3502
3684
|
}
|
|
3503
3685
|
function generatePrefix(name, db) {
|
|
3504
3686
|
const words = name.replace(/[^a-zA-Z0-9\s]/g, "").trim().split(/\s+/);
|
|
@@ -3522,14 +3704,23 @@ function generatePrefix(name, db) {
|
|
|
3522
3704
|
}
|
|
3523
3705
|
function createProject(input, db) {
|
|
3524
3706
|
const d = db || getDatabase();
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3707
|
+
return d.transaction(() => {
|
|
3708
|
+
const id = uuid();
|
|
3709
|
+
const timestamp = now();
|
|
3710
|
+
const derivedSlug = slugify(input.name);
|
|
3711
|
+
const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugify(input.task_list_id);
|
|
3712
|
+
if (!derivedSlug || !taskListId)
|
|
3713
|
+
throw new Error("Project name and task-list slug must be non-empty");
|
|
3714
|
+
const slugConflict = d.query("SELECT id FROM projects WHERE task_list_id = ? LIMIT 1").get(taskListId);
|
|
3715
|
+
if (slugConflict || !claimCanonicalSlug("project", "global", taskListId, id, d)) {
|
|
3716
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${taskListId}" already exists`);
|
|
3717
|
+
}
|
|
3718
|
+
const taskPrefix = input.task_prefix || generatePrefix(input.name, d);
|
|
3719
|
+
const machineId = currentStorageMachineId(d);
|
|
3720
|
+
d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, created_at, updated_at, machine_id)
|
|
3721
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, timestamp, timestamp, machineId]);
|
|
3722
|
+
return getProject(id, d);
|
|
3723
|
+
})();
|
|
3533
3724
|
}
|
|
3534
3725
|
function getProject(id, db) {
|
|
3535
3726
|
const d = db || getDatabase();
|
|
@@ -3557,6 +3748,9 @@ function updateProject(id, input, db) {
|
|
|
3557
3748
|
const project = getProject(id, d);
|
|
3558
3749
|
if (!project)
|
|
3559
3750
|
throw new ProjectNotFoundError(id);
|
|
3751
|
+
if ("task_list_id" in input) {
|
|
3752
|
+
throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
|
|
3753
|
+
}
|
|
3560
3754
|
const sets = ["updated_at = ?"];
|
|
3561
3755
|
const params = [now()];
|
|
3562
3756
|
if (input.name !== undefined) {
|
|
@@ -3567,10 +3761,6 @@ function updateProject(id, input, db) {
|
|
|
3567
3761
|
sets.push("description = ?");
|
|
3568
3762
|
params.push(input.description);
|
|
3569
3763
|
}
|
|
3570
|
-
if (input.task_list_id !== undefined) {
|
|
3571
|
-
sets.push("task_list_id = ?");
|
|
3572
|
-
params.push(input.task_list_id);
|
|
3573
|
-
}
|
|
3574
3764
|
if (input.path !== undefined) {
|
|
3575
3765
|
sets.push("path = ?");
|
|
3576
3766
|
params.push(input.path);
|
|
@@ -3581,29 +3771,41 @@ function updateProject(id, input, db) {
|
|
|
3581
3771
|
}
|
|
3582
3772
|
function renameProject(id, input, db) {
|
|
3583
3773
|
const d = db || getDatabase();
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3774
|
+
return d.transaction(() => {
|
|
3775
|
+
const project = getProject(id, d);
|
|
3776
|
+
if (!project)
|
|
3777
|
+
throw new ProjectNotFoundError(id);
|
|
3778
|
+
let taskListsUpdated = 0;
|
|
3779
|
+
const ts = now();
|
|
3780
|
+
if (input.new_slug !== undefined) {
|
|
3781
|
+
const normalised = normalizeSlug(input.new_slug);
|
|
3782
|
+
if (!normalised)
|
|
3783
|
+
throw new Error("Invalid slug \u2014 must be non-empty kebab-case");
|
|
3784
|
+
const oldSlug = project.task_list_id;
|
|
3785
|
+
if (normalised !== oldSlug) {
|
|
3786
|
+
const conflict = d.query("SELECT id FROM projects WHERE task_list_id = ? AND id != ?").get(normalised, id);
|
|
3787
|
+
if (conflict) {
|
|
3788
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalised}" is already used by another project`);
|
|
3789
|
+
}
|
|
3790
|
+
const taskListConflict = d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ? AND slug != COALESCE(?, '') LIMIT 1").get(id, normalised, oldSlug);
|
|
3791
|
+
if (taskListConflict) {
|
|
3792
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalised}" is already used in project "${project.name}"`);
|
|
3793
|
+
}
|
|
3794
|
+
releaseCanonicalSlugClaims("project", id, d);
|
|
3795
|
+
if (!claimCanonicalSlug("project", "global", normalised, id, d)) {
|
|
3796
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalised}" is already used by another project`);
|
|
3797
|
+
}
|
|
3798
|
+
d.run("UPDATE projects SET task_list_id = ?, updated_at = ? WHERE id = ?", [normalised, ts, id]);
|
|
3799
|
+
}
|
|
3800
|
+
if (oldSlug && (normalised !== oldSlug || input.name !== undefined && input.name !== project.name)) {
|
|
3801
|
+
taskListsUpdated = d.query("UPDATE task_lists SET slug = ?, name = COALESCE(?, name), updated_at = ? WHERE project_id = ? AND slug = ? RETURNING id").all(normalised, input.name ?? null, ts, id, oldSlug).length;
|
|
3802
|
+
}
|
|
3601
3803
|
}
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3804
|
+
if (input.name !== undefined && input.name !== project.name) {
|
|
3805
|
+
d.run("UPDATE projects SET name = ?, updated_at = ? WHERE id = ?", [input.name, ts, id]);
|
|
3806
|
+
}
|
|
3807
|
+
return { project: getProject(id, d), task_lists_updated: taskListsUpdated };
|
|
3808
|
+
})();
|
|
3607
3809
|
}
|
|
3608
3810
|
function deleteProject(id, db) {
|
|
3609
3811
|
const d = db || getDatabase();
|
|
@@ -3615,8 +3817,10 @@ function deleteProject(id, db) {
|
|
|
3615
3817
|
object_id: id,
|
|
3616
3818
|
payload: project
|
|
3617
3819
|
}, d);
|
|
3618
|
-
|
|
3619
|
-
|
|
3820
|
+
return d.transaction(() => {
|
|
3821
|
+
releaseCanonicalSlugClaims("project", id, d);
|
|
3822
|
+
return d.run("DELETE FROM projects WHERE id = ?", [id]).changes > 0;
|
|
3823
|
+
})();
|
|
3620
3824
|
}
|
|
3621
3825
|
function rowToSource(row) {
|
|
3622
3826
|
return {
|
|
@@ -5175,19 +5379,22 @@ function rowToTaskList(row) {
|
|
|
5175
5379
|
}
|
|
5176
5380
|
function createTaskList(input, db) {
|
|
5177
5381
|
const d = db || getDatabase();
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5382
|
+
return d.transaction(() => {
|
|
5383
|
+
const id = uuid();
|
|
5384
|
+
const timestamp = now();
|
|
5385
|
+
const slug = normalizeSlug(input.slug === undefined ? input.name : input.slug);
|
|
5386
|
+
if (!slug)
|
|
5387
|
+
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
5388
|
+
const machineId = currentStorageMachineId(d);
|
|
5389
|
+
const scopeKey = taskListSlugScopeKey(input.project_id);
|
|
5390
|
+
const existing = input.project_id ? d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ?").get(input.project_id, slug) : d.query("SELECT id FROM task_lists WHERE project_id IS NULL AND slug = ?").get(slug);
|
|
5391
|
+
if (existing || !claimCanonicalSlug("task_list", scopeKey, slug, id, d)) {
|
|
5392
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
|
|
5393
|
+
}
|
|
5394
|
+
d.run(`INSERT INTO task_lists (id, project_id, slug, name, description, metadata, created_at, updated_at, machine_id)
|
|
5395
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, input.project_id || null, slug, input.name, input.description || null, JSON.stringify(input.metadata || {}), timestamp, timestamp, machineId]);
|
|
5396
|
+
return getTaskList(id, d);
|
|
5397
|
+
})();
|
|
5191
5398
|
}
|
|
5192
5399
|
function getTaskList(id, db) {
|
|
5193
5400
|
const d = db || getDatabase();
|
|
@@ -5213,26 +5420,45 @@ function listTaskLists(projectId, db) {
|
|
|
5213
5420
|
}
|
|
5214
5421
|
function updateTaskList(id, input, db) {
|
|
5215
5422
|
const d = db || getDatabase();
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5423
|
+
return d.transaction(() => {
|
|
5424
|
+
const existing = getTaskList(id, d);
|
|
5425
|
+
if (!existing)
|
|
5426
|
+
throw new TaskListNotFoundError(id);
|
|
5427
|
+
const sets = ["updated_at = ?"];
|
|
5428
|
+
const params = [now()];
|
|
5429
|
+
if (input.slug !== undefined) {
|
|
5430
|
+
const slug = slugify(input.slug);
|
|
5431
|
+
if (!slug)
|
|
5432
|
+
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
5433
|
+
const duplicate = existing.project_id ? d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ? AND id != ?").get(existing.project_id, slug, id) : d.query("SELECT id FROM task_lists WHERE project_id IS NULL AND slug = ? AND id != ?").get(slug, id);
|
|
5434
|
+
if (duplicate) {
|
|
5435
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
|
|
5436
|
+
}
|
|
5437
|
+
if (slug !== existing.slug) {
|
|
5438
|
+
releaseCanonicalSlugClaims("task_list", id, d);
|
|
5439
|
+
if (!claimCanonicalSlug("task_list", taskListSlugScopeKey(existing.project_id), slug, id, d)) {
|
|
5440
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
|
|
5441
|
+
}
|
|
5442
|
+
}
|
|
5443
|
+
sets.push("slug = ?");
|
|
5444
|
+
params.push(slug);
|
|
5445
|
+
}
|
|
5446
|
+
if (input.name !== undefined) {
|
|
5447
|
+
sets.push("name = ?");
|
|
5448
|
+
params.push(input.name);
|
|
5449
|
+
}
|
|
5450
|
+
if (input.description !== undefined) {
|
|
5451
|
+
sets.push("description = ?");
|
|
5452
|
+
params.push(input.description);
|
|
5453
|
+
}
|
|
5454
|
+
if (input.metadata !== undefined) {
|
|
5455
|
+
sets.push("metadata = ?");
|
|
5456
|
+
params.push(JSON.stringify(input.metadata));
|
|
5457
|
+
}
|
|
5458
|
+
params.push(id);
|
|
5459
|
+
d.run(`UPDATE task_lists SET ${sets.join(", ")} WHERE id = ?`, params);
|
|
5460
|
+
return getTaskList(id, d);
|
|
5461
|
+
})();
|
|
5236
5462
|
}
|
|
5237
5463
|
function deleteTaskList(id, db) {
|
|
5238
5464
|
const d = db || getDatabase();
|
|
@@ -5244,7 +5470,10 @@ function deleteTaskList(id, db) {
|
|
|
5244
5470
|
object_id: id,
|
|
5245
5471
|
payload: list
|
|
5246
5472
|
}, d);
|
|
5247
|
-
return d.
|
|
5473
|
+
return d.transaction(() => {
|
|
5474
|
+
releaseCanonicalSlugClaims("task_list", id, d);
|
|
5475
|
+
return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
|
|
5476
|
+
})();
|
|
5248
5477
|
}
|
|
5249
5478
|
function ensureTaskList(name, slug, projectId, db) {
|
|
5250
5479
|
const d = db || getDatabase();
|
|
@@ -11364,6 +11593,14 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
11364
11593
|
skipped: 0,
|
|
11365
11594
|
errors: []
|
|
11366
11595
|
};
|
|
11596
|
+
result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
|
|
11597
|
+
if (result.errors.length === 0) {
|
|
11598
|
+
const existingProjects = d.query("SELECT id, task_list_id FROM projects").all();
|
|
11599
|
+
const existingTaskLists = d.query("SELECT id, project_id, slug FROM task_lists").all();
|
|
11600
|
+
result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
|
|
11601
|
+
}
|
|
11602
|
+
if (result.errors.length > 0)
|
|
11603
|
+
return result;
|
|
11367
11604
|
const applyRows = (objectType, table, columns, rows, updateClockColumn, afterUpsert) => {
|
|
11368
11605
|
for (const row of rows) {
|
|
11369
11606
|
try {
|
|
@@ -11571,6 +11808,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
11571
11808
|
getByPath: (path) => getProjectByPath(path, database()),
|
|
11572
11809
|
list: () => listProjects(database()),
|
|
11573
11810
|
update: (id, input) => updateProject(id, input, database()),
|
|
11811
|
+
rename: (id, input) => renameProject(id, input, database()),
|
|
11574
11812
|
delete: (id) => deleteProject(id, database())
|
|
11575
11813
|
},
|
|
11576
11814
|
plans: {
|
|
@@ -11668,6 +11906,110 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
|
|
|
11668
11906
|
)`
|
|
11669
11907
|
];
|
|
11670
11908
|
}
|
|
11909
|
+
|
|
11910
|
+
class PostgresScopedSlugMigrationConflictError extends Error {
|
|
11911
|
+
conflicts;
|
|
11912
|
+
constructor(conflicts) {
|
|
11913
|
+
const preview = conflicts.slice(0, 5).map((conflict) => `${conflict.object_type}:${conflict.scope || "global"}:${conflict.slug} [${conflict.object_ids.join(", ")}]`).join("; ");
|
|
11914
|
+
super(`Scoped slug unique-index preflight found ${conflicts.length} invalid or duplicate slug conflict(s): ${preview}. ` + "Resolve these records explicitly without deleting history, then rerun todos-serve migrate.");
|
|
11915
|
+
this.conflicts = conflicts;
|
|
11916
|
+
this.name = "PostgresScopedSlugMigrationConflictError";
|
|
11917
|
+
}
|
|
11918
|
+
}
|
|
11919
|
+
|
|
11920
|
+
class PostgresScopedSlugIndexBuildError extends Error {
|
|
11921
|
+
index_name;
|
|
11922
|
+
constructor(index_name, cause) {
|
|
11923
|
+
super(`Concurrent scoped-slug index build failed for ${index_name} after a clean duplicate audit. ` + "No records were rewritten; inspect pg_index for an invalid index, resolve any concurrent duplicate, and rerun todos-serve migrate.", { cause });
|
|
11924
|
+
this.index_name = index_name;
|
|
11925
|
+
this.name = "PostgresScopedSlugIndexBuildError";
|
|
11926
|
+
}
|
|
11927
|
+
}
|
|
11928
|
+
function postgresTodosScopedSlugPreflightSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
|
|
11929
|
+
assertSafeIdentifier(tableName);
|
|
11930
|
+
return `/* todos:scoped-slug-duplicate-audit */ WITH candidates AS (
|
|
11931
|
+
SELECT service, object_type, COALESCE(payload->>'project_id', '') AS scope,
|
|
11932
|
+
jsonb_typeof(payload->'project_id') AS scope_type,
|
|
11933
|
+
payload->>'slug' AS slug, jsonb_typeof(payload->'slug') AS slug_type, object_id
|
|
11934
|
+
FROM ${tableName}
|
|
11935
|
+
WHERE object_type = 'task_lists' AND deleted_at IS NULL
|
|
11936
|
+
UNION ALL
|
|
11937
|
+
SELECT service, object_type, '' AS scope, NULL::text AS scope_type, payload->>'task_list_id' AS slug,
|
|
11938
|
+
jsonb_typeof(payload->'task_list_id') AS slug_type, object_id
|
|
11939
|
+
FROM ${tableName}
|
|
11940
|
+
WHERE object_type = 'projects' AND deleted_at IS NULL
|
|
11941
|
+
), annotated AS (
|
|
11942
|
+
SELECT *, trim(both '-' from regexp_replace(lower(COALESCE(slug, '')), '[^a-z0-9]+', '-', 'g')) AS normalized_slug
|
|
11943
|
+
FROM candidates
|
|
11944
|
+
), invalid AS (
|
|
11945
|
+
SELECT service, object_type, scope, COALESCE(slug, '<null>') AS slug,
|
|
11946
|
+
ARRAY[object_id] AS object_ids, 1::integer AS duplicate_count, 'invalid'::text AS issue
|
|
11947
|
+
FROM annotated
|
|
11948
|
+
WHERE slug_type IS DISTINCT FROM 'string'
|
|
11949
|
+
OR slug IS NULL OR slug = '' OR normalized_slug = '' OR slug IS DISTINCT FROM normalized_slug
|
|
11950
|
+
OR (object_type = 'task_lists' AND (
|
|
11951
|
+
(scope_type IS NOT NULL AND scope_type NOT IN ('string', 'null'))
|
|
11952
|
+
OR (scope_type = 'string' AND btrim(scope) = '')
|
|
11953
|
+
))
|
|
11954
|
+
), duplicates AS (
|
|
11955
|
+
SELECT service, object_type, scope, slug,
|
|
11956
|
+
array_agg(object_id ORDER BY object_id) AS object_ids,
|
|
11957
|
+
count(*)::integer AS duplicate_count, 'duplicate'::text AS issue
|
|
11958
|
+
FROM annotated
|
|
11959
|
+
WHERE slug = normalized_slug AND slug <> ''
|
|
11960
|
+
GROUP BY service, object_type, scope, slug
|
|
11961
|
+
HAVING count(*) > 1
|
|
11962
|
+
) SELECT * FROM invalid
|
|
11963
|
+
UNION ALL SELECT * FROM duplicates
|
|
11964
|
+
ORDER BY service, object_type, scope, slug`;
|
|
11965
|
+
}
|
|
11966
|
+
function postgresTodosScopedSlugUniqueIndexSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
|
|
11967
|
+
assertSafeIdentifier(tableName);
|
|
11968
|
+
return [
|
|
11969
|
+
`CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_task_list_scope_slug_uidx
|
|
11970
|
+
ON ${tableName} (service, COALESCE(payload->>'project_id', ''), (payload->>'slug'))
|
|
11971
|
+
WHERE object_type = 'task_lists' AND deleted_at IS NULL AND COALESCE(payload->>'slug', '') <> ''`,
|
|
11972
|
+
`CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_project_task_list_slug_uidx
|
|
11973
|
+
ON ${tableName} (service, (payload->>'task_list_id'))
|
|
11974
|
+
WHERE object_type = 'projects' AND deleted_at IS NULL AND COALESCE(payload->>'task_list_id', '') <> ''`
|
|
11975
|
+
];
|
|
11976
|
+
}
|
|
11977
|
+
function postgresTodosScopedSlugIndexStatusSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
|
|
11978
|
+
assertSafeIdentifier(tableName);
|
|
11979
|
+
return `/* todos:scoped-slug-index-status */ SELECT index_class.relname AS index_name,
|
|
11980
|
+
index_meta.indisvalid AS is_valid, index_meta.indisready AS is_ready
|
|
11981
|
+
FROM pg_index index_meta
|
|
11982
|
+
JOIN pg_class index_class ON index_class.oid = index_meta.indexrelid
|
|
11983
|
+
WHERE index_meta.indrelid = to_regclass('${tableName}')
|
|
11984
|
+
AND index_class.relname IN (
|
|
11985
|
+
'${tableName}_task_list_scope_slug_uidx',
|
|
11986
|
+
'${tableName}_project_task_list_slug_uidx'
|
|
11987
|
+
)`;
|
|
11988
|
+
}
|
|
11989
|
+
async function ensurePostgresScopedSlugUniqueIndexes(client, tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
|
|
11990
|
+
const audit = await client.query(postgresTodosScopedSlugPreflightSql(tableName));
|
|
11991
|
+
if (audit.rows.length > 0)
|
|
11992
|
+
throw new PostgresScopedSlugMigrationConflictError(audit.rows);
|
|
11993
|
+
for (const sql of postgresTodosScopedSlugUniqueIndexSql(tableName)) {
|
|
11994
|
+
try {
|
|
11995
|
+
await client.query(sql);
|
|
11996
|
+
} catch (error) {
|
|
11997
|
+
const indexName = sql.match(/INDEX CONCURRENTLY IF NOT EXISTS ([a-zA-Z0-9_]+)/)?.[1] ?? "unknown_index";
|
|
11998
|
+
throw new PostgresScopedSlugIndexBuildError(indexName, error);
|
|
11999
|
+
}
|
|
12000
|
+
}
|
|
12001
|
+
const expected = new Set([
|
|
12002
|
+
`${tableName}_task_list_scope_slug_uidx`,
|
|
12003
|
+
`${tableName}_project_task_list_slug_uidx`
|
|
12004
|
+
]);
|
|
12005
|
+
const status = await client.query(postgresTodosScopedSlugIndexStatusSql(tableName));
|
|
12006
|
+
for (const indexName of expected) {
|
|
12007
|
+
const row = status.rows.find((candidate) => candidate.index_name === indexName);
|
|
12008
|
+
if (!row?.is_valid || !row.is_ready) {
|
|
12009
|
+
throw new PostgresScopedSlugIndexBuildError(indexName, new Error("index is missing, invalid, or not ready"));
|
|
12010
|
+
}
|
|
12011
|
+
}
|
|
12012
|
+
}
|
|
11671
12013
|
function postgresTodosCommentCursorIndexSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
|
|
11672
12014
|
assertSafeIdentifier(tableName);
|
|
11673
12015
|
return `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_comment_task_created_idx
|
|
@@ -11696,9 +12038,31 @@ class PostgresTodosSyncStore {
|
|
|
11696
12038
|
}
|
|
11697
12039
|
}
|
|
11698
12040
|
async pushSnapshot(snapshot, context = {}) {
|
|
12041
|
+
const routingErrors = validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists);
|
|
12042
|
+
if (routingErrors.length > 0) {
|
|
12043
|
+
throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
|
|
12044
|
+
}
|
|
12045
|
+
const existing = await this.client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
|
|
12046
|
+
FROM ${this.tableName}
|
|
12047
|
+
WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
|
|
12048
|
+
const existingProjects = [];
|
|
12049
|
+
const existingTaskLists = [];
|
|
12050
|
+
for (const row of existing.rows) {
|
|
12051
|
+
const payload = payloadRecord(row.payload);
|
|
12052
|
+
if (row.object_type === "projects")
|
|
12053
|
+
existingProjects.push(payload);
|
|
12054
|
+
if (row.object_type === "task_lists")
|
|
12055
|
+
existingTaskLists.push(payload);
|
|
12056
|
+
}
|
|
12057
|
+
const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
|
|
12058
|
+
if (destinationErrors.length > 0) {
|
|
12059
|
+
throw new Error(`Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
|
|
12060
|
+
}
|
|
11699
12061
|
const result = { records: 0, objectTypes: {} };
|
|
11700
12062
|
const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
|
|
11701
12063
|
for (const entry of snapshotEntries(snapshot)) {
|
|
12064
|
+
if (entry.deletedAt === null)
|
|
12065
|
+
assertCanonicalScopedSlugEntry(entry);
|
|
11702
12066
|
await this.client.query(`INSERT INTO ${this.tableName} (
|
|
11703
12067
|
service, object_type, object_id, payload, updated_at,
|
|
11704
12068
|
deleted_at, source_machine_id, version
|
|
@@ -11777,6 +12141,22 @@ function snapshotEntries(snapshot) {
|
|
|
11777
12141
|
}))
|
|
11778
12142
|
];
|
|
11779
12143
|
}
|
|
12144
|
+
function assertCanonicalScopedSlugEntry(entry) {
|
|
12145
|
+
if (!entry.payload || typeof entry.payload !== "object" || Array.isArray(entry.payload))
|
|
12146
|
+
return;
|
|
12147
|
+
const payload = entry.payload;
|
|
12148
|
+
if (entry.type === "projects" && !isCanonicalSlug(payload["task_list_id"])) {
|
|
12149
|
+
throw new Error("Invalid project task-list slug \u2014 sync requires non-empty canonical kebab-case");
|
|
12150
|
+
}
|
|
12151
|
+
if (entry.type === "task_lists") {
|
|
12152
|
+
if (!isCanonicalSlug(payload["slug"])) {
|
|
12153
|
+
throw new Error("Invalid task-list slug \u2014 sync requires non-empty canonical kebab-case");
|
|
12154
|
+
}
|
|
12155
|
+
if (!isValidTaskListProjectScope(payload["project_id"])) {
|
|
12156
|
+
throw new Error("Invalid task-list project scope \u2014 project_id must be null, missing, or a non-empty string");
|
|
12157
|
+
}
|
|
12158
|
+
}
|
|
12159
|
+
}
|
|
11780
12160
|
function entry(type, payload, fallbackUpdatedAt) {
|
|
11781
12161
|
const id = payload["id"];
|
|
11782
12162
|
if (typeof id !== "string" || !id)
|
|
@@ -11964,6 +12344,7 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
11964
12344
|
getByPath: async (path) => (await store.list("projects")).find((project) => project.path === path) ?? null,
|
|
11965
12345
|
list: async () => (await store.list("projects")).sort((a, b) => a.name.localeCompare(b.name)),
|
|
11966
12346
|
update: (id, input) => updateProject2(id, input, store),
|
|
12347
|
+
rename: (id, input, context) => store.renameProject(id, input.new_slug, input.name, context),
|
|
11967
12348
|
delete: (id, context) => store.delete("projects", id, context)
|
|
11968
12349
|
},
|
|
11969
12350
|
plans: {
|
|
@@ -12201,9 +12582,22 @@ class PostgresJsonRecordStore {
|
|
|
12201
12582
|
});
|
|
12202
12583
|
}
|
|
12203
12584
|
async upsert(type, value, context = {}) {
|
|
12585
|
+
if (type === "projects" && !isCanonicalSlug(value.task_list_id)) {
|
|
12586
|
+
throw new Error("Invalid project task-list slug \u2014 imports require non-empty canonical kebab-case");
|
|
12587
|
+
}
|
|
12588
|
+
if (type === "task_lists") {
|
|
12589
|
+
if (!isCanonicalSlug(value.slug)) {
|
|
12590
|
+
throw new Error("Invalid task-list slug \u2014 imports require non-empty canonical kebab-case");
|
|
12591
|
+
}
|
|
12592
|
+
if (!isValidTaskListProjectScope(value.project_id)) {
|
|
12593
|
+
throw new Error("Invalid task-list project scope \u2014 project_id must be null, missing, or a non-empty string");
|
|
12594
|
+
}
|
|
12595
|
+
}
|
|
12204
12596
|
await this.ensureSchema();
|
|
12205
12597
|
const updatedAt = stringValue2(value.updated_at) ?? stringValue2(value.created_at) ?? new Date().toISOString();
|
|
12206
|
-
|
|
12598
|
+
let result;
|
|
12599
|
+
try {
|
|
12600
|
+
result = await this.options.client.query(`INSERT INTO ${this.tableName} (
|
|
12207
12601
|
service, object_type, object_id, payload, updated_at,
|
|
12208
12602
|
deleted_at, source_machine_id, version
|
|
12209
12603
|
) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, NULL, $6, $7)
|
|
@@ -12218,14 +12612,23 @@ class PostgresJsonRecordStore {
|
|
|
12218
12612
|
OR (${this.tableName}.updated_at = EXCLUDED.updated_at
|
|
12219
12613
|
AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
|
|
12220
12614
|
RETURNING object_id`, [
|
|
12221
|
-
|
|
12222
|
-
|
|
12223
|
-
|
|
12224
|
-
|
|
12225
|
-
|
|
12226
|
-
|
|
12227
|
-
|
|
12228
|
-
|
|
12615
|
+
this.service,
|
|
12616
|
+
type,
|
|
12617
|
+
value.id,
|
|
12618
|
+
jsonbParam(value),
|
|
12619
|
+
updatedAt,
|
|
12620
|
+
context.requestId ?? this.sourceMachineId ?? null,
|
|
12621
|
+
numberValue2(value.version)
|
|
12622
|
+
]);
|
|
12623
|
+
} catch (error) {
|
|
12624
|
+
if (type === "task_lists" && isPostgresUniqueViolation(error)) {
|
|
12625
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${String(value.slug ?? "")}" already exists in this scope`);
|
|
12626
|
+
}
|
|
12627
|
+
if (type === "projects" && isPostgresUniqueViolation(error)) {
|
|
12628
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${String(value.task_list_id ?? "")}" already exists`);
|
|
12629
|
+
}
|
|
12630
|
+
throw error;
|
|
12631
|
+
}
|
|
12229
12632
|
if (result.rows.length === 0) {
|
|
12230
12633
|
const current = await this.get(type, value.id);
|
|
12231
12634
|
if (current)
|
|
@@ -12233,6 +12636,93 @@ class PostgresJsonRecordStore {
|
|
|
12233
12636
|
}
|
|
12234
12637
|
return value;
|
|
12235
12638
|
}
|
|
12639
|
+
async renameProject(id, newSlug, name, context = {}) {
|
|
12640
|
+
await this.ensureSchema();
|
|
12641
|
+
const normalizedSlug = slugifyRaw(newSlug);
|
|
12642
|
+
if (!normalizedSlug)
|
|
12643
|
+
throw new Error("Invalid slug \u2014 must be non-empty kebab-case");
|
|
12644
|
+
const timestamp = new Date().toISOString();
|
|
12645
|
+
try {
|
|
12646
|
+
const result = await this.options.client.query(`/* todos:rename-project-atomic */ WITH target AS (
|
|
12647
|
+
SELECT payload, payload->>'task_list_id' AS old_slug
|
|
12648
|
+
FROM ${this.tableName}
|
|
12649
|
+
WHERE service = $1 AND object_type = 'projects' AND object_id = $2 AND deleted_at IS NULL
|
|
12650
|
+
FOR UPDATE
|
|
12651
|
+
), project_conflict AS (
|
|
12652
|
+
SELECT 1 FROM ${this.tableName}
|
|
12653
|
+
WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
|
|
12654
|
+
AND deleted_at IS NULL AND payload->>'task_list_id' = $3 LIMIT 1
|
|
12655
|
+
), task_list_conflict AS (
|
|
12656
|
+
SELECT 1 FROM ${this.tableName} r, target
|
|
12657
|
+
WHERE r.service = $1 AND r.object_type = 'task_lists' AND r.deleted_at IS NULL
|
|
12658
|
+
AND r.payload->>'project_id' = $2 AND r.payload->>'slug' = $3
|
|
12659
|
+
AND r.payload->>'slug' IS DISTINCT FROM target.old_slug LIMIT 1
|
|
12660
|
+
), updated_lists AS (
|
|
12661
|
+
UPDATE ${this.tableName} r SET
|
|
12662
|
+
payload = r.payload || jsonb_build_object('slug', $3::text, 'updated_at', $5::text)
|
|
12663
|
+
|| CASE WHEN $4::text IS NULL THEN '{}'::jsonb ELSE jsonb_build_object('name', $4::text) END,
|
|
12664
|
+
updated_at = $5::timestamptz, version = COALESCE(r.version, 0) + 1,
|
|
12665
|
+
source_machine_id = COALESCE($6, r.source_machine_id)
|
|
12666
|
+
FROM target
|
|
12667
|
+
WHERE r.service = $1 AND r.object_type = 'task_lists' AND r.deleted_at IS NULL
|
|
12668
|
+
AND r.payload->>'project_id' = $2 AND r.payload->>'slug' = target.old_slug
|
|
12669
|
+
AND NOT EXISTS (SELECT 1 FROM project_conflict)
|
|
12670
|
+
AND NOT EXISTS (SELECT 1 FROM task_list_conflict)
|
|
12671
|
+
AND (target.old_slug IS DISTINCT FROM $3
|
|
12672
|
+
OR ($4::text IS NOT NULL AND r.payload->>'name' IS DISTINCT FROM $4))
|
|
12673
|
+
RETURNING 1
|
|
12674
|
+
), updated_project AS (
|
|
12675
|
+
UPDATE ${this.tableName} r SET
|
|
12676
|
+
payload = r.payload || jsonb_build_object('task_list_id', $3::text, 'updated_at', $5::text)
|
|
12677
|
+
|| CASE WHEN $4::text IS NULL THEN '{}'::jsonb ELSE jsonb_build_object('name', $4::text) END,
|
|
12678
|
+
updated_at = $5::timestamptz, version = COALESCE(r.version, 0) + 1,
|
|
12679
|
+
source_machine_id = COALESCE($6, r.source_machine_id)
|
|
12680
|
+
FROM target
|
|
12681
|
+
WHERE r.service = $1 AND r.object_type = 'projects' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
12682
|
+
AND NOT EXISTS (SELECT 1 FROM project_conflict)
|
|
12683
|
+
AND NOT EXISTS (SELECT 1 FROM task_list_conflict)
|
|
12684
|
+
AND (target.old_slug IS DISTINCT FROM $3
|
|
12685
|
+
OR ($4::text IS NOT NULL AND target.payload->>'name' IS DISTINCT FROM $4))
|
|
12686
|
+
RETURNING r.payload
|
|
12687
|
+
) SELECT
|
|
12688
|
+
EXISTS (SELECT 1 FROM target) AS found,
|
|
12689
|
+
EXISTS (SELECT 1 FROM project_conflict) AS project_conflict,
|
|
12690
|
+
EXISTS (SELECT 1 FROM task_list_conflict) AS task_list_conflict,
|
|
12691
|
+
COALESCE((SELECT payload FROM updated_project), (SELECT payload FROM target)) AS project,
|
|
12692
|
+
(SELECT count(*) FROM updated_lists) AS task_lists_updated`, [this.service, id, normalizedSlug, name ?? null, timestamp, this.machineId(context)]);
|
|
12693
|
+
const row = result.rows[0];
|
|
12694
|
+
if (!row?.found)
|
|
12695
|
+
throw new ProjectNotFoundError(id);
|
|
12696
|
+
if (row.project_conflict) {
|
|
12697
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalizedSlug}" is already used by another project`);
|
|
12698
|
+
}
|
|
12699
|
+
if (row.task_list_conflict) {
|
|
12700
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalizedSlug}" is already used in this project`);
|
|
12701
|
+
}
|
|
12702
|
+
return {
|
|
12703
|
+
project: payloadRecord2(row.project),
|
|
12704
|
+
task_lists_updated: Number(row.task_lists_updated)
|
|
12705
|
+
};
|
|
12706
|
+
} catch (error) {
|
|
12707
|
+
if (isPostgresUniqueViolation(error)) {
|
|
12708
|
+
const constraintName = postgresConstraintName(error);
|
|
12709
|
+
let projectConflict = constraintName.includes("project_task_list_slug_uidx");
|
|
12710
|
+
if (!constraintName) {
|
|
12711
|
+
const conflict = await this.options.client.query(`/* todos:classify-project-rename-conflict */ SELECT EXISTS (
|
|
12712
|
+
SELECT 1 FROM ${this.tableName}
|
|
12713
|
+
WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
|
|
12714
|
+
AND deleted_at IS NULL AND payload->>'task_list_id' = $3
|
|
12715
|
+
) AS project_conflict`, [this.service, id, normalizedSlug]);
|
|
12716
|
+
projectConflict = Boolean(conflict.rows[0]?.project_conflict);
|
|
12717
|
+
}
|
|
12718
|
+
if (projectConflict) {
|
|
12719
|
+
throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalizedSlug}" is already used by another project`);
|
|
12720
|
+
}
|
|
12721
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalizedSlug}" is already used in this project`);
|
|
12722
|
+
}
|
|
12723
|
+
throw error;
|
|
12724
|
+
}
|
|
12725
|
+
}
|
|
12236
12726
|
async incrementProjectTaskCounter(projectId, _context = {}) {
|
|
12237
12727
|
await this.ensureSchema();
|
|
12238
12728
|
const result = await this.options.client.query(`UPDATE ${this.tableName}
|
|
@@ -12654,12 +13144,16 @@ async function getChangedSince(since, filters, store) {
|
|
|
12654
13144
|
}
|
|
12655
13145
|
async function createProject2(input, store, context) {
|
|
12656
13146
|
const timestamp = new Date().toISOString();
|
|
13147
|
+
const derivedSlug = slugifyRaw(input.name);
|
|
13148
|
+
const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugifyRaw(input.task_list_id);
|
|
13149
|
+
if (!derivedSlug || !taskListId)
|
|
13150
|
+
throw new Error("Project name and task-list slug must be non-empty");
|
|
12657
13151
|
const project = {
|
|
12658
13152
|
id: randomUUID3(),
|
|
12659
13153
|
name: input.name,
|
|
12660
13154
|
path: input.path,
|
|
12661
13155
|
description: input.description ?? null,
|
|
12662
|
-
task_list_id:
|
|
13156
|
+
task_list_id: taskListId,
|
|
12663
13157
|
task_prefix: input.task_prefix ?? await generateProjectPrefix(input.name, store),
|
|
12664
13158
|
task_counter: 0,
|
|
12665
13159
|
created_at: timestamp,
|
|
@@ -12670,6 +13164,9 @@ async function createProject2(input, store, context) {
|
|
|
12670
13164
|
return store.upsert("projects", project, context);
|
|
12671
13165
|
}
|
|
12672
13166
|
async function updateProject2(id, input, store) {
|
|
13167
|
+
if ("task_list_id" in input) {
|
|
13168
|
+
throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
|
|
13169
|
+
}
|
|
12673
13170
|
const project = await requireRecord("projects", id, store);
|
|
12674
13171
|
const updated = { ...project, ...definedPatch(input), updated_at: new Date().toISOString() };
|
|
12675
13172
|
return store.upsert("projects", updated);
|
|
@@ -12778,10 +13275,13 @@ async function releaseAgent2(idOrName, sessionId, store, context) {
|
|
|
12778
13275
|
}
|
|
12779
13276
|
async function createTaskList2(input, store, context) {
|
|
12780
13277
|
const timestamp = new Date().toISOString();
|
|
13278
|
+
const slug = slugifyRaw(input.slug === undefined ? input.name : input.slug);
|
|
13279
|
+
if (!slug)
|
|
13280
|
+
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
12781
13281
|
return store.upsert("task_lists", {
|
|
12782
13282
|
id: randomUUID3(),
|
|
12783
13283
|
project_id: input.project_id ?? context?.projectId ?? null,
|
|
12784
|
-
slug
|
|
13284
|
+
slug,
|
|
12785
13285
|
name: input.name,
|
|
12786
13286
|
description: input.description ?? null,
|
|
12787
13287
|
metadata: input.metadata ?? {},
|
|
@@ -12793,9 +13293,20 @@ async function createTaskList2(input, store, context) {
|
|
|
12793
13293
|
}
|
|
12794
13294
|
async function updateTaskList2(id, input, store) {
|
|
12795
13295
|
const list = await requireRecord("task_lists", id, store);
|
|
13296
|
+
const patch = definedPatch(input);
|
|
13297
|
+
if (input.slug !== undefined) {
|
|
13298
|
+
const slug = slugifyRaw(input.slug);
|
|
13299
|
+
if (!slug)
|
|
13300
|
+
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
13301
|
+
const duplicate = (await store.list("task_lists")).find((candidate) => candidate.id !== id && candidate.project_id === list.project_id && candidate.slug === slug);
|
|
13302
|
+
if (duplicate) {
|
|
13303
|
+
throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
|
|
13304
|
+
}
|
|
13305
|
+
patch.slug = slug;
|
|
13306
|
+
}
|
|
12796
13307
|
return store.upsert("task_lists", {
|
|
12797
13308
|
...list,
|
|
12798
|
-
...
|
|
13309
|
+
...patch,
|
|
12799
13310
|
metadata: input.metadata ?? list.metadata,
|
|
12800
13311
|
updated_at: new Date().toISOString()
|
|
12801
13312
|
});
|
|
@@ -12879,6 +13390,16 @@ async function exportSnapshot(store) {
|
|
|
12879
13390
|
}
|
|
12880
13391
|
async function importSnapshot(snapshot, store, context) {
|
|
12881
13392
|
const result = { inserted: 0, updated: 0, deleted: 0, skipped: 0, errors: [] };
|
|
13393
|
+
result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
|
|
13394
|
+
if (result.errors.length > 0)
|
|
13395
|
+
return result;
|
|
13396
|
+
const [existingProjects, existingTaskLists] = await Promise.all([
|
|
13397
|
+
store.list("projects"),
|
|
13398
|
+
store.list("task_lists")
|
|
13399
|
+
]);
|
|
13400
|
+
result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
|
|
13401
|
+
if (result.errors.length > 0)
|
|
13402
|
+
return result;
|
|
12882
13403
|
const entries = [
|
|
12883
13404
|
...snapshot.tasks.map((row) => ["tasks", row]),
|
|
12884
13405
|
...snapshot.projects.map((row) => ["projects", row]),
|
|
@@ -12949,10 +13470,7 @@ async function generateProjectPrefix(name, store) {
|
|
|
12949
13470
|
return candidate;
|
|
12950
13471
|
}
|
|
12951
13472
|
function slugifyRaw(value) {
|
|
12952
|
-
return value
|
|
12953
|
-
}
|
|
12954
|
-
function slugify2(value) {
|
|
12955
|
-
return slugifyRaw(value) || "todos";
|
|
13473
|
+
return normalizeSlug(value);
|
|
12956
13474
|
}
|
|
12957
13475
|
function normalizePlanSlug2(value) {
|
|
12958
13476
|
const slug = slugifyRaw(value);
|
|
@@ -13006,6 +13524,16 @@ function compareClock(left, right) {
|
|
|
13006
13524
|
function numberValue2(value) {
|
|
13007
13525
|
return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
|
|
13008
13526
|
}
|
|
13527
|
+
function isPostgresUniqueViolation(error) {
|
|
13528
|
+
return typeof error === "object" && error !== null && error.code === "23505";
|
|
13529
|
+
}
|
|
13530
|
+
function postgresConstraintName(error) {
|
|
13531
|
+
if (typeof error !== "object" || error === null)
|
|
13532
|
+
return "";
|
|
13533
|
+
const candidate = error;
|
|
13534
|
+
const constraint = candidate.constraint ?? candidate.constraint_name;
|
|
13535
|
+
return typeof constraint === "string" ? constraint : "";
|
|
13536
|
+
}
|
|
13009
13537
|
|
|
13010
13538
|
// src/storage/shadow.ts
|
|
13011
13539
|
var SNAPSHOT_TO_OBJECT_TYPE = {
|
|
@@ -13271,6 +13799,19 @@ function createShadowTodosStorageAdapter(options) {
|
|
|
13271
13799
|
mirror.enqueueUpsert("projects", project, context);
|
|
13272
13800
|
return project;
|
|
13273
13801
|
},
|
|
13802
|
+
async rename(id, input, context) {
|
|
13803
|
+
const projectBefore = await local.projects.get(id, context);
|
|
13804
|
+
const cascadeCandidates = projectBefore?.task_list_id ? (await local.taskLists.list(id, context)).filter((list) => list.slug === projectBefore.task_list_id) : [];
|
|
13805
|
+
const result = await local.projects.rename(id, input, context);
|
|
13806
|
+
mirror.enqueueUpsert("projects", result.project, context);
|
|
13807
|
+
for (const candidate of cascadeCandidates) {
|
|
13808
|
+
const changed = await local.taskLists.get(candidate.id, context);
|
|
13809
|
+
if (changed && (changed.slug !== candidate.slug || changed.name !== candidate.name)) {
|
|
13810
|
+
mirror.enqueueUpsert("taskLists", changed, context);
|
|
13811
|
+
}
|
|
13812
|
+
}
|
|
13813
|
+
return result;
|
|
13814
|
+
},
|
|
13274
13815
|
async delete(id, context) {
|
|
13275
13816
|
const deleted = await local.projects.delete(id, context);
|
|
13276
13817
|
if (deleted)
|
|
@@ -14321,6 +14862,9 @@ export {
|
|
|
14321
14862
|
signAwsV4Request,
|
|
14322
14863
|
registerShadowExitFlush,
|
|
14323
14864
|
postgresTodosSyncSchemaSql,
|
|
14865
|
+
postgresTodosScopedSlugUniqueIndexSql,
|
|
14866
|
+
postgresTodosScopedSlugPreflightSql,
|
|
14867
|
+
postgresTodosScopedSlugIndexStatusSql,
|
|
14324
14868
|
postgresTodosCommentCursorIndexSql,
|
|
14325
14869
|
planRunArtifactsS3Sync,
|
|
14326
14870
|
parseStorageMode,
|
|
@@ -14343,6 +14887,7 @@ export {
|
|
|
14343
14887
|
getRuntimeShadowOutbox,
|
|
14344
14888
|
getCanonicalTodosRdsConfig,
|
|
14345
14889
|
exportSqliteTodosStorageSnapshot,
|
|
14890
|
+
ensurePostgresScopedSlugUniqueIndexes,
|
|
14346
14891
|
downloadRunArtifactsFromS3,
|
|
14347
14892
|
createTodosStorageAdapter,
|
|
14348
14893
|
createTodosShadowOutbox,
|
|
@@ -14366,6 +14911,8 @@ export {
|
|
|
14366
14911
|
TODOS_STORAGE_FALLBACK_ENV,
|
|
14367
14912
|
TODOS_STORAGE_ENV,
|
|
14368
14913
|
STORAGE_TABLES,
|
|
14914
|
+
PostgresScopedSlugMigrationConflictError,
|
|
14915
|
+
PostgresScopedSlugIndexBuildError,
|
|
14369
14916
|
COMMENT_REDACTION_BACKFILL_CONFIRMATION,
|
|
14370
14917
|
CANONICAL_TODOS_RDS_RUNTIME_PATH,
|
|
14371
14918
|
CANONICAL_TODOS_RDS_DATABASE,
|