@hasna/todos 0.11.88 → 0.11.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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/cli/index.js CHANGED
@@ -8876,9 +8876,8 @@ function cloudProjectPathBasename(value) {
8876
8876
  function uniqueProjectMatches(projects, predicate) {
8877
8877
  return [...new Map(projects.filter(predicate).map((project) => [project.id, project])).values()];
8878
8878
  }
8879
- async function cloudResolveProjectRef(client, ref) {
8879
+ function resolveCloudProjectRef(projects, ref) {
8880
8880
  const input = ref.trim();
8881
- const projects = await cloudListProjects(client);
8882
8881
  const normalizedRef = input.toLowerCase();
8883
8882
  const pathLike = input.startsWith(".") || input.includes("/") || input.includes("\\");
8884
8883
  const normalizedPath = pathLike ? resolvePath(input) : undefined;
@@ -8898,6 +8897,9 @@ async function cloudResolveProjectRef(client, ref) {
8898
8897
  }
8899
8898
  throw new Error(`Project not found: "${input}"`);
8900
8899
  }
8900
+ async function cloudResolveProjectRef(client, ref) {
8901
+ return resolveCloudProjectRef(await cloudListProjects(client), ref);
8902
+ }
8901
8903
  async function cloudListPlans(client, projectId) {
8902
8904
  const query = projectId ? { project_id: projectId } : {};
8903
8905
  const res = await client.list("plans", { query });
@@ -9232,6 +9234,13 @@ async function cloudCreateTaskList(client, input) {
9232
9234
  }
9233
9235
  return raw;
9234
9236
  }
9237
+ async function cloudRenameProject(client, ref, newSlug, name) {
9238
+ const id = await cloudResolveProjectRef(client, ref);
9239
+ const normalizedSlug = cloudProjectSlug(newSlug);
9240
+ if (!normalizedSlug)
9241
+ throw new Error("Invalid slug \u2014 must be non-empty kebab-case");
9242
+ return client.transport.post(`/projects/${encodeURIComponent(id)}/rename`, { new_slug: normalizedSlug, ...name !== undefined ? { name } : {} });
9243
+ }
9235
9244
  async function cloudDeleteTaskList(client, id) {
9236
9245
  await client.delete("task-lists", id);
9237
9246
  return true;
@@ -11076,6 +11085,95 @@ function ensureSchema(db) {
11076
11085
  )`);
11077
11086
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_project_machine_paths_project ON project_machine_paths(project_id)");
11078
11087
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_project_machine_paths_machine ON project_machine_paths(machine_id)");
11088
+ ensureTable("canonical_slug_claims", `
11089
+ CREATE TABLE canonical_slug_claims (
11090
+ kind TEXT NOT NULL CHECK(kind IN ('project', 'task_list')),
11091
+ scope_key TEXT NOT NULL,
11092
+ slug TEXT NOT NULL,
11093
+ object_id TEXT NOT NULL,
11094
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
11095
+ PRIMARY KEY (kind, scope_key, slug)
11096
+ )`);
11097
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_canonical_slug_claims_object ON canonical_slug_claims(kind, object_id)");
11098
+ ensureColumn("projects", "task_list_id", "TEXT");
11099
+ db.exec(`CREATE TRIGGER IF NOT EXISTS claim_project_canonical_slug_insert
11100
+ BEFORE INSERT ON projects
11101
+ WHEN NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> ''
11102
+ BEGIN
11103
+ SELECT CASE WHEN EXISTS (
11104
+ SELECT 1 FROM projects WHERE id <> NEW.id AND task_list_id = NEW.task_list_id
11105
+ ) THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
11106
+ INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
11107
+ VALUES ('project', 'global', NEW.task_list_id, NEW.id);
11108
+ SELECT CASE WHEN (
11109
+ SELECT object_id FROM canonical_slug_claims
11110
+ WHERE kind = 'project' AND scope_key = 'global' AND slug = NEW.task_list_id
11111
+ ) <> NEW.id THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
11112
+ END`);
11113
+ db.exec(`CREATE TRIGGER IF NOT EXISTS claim_project_canonical_slug_update
11114
+ BEFORE UPDATE OF task_list_id ON projects
11115
+ WHEN NEW.task_list_id IS NOT OLD.task_list_id
11116
+ BEGIN
11117
+ DELETE FROM canonical_slug_claims WHERE kind = 'project' AND object_id = NEW.id;
11118
+ SELECT CASE WHEN EXISTS (
11119
+ SELECT 1 FROM projects WHERE id <> NEW.id AND task_list_id = NEW.task_list_id
11120
+ ) AND NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> ''
11121
+ THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
11122
+ INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
11123
+ SELECT 'project', 'global', NEW.task_list_id, NEW.id
11124
+ WHERE NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> '';
11125
+ SELECT CASE WHEN NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> '' AND (
11126
+ SELECT object_id FROM canonical_slug_claims
11127
+ WHERE kind = 'project' AND scope_key = 'global' AND slug = NEW.task_list_id
11128
+ ) <> NEW.id THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
11129
+ END`);
11130
+ db.exec(`CREATE TRIGGER IF NOT EXISTS release_project_canonical_slug_delete
11131
+ AFTER DELETE ON projects
11132
+ BEGIN
11133
+ DELETE FROM canonical_slug_claims WHERE kind = 'project' AND object_id = OLD.id;
11134
+ END`);
11135
+ db.exec(`CREATE TRIGGER IF NOT EXISTS claim_task_list_canonical_slug_insert
11136
+ BEFORE INSERT ON task_lists
11137
+ WHEN NEW.slug IS NOT NULL AND NEW.slug <> ''
11138
+ BEGIN
11139
+ SELECT CASE WHEN EXISTS (
11140
+ SELECT 1 FROM task_lists
11141
+ WHERE id <> NEW.id AND project_id IS NEW.project_id AND slug = NEW.slug
11142
+ ) THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
11143
+ INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
11144
+ VALUES ('task_list', CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END, NEW.slug, NEW.id);
11145
+ SELECT CASE WHEN (
11146
+ SELECT object_id FROM canonical_slug_claims
11147
+ WHERE kind = 'task_list'
11148
+ AND scope_key = CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END
11149
+ AND slug = NEW.slug
11150
+ ) <> NEW.id THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
11151
+ END`);
11152
+ db.exec(`CREATE TRIGGER IF NOT EXISTS claim_task_list_canonical_slug_update
11153
+ BEFORE UPDATE OF slug, project_id ON task_lists
11154
+ WHEN NEW.slug IS NOT OLD.slug OR NEW.project_id IS NOT OLD.project_id
11155
+ BEGIN
11156
+ DELETE FROM canonical_slug_claims WHERE kind = 'task_list' AND object_id = NEW.id;
11157
+ SELECT CASE WHEN EXISTS (
11158
+ SELECT 1 FROM task_lists
11159
+ WHERE id <> NEW.id AND project_id IS NEW.project_id AND slug = NEW.slug
11160
+ ) AND NEW.slug IS NOT NULL AND NEW.slug <> ''
11161
+ THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
11162
+ INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
11163
+ SELECT 'task_list', CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END, NEW.slug, NEW.id
11164
+ WHERE NEW.slug IS NOT NULL AND NEW.slug <> '';
11165
+ SELECT CASE WHEN NEW.slug IS NOT NULL AND NEW.slug <> '' AND (
11166
+ SELECT object_id FROM canonical_slug_claims
11167
+ WHERE kind = 'task_list'
11168
+ AND scope_key = CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END
11169
+ AND slug = NEW.slug
11170
+ ) <> NEW.id THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
11171
+ END`);
11172
+ db.exec(`CREATE TRIGGER IF NOT EXISTS release_task_list_canonical_slug_delete
11173
+ AFTER DELETE ON task_lists
11174
+ BEGIN
11175
+ DELETE FROM canonical_slug_claims WHERE kind = 'task_list' AND object_id = OLD.id;
11176
+ END`);
11079
11177
  ensureTable("storage_tombstones", `
11080
11178
  CREATE TABLE storage_tombstones (
11081
11179
  id TEXT PRIMARY KEY,
@@ -12489,7 +12587,7 @@ var init_database = __esm(() => {
12489
12587
  });
12490
12588
 
12491
12589
  // src/types/index.ts
12492
- var TASK_STATUSES, TASK_PRIORITIES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, PlanNotFoundError, LockError, AgentNotFoundError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
12590
+ var TASK_STATUSES, TASK_PRIORITIES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
12493
12591
  var init_types = __esm(() => {
12494
12592
  TASK_STATUSES = [
12495
12593
  "pending",
@@ -12538,6 +12636,14 @@ var init_types = __esm(() => {
12538
12636
  this.name = "ProjectNotFoundError";
12539
12637
  }
12540
12638
  };
12639
+ ResourceConflictError = class ResourceConflictError extends Error {
12640
+ code;
12641
+ constructor(code, message) {
12642
+ super(message);
12643
+ this.code = code;
12644
+ this.name = "ResourceConflictError";
12645
+ }
12646
+ };
12541
12647
  PlanNotFoundError = class PlanNotFoundError extends Error {
12542
12648
  planId;
12543
12649
  static code = "PLAN_NOT_FOUND";
@@ -12690,6 +12796,91 @@ var init_storage_tombstones = __esm(() => {
12690
12796
  init_machines();
12691
12797
  });
12692
12798
 
12799
+ // src/lib/slugs.ts
12800
+ function normalizeSlug(value) {
12801
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
12802
+ }
12803
+ function isCanonicalSlug(value) {
12804
+ return typeof value === "string" && value.length > 0 && normalizeSlug(value) === value;
12805
+ }
12806
+ function isValidTaskListProjectScope(value) {
12807
+ return value === undefined || value === null || typeof value === "string" && value.trim().length > 0;
12808
+ }
12809
+ function validateSnapshotRoutingRecords(projects, taskLists) {
12810
+ const errors = [];
12811
+ const projectSlugs = new Map;
12812
+ const taskListSlugs = new Map;
12813
+ for (const project of projects) {
12814
+ if (!isCanonicalSlug(project.task_list_id)) {
12815
+ errors.push(`project ${project.id}: task_list_id must be non-empty canonical kebab-case`);
12816
+ continue;
12817
+ }
12818
+ const existing = projectSlugs.get(project.task_list_id);
12819
+ if (projectSlugs.has(project.task_list_id)) {
12820
+ errors.push(`project ${project.id}: task_list_id duplicates project ${existing}: ${project.task_list_id}`);
12821
+ } else {
12822
+ projectSlugs.set(project.task_list_id, project.id);
12823
+ }
12824
+ }
12825
+ for (const taskList of taskLists) {
12826
+ if (!isCanonicalSlug(taskList.slug)) {
12827
+ errors.push(`task list ${taskList.id}: slug must be non-empty canonical kebab-case`);
12828
+ continue;
12829
+ }
12830
+ if (!isValidTaskListProjectScope(taskList.project_id)) {
12831
+ errors.push(`task list ${taskList.id}: project_id must be null, missing, or a non-empty string`);
12832
+ continue;
12833
+ }
12834
+ const scope = taskList.project_id ?? null;
12835
+ const scopedSlugs = taskListSlugs.get(scope) ?? new Map;
12836
+ const existing = scopedSlugs.get(taskList.slug);
12837
+ if (scopedSlugs.has(taskList.slug)) {
12838
+ errors.push(`task list ${taskList.id}: slug duplicates task list ${existing} in the same scope: ${taskList.slug}`);
12839
+ } else {
12840
+ scopedSlugs.set(taskList.slug, taskList.id);
12841
+ taskListSlugs.set(scope, scopedSlugs);
12842
+ }
12843
+ }
12844
+ return errors;
12845
+ }
12846
+ function validateSnapshotRoutingDestinationConflicts(projects, taskLists, existingProjects, existingTaskLists) {
12847
+ const errors = [];
12848
+ for (const project of projects) {
12849
+ const current = existingProjects.find((candidate) => candidate.id === project.id);
12850
+ if (current?.task_list_id === project.task_list_id)
12851
+ continue;
12852
+ const conflict = existingProjects.find((candidate) => candidate.id !== project.id && candidate.task_list_id === project.task_list_id);
12853
+ if (conflict) {
12854
+ errors.push(`project ${project.id}: task_list_id conflicts with existing project ${conflict.id}: ${String(project.task_list_id)}`);
12855
+ }
12856
+ }
12857
+ for (const taskList of taskLists) {
12858
+ const projectId = taskList.project_id ?? null;
12859
+ const current = existingTaskLists.find((candidate) => candidate.id === taskList.id);
12860
+ if ((current?.project_id ?? null) === projectId && current?.slug === taskList.slug)
12861
+ continue;
12862
+ const conflict = existingTaskLists.find((candidate) => candidate.id !== taskList.id && (candidate.project_id ?? null) === projectId && candidate.slug === taskList.slug);
12863
+ if (conflict) {
12864
+ errors.push(`task list ${taskList.id}: slug conflicts with existing task list ${conflict.id} in the same scope: ${String(taskList.slug)}`);
12865
+ }
12866
+ }
12867
+ return errors;
12868
+ }
12869
+
12870
+ // src/db/slug-claims.ts
12871
+ function taskListSlugScopeKey(projectId) {
12872
+ return projectId ? `project:${projectId}` : "standalone:";
12873
+ }
12874
+ function claimCanonicalSlug(kind, scopeKey, slug, objectId, db) {
12875
+ db.run(`INSERT OR IGNORE INTO canonical_slug_claims (kind, scope_key, slug, object_id)
12876
+ VALUES (?, ?, ?, ?)`, [kind, scopeKey, slug, objectId]);
12877
+ const claim = db.query("SELECT object_id FROM canonical_slug_claims WHERE kind = ? AND scope_key = ? AND slug = ?").get(kind, scopeKey, slug);
12878
+ return claim?.object_id === objectId;
12879
+ }
12880
+ function releaseCanonicalSlugClaims(kind, objectId, db) {
12881
+ db.run("DELETE FROM canonical_slug_claims WHERE kind = ? AND object_id = ?", [kind, objectId]);
12882
+ }
12883
+
12693
12884
  // src/db/projects.ts
12694
12885
  var exports_projects = {};
12695
12886
  __export(exports_projects, {
@@ -12713,7 +12904,7 @@ __export(exports_projects, {
12713
12904
  addProjectSource: () => addProjectSource
12714
12905
  });
12715
12906
  function slugify(name) {
12716
- return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
12907
+ return normalizeSlug(name);
12717
12908
  }
12718
12909
  function generatePrefix(name, db) {
12719
12910
  const words = name.replace(/[^a-zA-Z0-9\s]/g, "").trim().split(/\s+/);
@@ -12737,14 +12928,23 @@ function generatePrefix(name, db) {
12737
12928
  }
12738
12929
  function createProject(input, db) {
12739
12930
  const d = db || getDatabase();
12740
- const id = uuid();
12741
- const timestamp = now();
12742
- const taskListId = input.task_list_id ?? `todos-${slugify(input.name)}`;
12743
- const taskPrefix = input.task_prefix || generatePrefix(input.name, d);
12744
- const machineId = currentStorageMachineId(d);
12745
- d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, created_at, updated_at, machine_id)
12746
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, timestamp, timestamp, machineId]);
12747
- return getProject(id, d);
12931
+ return d.transaction(() => {
12932
+ const id = uuid();
12933
+ const timestamp = now();
12934
+ const derivedSlug = slugify(input.name);
12935
+ const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugify(input.task_list_id);
12936
+ if (!derivedSlug || !taskListId)
12937
+ throw new Error("Project name and task-list slug must be non-empty");
12938
+ const slugConflict = d.query("SELECT id FROM projects WHERE task_list_id = ? LIMIT 1").get(taskListId);
12939
+ if (slugConflict || !claimCanonicalSlug("project", "global", taskListId, id, d)) {
12940
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${taskListId}" already exists`);
12941
+ }
12942
+ const taskPrefix = input.task_prefix || generatePrefix(input.name, d);
12943
+ const machineId = currentStorageMachineId(d);
12944
+ d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, created_at, updated_at, machine_id)
12945
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, timestamp, timestamp, machineId]);
12946
+ return getProject(id, d);
12947
+ })();
12748
12948
  }
12749
12949
  function getProject(id, db) {
12750
12950
  const d = db || getDatabase();
@@ -12772,6 +12972,9 @@ function updateProject(id, input, db) {
12772
12972
  const project = getProject(id, d);
12773
12973
  if (!project)
12774
12974
  throw new ProjectNotFoundError(id);
12975
+ if ("task_list_id" in input) {
12976
+ throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
12977
+ }
12775
12978
  const sets = ["updated_at = ?"];
12776
12979
  const params = [now()];
12777
12980
  if (input.name !== undefined) {
@@ -12782,10 +12985,6 @@ function updateProject(id, input, db) {
12782
12985
  sets.push("description = ?");
12783
12986
  params.push(input.description);
12784
12987
  }
12785
- if (input.task_list_id !== undefined) {
12786
- sets.push("task_list_id = ?");
12787
- params.push(input.task_list_id);
12788
- }
12789
12988
  if (input.path !== undefined) {
12790
12989
  sets.push("path = ?");
12791
12990
  params.push(input.path);
@@ -12796,29 +12995,41 @@ function updateProject(id, input, db) {
12796
12995
  }
12797
12996
  function renameProject(id, input, db) {
12798
12997
  const d = db || getDatabase();
12799
- const project = getProject(id, d);
12800
- if (!project)
12801
- throw new ProjectNotFoundError(id);
12802
- let taskListsUpdated = 0;
12803
- const ts = now();
12804
- if (input.new_slug !== undefined) {
12805
- const normalised = input.new_slug.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "");
12806
- if (!normalised)
12807
- throw new Error("Invalid slug \u2014 must be non-empty kebab-case");
12808
- const conflict = d.query("SELECT id FROM projects WHERE task_list_id = ? AND id != ?").get(normalised, id);
12809
- if (conflict)
12810
- throw new Error(`Slug "${normalised}" is already used by another project`);
12811
- const oldSlug = project.task_list_id;
12812
- d.run("UPDATE projects SET task_list_id = ?, updated_at = ? WHERE id = ?", [normalised, ts, id]);
12813
- if (oldSlug) {
12814
- 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]);
12815
- taskListsUpdated = result.changes;
12998
+ return d.transaction(() => {
12999
+ const project = getProject(id, d);
13000
+ if (!project)
13001
+ throw new ProjectNotFoundError(id);
13002
+ let taskListsUpdated = 0;
13003
+ const ts = now();
13004
+ if (input.new_slug !== undefined) {
13005
+ const normalised = normalizeSlug(input.new_slug);
13006
+ if (!normalised)
13007
+ throw new Error("Invalid slug \u2014 must be non-empty kebab-case");
13008
+ const oldSlug = project.task_list_id;
13009
+ if (normalised !== oldSlug) {
13010
+ const conflict = d.query("SELECT id FROM projects WHERE task_list_id = ? AND id != ?").get(normalised, id);
13011
+ if (conflict) {
13012
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalised}" is already used by another project`);
13013
+ }
13014
+ const taskListConflict = d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ? AND slug != COALESCE(?, '') LIMIT 1").get(id, normalised, oldSlug);
13015
+ if (taskListConflict) {
13016
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalised}" is already used in project "${project.name}"`);
13017
+ }
13018
+ releaseCanonicalSlugClaims("project", id, d);
13019
+ if (!claimCanonicalSlug("project", "global", normalised, id, d)) {
13020
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalised}" is already used by another project`);
13021
+ }
13022
+ d.run("UPDATE projects SET task_list_id = ?, updated_at = ? WHERE id = ?", [normalised, ts, id]);
13023
+ }
13024
+ if (oldSlug && (normalised !== oldSlug || input.name !== undefined && input.name !== project.name)) {
13025
+ 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;
13026
+ }
12816
13027
  }
12817
- }
12818
- if (input.name !== undefined) {
12819
- d.run("UPDATE projects SET name = ?, updated_at = ? WHERE id = ?", [input.name, ts, id]);
12820
- }
12821
- return { project: getProject(id, d), task_lists_updated: taskListsUpdated };
13028
+ if (input.name !== undefined && input.name !== project.name) {
13029
+ d.run("UPDATE projects SET name = ?, updated_at = ? WHERE id = ?", [input.name, ts, id]);
13030
+ }
13031
+ return { project: getProject(id, d), task_lists_updated: taskListsUpdated };
13032
+ })();
12822
13033
  }
12823
13034
  function deleteProject(id, db) {
12824
13035
  const d = db || getDatabase();
@@ -12830,8 +13041,10 @@ function deleteProject(id, db) {
12830
13041
  object_id: id,
12831
13042
  payload: project
12832
13043
  }, d);
12833
- const result = d.run("DELETE FROM projects WHERE id = ?", [id]);
12834
- return result.changes > 0;
13044
+ return d.transaction(() => {
13045
+ releaseCanonicalSlugClaims("project", id, d);
13046
+ return d.run("DELETE FROM projects WHERE id = ?", [id]).changes > 0;
13047
+ })();
12835
13048
  }
12836
13049
  function rowToSource(row) {
12837
13050
  return {
@@ -14519,19 +14732,22 @@ function rowToTaskList(row) {
14519
14732
  }
14520
14733
  function createTaskList(input, db) {
14521
14734
  const d = db || getDatabase();
14522
- const id = uuid();
14523
- const timestamp = now();
14524
- const slug = input.slug || slugify(input.name);
14525
- const machineId = currentStorageMachineId(d);
14526
- if (!input.project_id) {
14527
- const existing = d.query("SELECT id FROM task_lists WHERE project_id IS NULL AND slug = ?").get(slug);
14528
- if (existing) {
14529
- throw new Error(`Standalone task list with slug "${slug}" already exists`);
14530
- }
14531
- }
14532
- d.run(`INSERT INTO task_lists (id, project_id, slug, name, description, metadata, created_at, updated_at, machine_id)
14533
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, input.project_id || null, slug, input.name, input.description || null, JSON.stringify(input.metadata || {}), timestamp, timestamp, machineId]);
14534
- return getTaskList(id, d);
14735
+ return d.transaction(() => {
14736
+ const id = uuid();
14737
+ const timestamp = now();
14738
+ const slug = normalizeSlug(input.slug === undefined ? input.name : input.slug);
14739
+ if (!slug)
14740
+ throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
14741
+ const machineId = currentStorageMachineId(d);
14742
+ const scopeKey = taskListSlugScopeKey(input.project_id);
14743
+ 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);
14744
+ if (existing || !claimCanonicalSlug("task_list", scopeKey, slug, id, d)) {
14745
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
14746
+ }
14747
+ d.run(`INSERT INTO task_lists (id, project_id, slug, name, description, metadata, created_at, updated_at, machine_id)
14748
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, input.project_id || null, slug, input.name, input.description || null, JSON.stringify(input.metadata || {}), timestamp, timestamp, machineId]);
14749
+ return getTaskList(id, d);
14750
+ })();
14535
14751
  }
14536
14752
  function getTaskList(id, db) {
14537
14753
  const d = db || getDatabase();
@@ -14557,26 +14773,45 @@ function listTaskLists(projectId, db) {
14557
14773
  }
14558
14774
  function updateTaskList(id, input, db) {
14559
14775
  const d = db || getDatabase();
14560
- const existing = getTaskList(id, d);
14561
- if (!existing)
14562
- throw new TaskListNotFoundError(id);
14563
- const sets = ["updated_at = ?"];
14564
- const params = [now()];
14565
- if (input.name !== undefined) {
14566
- sets.push("name = ?");
14567
- params.push(input.name);
14568
- }
14569
- if (input.description !== undefined) {
14570
- sets.push("description = ?");
14571
- params.push(input.description);
14572
- }
14573
- if (input.metadata !== undefined) {
14574
- sets.push("metadata = ?");
14575
- params.push(JSON.stringify(input.metadata));
14576
- }
14577
- params.push(id);
14578
- d.run(`UPDATE task_lists SET ${sets.join(", ")} WHERE id = ?`, params);
14579
- return getTaskList(id, d);
14776
+ return d.transaction(() => {
14777
+ const existing = getTaskList(id, d);
14778
+ if (!existing)
14779
+ throw new TaskListNotFoundError(id);
14780
+ const sets = ["updated_at = ?"];
14781
+ const params = [now()];
14782
+ if (input.slug !== undefined) {
14783
+ const slug = slugify(input.slug);
14784
+ if (!slug)
14785
+ throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
14786
+ 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);
14787
+ if (duplicate) {
14788
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
14789
+ }
14790
+ if (slug !== existing.slug) {
14791
+ releaseCanonicalSlugClaims("task_list", id, d);
14792
+ if (!claimCanonicalSlug("task_list", taskListSlugScopeKey(existing.project_id), slug, id, d)) {
14793
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
14794
+ }
14795
+ }
14796
+ sets.push("slug = ?");
14797
+ params.push(slug);
14798
+ }
14799
+ if (input.name !== undefined) {
14800
+ sets.push("name = ?");
14801
+ params.push(input.name);
14802
+ }
14803
+ if (input.description !== undefined) {
14804
+ sets.push("description = ?");
14805
+ params.push(input.description);
14806
+ }
14807
+ if (input.metadata !== undefined) {
14808
+ sets.push("metadata = ?");
14809
+ params.push(JSON.stringify(input.metadata));
14810
+ }
14811
+ params.push(id);
14812
+ d.run(`UPDATE task_lists SET ${sets.join(", ")} WHERE id = ?`, params);
14813
+ return getTaskList(id, d);
14814
+ })();
14580
14815
  }
14581
14816
  function deleteTaskList(id, db) {
14582
14817
  const d = db || getDatabase();
@@ -14588,7 +14823,10 @@ function deleteTaskList(id, db) {
14588
14823
  object_id: id,
14589
14824
  payload: list
14590
14825
  }, d);
14591
- return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
14826
+ return d.transaction(() => {
14827
+ releaseCanonicalSlugClaims("task_list", id, d);
14828
+ return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
14829
+ })();
14592
14830
  }
14593
14831
  function ensureTaskList(name, slug, projectId, db) {
14594
14832
  const d = db || getDatabase();
@@ -20580,7 +20818,8 @@ function registerTaskCommands(program2) {
20580
20818
  if (cloud) {
20581
20819
  let task3;
20582
20820
  try {
20583
- const cloudProjectId = opts.project || globalOpts.project;
20821
+ const cloudProjectRef = opts.project || globalOpts.project;
20822
+ const cloudProjectId = cloudProjectRef ? await cloudResolveProjectRef(cloud, cloudProjectRef) : undefined;
20584
20823
  const cloudTaskListId = opts.list ? await cloudResolveTaskListRef(cloud, opts.list, cloudProjectId) : undefined;
20585
20824
  if (opts.list && !cloudTaskListId) {
20586
20825
  throw new Error(`Could not resolve task list ID or slug: ${opts.list}`);
@@ -24052,10 +24291,10 @@ function bootstrapProject(options = {}, db) {
24052
24291
  let project = ensureProject(options.name || discovery.projectName, discovery.projectPath, d);
24053
24292
  const createdProject = !beforeProject;
24054
24293
  if (project.task_list_id !== taskListSlug || options.name && project.name !== options.name) {
24055
- project = updateProject(project.id, {
24294
+ project = renameProject(project.id, {
24056
24295
  name: options.name ?? project.name,
24057
- task_list_id: taskListSlug
24058
- }, d);
24296
+ new_slug: taskListSlug
24297
+ }, d).project;
24059
24298
  }
24060
24299
  setMachineLocalPath(project.id, discovery.projectPath, d);
24061
24300
  const beforeTaskList = d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ?").get(project.id, taskListSlug);
@@ -32397,7 +32636,7 @@ function registerProjectCommands(program2) {
32397
32636
  if (existing) {
32398
32637
  project = existing;
32399
32638
  if (opts.taskListId) {
32400
- project = updateProject(existing.id, { task_list_id: opts.taskListId });
32639
+ project = renameProject(existing.id, { new_slug: opts.taskListId }).project;
32401
32640
  }
32402
32641
  } else {
32403
32642
  project = createProject({ name, path: projectPath, task_list_id: opts.taskListId });
@@ -32462,18 +32701,23 @@ function registerProjectCommands(program2) {
32462
32701
  const globalOpts = program2.opts();
32463
32702
  const useJson = opts.json || globalOpts.json;
32464
32703
  try {
32465
- const { renameProject: renameProject2 } = await Promise.resolve().then(() => (init_projects(), exports_projects));
32466
- const db = getDatabase();
32467
- let resolvedId = resolvePartialId(db, "projects", idOrSlug);
32468
- if (!resolvedId) {
32469
- const bySlug = db.query("SELECT id FROM projects WHERE task_list_id = ?").get(idOrSlug);
32470
- resolvedId = bySlug?.id ?? null;
32471
- }
32472
- if (!resolvedId) {
32473
- console.error(chalk4.red(`Project not found: ${idOrSlug}`));
32474
- process.exit(1);
32704
+ const cloud = getTodosCloudClient();
32705
+ let result;
32706
+ if (cloud) {
32707
+ result = await cloudRenameProject(cloud, idOrSlug, newSlug, opts.name);
32708
+ } else {
32709
+ const db = getDatabase();
32710
+ let resolvedId = resolvePartialId(db, "projects", idOrSlug);
32711
+ if (!resolvedId) {
32712
+ const bySlug = db.query("SELECT id FROM projects WHERE task_list_id = ?").get(idOrSlug);
32713
+ resolvedId = bySlug?.id ?? null;
32714
+ }
32715
+ if (!resolvedId) {
32716
+ console.error(chalk4.red(`Project not found: ${idOrSlug}`));
32717
+ process.exit(1);
32718
+ }
32719
+ result = renameProject(resolvedId, { name: opts.name, new_slug: newSlug });
32475
32720
  }
32476
- const result = renameProject2(resolvedId, { name: opts.name, new_slug: newSlug });
32477
32721
  if (useJson) {
32478
32722
  output({ project: result.project, task_lists_updated: result.task_lists_updated }, true);
32479
32723
  } else {
@@ -33828,7 +34072,7 @@ ${isOnline ? chalk5.green("\u25CF") : chalk5.dim("\u25CB")} ${chalk5.bold(agent.
33828
34072
  try {
33829
34073
  const globalOpts = program2.opts();
33830
34074
  const cloud = getTodosCloudClient();
33831
- const projectId = cloud ? globalOpts.project : autoProject(globalOpts);
34075
+ const projectId = cloud ? globalOpts.project ? await cloudResolveProjectRef(cloud, globalOpts.project) : undefined : autoProject(globalOpts);
33832
34076
  if (opts.add) {
33833
34077
  const input = { name: opts.add, slug: opts.slug, description: opts.description, project_id: projectId };
33834
34078
  const list = cloud ? await cloudCreateTaskList(cloud, input) : createTaskList(input);
@@ -37762,6 +38006,14 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
37762
38006
  skipped: 0,
37763
38007
  errors: []
37764
38008
  };
38009
+ result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
38010
+ if (result.errors.length === 0) {
38011
+ const existingProjects = d.query("SELECT id, task_list_id FROM projects").all();
38012
+ const existingTaskLists = d.query("SELECT id, project_id, slug FROM task_lists").all();
38013
+ result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
38014
+ }
38015
+ if (result.errors.length > 0)
38016
+ return result;
37765
38017
  const applyRows = (objectType3, table, columns, rows, updateClockColumn, afterUpsert) => {
37766
38018
  for (const row of rows) {
37767
38019
  try {
@@ -38134,6 +38386,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
38134
38386
  getByPath: (path) => getProjectByPath(path, database()),
38135
38387
  list: () => listProjects(database()),
38136
38388
  update: (id, input) => updateProject(id, input, database()),
38389
+ rename: (id, input) => renameProject(id, input, database()),
38137
38390
  delete: (id) => deleteProject(id, database())
38138
38391
  },
38139
38392
  plans: {
@@ -38241,6 +38494,91 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
38241
38494
  )`
38242
38495
  ];
38243
38496
  }
38497
+ function postgresTodosScopedSlugPreflightSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
38498
+ assertSafeIdentifier(tableName);
38499
+ return `/* todos:scoped-slug-duplicate-audit */ WITH candidates AS (
38500
+ SELECT service, object_type, COALESCE(payload->>'project_id', '') AS scope,
38501
+ jsonb_typeof(payload->'project_id') AS scope_type,
38502
+ payload->>'slug' AS slug, jsonb_typeof(payload->'slug') AS slug_type, object_id
38503
+ FROM ${tableName}
38504
+ WHERE object_type = 'task_lists' AND deleted_at IS NULL
38505
+ UNION ALL
38506
+ SELECT service, object_type, '' AS scope, NULL::text AS scope_type, payload->>'task_list_id' AS slug,
38507
+ jsonb_typeof(payload->'task_list_id') AS slug_type, object_id
38508
+ FROM ${tableName}
38509
+ WHERE object_type = 'projects' AND deleted_at IS NULL
38510
+ ), annotated AS (
38511
+ SELECT *, trim(both '-' from regexp_replace(lower(COALESCE(slug, '')), '[^a-z0-9]+', '-', 'g')) AS normalized_slug
38512
+ FROM candidates
38513
+ ), invalid AS (
38514
+ SELECT service, object_type, scope, COALESCE(slug, '<null>') AS slug,
38515
+ ARRAY[object_id] AS object_ids, 1::integer AS duplicate_count, 'invalid'::text AS issue
38516
+ FROM annotated
38517
+ WHERE slug_type IS DISTINCT FROM 'string'
38518
+ OR slug IS NULL OR slug = '' OR normalized_slug = '' OR slug IS DISTINCT FROM normalized_slug
38519
+ OR (object_type = 'task_lists' AND (
38520
+ (scope_type IS NOT NULL AND scope_type NOT IN ('string', 'null'))
38521
+ OR (scope_type = 'string' AND btrim(scope) = '')
38522
+ ))
38523
+ ), duplicates AS (
38524
+ SELECT service, object_type, scope, slug,
38525
+ array_agg(object_id ORDER BY object_id) AS object_ids,
38526
+ count(*)::integer AS duplicate_count, 'duplicate'::text AS issue
38527
+ FROM annotated
38528
+ WHERE slug = normalized_slug AND slug <> ''
38529
+ GROUP BY service, object_type, scope, slug
38530
+ HAVING count(*) > 1
38531
+ ) SELECT * FROM invalid
38532
+ UNION ALL SELECT * FROM duplicates
38533
+ ORDER BY service, object_type, scope, slug`;
38534
+ }
38535
+ function postgresTodosScopedSlugUniqueIndexSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
38536
+ assertSafeIdentifier(tableName);
38537
+ return [
38538
+ `CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_task_list_scope_slug_uidx
38539
+ ON ${tableName} (service, COALESCE(payload->>'project_id', ''), (payload->>'slug'))
38540
+ WHERE object_type = 'task_lists' AND deleted_at IS NULL AND COALESCE(payload->>'slug', '') <> ''`,
38541
+ `CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_project_task_list_slug_uidx
38542
+ ON ${tableName} (service, (payload->>'task_list_id'))
38543
+ WHERE object_type = 'projects' AND deleted_at IS NULL AND COALESCE(payload->>'task_list_id', '') <> ''`
38544
+ ];
38545
+ }
38546
+ function postgresTodosScopedSlugIndexStatusSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
38547
+ assertSafeIdentifier(tableName);
38548
+ return `/* todos:scoped-slug-index-status */ SELECT index_class.relname AS index_name,
38549
+ index_meta.indisvalid AS is_valid, index_meta.indisready AS is_ready
38550
+ FROM pg_index index_meta
38551
+ JOIN pg_class index_class ON index_class.oid = index_meta.indexrelid
38552
+ WHERE index_meta.indrelid = to_regclass('${tableName}')
38553
+ AND index_class.relname IN (
38554
+ '${tableName}_task_list_scope_slug_uidx',
38555
+ '${tableName}_project_task_list_slug_uidx'
38556
+ )`;
38557
+ }
38558
+ async function ensurePostgresScopedSlugUniqueIndexes(client, tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
38559
+ const audit = await client.query(postgresTodosScopedSlugPreflightSql(tableName));
38560
+ if (audit.rows.length > 0)
38561
+ throw new PostgresScopedSlugMigrationConflictError(audit.rows);
38562
+ for (const sql of postgresTodosScopedSlugUniqueIndexSql(tableName)) {
38563
+ try {
38564
+ await client.query(sql);
38565
+ } catch (error) {
38566
+ const indexName = sql.match(/INDEX CONCURRENTLY IF NOT EXISTS ([a-zA-Z0-9_]+)/)?.[1] ?? "unknown_index";
38567
+ throw new PostgresScopedSlugIndexBuildError(indexName, error);
38568
+ }
38569
+ }
38570
+ const expected = new Set([
38571
+ `${tableName}_task_list_scope_slug_uidx`,
38572
+ `${tableName}_project_task_list_slug_uidx`
38573
+ ]);
38574
+ const status = await client.query(postgresTodosScopedSlugIndexStatusSql(tableName));
38575
+ for (const indexName of expected) {
38576
+ const row = status.rows.find((candidate) => candidate.index_name === indexName);
38577
+ if (!row?.is_valid || !row.is_ready) {
38578
+ throw new PostgresScopedSlugIndexBuildError(indexName, new Error("index is missing, invalid, or not ready"));
38579
+ }
38580
+ }
38581
+ }
38244
38582
  function postgresTodosCommentCursorIndexSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
38245
38583
  assertSafeIdentifier(tableName);
38246
38584
  return `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_comment_task_created_idx
@@ -38269,9 +38607,31 @@ class PostgresTodosSyncStore {
38269
38607
  }
38270
38608
  }
38271
38609
  async pushSnapshot(snapshot, context = {}) {
38610
+ const routingErrors = validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists);
38611
+ if (routingErrors.length > 0) {
38612
+ throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
38613
+ }
38614
+ const existing = await this.client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
38615
+ FROM ${this.tableName}
38616
+ WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
38617
+ const existingProjects = [];
38618
+ const existingTaskLists = [];
38619
+ for (const row of existing.rows) {
38620
+ const payload = payloadRecord(row.payload);
38621
+ if (row.object_type === "projects")
38622
+ existingProjects.push(payload);
38623
+ if (row.object_type === "task_lists")
38624
+ existingTaskLists.push(payload);
38625
+ }
38626
+ const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
38627
+ if (destinationErrors.length > 0) {
38628
+ throw new Error(`Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
38629
+ }
38272
38630
  const result = { records: 0, objectTypes: {} };
38273
38631
  const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
38274
38632
  for (const entry of snapshotEntries(snapshot)) {
38633
+ if (entry.deletedAt === null)
38634
+ assertCanonicalScopedSlugEntry(entry);
38275
38635
  await this.client.query(`INSERT INTO ${this.tableName} (
38276
38636
  service, object_type, object_id, payload, updated_at,
38277
38637
  deleted_at, source_machine_id, version
@@ -38350,6 +38710,22 @@ function snapshotEntries(snapshot) {
38350
38710
  }))
38351
38711
  ];
38352
38712
  }
38713
+ function assertCanonicalScopedSlugEntry(entry) {
38714
+ if (!entry.payload || typeof entry.payload !== "object" || Array.isArray(entry.payload))
38715
+ return;
38716
+ const payload = entry.payload;
38717
+ if (entry.type === "projects" && !isCanonicalSlug(payload["task_list_id"])) {
38718
+ throw new Error("Invalid project task-list slug \u2014 sync requires non-empty canonical kebab-case");
38719
+ }
38720
+ if (entry.type === "task_lists") {
38721
+ if (!isCanonicalSlug(payload["slug"])) {
38722
+ throw new Error("Invalid task-list slug \u2014 sync requires non-empty canonical kebab-case");
38723
+ }
38724
+ if (!isValidTaskListProjectScope(payload["project_id"])) {
38725
+ throw new Error("Invalid task-list project scope \u2014 project_id must be null, missing, or a non-empty string");
38726
+ }
38727
+ }
38728
+ }
38353
38729
  function entry(type, payload, fallbackUpdatedAt) {
38354
38730
  const id = payload["id"];
38355
38731
  if (typeof id !== "string" || !id)
@@ -38432,7 +38808,26 @@ function assertSafeIdentifier(value) {
38432
38808
  if (!/^[a-z_][a-z0-9_]*$/i.test(value))
38433
38809
  throw new Error(`Unsafe Postgres identifier: ${value}`);
38434
38810
  }
38435
- var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records", DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors";
38811
+ var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records", DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors", PostgresScopedSlugMigrationConflictError, PostgresScopedSlugIndexBuildError;
38812
+ var init_postgres_sync = __esm(() => {
38813
+ PostgresScopedSlugMigrationConflictError = class PostgresScopedSlugMigrationConflictError extends Error {
38814
+ conflicts;
38815
+ constructor(conflicts) {
38816
+ const preview = conflicts.slice(0, 5).map((conflict) => `${conflict.object_type}:${conflict.scope || "global"}:${conflict.slug} [${conflict.object_ids.join(", ")}]`).join("; ");
38817
+ 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.");
38818
+ this.conflicts = conflicts;
38819
+ this.name = "PostgresScopedSlugMigrationConflictError";
38820
+ }
38821
+ };
38822
+ PostgresScopedSlugIndexBuildError = class PostgresScopedSlugIndexBuildError extends Error {
38823
+ index_name;
38824
+ constructor(index_name, cause) {
38825
+ 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 });
38826
+ this.index_name = index_name;
38827
+ this.name = "PostgresScopedSlugIndexBuildError";
38828
+ }
38829
+ };
38830
+ });
38436
38831
 
38437
38832
  // src/storage/shadow-outbox.ts
38438
38833
  class TodosShadowOutbox {
@@ -38684,6 +39079,7 @@ function createTodosShadowOutbox(options) {
38684
39079
  var MAX_BACKOFF_MS;
38685
39080
  var init_shadow_outbox = __esm(() => {
38686
39081
  init_local_sqlite();
39082
+ init_postgres_sync();
38687
39083
  init_shadow_outbox_schema();
38688
39084
  init_shadow_outbox_schema();
38689
39085
  MAX_BACKOFF_MS = 5 * 60000;
@@ -39188,6 +39584,7 @@ function createPostgresTodosStorageAdapter(options) {
39188
39584
  getByPath: async (path) => (await store.list("projects")).find((project) => project.path === path) ?? null,
39189
39585
  list: async () => (await store.list("projects")).sort((a, b) => a.name.localeCompare(b.name)),
39190
39586
  update: (id, input) => updateProject2(id, input, store),
39587
+ rename: (id, input, context) => store.renameProject(id, input.new_slug, input.name, context),
39191
39588
  delete: (id, context) => store.delete("projects", id, context)
39192
39589
  },
39193
39590
  plans: {
@@ -39425,9 +39822,22 @@ class PostgresJsonRecordStore {
39425
39822
  });
39426
39823
  }
39427
39824
  async upsert(type, value, context = {}) {
39825
+ if (type === "projects" && !isCanonicalSlug(value.task_list_id)) {
39826
+ throw new Error("Invalid project task-list slug \u2014 imports require non-empty canonical kebab-case");
39827
+ }
39828
+ if (type === "task_lists") {
39829
+ if (!isCanonicalSlug(value.slug)) {
39830
+ throw new Error("Invalid task-list slug \u2014 imports require non-empty canonical kebab-case");
39831
+ }
39832
+ if (!isValidTaskListProjectScope(value.project_id)) {
39833
+ throw new Error("Invalid task-list project scope \u2014 project_id must be null, missing, or a non-empty string");
39834
+ }
39835
+ }
39428
39836
  await this.ensureSchema();
39429
39837
  const updatedAt = stringValue2(value.updated_at) ?? stringValue2(value.created_at) ?? new Date().toISOString();
39430
- const result = await this.options.client.query(`INSERT INTO ${this.tableName} (
39838
+ let result;
39839
+ try {
39840
+ result = await this.options.client.query(`INSERT INTO ${this.tableName} (
39431
39841
  service, object_type, object_id, payload, updated_at,
39432
39842
  deleted_at, source_machine_id, version
39433
39843
  ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, NULL, $6, $7)
@@ -39442,14 +39852,23 @@ class PostgresJsonRecordStore {
39442
39852
  OR (${this.tableName}.updated_at = EXCLUDED.updated_at
39443
39853
  AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
39444
39854
  RETURNING object_id`, [
39445
- this.service,
39446
- type,
39447
- value.id,
39448
- jsonbParam(value),
39449
- updatedAt,
39450
- context.requestId ?? this.sourceMachineId ?? null,
39451
- numberValue2(value.version)
39452
- ]);
39855
+ this.service,
39856
+ type,
39857
+ value.id,
39858
+ jsonbParam(value),
39859
+ updatedAt,
39860
+ context.requestId ?? this.sourceMachineId ?? null,
39861
+ numberValue2(value.version)
39862
+ ]);
39863
+ } catch (error) {
39864
+ if (type === "task_lists" && isPostgresUniqueViolation(error)) {
39865
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${String(value.slug ?? "")}" already exists in this scope`);
39866
+ }
39867
+ if (type === "projects" && isPostgresUniqueViolation(error)) {
39868
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${String(value.task_list_id ?? "")}" already exists`);
39869
+ }
39870
+ throw error;
39871
+ }
39453
39872
  if (result.rows.length === 0) {
39454
39873
  const current = await this.get(type, value.id);
39455
39874
  if (current)
@@ -39457,6 +39876,93 @@ class PostgresJsonRecordStore {
39457
39876
  }
39458
39877
  return value;
39459
39878
  }
39879
+ async renameProject(id, newSlug, name, context = {}) {
39880
+ await this.ensureSchema();
39881
+ const normalizedSlug = slugifyRaw(newSlug);
39882
+ if (!normalizedSlug)
39883
+ throw new Error("Invalid slug \u2014 must be non-empty kebab-case");
39884
+ const timestamp = new Date().toISOString();
39885
+ try {
39886
+ const result = await this.options.client.query(`/* todos:rename-project-atomic */ WITH target AS (
39887
+ SELECT payload, payload->>'task_list_id' AS old_slug
39888
+ FROM ${this.tableName}
39889
+ WHERE service = $1 AND object_type = 'projects' AND object_id = $2 AND deleted_at IS NULL
39890
+ FOR UPDATE
39891
+ ), project_conflict AS (
39892
+ SELECT 1 FROM ${this.tableName}
39893
+ WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
39894
+ AND deleted_at IS NULL AND payload->>'task_list_id' = $3 LIMIT 1
39895
+ ), task_list_conflict AS (
39896
+ SELECT 1 FROM ${this.tableName} r, target
39897
+ WHERE r.service = $1 AND r.object_type = 'task_lists' AND r.deleted_at IS NULL
39898
+ AND r.payload->>'project_id' = $2 AND r.payload->>'slug' = $3
39899
+ AND r.payload->>'slug' IS DISTINCT FROM target.old_slug LIMIT 1
39900
+ ), updated_lists AS (
39901
+ UPDATE ${this.tableName} r SET
39902
+ payload = r.payload || jsonb_build_object('slug', $3::text, 'updated_at', $5::text)
39903
+ || CASE WHEN $4::text IS NULL THEN '{}'::jsonb ELSE jsonb_build_object('name', $4::text) END,
39904
+ updated_at = $5::timestamptz, version = COALESCE(r.version, 0) + 1,
39905
+ source_machine_id = COALESCE($6, r.source_machine_id)
39906
+ FROM target
39907
+ WHERE r.service = $1 AND r.object_type = 'task_lists' AND r.deleted_at IS NULL
39908
+ AND r.payload->>'project_id' = $2 AND r.payload->>'slug' = target.old_slug
39909
+ AND NOT EXISTS (SELECT 1 FROM project_conflict)
39910
+ AND NOT EXISTS (SELECT 1 FROM task_list_conflict)
39911
+ AND (target.old_slug IS DISTINCT FROM $3
39912
+ OR ($4::text IS NOT NULL AND r.payload->>'name' IS DISTINCT FROM $4))
39913
+ RETURNING 1
39914
+ ), updated_project AS (
39915
+ UPDATE ${this.tableName} r SET
39916
+ payload = r.payload || jsonb_build_object('task_list_id', $3::text, 'updated_at', $5::text)
39917
+ || CASE WHEN $4::text IS NULL THEN '{}'::jsonb ELSE jsonb_build_object('name', $4::text) END,
39918
+ updated_at = $5::timestamptz, version = COALESCE(r.version, 0) + 1,
39919
+ source_machine_id = COALESCE($6, r.source_machine_id)
39920
+ FROM target
39921
+ WHERE r.service = $1 AND r.object_type = 'projects' AND r.object_id = $2 AND r.deleted_at IS NULL
39922
+ AND NOT EXISTS (SELECT 1 FROM project_conflict)
39923
+ AND NOT EXISTS (SELECT 1 FROM task_list_conflict)
39924
+ AND (target.old_slug IS DISTINCT FROM $3
39925
+ OR ($4::text IS NOT NULL AND target.payload->>'name' IS DISTINCT FROM $4))
39926
+ RETURNING r.payload
39927
+ ) SELECT
39928
+ EXISTS (SELECT 1 FROM target) AS found,
39929
+ EXISTS (SELECT 1 FROM project_conflict) AS project_conflict,
39930
+ EXISTS (SELECT 1 FROM task_list_conflict) AS task_list_conflict,
39931
+ COALESCE((SELECT payload FROM updated_project), (SELECT payload FROM target)) AS project,
39932
+ (SELECT count(*) FROM updated_lists) AS task_lists_updated`, [this.service, id, normalizedSlug, name ?? null, timestamp, this.machineId(context)]);
39933
+ const row = result.rows[0];
39934
+ if (!row?.found)
39935
+ throw new ProjectNotFoundError(id);
39936
+ if (row.project_conflict) {
39937
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalizedSlug}" is already used by another project`);
39938
+ }
39939
+ if (row.task_list_conflict) {
39940
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalizedSlug}" is already used in this project`);
39941
+ }
39942
+ return {
39943
+ project: payloadRecord2(row.project),
39944
+ task_lists_updated: Number(row.task_lists_updated)
39945
+ };
39946
+ } catch (error) {
39947
+ if (isPostgresUniqueViolation(error)) {
39948
+ const constraintName = postgresConstraintName(error);
39949
+ let projectConflict = constraintName.includes("project_task_list_slug_uidx");
39950
+ if (!constraintName) {
39951
+ const conflict = await this.options.client.query(`/* todos:classify-project-rename-conflict */ SELECT EXISTS (
39952
+ SELECT 1 FROM ${this.tableName}
39953
+ WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
39954
+ AND deleted_at IS NULL AND payload->>'task_list_id' = $3
39955
+ ) AS project_conflict`, [this.service, id, normalizedSlug]);
39956
+ projectConflict = Boolean(conflict.rows[0]?.project_conflict);
39957
+ }
39958
+ if (projectConflict) {
39959
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalizedSlug}" is already used by another project`);
39960
+ }
39961
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalizedSlug}" is already used in this project`);
39962
+ }
39963
+ throw error;
39964
+ }
39965
+ }
39460
39966
  async incrementProjectTaskCounter(projectId, _context = {}) {
39461
39967
  await this.ensureSchema();
39462
39968
  const result = await this.options.client.query(`UPDATE ${this.tableName}
@@ -39876,12 +40382,16 @@ async function getChangedSince(since, filters, store) {
39876
40382
  }
39877
40383
  async function createProject2(input, store, context) {
39878
40384
  const timestamp = new Date().toISOString();
40385
+ const derivedSlug = slugifyRaw(input.name);
40386
+ const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugifyRaw(input.task_list_id);
40387
+ if (!derivedSlug || !taskListId)
40388
+ throw new Error("Project name and task-list slug must be non-empty");
39879
40389
  const project = {
39880
40390
  id: randomUUID3(),
39881
40391
  name: input.name,
39882
40392
  path: input.path,
39883
40393
  description: input.description ?? null,
39884
- task_list_id: input.task_list_id ?? `todos-${slugify2(input.name)}`,
40394
+ task_list_id: taskListId,
39885
40395
  task_prefix: input.task_prefix ?? await generateProjectPrefix(input.name, store),
39886
40396
  task_counter: 0,
39887
40397
  created_at: timestamp,
@@ -39892,6 +40402,9 @@ async function createProject2(input, store, context) {
39892
40402
  return store.upsert("projects", project, context);
39893
40403
  }
39894
40404
  async function updateProject2(id, input, store) {
40405
+ if ("task_list_id" in input) {
40406
+ throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
40407
+ }
39895
40408
  const project = await requireRecord("projects", id, store);
39896
40409
  const updated = { ...project, ...definedPatch(input), updated_at: new Date().toISOString() };
39897
40410
  return store.upsert("projects", updated);
@@ -40000,10 +40513,13 @@ async function releaseAgent2(idOrName, sessionId, store, context) {
40000
40513
  }
40001
40514
  async function createTaskList2(input, store, context) {
40002
40515
  const timestamp = new Date().toISOString();
40516
+ const slug = slugifyRaw(input.slug === undefined ? input.name : input.slug);
40517
+ if (!slug)
40518
+ throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
40003
40519
  return store.upsert("task_lists", {
40004
40520
  id: randomUUID3(),
40005
40521
  project_id: input.project_id ?? context?.projectId ?? null,
40006
- slug: input.slug ?? slugify2(input.name),
40522
+ slug,
40007
40523
  name: input.name,
40008
40524
  description: input.description ?? null,
40009
40525
  metadata: input.metadata ?? {},
@@ -40015,9 +40531,20 @@ async function createTaskList2(input, store, context) {
40015
40531
  }
40016
40532
  async function updateTaskList2(id, input, store) {
40017
40533
  const list = await requireRecord("task_lists", id, store);
40534
+ const patch = definedPatch(input);
40535
+ if (input.slug !== undefined) {
40536
+ const slug = slugifyRaw(input.slug);
40537
+ if (!slug)
40538
+ throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
40539
+ const duplicate = (await store.list("task_lists")).find((candidate) => candidate.id !== id && candidate.project_id === list.project_id && candidate.slug === slug);
40540
+ if (duplicate) {
40541
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
40542
+ }
40543
+ patch.slug = slug;
40544
+ }
40018
40545
  return store.upsert("task_lists", {
40019
40546
  ...list,
40020
- ...definedPatch(input),
40547
+ ...patch,
40021
40548
  metadata: input.metadata ?? list.metadata,
40022
40549
  updated_at: new Date().toISOString()
40023
40550
  });
@@ -40101,6 +40628,16 @@ async function exportSnapshot(store) {
40101
40628
  }
40102
40629
  async function importSnapshot(snapshot, store, context) {
40103
40630
  const result = { inserted: 0, updated: 0, deleted: 0, skipped: 0, errors: [] };
40631
+ result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
40632
+ if (result.errors.length > 0)
40633
+ return result;
40634
+ const [existingProjects, existingTaskLists] = await Promise.all([
40635
+ store.list("projects"),
40636
+ store.list("task_lists")
40637
+ ]);
40638
+ result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
40639
+ if (result.errors.length > 0)
40640
+ return result;
40104
40641
  const entries = [
40105
40642
  ...snapshot.tasks.map((row) => ["tasks", row]),
40106
40643
  ...snapshot.projects.map((row) => ["projects", row]),
@@ -40171,10 +40708,7 @@ async function generateProjectPrefix(name, store) {
40171
40708
  return candidate;
40172
40709
  }
40173
40710
  function slugifyRaw(value) {
40174
- return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
40175
- }
40176
- function slugify2(value) {
40177
- return slugifyRaw(value) || "todos";
40711
+ return normalizeSlug(value);
40178
40712
  }
40179
40713
  function normalizePlanSlug2(value) {
40180
40714
  const slug = slugifyRaw(value);
@@ -40228,9 +40762,20 @@ function compareClock(left, right) {
40228
40762
  function numberValue2(value) {
40229
40763
  return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
40230
40764
  }
40765
+ function isPostgresUniqueViolation(error) {
40766
+ return typeof error === "object" && error !== null && error.code === "23505";
40767
+ }
40768
+ function postgresConstraintName(error) {
40769
+ if (typeof error !== "object" || error === null)
40770
+ return "";
40771
+ const candidate = error;
40772
+ const constraint = candidate.constraint ?? candidate.constraint_name;
40773
+ return typeof constraint === "string" ? constraint : "";
40774
+ }
40231
40775
  var CLOUD_LOCK_EXPIRY_MINUTES = 30, TASK_ORDER_BY = "ORDER BY CASE payload->>'priority' WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END ASC, payload->>'created_at' ASC, payload->>'id' ASC";
40232
40776
  var init_postgres_adapter = __esm(() => {
40233
40777
  init_types();
40778
+ init_postgres_sync();
40234
40779
  init_redaction();
40235
40780
  });
40236
40781
 
@@ -40331,6 +40876,7 @@ function isCommentRedactionBackfillComplete(result) {
40331
40876
  var COMMENT_REDACTION_BACKFILL_CONFIRMATION = "REDACT_STORED_TODOS_COMMENTS";
40332
40877
  var init_comment_redaction_backfill = __esm(() => {
40333
40878
  init_redaction();
40879
+ init_postgres_sync();
40334
40880
  });
40335
40881
 
40336
40882
  // src/server/cloud.ts
@@ -40344,6 +40890,7 @@ __export(exports_cloud, {
40344
40890
  getCloudVerifier: () => getCloudVerifier,
40345
40891
  getCloudStorageAdapter: () => getCloudStorageAdapter,
40346
40892
  getApiKeyStore: () => getApiKeyStore,
40893
+ ensureCloudScopedSlugUniqueIndexes: () => ensureCloudScopedSlugUniqueIndexes,
40347
40894
  ensureCloudSchema: () => ensureCloudSchema,
40348
40895
  ensureCloudCommentCursorIndex: () => ensureCloudCommentCursorIndex,
40349
40896
  closeCloud: () => closeCloud,
@@ -40428,6 +40975,9 @@ async function ensureCloudSchema() {
40428
40975
  async function ensureCloudCommentCursorIndex() {
40429
40976
  await getClient().query(postgresTodosCommentCursorIndexSql());
40430
40977
  }
40978
+ async function ensureCloudScopedSlugUniqueIndexes() {
40979
+ await ensurePostgresScopedSlugUniqueIndexes(getClient());
40980
+ }
40431
40981
  async function normalizeCloudPayloads() {
40432
40982
  const client = getClient();
40433
40983
  const res = await client.query(`UPDATE todos_sync_records
@@ -40459,6 +41009,7 @@ var init_cloud = __esm(() => {
40459
41009
  init_auth();
40460
41010
  init_cloud_client();
40461
41011
  init_postgres_adapter();
41012
+ init_postgres_sync();
40462
41013
  init_comment_redaction_backfill();
40463
41014
  });
40464
41015
 
@@ -40483,6 +41034,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
40483
41034
  schemas: {
40484
41035
  Task: taskSchema,
40485
41036
  Project: projectSchema,
41037
+ TaskList: taskListSchema,
40486
41038
  TaskComment: taskCommentSchema,
40487
41039
  CreateTaskInput: {
40488
41040
  type: "object",
@@ -40511,12 +41063,65 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
40511
41063
  },
40512
41064
  CreateProjectInput: {
40513
41065
  type: "object",
41066
+ additionalProperties: false,
40514
41067
  required: ["name", "path"],
40515
41068
  properties: {
41069
+ name: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
41070
+ path: { type: "string", minLength: 1 },
41071
+ description: { type: "string" },
41072
+ task_list_id: { type: "string", minLength: 1, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
41073
+ task_prefix: { type: "string", minLength: 1 }
41074
+ }
41075
+ },
41076
+ UpdateProjectInput: {
41077
+ type: "object",
41078
+ additionalProperties: false,
41079
+ minProperties: 1,
41080
+ properties: {
41081
+ name: { type: "string", minLength: 1 },
41082
+ path: { type: "string", minLength: 1 },
41083
+ description: { type: "string", nullable: true }
41084
+ }
41085
+ },
41086
+ RenameProjectInput: {
41087
+ type: "object",
41088
+ additionalProperties: false,
41089
+ required: ["new_slug"],
41090
+ properties: {
41091
+ new_slug: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
41092
+ name: { type: "string", minLength: 1 }
41093
+ }
41094
+ },
41095
+ ErrorResponse: {
41096
+ type: "object",
41097
+ required: ["error"],
41098
+ properties: {
41099
+ error: { type: "string" },
41100
+ code: { type: "string" },
41101
+ conflict: { type: "boolean" }
41102
+ }
41103
+ },
41104
+ CreateTaskListInput: {
41105
+ type: "object",
41106
+ additionalProperties: false,
41107
+ required: ["name"],
41108
+ properties: {
41109
+ name: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
41110
+ slug: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
41111
+ project_id: { type: "string" },
41112
+ description: { type: "string" },
41113
+ metadata: { type: "object", additionalProperties: true }
41114
+ }
41115
+ },
41116
+ UpdateTaskListInput: {
41117
+ type: "object",
41118
+ additionalProperties: false,
41119
+ minProperties: 1,
41120
+ properties: {
41121
+ slug: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
40516
41122
  name: { type: "string" },
40517
- path: { type: "string" },
40518
41123
  description: { type: "string" },
40519
- task_prefix: { type: "string" }
41124
+ metadata: { type: "object", additionalProperties: true }
40520
41125
  }
40521
41126
  },
40522
41127
  CreateTaskCommentInput: {
@@ -40725,7 +41330,10 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
40725
41330
  required: true,
40726
41331
  content: { "application/json": { schema: { $ref: "#/components/schemas/CreateProjectInput" } } }
40727
41332
  },
40728
- responses: { "201": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" } } } } } } }
41333
+ responses: {
41334
+ "201": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" } } } } } },
41335
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
41336
+ }
40729
41337
  }
40730
41338
  },
40731
41339
  "/v1/projects/{id}": {
@@ -40734,6 +41342,86 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
40734
41342
  summary: "Get a project by id",
40735
41343
  parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
40736
41344
  responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" } } } } } } }
41345
+ },
41346
+ patch: {
41347
+ operationId: "updateProject",
41348
+ summary: "Update a project",
41349
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
41350
+ requestBody: {
41351
+ required: true,
41352
+ content: { "application/json": { schema: { $ref: "#/components/schemas/UpdateProjectInput" } } }
41353
+ },
41354
+ responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" } } } } } } }
41355
+ },
41356
+ delete: {
41357
+ operationId: "deleteProject",
41358
+ summary: "Delete a project",
41359
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
41360
+ responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean" }, id: { type: "string" } } } } } } }
41361
+ }
41362
+ },
41363
+ "/v1/projects/{id}/rename": {
41364
+ post: {
41365
+ operationId: "renameProject",
41366
+ summary: "Atomically rename a project and its canonical task list",
41367
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
41368
+ requestBody: {
41369
+ required: true,
41370
+ content: { "application/json": { schema: { $ref: "#/components/schemas/RenameProjectInput" } } }
41371
+ },
41372
+ responses: {
41373
+ "200": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" }, task_lists_updated: { type: "number" } } } } } },
41374
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
41375
+ }
41376
+ }
41377
+ },
41378
+ "/v1/task-lists": {
41379
+ get: {
41380
+ operationId: "listTaskLists",
41381
+ summary: "List task lists",
41382
+ parameters: [{ name: "project_id", in: "query", schema: { type: "string" } }],
41383
+ responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { task_lists: { type: "array", items: { $ref: "#/components/schemas/TaskList" } }, count: { type: "number" } } } } } } }
41384
+ },
41385
+ post: {
41386
+ operationId: "createTaskList",
41387
+ summary: "Create a task list",
41388
+ requestBody: {
41389
+ required: true,
41390
+ content: { "application/json": { schema: { $ref: "#/components/schemas/CreateTaskListInput" } } }
41391
+ },
41392
+ responses: {
41393
+ "201": { content: { "application/json": { schema: { type: "object", properties: { task_list: { $ref: "#/components/schemas/TaskList" } } } } } },
41394
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
41395
+ }
41396
+ }
41397
+ },
41398
+ "/v1/task-lists/{id}": {
41399
+ get: {
41400
+ operationId: "getTaskList",
41401
+ summary: "Get a task list by id",
41402
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
41403
+ responses: {
41404
+ "200": { content: { "application/json": { schema: { type: "object", properties: { task_list: { $ref: "#/components/schemas/TaskList" } } } } } }
41405
+ }
41406
+ },
41407
+ patch: {
41408
+ operationId: "updateTaskList",
41409
+ summary: "Update a task list",
41410
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
41411
+ requestBody: {
41412
+ required: true,
41413
+ content: { "application/json": { schema: { $ref: "#/components/schemas/UpdateTaskListInput" } } }
41414
+ },
41415
+ responses: {
41416
+ "200": { content: { "application/json": { schema: { type: "object", properties: { task_list: { $ref: "#/components/schemas/TaskList" } } } } } },
41417
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
41418
+ }
41419
+ },
41420
+ delete: {
41421
+ operationId: "deleteTaskList",
41422
+ summary: "Delete a task list",
41423
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
41424
+ responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean" }, id: { type: "string" } } } } } } }
40737
41425
  }
40738
41426
  },
40739
41427
  "/v1/stats": {
@@ -40800,7 +41488,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
40800
41488
  }
40801
41489
  };
40802
41490
  }
40803
- var taskSchema, projectSchema, taskCommentSchema;
41491
+ var taskSchema, projectSchema, taskListSchema, taskCommentSchema;
40804
41492
  var init_openapi = __esm(() => {
40805
41493
  init_package_version();
40806
41494
  taskSchema = {
@@ -40827,6 +41515,22 @@ var init_openapi = __esm(() => {
40827
41515
  name: { type: "string" },
40828
41516
  path: { type: "string" },
40829
41517
  description: { type: "string", nullable: true },
41518
+ task_list_id: { type: "string", nullable: true },
41519
+ task_prefix: { type: "string", nullable: true },
41520
+ task_counter: { type: "number" },
41521
+ created_at: { type: "string" },
41522
+ updated_at: { type: "string" }
41523
+ }
41524
+ };
41525
+ taskListSchema = {
41526
+ type: "object",
41527
+ properties: {
41528
+ id: { type: "string" },
41529
+ project_id: { type: "string", nullable: true },
41530
+ slug: { type: "string" },
41531
+ name: { type: "string" },
41532
+ description: { type: "string", nullable: true },
41533
+ metadata: { type: "object", additionalProperties: true },
40830
41534
  created_at: { type: "string" },
40831
41535
  updated_at: { type: "string" }
40832
41536
  }
@@ -40860,6 +41564,48 @@ function json2(body, status = 200) {
40860
41564
  function error(status, message, extra) {
40861
41565
  return json2({ error: message, ...extra ?? {} }, status);
40862
41566
  }
41567
+ function validateProjectPatch(value) {
41568
+ if (!value || typeof value !== "object" || Array.isArray(value))
41569
+ return { ok: false, message: "project patch must be an object" };
41570
+ const body = value;
41571
+ const allowed = new Set(["name", "path", "description"]);
41572
+ const unknown = Object.keys(body).find((key) => !allowed.has(key));
41573
+ if (unknown)
41574
+ return { ok: false, message: `unknown project field: ${unknown}` };
41575
+ if (Object.keys(body).length === 0)
41576
+ return { ok: false, message: "project patch must not be empty" };
41577
+ if (body["name"] !== undefined && (typeof body["name"] !== "string" || !body["name"].trim()))
41578
+ return { ok: false, message: "name must be a non-empty string" };
41579
+ if (body["path"] !== undefined && (typeof body["path"] !== "string" || !body["path"].trim()))
41580
+ return { ok: false, message: "path must be a non-empty string" };
41581
+ if (body["description"] !== undefined && body["description"] !== null && typeof body["description"] !== "string")
41582
+ return { ok: false, message: "description must be a string or null" };
41583
+ return { ok: true, patch: body };
41584
+ }
41585
+ function validateProjectCreate(value) {
41586
+ if (!value || typeof value !== "object" || Array.isArray(value))
41587
+ return { ok: false, message: "project body must be an object" };
41588
+ const body = value;
41589
+ const allowed = new Set(["name", "path", "description", "task_list_id", "task_prefix"]);
41590
+ const unknown = Object.keys(body).find((key) => !allowed.has(key));
41591
+ if (unknown)
41592
+ return { ok: false, message: `unknown project field: ${unknown}` };
41593
+ if (typeof body["name"] !== "string" || !body["name"].trim())
41594
+ return { ok: false, message: "name must be a non-empty string" };
41595
+ if (!normalizeSlug(body["name"]))
41596
+ return { ok: false, message: "name must produce a non-empty canonical slug" };
41597
+ if (typeof body["path"] !== "string" || !body["path"].trim())
41598
+ return { ok: false, message: "path must be a non-empty string" };
41599
+ if (body["description"] !== undefined && typeof body["description"] !== "string")
41600
+ return { ok: false, message: "description must be a string" };
41601
+ if (body["task_list_id"] !== undefined && !isCanonicalSlug(body["task_list_id"])) {
41602
+ return { ok: false, message: "task_list_id must be non-empty canonical kebab-case" };
41603
+ }
41604
+ if (body["task_prefix"] !== undefined && (typeof body["task_prefix"] !== "string" || !body["task_prefix"].trim())) {
41605
+ return { ok: false, message: "task_prefix must be a non-empty string" };
41606
+ }
41607
+ return { ok: true, input: body };
41608
+ }
40863
41609
  async function readJson(req) {
40864
41610
  try {
40865
41611
  const text = await req.text();
@@ -41340,14 +42086,31 @@ async function handleV1Request(req, url, dependencies = {}) {
41340
42086
  }
41341
42087
  if (method === "POST") {
41342
42088
  const body = await readJson(req);
41343
- if (!body || typeof body.name !== "string" || typeof body.path !== "string") {
41344
- return error(400, "name and path are required");
41345
- }
41346
- const project = await store.projects.create(body, contextFromPrincipal(principal));
42089
+ if (!body)
42090
+ return error(400, "invalid JSON body");
42091
+ const validated = validateProjectCreate(body);
42092
+ if (!validated.ok)
42093
+ return error(400, validated.message);
42094
+ const project = await store.projects.create(validated.input, contextFromPrincipal(principal));
41347
42095
  return json2({ project }, 201);
41348
42096
  }
41349
42097
  return error(405, `method ${method} not allowed on /v1/projects`);
41350
42098
  }
42099
+ if (action === "rename") {
42100
+ if (method !== "POST")
42101
+ return error(405, `method ${method} not allowed on /v1/projects/:id/rename`);
42102
+ const body = await readJson(req);
42103
+ if (!body || typeof body.new_slug !== "string" || !body.new_slug.trim() || !normalizeSlug(body.new_slug)) {
42104
+ return error(400, "new_slug must be a non-empty string");
42105
+ }
42106
+ if (body.name !== undefined && (typeof body.name !== "string" || !body.name.trim())) {
42107
+ return error(400, "name must be a non-empty string");
42108
+ }
42109
+ const unknownField = Object.keys(body).find((key) => !["new_slug", "name"].includes(key));
42110
+ if (unknownField)
42111
+ return error(400, `unknown project rename field: ${unknownField}`);
42112
+ return json2(await store.projects.rename(id, body, contextFromPrincipal(principal)));
42113
+ }
41351
42114
  if (method === "GET") {
41352
42115
  const project = await store.projects.get(id);
41353
42116
  return project ? json2({ project }) : error(404, "project not found");
@@ -41356,8 +42119,13 @@ async function handleV1Request(req, url, dependencies = {}) {
41356
42119
  const body = await readJson(req);
41357
42120
  if (!body)
41358
42121
  return error(400, "invalid JSON body");
41359
- const project = await store.projects.update(id, body);
41360
- return project ? json2({ project }) : error(404, "project not found");
42122
+ const validated = validateProjectPatch(body);
42123
+ if (!validated.ok)
42124
+ return error(400, validated.message);
42125
+ if (!await store.projects.get(id))
42126
+ return error(404, "project not found");
42127
+ const project = await store.projects.update(id, validated.patch);
42128
+ return json2({ project });
41361
42129
  }
41362
42130
  if (method === "DELETE") {
41363
42131
  await store.projects.delete(id, contextFromPrincipal(principal));
@@ -41444,6 +42212,21 @@ async function handleV1Request(req, url, dependencies = {}) {
41444
42212
  const body = await readJson(req);
41445
42213
  if (!body || typeof body.name !== "string" || !body.name.trim())
41446
42214
  return error(400, "name is required");
42215
+ const unknownField = Object.keys(body).find((key) => !["name", "slug", "project_id", "description", "metadata"].includes(key));
42216
+ if (unknownField)
42217
+ return error(400, `unsupported task-list create field: ${unknownField}`);
42218
+ if (body.slug !== undefined && typeof body.slug !== "string")
42219
+ return error(400, "slug must be a string");
42220
+ if (body.project_id !== undefined && (typeof body.project_id !== "string" || !body.project_id.trim()))
42221
+ return error(400, "project_id must be a non-empty string");
42222
+ if (body.description !== undefined && typeof body.description !== "string")
42223
+ return error(400, "description must be a string");
42224
+ if (body.metadata !== undefined && (!body.metadata || typeof body.metadata !== "object" || Array.isArray(body.metadata))) {
42225
+ return error(400, "metadata must be an object");
42226
+ }
42227
+ if (!normalizeSlug(body.slug === undefined ? body.name : body.slug)) {
42228
+ return error(400, "task-list slug must be non-empty kebab-case");
42229
+ }
41447
42230
  const taskList = await store.taskLists.create(body, contextFromPrincipal(principal));
41448
42231
  return json2({ task_list: taskList }, 201);
41449
42232
  }
@@ -41451,6 +42234,29 @@ async function handleV1Request(req, url, dependencies = {}) {
41451
42234
  const taskList = await store.taskLists.get(id);
41452
42235
  return taskList ? json2({ task_list: taskList }) : error(404, "task list not found");
41453
42236
  }
42237
+ if (id && (method === "PATCH" || method === "PUT")) {
42238
+ const body = await readJson(req);
42239
+ if (!body)
42240
+ return error(400, "invalid JSON body");
42241
+ const unknownField = Object.keys(body).find((key) => !["slug", "name", "description", "metadata"].includes(key));
42242
+ if (unknownField)
42243
+ return error(400, `unsupported task-list update field: ${unknownField}`);
42244
+ if (Object.keys(body).length === 0)
42245
+ return error(400, "task-list update must not be empty");
42246
+ if (body.slug !== undefined && (typeof body.slug !== "string" || !normalizeSlug(body.slug)))
42247
+ return error(400, "slug must be a non-empty string");
42248
+ if (body.name !== undefined && (typeof body.name !== "string" || !body.name.trim()))
42249
+ return error(400, "name must be a non-empty string");
42250
+ if (body.description !== undefined && typeof body.description !== "string")
42251
+ return error(400, "description must be a string");
42252
+ if (body.metadata !== undefined && (!body.metadata || typeof body.metadata !== "object" || Array.isArray(body.metadata))) {
42253
+ return error(400, "metadata must be an object");
42254
+ }
42255
+ if (!await store.taskLists.get(id))
42256
+ return error(404, "task list not found");
42257
+ const taskList = await store.taskLists.update(id, body);
42258
+ return json2({ task_list: taskList });
42259
+ }
41454
42260
  if (id && method === "DELETE") {
41455
42261
  const deleted = await store.taskLists.delete(id, contextFromPrincipal(principal));
41456
42262
  return deleted ? json2({ deleted: true, id }) : error(404, "task list not found");
@@ -41523,6 +42329,10 @@ async function handleV1Request(req, url, dependencies = {}) {
41523
42329
  } catch (e) {
41524
42330
  if (e instanceof LockError)
41525
42331
  return error(409, e.message, { code: LockError.code });
42332
+ if (e instanceof ResourceConflictError)
42333
+ return error(409, e.message, { code: e.code, conflict: true });
42334
+ if (e instanceof ProjectNotFoundError)
42335
+ return error(404, e.message, { code: ProjectNotFoundError.code });
41526
42336
  return error(500, e.message || "internal error");
41527
42337
  }
41528
42338
  }
@@ -79668,6 +80478,7 @@ function createHybridTodosStorageAdapter(options) {
79668
80478
  };
79669
80479
  }
79670
80480
  var init_hybrid = __esm(() => {
80481
+ init_postgres_sync();
79671
80482
  init_local_sqlite();
79672
80483
  });
79673
80484
 
@@ -79925,6 +80736,19 @@ function createShadowTodosStorageAdapter(options) {
79925
80736
  mirror.enqueueUpsert("projects", project, context);
79926
80737
  return project;
79927
80738
  },
80739
+ async rename(id, input, context) {
80740
+ const projectBefore = await local.projects.get(id, context);
80741
+ const cascadeCandidates = projectBefore?.task_list_id ? (await local.taskLists.list(id, context)).filter((list) => list.slug === projectBefore.task_list_id) : [];
80742
+ const result = await local.projects.rename(id, input, context);
80743
+ mirror.enqueueUpsert("projects", result.project, context);
80744
+ for (const candidate of cascadeCandidates) {
80745
+ const changed = await local.taskLists.get(candidate.id, context);
80746
+ if (changed && (changed.slug !== candidate.slug || changed.name !== candidate.name)) {
80747
+ mirror.enqueueUpsert("taskLists", changed, context);
80748
+ }
80749
+ }
80750
+ return result;
80751
+ },
79928
80752
  async delete(id, context) {
79929
80753
  const deleted = await local.projects.delete(id, context);
79930
80754
  if (deleted)
@@ -80027,6 +80851,7 @@ function isAgent(value) {
80027
80851
  var SNAPSHOT_TO_OBJECT_TYPE;
80028
80852
  var init_shadow = __esm(() => {
80029
80853
  init_local_sqlite();
80854
+ init_postgres_sync();
80030
80855
  SNAPSHOT_TO_OBJECT_TYPE = {
80031
80856
  tasks: "tasks",
80032
80857
  projects: "projects",
@@ -80542,6 +81367,9 @@ __export(exports_storage, {
80542
81367
  signAwsV4Request: () => signAwsV4Request,
80543
81368
  registerShadowExitFlush: () => registerShadowExitFlush,
80544
81369
  postgresTodosSyncSchemaSql: () => postgresTodosSyncSchemaSql,
81370
+ postgresTodosScopedSlugUniqueIndexSql: () => postgresTodosScopedSlugUniqueIndexSql,
81371
+ postgresTodosScopedSlugPreflightSql: () => postgresTodosScopedSlugPreflightSql,
81372
+ postgresTodosScopedSlugIndexStatusSql: () => postgresTodosScopedSlugIndexStatusSql,
80545
81373
  postgresTodosCommentCursorIndexSql: () => postgresTodosCommentCursorIndexSql,
80546
81374
  planRunArtifactsS3Sync: () => planRunArtifactsS3Sync,
80547
81375
  parseStorageMode: () => parseStorageMode,
@@ -80564,6 +81392,7 @@ __export(exports_storage, {
80564
81392
  getRuntimeShadowOutbox: () => getRuntimeShadowOutbox,
80565
81393
  getCanonicalTodosRdsConfig: () => getCanonicalTodosRdsConfig,
80566
81394
  exportSqliteTodosStorageSnapshot: () => exportSqliteTodosStorageSnapshot,
81395
+ ensurePostgresScopedSlugUniqueIndexes: () => ensurePostgresScopedSlugUniqueIndexes,
80567
81396
  downloadRunArtifactsFromS3: () => downloadRunArtifactsFromS3,
80568
81397
  createTodosStorageAdapter: () => createTodosStorageAdapter,
80569
81398
  createTodosShadowOutbox: () => createTodosShadowOutbox,
@@ -80589,6 +81418,8 @@ __export(exports_storage, {
80589
81418
  STORAGE_TABLES: () => STORAGE_TABLES,
80590
81419
  SHADOW_TRIGGER_TABLES: () => SHADOW_TRIGGER_TABLES,
80591
81420
  PostgresTodosSyncStore: () => PostgresTodosSyncStore,
81421
+ PostgresScopedSlugMigrationConflictError: () => PostgresScopedSlugMigrationConflictError,
81422
+ PostgresScopedSlugIndexBuildError: () => PostgresScopedSlugIndexBuildError,
80592
81423
  DEFAULT_TODOS_POSTGRES_SYNC_TABLE: () => DEFAULT_TODOS_POSTGRES_SYNC_TABLE,
80593
81424
  DEFAULT_TODOS_POSTGRES_CURSOR_TABLE: () => DEFAULT_TODOS_POSTGRES_CURSOR_TABLE,
80594
81425
  COMMENT_REDACTION_BACKFILL_CONFIRMATION: () => COMMENT_REDACTION_BACKFILL_CONFIRMATION,
@@ -80606,6 +81437,7 @@ var init_storage2 = __esm(() => {
80606
81437
  init_hybrid();
80607
81438
  init_local_sqlite();
80608
81439
  init_sqlite_snapshot();
81440
+ init_postgres_sync();
80609
81441
  init_comment_redaction_backfill();
80610
81442
  init_postgres_adapter();
80611
81443
  init_s3_artifacts();