@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/cli/index.js CHANGED
@@ -8783,6 +8783,7 @@ var init_redaction = __esm(() => {
8783
8783
  });
8784
8784
 
8785
8785
  // src/cli/cloud-router.ts
8786
+ import { resolve as resolvePath } from "path";
8786
8787
  function getTodosCloudClient(env = process.env) {
8787
8788
  if (_cache !== undefined)
8788
8789
  return _cache.client;
@@ -8866,6 +8867,39 @@ async function cloudListProjects(client) {
8866
8867
  const envelope = res.raw;
8867
8868
  return Array.isArray(envelope?.projects) ? envelope.projects : res.items;
8868
8869
  }
8870
+ function cloudProjectSlug(value) {
8871
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
8872
+ }
8873
+ function cloudProjectPathBasename(value) {
8874
+ return value.replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? value;
8875
+ }
8876
+ function uniqueProjectMatches(projects, predicate) {
8877
+ return [...new Map(projects.filter(predicate).map((project) => [project.id, project])).values()];
8878
+ }
8879
+ function resolveCloudProjectRef(projects, ref) {
8880
+ const input = ref.trim();
8881
+ const normalizedRef = input.toLowerCase();
8882
+ const pathLike = input.startsWith(".") || input.includes("/") || input.includes("\\");
8883
+ const normalizedPath = pathLike ? resolvePath(input) : undefined;
8884
+ const slug = cloudProjectSlug(pathLike ? cloudProjectPathBasename(input) : input);
8885
+ const matchGroups = [
8886
+ uniqueProjectMatches(projects, (project) => project.id.toLowerCase() === normalizedRef),
8887
+ uniqueProjectMatches(projects, (project) => project.path === input || normalizedPath !== undefined && resolvePath(project.path) === normalizedPath),
8888
+ uniqueProjectMatches(projects, (project) => project.name.toLowerCase() === normalizedRef),
8889
+ uniqueProjectMatches(projects, (project) => project.task_list_id === input || cloudProjectSlug(project.name) === slug || cloudProjectSlug(cloudProjectPathBasename(project.path)) === slug),
8890
+ uniqueProjectMatches(projects, (project) => project.id.toLowerCase().startsWith(normalizedRef))
8891
+ ];
8892
+ for (const matches of matchGroups) {
8893
+ if (matches.length === 1)
8894
+ return matches[0].id;
8895
+ if (matches.length > 1)
8896
+ throw new Error(`Project reference is ambiguous: "${input}"`);
8897
+ }
8898
+ throw new Error(`Project not found: "${input}"`);
8899
+ }
8900
+ async function cloudResolveProjectRef(client, ref) {
8901
+ return resolveCloudProjectRef(await cloudListProjects(client), ref);
8902
+ }
8869
8903
  async function cloudListPlans(client, projectId) {
8870
8904
  const query = projectId ? { project_id: projectId } : {};
8871
8905
  const res = await client.list("plans", { query });
@@ -9168,12 +9202,30 @@ async function cloudListTaskLists(client, projectId) {
9168
9202
  return Array.isArray(raw) ? raw : [];
9169
9203
  }
9170
9204
  async function cloudResolveTaskListRef(client, ref, projectId) {
9205
+ const input = ref.trim();
9206
+ const normalizedIdRef = input.toLowerCase();
9207
+ if (UUID_RE.test(input) && !projectId)
9208
+ return normalizedIdRef;
9171
9209
  const lists = await cloudListTaskLists(client, projectId);
9172
- const exact = lists.find((list) => list.id === ref || list.slug === ref);
9173
- if (exact)
9174
- return exact.id;
9175
- const prefixes = lists.filter((list) => list.id.startsWith(ref));
9176
- return prefixes.length === 1 ? prefixes[0].id : null;
9210
+ const exactIds = lists.filter((list) => list.id.toLowerCase() === normalizedIdRef);
9211
+ if (exactIds.length === 1)
9212
+ return exactIds[0].id;
9213
+ if (exactIds.length > 1) {
9214
+ throw new Error(`Task list reference is ambiguous: "${input}"`);
9215
+ }
9216
+ const slugs = lists.filter((list) => list.slug === input);
9217
+ if (slugs.length === 1)
9218
+ return slugs[0].id;
9219
+ if (slugs.length > 1) {
9220
+ throw new Error(`Task list reference is ambiguous: "${input}"`);
9221
+ }
9222
+ const prefixes = lists.filter((list) => list.id.toLowerCase().startsWith(normalizedIdRef));
9223
+ if (prefixes.length === 1)
9224
+ return prefixes[0].id;
9225
+ if (prefixes.length > 1) {
9226
+ throw new Error(`Task list reference is ambiguous: "${input}"`);
9227
+ }
9228
+ throw new Error(`Task list not found: "${input}"`);
9177
9229
  }
9178
9230
  async function cloudCreateTaskList(client, input) {
9179
9231
  const raw = await client.transport.post("/task-lists", input);
@@ -9182,6 +9234,13 @@ async function cloudCreateTaskList(client, input) {
9182
9234
  }
9183
9235
  return raw;
9184
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
+ }
9185
9244
  async function cloudDeleteTaskList(client, id) {
9186
9245
  await client.delete("task-lists", id);
9187
9246
  return true;
@@ -9309,10 +9368,11 @@ async function cloudTimeline(client, options = {}) {
9309
9368
  const limit = options.limit ?? 50;
9310
9369
  return { entries: entries.slice(offset, offset + limit), total, limit, offset };
9311
9370
  }
9312
- var _cache, PRIORITY_RANK;
9371
+ var UUID_RE, _cache, PRIORITY_RANK;
9313
9372
  var init_cloud_router = __esm(() => {
9314
9373
  init_storage();
9315
9374
  init_redaction();
9375
+ UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9316
9376
  PRIORITY_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
9317
9377
  });
9318
9378
 
@@ -11025,6 +11085,95 @@ function ensureSchema(db) {
11025
11085
  )`);
11026
11086
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_project_machine_paths_project ON project_machine_paths(project_id)");
11027
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`);
11028
11177
  ensureTable("storage_tombstones", `
11029
11178
  CREATE TABLE storage_tombstones (
11030
11179
  id TEXT PRIMARY KEY,
@@ -12438,7 +12587,7 @@ var init_database = __esm(() => {
12438
12587
  });
12439
12588
 
12440
12589
  // src/types/index.ts
12441
- 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;
12442
12591
  var init_types = __esm(() => {
12443
12592
  TASK_STATUSES = [
12444
12593
  "pending",
@@ -12487,6 +12636,14 @@ var init_types = __esm(() => {
12487
12636
  this.name = "ProjectNotFoundError";
12488
12637
  }
12489
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
+ };
12490
12647
  PlanNotFoundError = class PlanNotFoundError extends Error {
12491
12648
  planId;
12492
12649
  static code = "PLAN_NOT_FOUND";
@@ -12639,6 +12796,91 @@ var init_storage_tombstones = __esm(() => {
12639
12796
  init_machines();
12640
12797
  });
12641
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
+
12642
12884
  // src/db/projects.ts
12643
12885
  var exports_projects = {};
12644
12886
  __export(exports_projects, {
@@ -12662,7 +12904,7 @@ __export(exports_projects, {
12662
12904
  addProjectSource: () => addProjectSource
12663
12905
  });
12664
12906
  function slugify(name) {
12665
- return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
12907
+ return normalizeSlug(name);
12666
12908
  }
12667
12909
  function generatePrefix(name, db) {
12668
12910
  const words = name.replace(/[^a-zA-Z0-9\s]/g, "").trim().split(/\s+/);
@@ -12686,14 +12928,23 @@ function generatePrefix(name, db) {
12686
12928
  }
12687
12929
  function createProject(input, db) {
12688
12930
  const d = db || getDatabase();
12689
- const id = uuid();
12690
- const timestamp = now();
12691
- const taskListId = input.task_list_id ?? `todos-${slugify(input.name)}`;
12692
- const taskPrefix = input.task_prefix || generatePrefix(input.name, d);
12693
- const machineId = currentStorageMachineId(d);
12694
- d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, created_at, updated_at, machine_id)
12695
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, timestamp, timestamp, machineId]);
12696
- 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
+ })();
12697
12948
  }
12698
12949
  function getProject(id, db) {
12699
12950
  const d = db || getDatabase();
@@ -12721,6 +12972,9 @@ function updateProject(id, input, db) {
12721
12972
  const project = getProject(id, d);
12722
12973
  if (!project)
12723
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
+ }
12724
12978
  const sets = ["updated_at = ?"];
12725
12979
  const params = [now()];
12726
12980
  if (input.name !== undefined) {
@@ -12731,10 +12985,6 @@ function updateProject(id, input, db) {
12731
12985
  sets.push("description = ?");
12732
12986
  params.push(input.description);
12733
12987
  }
12734
- if (input.task_list_id !== undefined) {
12735
- sets.push("task_list_id = ?");
12736
- params.push(input.task_list_id);
12737
- }
12738
12988
  if (input.path !== undefined) {
12739
12989
  sets.push("path = ?");
12740
12990
  params.push(input.path);
@@ -12745,29 +12995,41 @@ function updateProject(id, input, db) {
12745
12995
  }
12746
12996
  function renameProject(id, input, db) {
12747
12997
  const d = db || getDatabase();
12748
- const project = getProject(id, d);
12749
- if (!project)
12750
- throw new ProjectNotFoundError(id);
12751
- let taskListsUpdated = 0;
12752
- const ts = now();
12753
- if (input.new_slug !== undefined) {
12754
- const normalised = input.new_slug.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "");
12755
- if (!normalised)
12756
- throw new Error("Invalid slug \u2014 must be non-empty kebab-case");
12757
- const conflict = d.query("SELECT id FROM projects WHERE task_list_id = ? AND id != ?").get(normalised, id);
12758
- if (conflict)
12759
- throw new Error(`Slug "${normalised}" is already used by another project`);
12760
- const oldSlug = project.task_list_id;
12761
- d.run("UPDATE projects SET task_list_id = ?, updated_at = ? WHERE id = ?", [normalised, ts, id]);
12762
- if (oldSlug) {
12763
- 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]);
12764
- 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
+ }
12765
13027
  }
12766
- }
12767
- if (input.name !== undefined) {
12768
- d.run("UPDATE projects SET name = ?, updated_at = ? WHERE id = ?", [input.name, ts, id]);
12769
- }
12770
- 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
+ })();
12771
13033
  }
12772
13034
  function deleteProject(id, db) {
12773
13035
  const d = db || getDatabase();
@@ -12779,8 +13041,10 @@ function deleteProject(id, db) {
12779
13041
  object_id: id,
12780
13042
  payload: project
12781
13043
  }, d);
12782
- const result = d.run("DELETE FROM projects WHERE id = ?", [id]);
12783
- 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
+ })();
12784
13048
  }
12785
13049
  function rowToSource(row) {
12786
13050
  return {
@@ -14468,19 +14732,22 @@ function rowToTaskList(row) {
14468
14732
  }
14469
14733
  function createTaskList(input, db) {
14470
14734
  const d = db || getDatabase();
14471
- const id = uuid();
14472
- const timestamp = now();
14473
- const slug = input.slug || slugify(input.name);
14474
- const machineId = currentStorageMachineId(d);
14475
- if (!input.project_id) {
14476
- const existing = d.query("SELECT id FROM task_lists WHERE project_id IS NULL AND slug = ?").get(slug);
14477
- if (existing) {
14478
- throw new Error(`Standalone task list with slug "${slug}" already exists`);
14479
- }
14480
- }
14481
- d.run(`INSERT INTO task_lists (id, project_id, slug, name, description, metadata, created_at, updated_at, machine_id)
14482
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, input.project_id || null, slug, input.name, input.description || null, JSON.stringify(input.metadata || {}), timestamp, timestamp, machineId]);
14483
- 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
+ })();
14484
14751
  }
14485
14752
  function getTaskList(id, db) {
14486
14753
  const d = db || getDatabase();
@@ -14506,26 +14773,45 @@ function listTaskLists(projectId, db) {
14506
14773
  }
14507
14774
  function updateTaskList(id, input, db) {
14508
14775
  const d = db || getDatabase();
14509
- const existing = getTaskList(id, d);
14510
- if (!existing)
14511
- throw new TaskListNotFoundError(id);
14512
- const sets = ["updated_at = ?"];
14513
- const params = [now()];
14514
- if (input.name !== undefined) {
14515
- sets.push("name = ?");
14516
- params.push(input.name);
14517
- }
14518
- if (input.description !== undefined) {
14519
- sets.push("description = ?");
14520
- params.push(input.description);
14521
- }
14522
- if (input.metadata !== undefined) {
14523
- sets.push("metadata = ?");
14524
- params.push(JSON.stringify(input.metadata));
14525
- }
14526
- params.push(id);
14527
- d.run(`UPDATE task_lists SET ${sets.join(", ")} WHERE id = ?`, params);
14528
- 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
+ })();
14529
14815
  }
14530
14816
  function deleteTaskList(id, db) {
14531
14817
  const d = db || getDatabase();
@@ -14537,7 +14823,10 @@ function deleteTaskList(id, db) {
14537
14823
  object_id: id,
14538
14824
  payload: list
14539
14825
  }, d);
14540
- 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
+ })();
14541
14830
  }
14542
14831
  function ensureTaskList(name, slug, projectId, db) {
14543
14832
  const d = db || getDatabase();
@@ -20529,7 +20818,8 @@ function registerTaskCommands(program2) {
20529
20818
  if (cloud) {
20530
20819
  let task3;
20531
20820
  try {
20532
- const cloudProjectId = opts.project || globalOpts.project;
20821
+ const cloudProjectRef = opts.project || globalOpts.project;
20822
+ const cloudProjectId = cloudProjectRef ? await cloudResolveProjectRef(cloud, cloudProjectRef) : undefined;
20533
20823
  const cloudTaskListId = opts.list ? await cloudResolveTaskListRef(cloud, opts.list, cloudProjectId) : undefined;
20534
20824
  if (opts.list && !cloudTaskListId) {
20535
20825
  throw new Error(`Could not resolve task list ID or slug: ${opts.list}`);
@@ -20743,12 +21033,12 @@ function registerTaskCommands(program2) {
20743
21033
  console.log(` ${chalk2.dim("Manifest:")} ${state.pointers.latest_manifest_path}`);
20744
21034
  }
20745
21035
  });
20746
- program2.command("list").description("List tasks").option("-s, --status <status>", "Filter by status").option("-p, --priority <priority>", "Filter by priority").option("--assigned <agent>", "Filter by assigned agent").option("--tags <tags>", "Filter by tags (comma-separated)").option("--tag <tags>", "Filter by tags (alias for --tags)").option("-a, --all", "Show all tasks (including completed/cancelled)").option("--list <id>", "Filter by task list ID").option("--task-list <id>", "Filter by task list ID (alias for --list)").option("--project-name <name>", "Filter by project name").option("--agent-name <name>", "Filter by agent name/assigned").option("--sort <field>", "Sort by: updated, created, priority, status").option("--format <fmt>", "Output format: table (default), compact, csv, json").option("--due-today", "Only tasks due today or earlier").option("--overdue", "Only overdue tasks (past due_at)").option("--recurring", "Only recurring tasks").option("--limit <n>", "Max tasks to return").action(async (opts) => {
21036
+ program2.command("list").description("List tasks").option("-s, --status <status>", "Filter by status").option("-p, --priority <priority>", "Filter by priority").option("--assigned <agent>", "Filter by assigned agent").option("--tags <tags>", "Filter by tags (comma-separated)").option("--tag <tags>", "Filter by tags (alias for --tags)").option("-a, --all", "Show all tasks (including completed/cancelled)").option("--list <ref>", "Filter by task list UUID, unique UUID prefix, or project-scoped slug").option("--task-list <ref>", "Filter by task list UUID, unique UUID prefix, or project-scoped slug (alias for --list)").option("--project-name <name>", "Filter by project name").option("--agent-name <name>", "Filter by agent name/assigned").option("--sort <field>", "Sort by: updated, created, priority, status").option("--format <fmt>", "Output format: table (default), compact, csv, json").option("--due-today", "Only tasks due today or earlier").option("--overdue", "Only overdue tasks (past due_at)").option("--recurring", "Only recurring tasks").option("--limit <n>", "Max tasks to return").action(async (opts) => {
20747
21037
  const globalOpts = program2.opts();
20748
21038
  opts.tags = opts.tags || opts.tag;
20749
21039
  opts.list = opts.list || opts.taskList;
20750
21040
  const cloud = getTodosCloudClient();
20751
- const projectId = cloud ? undefined : autoProject(globalOpts);
21041
+ const projectId = cloud && globalOpts.project ? await cloudResolveProjectRef(cloud, globalOpts.project) : cloud ? undefined : autoProject(globalOpts);
20752
21042
  const hasAssignedFilter = Boolean(opts.assigned || opts.agentName);
20753
21043
  const hasExplicitProjectFilter = Boolean(globalOpts.project || opts.projectName);
20754
21044
  const allowedSortFields = new Set(["updated", "created", "priority", "status"]);
@@ -20766,7 +21056,7 @@ function registerTaskCommands(program2) {
20766
21056
  filter["project_id"] = projectId;
20767
21057
  }
20768
21058
  if (opts.list && cloud) {
20769
- filter["task_list_id"] = opts.list;
21059
+ filter["task_list_id"] = await cloudResolveTaskListRef(cloud, opts.list, projectId);
20770
21060
  } else if (opts.list) {
20771
21061
  const db = getDatabase();
20772
21062
  const listId = resolvePartialId(db, "task_lists", opts.list);
@@ -24001,10 +24291,10 @@ function bootstrapProject(options = {}, db) {
24001
24291
  let project = ensureProject(options.name || discovery.projectName, discovery.projectPath, d);
24002
24292
  const createdProject = !beforeProject;
24003
24293
  if (project.task_list_id !== taskListSlug || options.name && project.name !== options.name) {
24004
- project = updateProject(project.id, {
24294
+ project = renameProject(project.id, {
24005
24295
  name: options.name ?? project.name,
24006
- task_list_id: taskListSlug
24007
- }, d);
24296
+ new_slug: taskListSlug
24297
+ }, d).project;
24008
24298
  }
24009
24299
  setMachineLocalPath(project.id, discovery.projectPath, d);
24010
24300
  const beforeTaskList = d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ?").get(project.id, taskListSlug);
@@ -32346,7 +32636,7 @@ function registerProjectCommands(program2) {
32346
32636
  if (existing) {
32347
32637
  project = existing;
32348
32638
  if (opts.taskListId) {
32349
- project = updateProject(existing.id, { task_list_id: opts.taskListId });
32639
+ project = renameProject(existing.id, { new_slug: opts.taskListId }).project;
32350
32640
  }
32351
32641
  } else {
32352
32642
  project = createProject({ name, path: projectPath, task_list_id: opts.taskListId });
@@ -32411,18 +32701,23 @@ function registerProjectCommands(program2) {
32411
32701
  const globalOpts = program2.opts();
32412
32702
  const useJson = opts.json || globalOpts.json;
32413
32703
  try {
32414
- const { renameProject: renameProject2 } = await Promise.resolve().then(() => (init_projects(), exports_projects));
32415
- const db = getDatabase();
32416
- let resolvedId = resolvePartialId(db, "projects", idOrSlug);
32417
- if (!resolvedId) {
32418
- const bySlug = db.query("SELECT id FROM projects WHERE task_list_id = ?").get(idOrSlug);
32419
- resolvedId = bySlug?.id ?? null;
32420
- }
32421
- if (!resolvedId) {
32422
- console.error(chalk4.red(`Project not found: ${idOrSlug}`));
32423
- 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 });
32424
32720
  }
32425
- const result = renameProject2(resolvedId, { name: opts.name, new_slug: newSlug });
32426
32721
  if (useJson) {
32427
32722
  output({ project: result.project, task_lists_updated: result.task_lists_updated }, true);
32428
32723
  } else {
@@ -33777,7 +34072,7 @@ ${isOnline ? chalk5.green("\u25CF") : chalk5.dim("\u25CB")} ${chalk5.bold(agent.
33777
34072
  try {
33778
34073
  const globalOpts = program2.opts();
33779
34074
  const cloud = getTodosCloudClient();
33780
- const projectId = cloud ? globalOpts.project : autoProject(globalOpts);
34075
+ const projectId = cloud ? globalOpts.project ? await cloudResolveProjectRef(cloud, globalOpts.project) : undefined : autoProject(globalOpts);
33781
34076
  if (opts.add) {
33782
34077
  const input = { name: opts.add, slug: opts.slug, description: opts.description, project_id: projectId };
33783
34078
  const list = cloud ? await cloudCreateTaskList(cloud, input) : createTaskList(input);
@@ -37711,6 +38006,14 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
37711
38006
  skipped: 0,
37712
38007
  errors: []
37713
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;
37714
38017
  const applyRows = (objectType3, table, columns, rows, updateClockColumn, afterUpsert) => {
37715
38018
  for (const row of rows) {
37716
38019
  try {
@@ -38083,6 +38386,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
38083
38386
  getByPath: (path) => getProjectByPath(path, database()),
38084
38387
  list: () => listProjects(database()),
38085
38388
  update: (id, input) => updateProject(id, input, database()),
38389
+ rename: (id, input) => renameProject(id, input, database()),
38086
38390
  delete: (id) => deleteProject(id, database())
38087
38391
  },
38088
38392
  plans: {
@@ -38190,6 +38494,91 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
38190
38494
  )`
38191
38495
  ];
38192
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
+ }
38193
38582
  function postgresTodosCommentCursorIndexSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
38194
38583
  assertSafeIdentifier(tableName);
38195
38584
  return `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_comment_task_created_idx
@@ -38218,9 +38607,31 @@ class PostgresTodosSyncStore {
38218
38607
  }
38219
38608
  }
38220
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
+ }
38221
38630
  const result = { records: 0, objectTypes: {} };
38222
38631
  const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
38223
38632
  for (const entry of snapshotEntries(snapshot)) {
38633
+ if (entry.deletedAt === null)
38634
+ assertCanonicalScopedSlugEntry(entry);
38224
38635
  await this.client.query(`INSERT INTO ${this.tableName} (
38225
38636
  service, object_type, object_id, payload, updated_at,
38226
38637
  deleted_at, source_machine_id, version
@@ -38299,6 +38710,22 @@ function snapshotEntries(snapshot) {
38299
38710
  }))
38300
38711
  ];
38301
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
+ }
38302
38729
  function entry(type, payload, fallbackUpdatedAt) {
38303
38730
  const id = payload["id"];
38304
38731
  if (typeof id !== "string" || !id)
@@ -38381,7 +38808,26 @@ function assertSafeIdentifier(value) {
38381
38808
  if (!/^[a-z_][a-z0-9_]*$/i.test(value))
38382
38809
  throw new Error(`Unsafe Postgres identifier: ${value}`);
38383
38810
  }
38384
- 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
+ });
38385
38831
 
38386
38832
  // src/storage/shadow-outbox.ts
38387
38833
  class TodosShadowOutbox {
@@ -38633,6 +39079,7 @@ function createTodosShadowOutbox(options) {
38633
39079
  var MAX_BACKOFF_MS;
38634
39080
  var init_shadow_outbox = __esm(() => {
38635
39081
  init_local_sqlite();
39082
+ init_postgres_sync();
38636
39083
  init_shadow_outbox_schema();
38637
39084
  init_shadow_outbox_schema();
38638
39085
  MAX_BACKOFF_MS = 5 * 60000;
@@ -39137,6 +39584,7 @@ function createPostgresTodosStorageAdapter(options) {
39137
39584
  getByPath: async (path) => (await store.list("projects")).find((project) => project.path === path) ?? null,
39138
39585
  list: async () => (await store.list("projects")).sort((a, b) => a.name.localeCompare(b.name)),
39139
39586
  update: (id, input) => updateProject2(id, input, store),
39587
+ rename: (id, input, context) => store.renameProject(id, input.new_slug, input.name, context),
39140
39588
  delete: (id, context) => store.delete("projects", id, context)
39141
39589
  },
39142
39590
  plans: {
@@ -39374,9 +39822,22 @@ class PostgresJsonRecordStore {
39374
39822
  });
39375
39823
  }
39376
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
+ }
39377
39836
  await this.ensureSchema();
39378
39837
  const updatedAt = stringValue2(value.updated_at) ?? stringValue2(value.created_at) ?? new Date().toISOString();
39379
- 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} (
39380
39841
  service, object_type, object_id, payload, updated_at,
39381
39842
  deleted_at, source_machine_id, version
39382
39843
  ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, NULL, $6, $7)
@@ -39391,14 +39852,23 @@ class PostgresJsonRecordStore {
39391
39852
  OR (${this.tableName}.updated_at = EXCLUDED.updated_at
39392
39853
  AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
39393
39854
  RETURNING object_id`, [
39394
- this.service,
39395
- type,
39396
- value.id,
39397
- jsonbParam(value),
39398
- updatedAt,
39399
- context.requestId ?? this.sourceMachineId ?? null,
39400
- numberValue2(value.version)
39401
- ]);
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
+ }
39402
39872
  if (result.rows.length === 0) {
39403
39873
  const current = await this.get(type, value.id);
39404
39874
  if (current)
@@ -39406,6 +39876,93 @@ class PostgresJsonRecordStore {
39406
39876
  }
39407
39877
  return value;
39408
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
+ }
39409
39966
  async incrementProjectTaskCounter(projectId, _context = {}) {
39410
39967
  await this.ensureSchema();
39411
39968
  const result = await this.options.client.query(`UPDATE ${this.tableName}
@@ -39825,12 +40382,16 @@ async function getChangedSince(since, filters, store) {
39825
40382
  }
39826
40383
  async function createProject2(input, store, context) {
39827
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");
39828
40389
  const project = {
39829
40390
  id: randomUUID3(),
39830
40391
  name: input.name,
39831
40392
  path: input.path,
39832
40393
  description: input.description ?? null,
39833
- task_list_id: input.task_list_id ?? `todos-${slugify2(input.name)}`,
40394
+ task_list_id: taskListId,
39834
40395
  task_prefix: input.task_prefix ?? await generateProjectPrefix(input.name, store),
39835
40396
  task_counter: 0,
39836
40397
  created_at: timestamp,
@@ -39841,6 +40402,9 @@ async function createProject2(input, store, context) {
39841
40402
  return store.upsert("projects", project, context);
39842
40403
  }
39843
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
+ }
39844
40408
  const project = await requireRecord("projects", id, store);
39845
40409
  const updated = { ...project, ...definedPatch(input), updated_at: new Date().toISOString() };
39846
40410
  return store.upsert("projects", updated);
@@ -39949,10 +40513,13 @@ async function releaseAgent2(idOrName, sessionId, store, context) {
39949
40513
  }
39950
40514
  async function createTaskList2(input, store, context) {
39951
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");
39952
40519
  return store.upsert("task_lists", {
39953
40520
  id: randomUUID3(),
39954
40521
  project_id: input.project_id ?? context?.projectId ?? null,
39955
- slug: input.slug ?? slugify2(input.name),
40522
+ slug,
39956
40523
  name: input.name,
39957
40524
  description: input.description ?? null,
39958
40525
  metadata: input.metadata ?? {},
@@ -39964,9 +40531,20 @@ async function createTaskList2(input, store, context) {
39964
40531
  }
39965
40532
  async function updateTaskList2(id, input, store) {
39966
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
+ }
39967
40545
  return store.upsert("task_lists", {
39968
40546
  ...list,
39969
- ...definedPatch(input),
40547
+ ...patch,
39970
40548
  metadata: input.metadata ?? list.metadata,
39971
40549
  updated_at: new Date().toISOString()
39972
40550
  });
@@ -40050,6 +40628,16 @@ async function exportSnapshot(store) {
40050
40628
  }
40051
40629
  async function importSnapshot(snapshot, store, context) {
40052
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;
40053
40641
  const entries = [
40054
40642
  ...snapshot.tasks.map((row) => ["tasks", row]),
40055
40643
  ...snapshot.projects.map((row) => ["projects", row]),
@@ -40120,10 +40708,7 @@ async function generateProjectPrefix(name, store) {
40120
40708
  return candidate;
40121
40709
  }
40122
40710
  function slugifyRaw(value) {
40123
- return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
40124
- }
40125
- function slugify2(value) {
40126
- return slugifyRaw(value) || "todos";
40711
+ return normalizeSlug(value);
40127
40712
  }
40128
40713
  function normalizePlanSlug2(value) {
40129
40714
  const slug = slugifyRaw(value);
@@ -40177,9 +40762,20 @@ function compareClock(left, right) {
40177
40762
  function numberValue2(value) {
40178
40763
  return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
40179
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
+ }
40180
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";
40181
40776
  var init_postgres_adapter = __esm(() => {
40182
40777
  init_types();
40778
+ init_postgres_sync();
40183
40779
  init_redaction();
40184
40780
  });
40185
40781
 
@@ -40280,6 +40876,7 @@ function isCommentRedactionBackfillComplete(result) {
40280
40876
  var COMMENT_REDACTION_BACKFILL_CONFIRMATION = "REDACT_STORED_TODOS_COMMENTS";
40281
40877
  var init_comment_redaction_backfill = __esm(() => {
40282
40878
  init_redaction();
40879
+ init_postgres_sync();
40283
40880
  });
40284
40881
 
40285
40882
  // src/server/cloud.ts
@@ -40293,6 +40890,7 @@ __export(exports_cloud, {
40293
40890
  getCloudVerifier: () => getCloudVerifier,
40294
40891
  getCloudStorageAdapter: () => getCloudStorageAdapter,
40295
40892
  getApiKeyStore: () => getApiKeyStore,
40893
+ ensureCloudScopedSlugUniqueIndexes: () => ensureCloudScopedSlugUniqueIndexes,
40296
40894
  ensureCloudSchema: () => ensureCloudSchema,
40297
40895
  ensureCloudCommentCursorIndex: () => ensureCloudCommentCursorIndex,
40298
40896
  closeCloud: () => closeCloud,
@@ -40377,6 +40975,9 @@ async function ensureCloudSchema() {
40377
40975
  async function ensureCloudCommentCursorIndex() {
40378
40976
  await getClient().query(postgresTodosCommentCursorIndexSql());
40379
40977
  }
40978
+ async function ensureCloudScopedSlugUniqueIndexes() {
40979
+ await ensurePostgresScopedSlugUniqueIndexes(getClient());
40980
+ }
40380
40981
  async function normalizeCloudPayloads() {
40381
40982
  const client = getClient();
40382
40983
  const res = await client.query(`UPDATE todos_sync_records
@@ -40408,6 +41009,7 @@ var init_cloud = __esm(() => {
40408
41009
  init_auth();
40409
41010
  init_cloud_client();
40410
41011
  init_postgres_adapter();
41012
+ init_postgres_sync();
40411
41013
  init_comment_redaction_backfill();
40412
41014
  });
40413
41015
 
@@ -40432,6 +41034,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
40432
41034
  schemas: {
40433
41035
  Task: taskSchema,
40434
41036
  Project: projectSchema,
41037
+ TaskList: taskListSchema,
40435
41038
  TaskComment: taskCommentSchema,
40436
41039
  CreateTaskInput: {
40437
41040
  type: "object",
@@ -40460,12 +41063,65 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
40460
41063
  },
40461
41064
  CreateProjectInput: {
40462
41065
  type: "object",
41066
+ additionalProperties: false,
40463
41067
  required: ["name", "path"],
40464
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].*" },
40465
41122
  name: { type: "string" },
40466
- path: { type: "string" },
40467
41123
  description: { type: "string" },
40468
- task_prefix: { type: "string" }
41124
+ metadata: { type: "object", additionalProperties: true }
40469
41125
  }
40470
41126
  },
40471
41127
  CreateTaskCommentInput: {
@@ -40674,7 +41330,10 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
40674
41330
  required: true,
40675
41331
  content: { "application/json": { schema: { $ref: "#/components/schemas/CreateProjectInput" } } }
40676
41332
  },
40677
- 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
+ }
40678
41337
  }
40679
41338
  },
40680
41339
  "/v1/projects/{id}": {
@@ -40683,6 +41342,86 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
40683
41342
  summary: "Get a project by id",
40684
41343
  parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
40685
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" } } } } } } }
40686
41425
  }
40687
41426
  },
40688
41427
  "/v1/stats": {
@@ -40749,7 +41488,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
40749
41488
  }
40750
41489
  };
40751
41490
  }
40752
- var taskSchema, projectSchema, taskCommentSchema;
41491
+ var taskSchema, projectSchema, taskListSchema, taskCommentSchema;
40753
41492
  var init_openapi = __esm(() => {
40754
41493
  init_package_version();
40755
41494
  taskSchema = {
@@ -40776,6 +41515,22 @@ var init_openapi = __esm(() => {
40776
41515
  name: { type: "string" },
40777
41516
  path: { type: "string" },
40778
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 },
40779
41534
  created_at: { type: "string" },
40780
41535
  updated_at: { type: "string" }
40781
41536
  }
@@ -40809,6 +41564,48 @@ function json2(body, status = 200) {
40809
41564
  function error(status, message, extra) {
40810
41565
  return json2({ error: message, ...extra ?? {} }, status);
40811
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
+ }
40812
41609
  async function readJson(req) {
40813
41610
  try {
40814
41611
  const text = await req.text();
@@ -41289,14 +42086,31 @@ async function handleV1Request(req, url, dependencies = {}) {
41289
42086
  }
41290
42087
  if (method === "POST") {
41291
42088
  const body = await readJson(req);
41292
- if (!body || typeof body.name !== "string" || typeof body.path !== "string") {
41293
- return error(400, "name and path are required");
41294
- }
41295
- 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));
41296
42095
  return json2({ project }, 201);
41297
42096
  }
41298
42097
  return error(405, `method ${method} not allowed on /v1/projects`);
41299
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
+ }
41300
42114
  if (method === "GET") {
41301
42115
  const project = await store.projects.get(id);
41302
42116
  return project ? json2({ project }) : error(404, "project not found");
@@ -41305,8 +42119,13 @@ async function handleV1Request(req, url, dependencies = {}) {
41305
42119
  const body = await readJson(req);
41306
42120
  if (!body)
41307
42121
  return error(400, "invalid JSON body");
41308
- const project = await store.projects.update(id, body);
41309
- 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 });
41310
42129
  }
41311
42130
  if (method === "DELETE") {
41312
42131
  await store.projects.delete(id, contextFromPrincipal(principal));
@@ -41393,6 +42212,21 @@ async function handleV1Request(req, url, dependencies = {}) {
41393
42212
  const body = await readJson(req);
41394
42213
  if (!body || typeof body.name !== "string" || !body.name.trim())
41395
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
+ }
41396
42230
  const taskList = await store.taskLists.create(body, contextFromPrincipal(principal));
41397
42231
  return json2({ task_list: taskList }, 201);
41398
42232
  }
@@ -41400,6 +42234,29 @@ async function handleV1Request(req, url, dependencies = {}) {
41400
42234
  const taskList = await store.taskLists.get(id);
41401
42235
  return taskList ? json2({ task_list: taskList }) : error(404, "task list not found");
41402
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
+ }
41403
42260
  if (id && method === "DELETE") {
41404
42261
  const deleted = await store.taskLists.delete(id, contextFromPrincipal(principal));
41405
42262
  return deleted ? json2({ deleted: true, id }) : error(404, "task list not found");
@@ -41472,6 +42329,10 @@ async function handleV1Request(req, url, dependencies = {}) {
41472
42329
  } catch (e) {
41473
42330
  if (e instanceof LockError)
41474
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 });
41475
42336
  return error(500, e.message || "internal error");
41476
42337
  }
41477
42338
  }
@@ -79617,6 +80478,7 @@ function createHybridTodosStorageAdapter(options) {
79617
80478
  };
79618
80479
  }
79619
80480
  var init_hybrid = __esm(() => {
80481
+ init_postgres_sync();
79620
80482
  init_local_sqlite();
79621
80483
  });
79622
80484
 
@@ -79874,6 +80736,19 @@ function createShadowTodosStorageAdapter(options) {
79874
80736
  mirror.enqueueUpsert("projects", project, context);
79875
80737
  return project;
79876
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
+ },
79877
80752
  async delete(id, context) {
79878
80753
  const deleted = await local.projects.delete(id, context);
79879
80754
  if (deleted)
@@ -79976,6 +80851,7 @@ function isAgent(value) {
79976
80851
  var SNAPSHOT_TO_OBJECT_TYPE;
79977
80852
  var init_shadow = __esm(() => {
79978
80853
  init_local_sqlite();
80854
+ init_postgres_sync();
79979
80855
  SNAPSHOT_TO_OBJECT_TYPE = {
79980
80856
  tasks: "tasks",
79981
80857
  projects: "projects",
@@ -80491,6 +81367,9 @@ __export(exports_storage, {
80491
81367
  signAwsV4Request: () => signAwsV4Request,
80492
81368
  registerShadowExitFlush: () => registerShadowExitFlush,
80493
81369
  postgresTodosSyncSchemaSql: () => postgresTodosSyncSchemaSql,
81370
+ postgresTodosScopedSlugUniqueIndexSql: () => postgresTodosScopedSlugUniqueIndexSql,
81371
+ postgresTodosScopedSlugPreflightSql: () => postgresTodosScopedSlugPreflightSql,
81372
+ postgresTodosScopedSlugIndexStatusSql: () => postgresTodosScopedSlugIndexStatusSql,
80494
81373
  postgresTodosCommentCursorIndexSql: () => postgresTodosCommentCursorIndexSql,
80495
81374
  planRunArtifactsS3Sync: () => planRunArtifactsS3Sync,
80496
81375
  parseStorageMode: () => parseStorageMode,
@@ -80513,6 +81392,7 @@ __export(exports_storage, {
80513
81392
  getRuntimeShadowOutbox: () => getRuntimeShadowOutbox,
80514
81393
  getCanonicalTodosRdsConfig: () => getCanonicalTodosRdsConfig,
80515
81394
  exportSqliteTodosStorageSnapshot: () => exportSqliteTodosStorageSnapshot,
81395
+ ensurePostgresScopedSlugUniqueIndexes: () => ensurePostgresScopedSlugUniqueIndexes,
80516
81396
  downloadRunArtifactsFromS3: () => downloadRunArtifactsFromS3,
80517
81397
  createTodosStorageAdapter: () => createTodosStorageAdapter,
80518
81398
  createTodosShadowOutbox: () => createTodosShadowOutbox,
@@ -80538,6 +81418,8 @@ __export(exports_storage, {
80538
81418
  STORAGE_TABLES: () => STORAGE_TABLES,
80539
81419
  SHADOW_TRIGGER_TABLES: () => SHADOW_TRIGGER_TABLES,
80540
81420
  PostgresTodosSyncStore: () => PostgresTodosSyncStore,
81421
+ PostgresScopedSlugMigrationConflictError: () => PostgresScopedSlugMigrationConflictError,
81422
+ PostgresScopedSlugIndexBuildError: () => PostgresScopedSlugIndexBuildError,
80541
81423
  DEFAULT_TODOS_POSTGRES_SYNC_TABLE: () => DEFAULT_TODOS_POSTGRES_SYNC_TABLE,
80542
81424
  DEFAULT_TODOS_POSTGRES_CURSOR_TABLE: () => DEFAULT_TODOS_POSTGRES_CURSOR_TABLE,
80543
81425
  COMMENT_REDACTION_BACKFILL_CONFIRMATION: () => COMMENT_REDACTION_BACKFILL_CONFIRMATION,
@@ -80555,6 +81437,7 @@ var init_storage2 = __esm(() => {
80555
81437
  init_hybrid();
80556
81438
  init_local_sqlite();
80557
81439
  init_sqlite_snapshot();
81440
+ init_postgres_sync();
80558
81441
  init_comment_redaction_backfill();
80559
81442
  init_postgres_adapter();
80560
81443
  init_s3_artifacts();