@hasna/todos 0.11.88 → 0.11.90

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.
Files changed (44) hide show
  1. package/dist/cli/cloud-router.d.ts +10 -2
  2. package/dist/cli/cloud-router.d.ts.map +1 -1
  3. package/dist/cli/commands/agent-commands.d.ts.map +1 -1
  4. package/dist/cli/commands/project-commands.d.ts.map +1 -1
  5. package/dist/cli/commands/task-commands.d.ts.map +1 -1
  6. package/dist/cli/index.js +950 -118
  7. package/dist/contracts.js +301 -72
  8. package/dist/db/projects.d.ts +2 -2
  9. package/dist/db/projects.d.ts.map +1 -1
  10. package/dist/db/schema.d.ts.map +1 -1
  11. package/dist/db/slug-claims.d.ts +11 -0
  12. package/dist/db/slug-claims.d.ts.map +1 -0
  13. package/dist/db/task-lists.d.ts.map +1 -1
  14. package/dist/index.js +641 -100
  15. package/dist/lib/slugs.d.ts +19 -0
  16. package/dist/lib/slugs.d.ts.map +1 -0
  17. package/dist/mcp/index.js +897 -102
  18. package/dist/registry.js +301 -72
  19. package/dist/release-provenance.json +3 -3
  20. package/dist/sdk/index.js +56 -0
  21. package/dist/sdk/v1.generated.d.ts +79 -0
  22. package/dist/sdk/v1.generated.d.ts.map +1 -1
  23. package/dist/server/cloud.d.ts +2 -0
  24. package/dist/server/cloud.d.ts.map +1 -1
  25. package/dist/server/index.js +906 -108
  26. package/dist/server/openapi.d.ts +464 -0
  27. package/dist/server/openapi.d.ts.map +1 -1
  28. package/dist/server/v1.d.ts.map +1 -1
  29. package/dist/storage/index.d.ts +2 -2
  30. package/dist/storage/index.d.ts.map +1 -1
  31. package/dist/storage/interfaces.d.ts +3 -2
  32. package/dist/storage/interfaces.d.ts.map +1 -1
  33. package/dist/storage/local-sqlite.d.ts.map +1 -1
  34. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  35. package/dist/storage/postgres-sync.d.ts +30 -0
  36. package/dist/storage/postgres-sync.d.ts.map +1 -1
  37. package/dist/storage/shadow.d.ts.map +1 -1
  38. package/dist/storage/sqlite-snapshot.d.ts.map +1 -1
  39. package/dist/storage.d.ts +2 -2
  40. package/dist/storage.d.ts.map +1 -1
  41. package/dist/storage.js +635 -88
  42. package/dist/types/index.d.ts +19 -0
  43. package/dist/types/index.d.ts.map +1 -1
  44. 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.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
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
- const id = uuid();
3526
- const timestamp = now();
3527
- const taskListId = input.task_list_id ?? `todos-${slugify(input.name)}`;
3528
- const taskPrefix = input.task_prefix || generatePrefix(input.name, d);
3529
- const machineId = currentStorageMachineId(d);
3530
- d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, created_at, updated_at, machine_id)
3531
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, timestamp, timestamp, machineId]);
3532
- return getProject(id, d);
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
- const project = getProject(id, d);
3585
- if (!project)
3586
- throw new ProjectNotFoundError(id);
3587
- let taskListsUpdated = 0;
3588
- const ts = now();
3589
- if (input.new_slug !== undefined) {
3590
- const normalised = input.new_slug.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "");
3591
- if (!normalised)
3592
- throw new Error("Invalid slug \u2014 must be non-empty kebab-case");
3593
- const conflict = d.query("SELECT id FROM projects WHERE task_list_id = ? AND id != ?").get(normalised, id);
3594
- if (conflict)
3595
- throw new Error(`Slug "${normalised}" is already used by another project`);
3596
- const oldSlug = project.task_list_id;
3597
- d.run("UPDATE projects SET task_list_id = ?, updated_at = ? WHERE id = ?", [normalised, ts, id]);
3598
- if (oldSlug) {
3599
- const result = d.run("UPDATE task_lists SET slug = ?, name = COALESCE(?, name), updated_at = ? WHERE project_id = ? AND slug = ?", [normalised, input.name ?? null, ts, id, oldSlug]);
3600
- taskListsUpdated = result.changes;
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
- if (input.name !== undefined) {
3604
- d.run("UPDATE projects SET name = ?, updated_at = ? WHERE id = ?", [input.name, ts, id]);
3605
- }
3606
- return { project: getProject(id, d), task_lists_updated: taskListsUpdated };
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
- const result = d.run("DELETE FROM projects WHERE id = ?", [id]);
3619
- return result.changes > 0;
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
- const id = uuid();
5179
- const timestamp = now();
5180
- const slug = input.slug || slugify(input.name);
5181
- const machineId = currentStorageMachineId(d);
5182
- if (!input.project_id) {
5183
- const existing = d.query("SELECT id FROM task_lists WHERE project_id IS NULL AND slug = ?").get(slug);
5184
- if (existing) {
5185
- throw new Error(`Standalone task list with slug "${slug}" already exists`);
5186
- }
5187
- }
5188
- d.run(`INSERT INTO task_lists (id, project_id, slug, name, description, metadata, created_at, updated_at, machine_id)
5189
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, input.project_id || null, slug, input.name, input.description || null, JSON.stringify(input.metadata || {}), timestamp, timestamp, machineId]);
5190
- return getTaskList(id, d);
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
- const existing = getTaskList(id, d);
5217
- if (!existing)
5218
- throw new TaskListNotFoundError(id);
5219
- const sets = ["updated_at = ?"];
5220
- const params = [now()];
5221
- if (input.name !== undefined) {
5222
- sets.push("name = ?");
5223
- params.push(input.name);
5224
- }
5225
- if (input.description !== undefined) {
5226
- sets.push("description = ?");
5227
- params.push(input.description);
5228
- }
5229
- if (input.metadata !== undefined) {
5230
- sets.push("metadata = ?");
5231
- params.push(JSON.stringify(input.metadata));
5232
- }
5233
- params.push(id);
5234
- d.run(`UPDATE task_lists SET ${sets.join(", ")} WHERE id = ?`, params);
5235
- return getTaskList(id, d);
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.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
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
- const result = await this.options.client.query(`INSERT INTO ${this.tableName} (
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
- this.service,
12222
- type,
12223
- value.id,
12224
- jsonbParam(value),
12225
- updatedAt,
12226
- context.requestId ?? this.sourceMachineId ?? null,
12227
- numberValue2(value.version)
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: input.task_list_id ?? `todos-${slugify2(input.name)}`,
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: input.slug ?? slugify2(input.name),
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
- ...definedPatch(input),
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.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
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,