@hasna/todos 0.11.87 → 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.
Files changed (44) hide show
  1. package/dist/cli/cloud-router.d.ts +13 -3
  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 +1008 -125
  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/index.js CHANGED
@@ -1748,6 +1748,95 @@ function ensureSchema(db) {
1748
1748
  )`);
1749
1749
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_project_machine_paths_project ON project_machine_paths(project_id)");
1750
1750
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_project_machine_paths_machine ON project_machine_paths(machine_id)");
1751
+ ensureTable("canonical_slug_claims", `
1752
+ CREATE TABLE canonical_slug_claims (
1753
+ kind TEXT NOT NULL CHECK(kind IN ('project', 'task_list')),
1754
+ scope_key TEXT NOT NULL,
1755
+ slug TEXT NOT NULL,
1756
+ object_id TEXT NOT NULL,
1757
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1758
+ PRIMARY KEY (kind, scope_key, slug)
1759
+ )`);
1760
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_canonical_slug_claims_object ON canonical_slug_claims(kind, object_id)");
1761
+ ensureColumn("projects", "task_list_id", "TEXT");
1762
+ db.exec(`CREATE TRIGGER IF NOT EXISTS claim_project_canonical_slug_insert
1763
+ BEFORE INSERT ON projects
1764
+ WHEN NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> ''
1765
+ BEGIN
1766
+ SELECT CASE WHEN EXISTS (
1767
+ SELECT 1 FROM projects WHERE id <> NEW.id AND task_list_id = NEW.task_list_id
1768
+ ) THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
1769
+ INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
1770
+ VALUES ('project', 'global', NEW.task_list_id, NEW.id);
1771
+ SELECT CASE WHEN (
1772
+ SELECT object_id FROM canonical_slug_claims
1773
+ WHERE kind = 'project' AND scope_key = 'global' AND slug = NEW.task_list_id
1774
+ ) <> NEW.id THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
1775
+ END`);
1776
+ db.exec(`CREATE TRIGGER IF NOT EXISTS claim_project_canonical_slug_update
1777
+ BEFORE UPDATE OF task_list_id ON projects
1778
+ WHEN NEW.task_list_id IS NOT OLD.task_list_id
1779
+ BEGIN
1780
+ DELETE FROM canonical_slug_claims WHERE kind = 'project' AND object_id = NEW.id;
1781
+ SELECT CASE WHEN EXISTS (
1782
+ SELECT 1 FROM projects WHERE id <> NEW.id AND task_list_id = NEW.task_list_id
1783
+ ) AND NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> ''
1784
+ THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
1785
+ INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
1786
+ SELECT 'project', 'global', NEW.task_list_id, NEW.id
1787
+ WHERE NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> '';
1788
+ SELECT CASE WHEN NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> '' AND (
1789
+ SELECT object_id FROM canonical_slug_claims
1790
+ WHERE kind = 'project' AND scope_key = 'global' AND slug = NEW.task_list_id
1791
+ ) <> NEW.id THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
1792
+ END`);
1793
+ db.exec(`CREATE TRIGGER IF NOT EXISTS release_project_canonical_slug_delete
1794
+ AFTER DELETE ON projects
1795
+ BEGIN
1796
+ DELETE FROM canonical_slug_claims WHERE kind = 'project' AND object_id = OLD.id;
1797
+ END`);
1798
+ db.exec(`CREATE TRIGGER IF NOT EXISTS claim_task_list_canonical_slug_insert
1799
+ BEFORE INSERT ON task_lists
1800
+ WHEN NEW.slug IS NOT NULL AND NEW.slug <> ''
1801
+ BEGIN
1802
+ SELECT CASE WHEN EXISTS (
1803
+ SELECT 1 FROM task_lists
1804
+ WHERE id <> NEW.id AND project_id IS NEW.project_id AND slug = NEW.slug
1805
+ ) THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
1806
+ INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
1807
+ VALUES ('task_list', CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END, NEW.slug, NEW.id);
1808
+ SELECT CASE WHEN (
1809
+ SELECT object_id FROM canonical_slug_claims
1810
+ WHERE kind = 'task_list'
1811
+ AND scope_key = CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END
1812
+ AND slug = NEW.slug
1813
+ ) <> NEW.id THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
1814
+ END`);
1815
+ db.exec(`CREATE TRIGGER IF NOT EXISTS claim_task_list_canonical_slug_update
1816
+ BEFORE UPDATE OF slug, project_id ON task_lists
1817
+ WHEN NEW.slug IS NOT OLD.slug OR NEW.project_id IS NOT OLD.project_id
1818
+ BEGIN
1819
+ DELETE FROM canonical_slug_claims WHERE kind = 'task_list' AND object_id = NEW.id;
1820
+ SELECT CASE WHEN EXISTS (
1821
+ SELECT 1 FROM task_lists
1822
+ WHERE id <> NEW.id AND project_id IS NEW.project_id AND slug = NEW.slug
1823
+ ) AND NEW.slug IS NOT NULL AND NEW.slug <> ''
1824
+ THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
1825
+ INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
1826
+ SELECT 'task_list', CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END, NEW.slug, NEW.id
1827
+ WHERE NEW.slug IS NOT NULL AND NEW.slug <> '';
1828
+ SELECT CASE WHEN NEW.slug IS NOT NULL AND NEW.slug <> '' AND (
1829
+ SELECT object_id FROM canonical_slug_claims
1830
+ WHERE kind = 'task_list'
1831
+ AND scope_key = CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END
1832
+ AND slug = NEW.slug
1833
+ ) <> NEW.id THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
1834
+ END`);
1835
+ db.exec(`CREATE TRIGGER IF NOT EXISTS release_task_list_canonical_slug_delete
1836
+ AFTER DELETE ON task_lists
1837
+ BEGIN
1838
+ DELETE FROM canonical_slug_claims WHERE kind = 'task_list' AND object_id = OLD.id;
1839
+ END`);
1751
1840
  ensureTable("storage_tombstones", `
1752
1841
  CREATE TABLE storage_tombstones (
1753
1842
  id TEXT PRIMARY KEY,
@@ -3141,7 +3230,7 @@ var init_database = __esm(() => {
3141
3230
  });
3142
3231
 
3143
3232
  // src/types/index.ts
3144
- var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, PlanNotFoundError, LockError, AgentNotFoundError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
3233
+ var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
3145
3234
  var init_types = __esm(() => {
3146
3235
  TASK_STATUSES = [
3147
3236
  "pending",
@@ -3191,6 +3280,14 @@ var init_types = __esm(() => {
3191
3280
  this.name = "ProjectNotFoundError";
3192
3281
  }
3193
3282
  };
3283
+ ResourceConflictError = class ResourceConflictError extends Error {
3284
+ code;
3285
+ constructor(code, message) {
3286
+ super(message);
3287
+ this.code = code;
3288
+ this.name = "ResourceConflictError";
3289
+ }
3290
+ };
3194
3291
  PlanNotFoundError = class PlanNotFoundError extends Error {
3195
3292
  planId;
3196
3293
  static code = "PLAN_NOT_FOUND";
@@ -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();
@@ -22982,6 +23211,14 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
22982
23211
  skipped: 0,
22983
23212
  errors: []
22984
23213
  };
23214
+ result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
23215
+ if (result.errors.length === 0) {
23216
+ const existingProjects = d.query("SELECT id, task_list_id FROM projects").all();
23217
+ const existingTaskLists = d.query("SELECT id, project_id, slug FROM task_lists").all();
23218
+ result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
23219
+ }
23220
+ if (result.errors.length > 0)
23221
+ return result;
22985
23222
  const applyRows = (objectType, table, columns, rows, updateClockColumn, afterUpsert) => {
22986
23223
  for (const row of rows) {
22987
23224
  try {
@@ -23189,6 +23426,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
23189
23426
  getByPath: (path) => getProjectByPath(path, database()),
23190
23427
  list: () => listProjects(database()),
23191
23428
  update: (id, input) => updateProject(id, input, database()),
23429
+ rename: (id, input) => renameProject(id, input, database()),
23192
23430
  delete: (id) => deleteProject(id, database())
23193
23431
  },
23194
23432
  plans: {
@@ -23286,6 +23524,110 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
23286
23524
  )`
23287
23525
  ];
23288
23526
  }
23527
+
23528
+ class PostgresScopedSlugMigrationConflictError extends Error {
23529
+ conflicts;
23530
+ constructor(conflicts) {
23531
+ const preview = conflicts.slice(0, 5).map((conflict) => `${conflict.object_type}:${conflict.scope || "global"}:${conflict.slug} [${conflict.object_ids.join(", ")}]`).join("; ");
23532
+ 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.");
23533
+ this.conflicts = conflicts;
23534
+ this.name = "PostgresScopedSlugMigrationConflictError";
23535
+ }
23536
+ }
23537
+
23538
+ class PostgresScopedSlugIndexBuildError extends Error {
23539
+ index_name;
23540
+ constructor(index_name, cause) {
23541
+ 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 });
23542
+ this.index_name = index_name;
23543
+ this.name = "PostgresScopedSlugIndexBuildError";
23544
+ }
23545
+ }
23546
+ function postgresTodosScopedSlugPreflightSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
23547
+ assertSafeIdentifier(tableName);
23548
+ return `/* todos:scoped-slug-duplicate-audit */ WITH candidates AS (
23549
+ SELECT service, object_type, COALESCE(payload->>'project_id', '') AS scope,
23550
+ jsonb_typeof(payload->'project_id') AS scope_type,
23551
+ payload->>'slug' AS slug, jsonb_typeof(payload->'slug') AS slug_type, object_id
23552
+ FROM ${tableName}
23553
+ WHERE object_type = 'task_lists' AND deleted_at IS NULL
23554
+ UNION ALL
23555
+ SELECT service, object_type, '' AS scope, NULL::text AS scope_type, payload->>'task_list_id' AS slug,
23556
+ jsonb_typeof(payload->'task_list_id') AS slug_type, object_id
23557
+ FROM ${tableName}
23558
+ WHERE object_type = 'projects' AND deleted_at IS NULL
23559
+ ), annotated AS (
23560
+ SELECT *, trim(both '-' from regexp_replace(lower(COALESCE(slug, '')), '[^a-z0-9]+', '-', 'g')) AS normalized_slug
23561
+ FROM candidates
23562
+ ), invalid AS (
23563
+ SELECT service, object_type, scope, COALESCE(slug, '<null>') AS slug,
23564
+ ARRAY[object_id] AS object_ids, 1::integer AS duplicate_count, 'invalid'::text AS issue
23565
+ FROM annotated
23566
+ WHERE slug_type IS DISTINCT FROM 'string'
23567
+ OR slug IS NULL OR slug = '' OR normalized_slug = '' OR slug IS DISTINCT FROM normalized_slug
23568
+ OR (object_type = 'task_lists' AND (
23569
+ (scope_type IS NOT NULL AND scope_type NOT IN ('string', 'null'))
23570
+ OR (scope_type = 'string' AND btrim(scope) = '')
23571
+ ))
23572
+ ), duplicates AS (
23573
+ SELECT service, object_type, scope, slug,
23574
+ array_agg(object_id ORDER BY object_id) AS object_ids,
23575
+ count(*)::integer AS duplicate_count, 'duplicate'::text AS issue
23576
+ FROM annotated
23577
+ WHERE slug = normalized_slug AND slug <> ''
23578
+ GROUP BY service, object_type, scope, slug
23579
+ HAVING count(*) > 1
23580
+ ) SELECT * FROM invalid
23581
+ UNION ALL SELECT * FROM duplicates
23582
+ ORDER BY service, object_type, scope, slug`;
23583
+ }
23584
+ function postgresTodosScopedSlugUniqueIndexSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
23585
+ assertSafeIdentifier(tableName);
23586
+ return [
23587
+ `CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_task_list_scope_slug_uidx
23588
+ ON ${tableName} (service, COALESCE(payload->>'project_id', ''), (payload->>'slug'))
23589
+ WHERE object_type = 'task_lists' AND deleted_at IS NULL AND COALESCE(payload->>'slug', '') <> ''`,
23590
+ `CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_project_task_list_slug_uidx
23591
+ ON ${tableName} (service, (payload->>'task_list_id'))
23592
+ WHERE object_type = 'projects' AND deleted_at IS NULL AND COALESCE(payload->>'task_list_id', '') <> ''`
23593
+ ];
23594
+ }
23595
+ function postgresTodosScopedSlugIndexStatusSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
23596
+ assertSafeIdentifier(tableName);
23597
+ return `/* todos:scoped-slug-index-status */ SELECT index_class.relname AS index_name,
23598
+ index_meta.indisvalid AS is_valid, index_meta.indisready AS is_ready
23599
+ FROM pg_index index_meta
23600
+ JOIN pg_class index_class ON index_class.oid = index_meta.indexrelid
23601
+ WHERE index_meta.indrelid = to_regclass('${tableName}')
23602
+ AND index_class.relname IN (
23603
+ '${tableName}_task_list_scope_slug_uidx',
23604
+ '${tableName}_project_task_list_slug_uidx'
23605
+ )`;
23606
+ }
23607
+ async function ensurePostgresScopedSlugUniqueIndexes(client, tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
23608
+ const audit = await client.query(postgresTodosScopedSlugPreflightSql(tableName));
23609
+ if (audit.rows.length > 0)
23610
+ throw new PostgresScopedSlugMigrationConflictError(audit.rows);
23611
+ for (const sql of postgresTodosScopedSlugUniqueIndexSql(tableName)) {
23612
+ try {
23613
+ await client.query(sql);
23614
+ } catch (error) {
23615
+ const indexName = sql.match(/INDEX CONCURRENTLY IF NOT EXISTS ([a-zA-Z0-9_]+)/)?.[1] ?? "unknown_index";
23616
+ throw new PostgresScopedSlugIndexBuildError(indexName, error);
23617
+ }
23618
+ }
23619
+ const expected = new Set([
23620
+ `${tableName}_task_list_scope_slug_uidx`,
23621
+ `${tableName}_project_task_list_slug_uidx`
23622
+ ]);
23623
+ const status = await client.query(postgresTodosScopedSlugIndexStatusSql(tableName));
23624
+ for (const indexName of expected) {
23625
+ const row = status.rows.find((candidate) => candidate.index_name === indexName);
23626
+ if (!row?.is_valid || !row.is_ready) {
23627
+ throw new PostgresScopedSlugIndexBuildError(indexName, new Error("index is missing, invalid, or not ready"));
23628
+ }
23629
+ }
23630
+ }
23289
23631
  function postgresTodosCommentCursorIndexSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
23290
23632
  assertSafeIdentifier(tableName);
23291
23633
  return `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_comment_task_created_idx
@@ -23314,9 +23656,31 @@ class PostgresTodosSyncStore {
23314
23656
  }
23315
23657
  }
23316
23658
  async pushSnapshot(snapshot, context = {}) {
23659
+ const routingErrors = validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists);
23660
+ if (routingErrors.length > 0) {
23661
+ throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
23662
+ }
23663
+ const existing = await this.client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
23664
+ FROM ${this.tableName}
23665
+ WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
23666
+ const existingProjects = [];
23667
+ const existingTaskLists = [];
23668
+ for (const row of existing.rows) {
23669
+ const payload = payloadRecord(row.payload);
23670
+ if (row.object_type === "projects")
23671
+ existingProjects.push(payload);
23672
+ if (row.object_type === "task_lists")
23673
+ existingTaskLists.push(payload);
23674
+ }
23675
+ const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
23676
+ if (destinationErrors.length > 0) {
23677
+ throw new Error(`Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
23678
+ }
23317
23679
  const result = { records: 0, objectTypes: {} };
23318
23680
  const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
23319
23681
  for (const entry of snapshotEntries(snapshot)) {
23682
+ if (entry.deletedAt === null)
23683
+ assertCanonicalScopedSlugEntry(entry);
23320
23684
  await this.client.query(`INSERT INTO ${this.tableName} (
23321
23685
  service, object_type, object_id, payload, updated_at,
23322
23686
  deleted_at, source_machine_id, version
@@ -23395,6 +23759,22 @@ function snapshotEntries(snapshot) {
23395
23759
  }))
23396
23760
  ];
23397
23761
  }
23762
+ function assertCanonicalScopedSlugEntry(entry) {
23763
+ if (!entry.payload || typeof entry.payload !== "object" || Array.isArray(entry.payload))
23764
+ return;
23765
+ const payload = entry.payload;
23766
+ if (entry.type === "projects" && !isCanonicalSlug(payload["task_list_id"])) {
23767
+ throw new Error("Invalid project task-list slug \u2014 sync requires non-empty canonical kebab-case");
23768
+ }
23769
+ if (entry.type === "task_lists") {
23770
+ if (!isCanonicalSlug(payload["slug"])) {
23771
+ throw new Error("Invalid task-list slug \u2014 sync requires non-empty canonical kebab-case");
23772
+ }
23773
+ if (!isValidTaskListProjectScope(payload["project_id"])) {
23774
+ throw new Error("Invalid task-list project scope \u2014 project_id must be null, missing, or a non-empty string");
23775
+ }
23776
+ }
23777
+ }
23398
23778
  function entry(type, payload, fallbackUpdatedAt) {
23399
23779
  const id = payload["id"];
23400
23780
  if (typeof id !== "string" || !id)
@@ -23582,6 +23962,7 @@ function createPostgresTodosStorageAdapter(options) {
23582
23962
  getByPath: async (path) => (await store.list("projects")).find((project) => project.path === path) ?? null,
23583
23963
  list: async () => (await store.list("projects")).sort((a, b) => a.name.localeCompare(b.name)),
23584
23964
  update: (id, input) => updateProject2(id, input, store),
23965
+ rename: (id, input, context) => store.renameProject(id, input.new_slug, input.name, context),
23585
23966
  delete: (id, context) => store.delete("projects", id, context)
23586
23967
  },
23587
23968
  plans: {
@@ -23819,9 +24200,22 @@ class PostgresJsonRecordStore {
23819
24200
  });
23820
24201
  }
23821
24202
  async upsert(type, value, context = {}) {
24203
+ if (type === "projects" && !isCanonicalSlug(value.task_list_id)) {
24204
+ throw new Error("Invalid project task-list slug \u2014 imports require non-empty canonical kebab-case");
24205
+ }
24206
+ if (type === "task_lists") {
24207
+ if (!isCanonicalSlug(value.slug)) {
24208
+ throw new Error("Invalid task-list slug \u2014 imports require non-empty canonical kebab-case");
24209
+ }
24210
+ if (!isValidTaskListProjectScope(value.project_id)) {
24211
+ throw new Error("Invalid task-list project scope \u2014 project_id must be null, missing, or a non-empty string");
24212
+ }
24213
+ }
23822
24214
  await this.ensureSchema();
23823
24215
  const updatedAt = stringValue2(value.updated_at) ?? stringValue2(value.created_at) ?? new Date().toISOString();
23824
- const result = await this.options.client.query(`INSERT INTO ${this.tableName} (
24216
+ let result;
24217
+ try {
24218
+ result = await this.options.client.query(`INSERT INTO ${this.tableName} (
23825
24219
  service, object_type, object_id, payload, updated_at,
23826
24220
  deleted_at, source_machine_id, version
23827
24221
  ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, NULL, $6, $7)
@@ -23836,14 +24230,23 @@ class PostgresJsonRecordStore {
23836
24230
  OR (${this.tableName}.updated_at = EXCLUDED.updated_at
23837
24231
  AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
23838
24232
  RETURNING object_id`, [
23839
- this.service,
23840
- type,
23841
- value.id,
23842
- jsonbParam(value),
23843
- updatedAt,
23844
- context.requestId ?? this.sourceMachineId ?? null,
23845
- numberValue3(value.version)
23846
- ]);
24233
+ this.service,
24234
+ type,
24235
+ value.id,
24236
+ jsonbParam(value),
24237
+ updatedAt,
24238
+ context.requestId ?? this.sourceMachineId ?? null,
24239
+ numberValue3(value.version)
24240
+ ]);
24241
+ } catch (error) {
24242
+ if (type === "task_lists" && isPostgresUniqueViolation(error)) {
24243
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${String(value.slug ?? "")}" already exists in this scope`);
24244
+ }
24245
+ if (type === "projects" && isPostgresUniqueViolation(error)) {
24246
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${String(value.task_list_id ?? "")}" already exists`);
24247
+ }
24248
+ throw error;
24249
+ }
23847
24250
  if (result.rows.length === 0) {
23848
24251
  const current = await this.get(type, value.id);
23849
24252
  if (current)
@@ -23851,6 +24254,93 @@ class PostgresJsonRecordStore {
23851
24254
  }
23852
24255
  return value;
23853
24256
  }
24257
+ async renameProject(id, newSlug, name, context = {}) {
24258
+ await this.ensureSchema();
24259
+ const normalizedSlug = slugifyRaw(newSlug);
24260
+ if (!normalizedSlug)
24261
+ throw new Error("Invalid slug \u2014 must be non-empty kebab-case");
24262
+ const timestamp2 = new Date().toISOString();
24263
+ try {
24264
+ const result = await this.options.client.query(`/* todos:rename-project-atomic */ WITH target AS (
24265
+ SELECT payload, payload->>'task_list_id' AS old_slug
24266
+ FROM ${this.tableName}
24267
+ WHERE service = $1 AND object_type = 'projects' AND object_id = $2 AND deleted_at IS NULL
24268
+ FOR UPDATE
24269
+ ), project_conflict AS (
24270
+ SELECT 1 FROM ${this.tableName}
24271
+ WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
24272
+ AND deleted_at IS NULL AND payload->>'task_list_id' = $3 LIMIT 1
24273
+ ), task_list_conflict AS (
24274
+ SELECT 1 FROM ${this.tableName} r, target
24275
+ WHERE r.service = $1 AND r.object_type = 'task_lists' AND r.deleted_at IS NULL
24276
+ AND r.payload->>'project_id' = $2 AND r.payload->>'slug' = $3
24277
+ AND r.payload->>'slug' IS DISTINCT FROM target.old_slug LIMIT 1
24278
+ ), updated_lists AS (
24279
+ UPDATE ${this.tableName} r SET
24280
+ payload = r.payload || jsonb_build_object('slug', $3::text, 'updated_at', $5::text)
24281
+ || CASE WHEN $4::text IS NULL THEN '{}'::jsonb ELSE jsonb_build_object('name', $4::text) END,
24282
+ updated_at = $5::timestamptz, version = COALESCE(r.version, 0) + 1,
24283
+ source_machine_id = COALESCE($6, r.source_machine_id)
24284
+ FROM target
24285
+ WHERE r.service = $1 AND r.object_type = 'task_lists' AND r.deleted_at IS NULL
24286
+ AND r.payload->>'project_id' = $2 AND r.payload->>'slug' = target.old_slug
24287
+ AND NOT EXISTS (SELECT 1 FROM project_conflict)
24288
+ AND NOT EXISTS (SELECT 1 FROM task_list_conflict)
24289
+ AND (target.old_slug IS DISTINCT FROM $3
24290
+ OR ($4::text IS NOT NULL AND r.payload->>'name' IS DISTINCT FROM $4))
24291
+ RETURNING 1
24292
+ ), updated_project AS (
24293
+ UPDATE ${this.tableName} r SET
24294
+ payload = r.payload || jsonb_build_object('task_list_id', $3::text, 'updated_at', $5::text)
24295
+ || CASE WHEN $4::text IS NULL THEN '{}'::jsonb ELSE jsonb_build_object('name', $4::text) END,
24296
+ updated_at = $5::timestamptz, version = COALESCE(r.version, 0) + 1,
24297
+ source_machine_id = COALESCE($6, r.source_machine_id)
24298
+ FROM target
24299
+ WHERE r.service = $1 AND r.object_type = 'projects' AND r.object_id = $2 AND r.deleted_at IS NULL
24300
+ AND NOT EXISTS (SELECT 1 FROM project_conflict)
24301
+ AND NOT EXISTS (SELECT 1 FROM task_list_conflict)
24302
+ AND (target.old_slug IS DISTINCT FROM $3
24303
+ OR ($4::text IS NOT NULL AND target.payload->>'name' IS DISTINCT FROM $4))
24304
+ RETURNING r.payload
24305
+ ) SELECT
24306
+ EXISTS (SELECT 1 FROM target) AS found,
24307
+ EXISTS (SELECT 1 FROM project_conflict) AS project_conflict,
24308
+ EXISTS (SELECT 1 FROM task_list_conflict) AS task_list_conflict,
24309
+ COALESCE((SELECT payload FROM updated_project), (SELECT payload FROM target)) AS project,
24310
+ (SELECT count(*) FROM updated_lists) AS task_lists_updated`, [this.service, id, normalizedSlug, name ?? null, timestamp2, this.machineId(context)]);
24311
+ const row = result.rows[0];
24312
+ if (!row?.found)
24313
+ throw new ProjectNotFoundError(id);
24314
+ if (row.project_conflict) {
24315
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalizedSlug}" is already used by another project`);
24316
+ }
24317
+ if (row.task_list_conflict) {
24318
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalizedSlug}" is already used in this project`);
24319
+ }
24320
+ return {
24321
+ project: payloadRecord2(row.project),
24322
+ task_lists_updated: Number(row.task_lists_updated)
24323
+ };
24324
+ } catch (error) {
24325
+ if (isPostgresUniqueViolation(error)) {
24326
+ const constraintName = postgresConstraintName(error);
24327
+ let projectConflict = constraintName.includes("project_task_list_slug_uidx");
24328
+ if (!constraintName) {
24329
+ const conflict = await this.options.client.query(`/* todos:classify-project-rename-conflict */ SELECT EXISTS (
24330
+ SELECT 1 FROM ${this.tableName}
24331
+ WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
24332
+ AND deleted_at IS NULL AND payload->>'task_list_id' = $3
24333
+ ) AS project_conflict`, [this.service, id, normalizedSlug]);
24334
+ projectConflict = Boolean(conflict.rows[0]?.project_conflict);
24335
+ }
24336
+ if (projectConflict) {
24337
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalizedSlug}" is already used by another project`);
24338
+ }
24339
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalizedSlug}" is already used in this project`);
24340
+ }
24341
+ throw error;
24342
+ }
24343
+ }
23854
24344
  async incrementProjectTaskCounter(projectId, _context = {}) {
23855
24345
  await this.ensureSchema();
23856
24346
  const result = await this.options.client.query(`UPDATE ${this.tableName}
@@ -24272,12 +24762,16 @@ async function getChangedSince(since, filters, store) {
24272
24762
  }
24273
24763
  async function createProject2(input, store, context) {
24274
24764
  const timestamp2 = new Date().toISOString();
24765
+ const derivedSlug = slugifyRaw(input.name);
24766
+ const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugifyRaw(input.task_list_id);
24767
+ if (!derivedSlug || !taskListId)
24768
+ throw new Error("Project name and task-list slug must be non-empty");
24275
24769
  const project = {
24276
24770
  id: randomUUID3(),
24277
24771
  name: input.name,
24278
24772
  path: input.path,
24279
24773
  description: input.description ?? null,
24280
- task_list_id: input.task_list_id ?? `todos-${slugify2(input.name)}`,
24774
+ task_list_id: taskListId,
24281
24775
  task_prefix: input.task_prefix ?? await generateProjectPrefix(input.name, store),
24282
24776
  task_counter: 0,
24283
24777
  created_at: timestamp2,
@@ -24288,6 +24782,9 @@ async function createProject2(input, store, context) {
24288
24782
  return store.upsert("projects", project, context);
24289
24783
  }
24290
24784
  async function updateProject2(id, input, store) {
24785
+ if ("task_list_id" in input) {
24786
+ throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
24787
+ }
24291
24788
  const project = await requireRecord("projects", id, store);
24292
24789
  const updated = { ...project, ...definedPatch(input), updated_at: new Date().toISOString() };
24293
24790
  return store.upsert("projects", updated);
@@ -24396,10 +24893,13 @@ async function releaseAgent2(idOrName, sessionId, store, context) {
24396
24893
  }
24397
24894
  async function createTaskList2(input, store, context) {
24398
24895
  const timestamp2 = new Date().toISOString();
24896
+ const slug = slugifyRaw(input.slug === undefined ? input.name : input.slug);
24897
+ if (!slug)
24898
+ throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
24399
24899
  return store.upsert("task_lists", {
24400
24900
  id: randomUUID3(),
24401
24901
  project_id: input.project_id ?? context?.projectId ?? null,
24402
- slug: input.slug ?? slugify2(input.name),
24902
+ slug,
24403
24903
  name: input.name,
24404
24904
  description: input.description ?? null,
24405
24905
  metadata: input.metadata ?? {},
@@ -24411,9 +24911,20 @@ async function createTaskList2(input, store, context) {
24411
24911
  }
24412
24912
  async function updateTaskList2(id, input, store) {
24413
24913
  const list = await requireRecord("task_lists", id, store);
24914
+ const patch = definedPatch(input);
24915
+ if (input.slug !== undefined) {
24916
+ const slug = slugifyRaw(input.slug);
24917
+ if (!slug)
24918
+ throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
24919
+ const duplicate = (await store.list("task_lists")).find((candidate) => candidate.id !== id && candidate.project_id === list.project_id && candidate.slug === slug);
24920
+ if (duplicate) {
24921
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
24922
+ }
24923
+ patch.slug = slug;
24924
+ }
24414
24925
  return store.upsert("task_lists", {
24415
24926
  ...list,
24416
- ...definedPatch(input),
24927
+ ...patch,
24417
24928
  metadata: input.metadata ?? list.metadata,
24418
24929
  updated_at: new Date().toISOString()
24419
24930
  });
@@ -24497,6 +25008,16 @@ async function exportSnapshot(store) {
24497
25008
  }
24498
25009
  async function importSnapshot(snapshot, store, context) {
24499
25010
  const result = { inserted: 0, updated: 0, deleted: 0, skipped: 0, errors: [] };
25011
+ result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
25012
+ if (result.errors.length > 0)
25013
+ return result;
25014
+ const [existingProjects, existingTaskLists] = await Promise.all([
25015
+ store.list("projects"),
25016
+ store.list("task_lists")
25017
+ ]);
25018
+ result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
25019
+ if (result.errors.length > 0)
25020
+ return result;
24500
25021
  const entries = [
24501
25022
  ...snapshot.tasks.map((row) => ["tasks", row]),
24502
25023
  ...snapshot.projects.map((row) => ["projects", row]),
@@ -24567,10 +25088,7 @@ async function generateProjectPrefix(name, store) {
24567
25088
  return candidate;
24568
25089
  }
24569
25090
  function slugifyRaw(value) {
24570
- return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
24571
- }
24572
- function slugify2(value) {
24573
- return slugifyRaw(value) || "todos";
25091
+ return normalizeSlug(value);
24574
25092
  }
24575
25093
  function normalizePlanSlug2(value) {
24576
25094
  const slug = slugifyRaw(value);
@@ -24624,6 +25142,16 @@ function compareClock(left, right) {
24624
25142
  function numberValue3(value) {
24625
25143
  return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
24626
25144
  }
25145
+ function isPostgresUniqueViolation(error) {
25146
+ return typeof error === "object" && error !== null && error.code === "23505";
25147
+ }
25148
+ function postgresConstraintName(error) {
25149
+ if (typeof error !== "object" || error === null)
25150
+ return "";
25151
+ const candidate = error;
25152
+ const constraint = candidate.constraint ?? candidate.constraint_name;
25153
+ return typeof constraint === "string" ? constraint : "";
25154
+ }
24627
25155
 
24628
25156
  // src/storage/shadow.ts
24629
25157
  var SNAPSHOT_TO_OBJECT_TYPE = {
@@ -24889,6 +25417,19 @@ function createShadowTodosStorageAdapter(options) {
24889
25417
  mirror.enqueueUpsert("projects", project, context);
24890
25418
  return project;
24891
25419
  },
25420
+ async rename(id, input, context) {
25421
+ const projectBefore = await local.projects.get(id, context);
25422
+ const cascadeCandidates = projectBefore?.task_list_id ? (await local.taskLists.list(id, context)).filter((list) => list.slug === projectBefore.task_list_id) : [];
25423
+ const result = await local.projects.rename(id, input, context);
25424
+ mirror.enqueueUpsert("projects", result.project, context);
25425
+ for (const candidate of cascadeCandidates) {
25426
+ const changed = await local.taskLists.get(candidate.id, context);
25427
+ if (changed && (changed.slug !== candidate.slug || changed.name !== candidate.name)) {
25428
+ mirror.enqueueUpsert("taskLists", changed, context);
25429
+ }
25430
+ }
25431
+ return result;
25432
+ },
24892
25433
  async delete(id, context) {
24893
25434
  const deleted = await local.projects.delete(id, context);
24894
25435
  if (deleted)
@@ -28800,10 +29341,10 @@ function bootstrapProject(options = {}, db) {
28800
29341
  let project = ensureProject(options.name || discovery.projectName, discovery.projectPath, d);
28801
29342
  const createdProject = !beforeProject;
28802
29343
  if (project.task_list_id !== taskListSlug || options.name && project.name !== options.name) {
28803
- project = updateProject(project.id, {
29344
+ project = renameProject(project.id, {
28804
29345
  name: options.name ?? project.name,
28805
- task_list_id: taskListSlug
28806
- }, d);
29346
+ new_slug: taskListSlug
29347
+ }, d).project;
28807
29348
  }
28808
29349
  setMachineLocalPath(project.id, discovery.projectPath, d);
28809
29350
  const beforeTaskList = d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ?").get(project.id, taskListSlug);
@@ -31751,7 +32292,7 @@ function getTaskLabels(taskId, db) {
31751
32292
  // src/db/custom-fields.ts
31752
32293
  init_database();
31753
32294
  var CUSTOM_FIELD_TYPES = ["text", "number", "boolean", "date", "enum"];
31754
- function slugify3(name) {
32295
+ function slugify2(name) {
31755
32296
  return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
31756
32297
  }
31757
32298
  function rowToDef(row) {
@@ -31772,7 +32313,7 @@ function createCustomFieldDefinition(input, db) {
31772
32313
  const d = db || getDatabase();
31773
32314
  const id = uuid();
31774
32315
  const ts = now();
31775
- const slug = slugify3(input.name);
32316
+ const slug = slugify2(input.name);
31776
32317
  d.run(`INSERT INTO custom_field_definitions (
31777
32318
  id, project_id, name, slug, field_type, options, required, default_value, sort_order, created_at, updated_at
31778
32319
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
@@ -31794,7 +32335,7 @@ function getCustomFieldDefinition(idOrSlug, db) {
31794
32335
  const d = db || getDatabase();
31795
32336
  let row = d.query("SELECT * FROM custom_field_definitions WHERE id = ?").get(idOrSlug);
31796
32337
  if (!row)
31797
- row = d.query("SELECT * FROM custom_field_definitions WHERE slug = ?").get(slugify3(idOrSlug));
32338
+ row = d.query("SELECT * FROM custom_field_definitions WHERE slug = ?").get(slugify2(idOrSlug));
31798
32339
  return row ? rowToDef(row) : null;
31799
32340
  }
31800
32341
  function listCustomFieldDefinitions(projectId, db) {
@@ -36016,7 +36557,7 @@ function searchTasks(options, projectId, taskListId, db) {
36016
36557
  init_projects();
36017
36558
  init_plans();
36018
36559
  var SAVED_VIEWS_SCHEMA = "todos.saved_views.v1";
36019
- function slugify4(name) {
36560
+ function slugify3(name) {
36020
36561
  return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
36021
36562
  }
36022
36563
  function rowToView(row) {
@@ -36035,7 +36576,7 @@ function createSavedView(input, db) {
36035
36576
  const d = db || getDatabase();
36036
36577
  const id = uuid();
36037
36578
  const ts = now();
36038
- const slug = input.slug ?? slugify4(input.name);
36579
+ const slug = input.slug ?? slugify3(input.name);
36039
36580
  d.run(`INSERT INTO saved_views (id, name, slug, entity_type, filters, created_at, updated_at)
36040
36581
  VALUES (?, ?, ?, ?, ?, ?, ?)`, [id, input.name, slug, input.entity_type ?? "task", JSON.stringify(input.filters ?? {}), ts, ts]);
36041
36582
  return getSavedView(id, d);
@@ -40542,7 +41083,7 @@ function storePath(cwd) {
40542
41083
  function versionsDir(cwd) {
40543
41084
  return join22(storeDir(cwd), "versions");
40544
41085
  }
40545
- function slugify5(name) {
41086
+ function slugify4(name) {
40546
41087
  return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
40547
41088
  }
40548
41089
  function emptyStore2() {
@@ -40581,7 +41122,7 @@ function createUserScaffold(input, db, cwd) {
40581
41122
  const store = loadUserScaffoldStore(cwd);
40582
41123
  const id = uuid();
40583
41124
  const ts = now();
40584
- const slug = slugify5(input.name);
41125
+ const slug = slugify4(input.name);
40585
41126
  if (Object.values(store.scaffolds).some((s) => s.slug === slug)) {
40586
41127
  throw new Error(`Scaffold slug already exists: ${slug}`);
40587
41128
  }
@@ -40618,8 +41159,8 @@ function updateUserScaffold(idOrSlug, updates, db, cwd) {
40618
41159
  if (!existing)
40619
41160
  throw new Error(`Scaffold not found: ${idOrSlug}`);
40620
41161
  snapshotVersion(existing, cwd);
40621
- if (updates.name && slugify5(updates.name) !== existing.slug) {
40622
- const newSlug = slugify5(updates.name);
41162
+ if (updates.name && slugify4(updates.name) !== existing.slug) {
41163
+ const newSlug = slugify4(updates.name);
40623
41164
  if (Object.values(store.scaffolds).some((s) => s.id !== existing.id && s.slug === newSlug)) {
40624
41165
  throw new Error(`Scaffold slug conflict: ${newSlug}`);
40625
41166
  }