@hasna/todos 0.11.88 → 0.11.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/cli/cloud-router.d.ts +10 -2
  2. package/dist/cli/cloud-router.d.ts.map +1 -1
  3. package/dist/cli/commands/agent-commands.d.ts.map +1 -1
  4. package/dist/cli/commands/project-commands.d.ts.map +1 -1
  5. package/dist/cli/commands/task-commands.d.ts.map +1 -1
  6. package/dist/cli/index.js +950 -118
  7. package/dist/contracts.js +301 -72
  8. package/dist/db/projects.d.ts +2 -2
  9. package/dist/db/projects.d.ts.map +1 -1
  10. package/dist/db/schema.d.ts.map +1 -1
  11. package/dist/db/slug-claims.d.ts +11 -0
  12. package/dist/db/slug-claims.d.ts.map +1 -0
  13. package/dist/db/task-lists.d.ts.map +1 -1
  14. package/dist/index.js +641 -100
  15. package/dist/lib/slugs.d.ts +19 -0
  16. package/dist/lib/slugs.d.ts.map +1 -0
  17. package/dist/mcp/index.js +897 -102
  18. package/dist/registry.js +301 -72
  19. package/dist/release-provenance.json +3 -3
  20. package/dist/sdk/index.js +56 -0
  21. package/dist/sdk/v1.generated.d.ts +79 -0
  22. package/dist/sdk/v1.generated.d.ts.map +1 -1
  23. package/dist/server/cloud.d.ts +2 -0
  24. package/dist/server/cloud.d.ts.map +1 -1
  25. package/dist/server/index.js +906 -108
  26. package/dist/server/openapi.d.ts +464 -0
  27. package/dist/server/openapi.d.ts.map +1 -1
  28. package/dist/server/v1.d.ts.map +1 -1
  29. package/dist/storage/index.d.ts +2 -2
  30. package/dist/storage/index.d.ts.map +1 -1
  31. package/dist/storage/interfaces.d.ts +3 -2
  32. package/dist/storage/interfaces.d.ts.map +1 -1
  33. package/dist/storage/local-sqlite.d.ts.map +1 -1
  34. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  35. package/dist/storage/postgres-sync.d.ts +30 -0
  36. package/dist/storage/postgres-sync.d.ts.map +1 -1
  37. package/dist/storage/shadow.d.ts.map +1 -1
  38. package/dist/storage/sqlite-snapshot.d.ts.map +1 -1
  39. package/dist/storage.d.ts +2 -2
  40. package/dist/storage.d.ts.map +1 -1
  41. package/dist/storage.js +635 -88
  42. package/dist/types/index.d.ts +19 -0
  43. package/dist/types/index.d.ts.map +1 -1
  44. package/package.json +1 -1
package/dist/mcp/index.js CHANGED
@@ -1749,6 +1749,95 @@ function ensureSchema(db) {
1749
1749
  )`);
1750
1750
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_project_machine_paths_project ON project_machine_paths(project_id)");
1751
1751
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_project_machine_paths_machine ON project_machine_paths(machine_id)");
1752
+ ensureTable("canonical_slug_claims", `
1753
+ CREATE TABLE canonical_slug_claims (
1754
+ kind TEXT NOT NULL CHECK(kind IN ('project', 'task_list')),
1755
+ scope_key TEXT NOT NULL,
1756
+ slug TEXT NOT NULL,
1757
+ object_id TEXT NOT NULL,
1758
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1759
+ PRIMARY KEY (kind, scope_key, slug)
1760
+ )`);
1761
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_canonical_slug_claims_object ON canonical_slug_claims(kind, object_id)");
1762
+ ensureColumn("projects", "task_list_id", "TEXT");
1763
+ db.exec(`CREATE TRIGGER IF NOT EXISTS claim_project_canonical_slug_insert
1764
+ BEFORE INSERT ON projects
1765
+ WHEN NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> ''
1766
+ BEGIN
1767
+ SELECT CASE WHEN EXISTS (
1768
+ SELECT 1 FROM projects WHERE id <> NEW.id AND task_list_id = NEW.task_list_id
1769
+ ) THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
1770
+ INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
1771
+ VALUES ('project', 'global', NEW.task_list_id, NEW.id);
1772
+ SELECT CASE WHEN (
1773
+ SELECT object_id FROM canonical_slug_claims
1774
+ WHERE kind = 'project' AND scope_key = 'global' AND slug = NEW.task_list_id
1775
+ ) <> NEW.id THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
1776
+ END`);
1777
+ db.exec(`CREATE TRIGGER IF NOT EXISTS claim_project_canonical_slug_update
1778
+ BEFORE UPDATE OF task_list_id ON projects
1779
+ WHEN NEW.task_list_id IS NOT OLD.task_list_id
1780
+ BEGIN
1781
+ DELETE FROM canonical_slug_claims WHERE kind = 'project' AND object_id = NEW.id;
1782
+ SELECT CASE WHEN EXISTS (
1783
+ SELECT 1 FROM projects WHERE id <> NEW.id AND task_list_id = NEW.task_list_id
1784
+ ) AND NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> ''
1785
+ THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
1786
+ INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
1787
+ SELECT 'project', 'global', NEW.task_list_id, NEW.id
1788
+ WHERE NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> '';
1789
+ SELECT CASE WHEN NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> '' AND (
1790
+ SELECT object_id FROM canonical_slug_claims
1791
+ WHERE kind = 'project' AND scope_key = 'global' AND slug = NEW.task_list_id
1792
+ ) <> NEW.id THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
1793
+ END`);
1794
+ db.exec(`CREATE TRIGGER IF NOT EXISTS release_project_canonical_slug_delete
1795
+ AFTER DELETE ON projects
1796
+ BEGIN
1797
+ DELETE FROM canonical_slug_claims WHERE kind = 'project' AND object_id = OLD.id;
1798
+ END`);
1799
+ db.exec(`CREATE TRIGGER IF NOT EXISTS claim_task_list_canonical_slug_insert
1800
+ BEFORE INSERT ON task_lists
1801
+ WHEN NEW.slug IS NOT NULL AND NEW.slug <> ''
1802
+ BEGIN
1803
+ SELECT CASE WHEN EXISTS (
1804
+ SELECT 1 FROM task_lists
1805
+ WHERE id <> NEW.id AND project_id IS NEW.project_id AND slug = NEW.slug
1806
+ ) THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
1807
+ INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
1808
+ VALUES ('task_list', CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END, NEW.slug, NEW.id);
1809
+ SELECT CASE WHEN (
1810
+ SELECT object_id FROM canonical_slug_claims
1811
+ WHERE kind = 'task_list'
1812
+ AND scope_key = CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END
1813
+ AND slug = NEW.slug
1814
+ ) <> NEW.id THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
1815
+ END`);
1816
+ db.exec(`CREATE TRIGGER IF NOT EXISTS claim_task_list_canonical_slug_update
1817
+ BEFORE UPDATE OF slug, project_id ON task_lists
1818
+ WHEN NEW.slug IS NOT OLD.slug OR NEW.project_id IS NOT OLD.project_id
1819
+ BEGIN
1820
+ DELETE FROM canonical_slug_claims WHERE kind = 'task_list' AND object_id = NEW.id;
1821
+ SELECT CASE WHEN EXISTS (
1822
+ SELECT 1 FROM task_lists
1823
+ WHERE id <> NEW.id AND project_id IS NEW.project_id AND slug = NEW.slug
1824
+ ) AND NEW.slug IS NOT NULL AND NEW.slug <> ''
1825
+ THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
1826
+ INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
1827
+ SELECT 'task_list', CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END, NEW.slug, NEW.id
1828
+ WHERE NEW.slug IS NOT NULL AND NEW.slug <> '';
1829
+ SELECT CASE WHEN NEW.slug IS NOT NULL AND NEW.slug <> '' AND (
1830
+ SELECT object_id FROM canonical_slug_claims
1831
+ WHERE kind = 'task_list'
1832
+ AND scope_key = CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END
1833
+ AND slug = NEW.slug
1834
+ ) <> NEW.id THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
1835
+ END`);
1836
+ db.exec(`CREATE TRIGGER IF NOT EXISTS release_task_list_canonical_slug_delete
1837
+ AFTER DELETE ON task_lists
1838
+ BEGIN
1839
+ DELETE FROM canonical_slug_claims WHERE kind = 'task_list' AND object_id = OLD.id;
1840
+ END`);
1752
1841
  ensureTable("storage_tombstones", `
1753
1842
  CREATE TABLE storage_tombstones (
1754
1843
  id TEXT PRIMARY KEY,
@@ -3898,7 +3987,7 @@ var init_agents = __esm(() => {
3898
3987
  async function logError(_message, _opts) {}
3899
3988
 
3900
3989
  // src/types/index.ts
3901
- var TASK_STATUSES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, PlanNotFoundError, LockError, AgentNotFoundError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
3990
+ var TASK_STATUSES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
3902
3991
  var init_types = __esm(() => {
3903
3992
  TASK_STATUSES = [
3904
3993
  "pending",
@@ -3941,6 +4030,14 @@ var init_types = __esm(() => {
3941
4030
  this.name = "ProjectNotFoundError";
3942
4031
  }
3943
4032
  };
4033
+ ResourceConflictError = class ResourceConflictError extends Error {
4034
+ code;
4035
+ constructor(code, message) {
4036
+ super(message);
4037
+ this.code = code;
4038
+ this.name = "ResourceConflictError";
4039
+ }
4040
+ };
3944
4041
  PlanNotFoundError = class PlanNotFoundError extends Error {
3945
4042
  planId;
3946
4043
  static code = "PLAN_NOT_FOUND";
@@ -8188,6 +8285,91 @@ var init_config2 = __esm(() => {
8188
8285
  };
8189
8286
  });
8190
8287
 
8288
+ // src/lib/slugs.ts
8289
+ function normalizeSlug(value) {
8290
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
8291
+ }
8292
+ function isCanonicalSlug(value) {
8293
+ return typeof value === "string" && value.length > 0 && normalizeSlug(value) === value;
8294
+ }
8295
+ function isValidTaskListProjectScope(value) {
8296
+ return value === undefined || value === null || typeof value === "string" && value.trim().length > 0;
8297
+ }
8298
+ function validateSnapshotRoutingRecords(projects, taskLists) {
8299
+ const errors2 = [];
8300
+ const projectSlugs = new Map;
8301
+ const taskListSlugs = new Map;
8302
+ for (const project of projects) {
8303
+ if (!isCanonicalSlug(project.task_list_id)) {
8304
+ errors2.push(`project ${project.id}: task_list_id must be non-empty canonical kebab-case`);
8305
+ continue;
8306
+ }
8307
+ const existing = projectSlugs.get(project.task_list_id);
8308
+ if (projectSlugs.has(project.task_list_id)) {
8309
+ errors2.push(`project ${project.id}: task_list_id duplicates project ${existing}: ${project.task_list_id}`);
8310
+ } else {
8311
+ projectSlugs.set(project.task_list_id, project.id);
8312
+ }
8313
+ }
8314
+ for (const taskList of taskLists) {
8315
+ if (!isCanonicalSlug(taskList.slug)) {
8316
+ errors2.push(`task list ${taskList.id}: slug must be non-empty canonical kebab-case`);
8317
+ continue;
8318
+ }
8319
+ if (!isValidTaskListProjectScope(taskList.project_id)) {
8320
+ errors2.push(`task list ${taskList.id}: project_id must be null, missing, or a non-empty string`);
8321
+ continue;
8322
+ }
8323
+ const scope = taskList.project_id ?? null;
8324
+ const scopedSlugs = taskListSlugs.get(scope) ?? new Map;
8325
+ const existing = scopedSlugs.get(taskList.slug);
8326
+ if (scopedSlugs.has(taskList.slug)) {
8327
+ errors2.push(`task list ${taskList.id}: slug duplicates task list ${existing} in the same scope: ${taskList.slug}`);
8328
+ } else {
8329
+ scopedSlugs.set(taskList.slug, taskList.id);
8330
+ taskListSlugs.set(scope, scopedSlugs);
8331
+ }
8332
+ }
8333
+ return errors2;
8334
+ }
8335
+ function validateSnapshotRoutingDestinationConflicts(projects, taskLists, existingProjects, existingTaskLists) {
8336
+ const errors2 = [];
8337
+ for (const project of projects) {
8338
+ const current = existingProjects.find((candidate) => candidate.id === project.id);
8339
+ if (current?.task_list_id === project.task_list_id)
8340
+ continue;
8341
+ const conflict = existingProjects.find((candidate) => candidate.id !== project.id && candidate.task_list_id === project.task_list_id);
8342
+ if (conflict) {
8343
+ errors2.push(`project ${project.id}: task_list_id conflicts with existing project ${conflict.id}: ${String(project.task_list_id)}`);
8344
+ }
8345
+ }
8346
+ for (const taskList of taskLists) {
8347
+ const projectId = taskList.project_id ?? null;
8348
+ const current = existingTaskLists.find((candidate) => candidate.id === taskList.id);
8349
+ if ((current?.project_id ?? null) === projectId && current?.slug === taskList.slug)
8350
+ continue;
8351
+ const conflict = existingTaskLists.find((candidate) => candidate.id !== taskList.id && (candidate.project_id ?? null) === projectId && candidate.slug === taskList.slug);
8352
+ if (conflict) {
8353
+ errors2.push(`task list ${taskList.id}: slug conflicts with existing task list ${conflict.id} in the same scope: ${String(taskList.slug)}`);
8354
+ }
8355
+ }
8356
+ return errors2;
8357
+ }
8358
+
8359
+ // src/db/slug-claims.ts
8360
+ function taskListSlugScopeKey(projectId) {
8361
+ return projectId ? `project:${projectId}` : "standalone:";
8362
+ }
8363
+ function claimCanonicalSlug(kind, scopeKey, slug, objectId, db) {
8364
+ db.run(`INSERT OR IGNORE INTO canonical_slug_claims (kind, scope_key, slug, object_id)
8365
+ VALUES (?, ?, ?, ?)`, [kind, scopeKey, slug, objectId]);
8366
+ const claim = db.query("SELECT object_id FROM canonical_slug_claims WHERE kind = ? AND scope_key = ? AND slug = ?").get(kind, scopeKey, slug);
8367
+ return claim?.object_id === objectId;
8368
+ }
8369
+ function releaseCanonicalSlugClaims(kind, objectId, db) {
8370
+ db.run("DELETE FROM canonical_slug_claims WHERE kind = ? AND object_id = ?", [kind, objectId]);
8371
+ }
8372
+
8191
8373
  // src/db/projects.ts
8192
8374
  var exports_projects = {};
8193
8375
  __export(exports_projects, {
@@ -8211,7 +8393,7 @@ __export(exports_projects, {
8211
8393
  addProjectSource: () => addProjectSource
8212
8394
  });
8213
8395
  function slugify(name) {
8214
- return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
8396
+ return normalizeSlug(name);
8215
8397
  }
8216
8398
  function generatePrefix(name, db) {
8217
8399
  const words = name.replace(/[^a-zA-Z0-9\s]/g, "").trim().split(/\s+/);
@@ -8235,14 +8417,23 @@ function generatePrefix(name, db) {
8235
8417
  }
8236
8418
  function createProject(input, db) {
8237
8419
  const d = db || getDatabase();
8238
- const id = uuid();
8239
- const timestamp = now();
8240
- const taskListId = input.task_list_id ?? `todos-${slugify(input.name)}`;
8241
- const taskPrefix = input.task_prefix || generatePrefix(input.name, d);
8242
- const machineId = currentStorageMachineId(d);
8243
- d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, created_at, updated_at, machine_id)
8244
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, timestamp, timestamp, machineId]);
8245
- return getProject(id, d);
8420
+ return d.transaction(() => {
8421
+ const id = uuid();
8422
+ const timestamp = now();
8423
+ const derivedSlug = slugify(input.name);
8424
+ const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugify(input.task_list_id);
8425
+ if (!derivedSlug || !taskListId)
8426
+ throw new Error("Project name and task-list slug must be non-empty");
8427
+ const slugConflict = d.query("SELECT id FROM projects WHERE task_list_id = ? LIMIT 1").get(taskListId);
8428
+ if (slugConflict || !claimCanonicalSlug("project", "global", taskListId, id, d)) {
8429
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${taskListId}" already exists`);
8430
+ }
8431
+ const taskPrefix = input.task_prefix || generatePrefix(input.name, d);
8432
+ const machineId = currentStorageMachineId(d);
8433
+ d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, created_at, updated_at, machine_id)
8434
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, timestamp, timestamp, machineId]);
8435
+ return getProject(id, d);
8436
+ })();
8246
8437
  }
8247
8438
  function getProject(id, db) {
8248
8439
  const d = db || getDatabase();
@@ -8270,6 +8461,9 @@ function updateProject(id, input, db) {
8270
8461
  const project = getProject(id, d);
8271
8462
  if (!project)
8272
8463
  throw new ProjectNotFoundError(id);
8464
+ if ("task_list_id" in input) {
8465
+ throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
8466
+ }
8273
8467
  const sets = ["updated_at = ?"];
8274
8468
  const params = [now()];
8275
8469
  if (input.name !== undefined) {
@@ -8280,10 +8474,6 @@ function updateProject(id, input, db) {
8280
8474
  sets.push("description = ?");
8281
8475
  params.push(input.description);
8282
8476
  }
8283
- if (input.task_list_id !== undefined) {
8284
- sets.push("task_list_id = ?");
8285
- params.push(input.task_list_id);
8286
- }
8287
8477
  if (input.path !== undefined) {
8288
8478
  sets.push("path = ?");
8289
8479
  params.push(input.path);
@@ -8294,29 +8484,41 @@ function updateProject(id, input, db) {
8294
8484
  }
8295
8485
  function renameProject(id, input, db) {
8296
8486
  const d = db || getDatabase();
8297
- const project = getProject(id, d);
8298
- if (!project)
8299
- throw new ProjectNotFoundError(id);
8300
- let taskListsUpdated = 0;
8301
- const ts = now();
8302
- if (input.new_slug !== undefined) {
8303
- const normalised = input.new_slug.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "");
8304
- if (!normalised)
8305
- throw new Error("Invalid slug \u2014 must be non-empty kebab-case");
8306
- const conflict = d.query("SELECT id FROM projects WHERE task_list_id = ? AND id != ?").get(normalised, id);
8307
- if (conflict)
8308
- throw new Error(`Slug "${normalised}" is already used by another project`);
8309
- const oldSlug = project.task_list_id;
8310
- d.run("UPDATE projects SET task_list_id = ?, updated_at = ? WHERE id = ?", [normalised, ts, id]);
8311
- if (oldSlug) {
8312
- 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]);
8313
- taskListsUpdated = result.changes;
8487
+ return d.transaction(() => {
8488
+ const project = getProject(id, d);
8489
+ if (!project)
8490
+ throw new ProjectNotFoundError(id);
8491
+ let taskListsUpdated = 0;
8492
+ const ts = now();
8493
+ if (input.new_slug !== undefined) {
8494
+ const normalised = normalizeSlug(input.new_slug);
8495
+ if (!normalised)
8496
+ throw new Error("Invalid slug \u2014 must be non-empty kebab-case");
8497
+ const oldSlug = project.task_list_id;
8498
+ if (normalised !== oldSlug) {
8499
+ const conflict = d.query("SELECT id FROM projects WHERE task_list_id = ? AND id != ?").get(normalised, id);
8500
+ if (conflict) {
8501
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalised}" is already used by another project`);
8502
+ }
8503
+ const taskListConflict = d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ? AND slug != COALESCE(?, '') LIMIT 1").get(id, normalised, oldSlug);
8504
+ if (taskListConflict) {
8505
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalised}" is already used in project "${project.name}"`);
8506
+ }
8507
+ releaseCanonicalSlugClaims("project", id, d);
8508
+ if (!claimCanonicalSlug("project", "global", normalised, id, d)) {
8509
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalised}" is already used by another project`);
8510
+ }
8511
+ d.run("UPDATE projects SET task_list_id = ?, updated_at = ? WHERE id = ?", [normalised, ts, id]);
8512
+ }
8513
+ if (oldSlug && (normalised !== oldSlug || input.name !== undefined && input.name !== project.name)) {
8514
+ 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;
8515
+ }
8314
8516
  }
8315
- }
8316
- if (input.name !== undefined) {
8317
- d.run("UPDATE projects SET name = ?, updated_at = ? WHERE id = ?", [input.name, ts, id]);
8318
- }
8319
- return { project: getProject(id, d), task_lists_updated: taskListsUpdated };
8517
+ if (input.name !== undefined && input.name !== project.name) {
8518
+ d.run("UPDATE projects SET name = ?, updated_at = ? WHERE id = ?", [input.name, ts, id]);
8519
+ }
8520
+ return { project: getProject(id, d), task_lists_updated: taskListsUpdated };
8521
+ })();
8320
8522
  }
8321
8523
  function deleteProject(id, db) {
8322
8524
  const d = db || getDatabase();
@@ -8328,8 +8530,10 @@ function deleteProject(id, db) {
8328
8530
  object_id: id,
8329
8531
  payload: project
8330
8532
  }, d);
8331
- const result = d.run("DELETE FROM projects WHERE id = ?", [id]);
8332
- return result.changes > 0;
8533
+ return d.transaction(() => {
8534
+ releaseCanonicalSlugClaims("project", id, d);
8535
+ return d.run("DELETE FROM projects WHERE id = ?", [id]).changes > 0;
8536
+ })();
8333
8537
  }
8334
8538
  function rowToSource(row) {
8335
8539
  return {
@@ -9882,19 +10086,22 @@ function rowToTaskList(row) {
9882
10086
  }
9883
10087
  function createTaskList(input, db) {
9884
10088
  const d = db || getDatabase();
9885
- const id = uuid();
9886
- const timestamp = now();
9887
- const slug = input.slug || slugify(input.name);
9888
- const machineId = currentStorageMachineId(d);
9889
- if (!input.project_id) {
9890
- const existing = d.query("SELECT id FROM task_lists WHERE project_id IS NULL AND slug = ?").get(slug);
9891
- if (existing) {
9892
- throw new Error(`Standalone task list with slug "${slug}" already exists`);
9893
- }
9894
- }
9895
- d.run(`INSERT INTO task_lists (id, project_id, slug, name, description, metadata, created_at, updated_at, machine_id)
9896
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, input.project_id || null, slug, input.name, input.description || null, JSON.stringify(input.metadata || {}), timestamp, timestamp, machineId]);
9897
- return getTaskList(id, d);
10089
+ return d.transaction(() => {
10090
+ const id = uuid();
10091
+ const timestamp = now();
10092
+ const slug = normalizeSlug(input.slug === undefined ? input.name : input.slug);
10093
+ if (!slug)
10094
+ throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
10095
+ const machineId = currentStorageMachineId(d);
10096
+ const scopeKey = taskListSlugScopeKey(input.project_id);
10097
+ 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);
10098
+ if (existing || !claimCanonicalSlug("task_list", scopeKey, slug, id, d)) {
10099
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
10100
+ }
10101
+ d.run(`INSERT INTO task_lists (id, project_id, slug, name, description, metadata, created_at, updated_at, machine_id)
10102
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, input.project_id || null, slug, input.name, input.description || null, JSON.stringify(input.metadata || {}), timestamp, timestamp, machineId]);
10103
+ return getTaskList(id, d);
10104
+ })();
9898
10105
  }
9899
10106
  function getTaskList(id, db) {
9900
10107
  const d = db || getDatabase();
@@ -9920,26 +10127,45 @@ function listTaskLists(projectId, db) {
9920
10127
  }
9921
10128
  function updateTaskList(id, input, db) {
9922
10129
  const d = db || getDatabase();
9923
- const existing = getTaskList(id, d);
9924
- if (!existing)
9925
- throw new TaskListNotFoundError(id);
9926
- const sets = ["updated_at = ?"];
9927
- const params = [now()];
9928
- if (input.name !== undefined) {
9929
- sets.push("name = ?");
9930
- params.push(input.name);
9931
- }
9932
- if (input.description !== undefined) {
9933
- sets.push("description = ?");
9934
- params.push(input.description);
9935
- }
9936
- if (input.metadata !== undefined) {
9937
- sets.push("metadata = ?");
9938
- params.push(JSON.stringify(input.metadata));
9939
- }
9940
- params.push(id);
9941
- d.run(`UPDATE task_lists SET ${sets.join(", ")} WHERE id = ?`, params);
9942
- return getTaskList(id, d);
10130
+ return d.transaction(() => {
10131
+ const existing = getTaskList(id, d);
10132
+ if (!existing)
10133
+ throw new TaskListNotFoundError(id);
10134
+ const sets = ["updated_at = ?"];
10135
+ const params = [now()];
10136
+ if (input.slug !== undefined) {
10137
+ const slug = slugify(input.slug);
10138
+ if (!slug)
10139
+ throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
10140
+ 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);
10141
+ if (duplicate) {
10142
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
10143
+ }
10144
+ if (slug !== existing.slug) {
10145
+ releaseCanonicalSlugClaims("task_list", id, d);
10146
+ if (!claimCanonicalSlug("task_list", taskListSlugScopeKey(existing.project_id), slug, id, d)) {
10147
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
10148
+ }
10149
+ }
10150
+ sets.push("slug = ?");
10151
+ params.push(slug);
10152
+ }
10153
+ if (input.name !== undefined) {
10154
+ sets.push("name = ?");
10155
+ params.push(input.name);
10156
+ }
10157
+ if (input.description !== undefined) {
10158
+ sets.push("description = ?");
10159
+ params.push(input.description);
10160
+ }
10161
+ if (input.metadata !== undefined) {
10162
+ sets.push("metadata = ?");
10163
+ params.push(JSON.stringify(input.metadata));
10164
+ }
10165
+ params.push(id);
10166
+ d.run(`UPDATE task_lists SET ${sets.join(", ")} WHERE id = ?`, params);
10167
+ return getTaskList(id, d);
10168
+ })();
9943
10169
  }
9944
10170
  function deleteTaskList(id, db) {
9945
10171
  const d = db || getDatabase();
@@ -9951,7 +10177,10 @@ function deleteTaskList(id, db) {
9951
10177
  object_id: id,
9952
10178
  payload: list
9953
10179
  }, d);
9954
- return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
10180
+ return d.transaction(() => {
10181
+ releaseCanonicalSlugClaims("task_list", id, d);
10182
+ return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
10183
+ })();
9955
10184
  }
9956
10185
  function ensureTaskList(name, slug, projectId, db) {
9957
10186
  const d = db || getDatabase();
@@ -23352,10 +23581,10 @@ function bootstrapProject(options = {}, db) {
23352
23581
  let project = ensureProject(options.name || discovery.projectName, discovery.projectPath, d);
23353
23582
  const createdProject = !beforeProject;
23354
23583
  if (project.task_list_id !== taskListSlug || options.name && project.name !== options.name) {
23355
- project = updateProject(project.id, {
23584
+ project = renameProject(project.id, {
23356
23585
  name: options.name ?? project.name,
23357
- task_list_id: taskListSlug
23358
- }, d);
23586
+ new_slug: taskListSlug
23587
+ }, d).project;
23359
23588
  }
23360
23589
  setMachineLocalPath(project.id, discovery.projectPath, d);
23361
23590
  const beforeTaskList = d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ?").get(project.id, taskListSlug);
@@ -45809,6 +46038,14 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
45809
46038
  skipped: 0,
45810
46039
  errors: []
45811
46040
  };
46041
+ result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
46042
+ if (result.errors.length === 0) {
46043
+ const existingProjects = d.query("SELECT id, task_list_id FROM projects").all();
46044
+ const existingTaskLists = d.query("SELECT id, project_id, slug FROM task_lists").all();
46045
+ result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
46046
+ }
46047
+ if (result.errors.length > 0)
46048
+ return result;
45812
46049
  const applyRows = (objectType3, table, columns, rows, updateClockColumn, afterUpsert) => {
45813
46050
  for (const row of rows) {
45814
46051
  try {
@@ -46181,6 +46418,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
46181
46418
  getByPath: (path) => getProjectByPath(path, database()),
46182
46419
  list: () => listProjects(database()),
46183
46420
  update: (id, input) => updateProject(id, input, database()),
46421
+ rename: (id, input) => renameProject(id, input, database()),
46184
46422
  delete: (id) => deleteProject(id, database())
46185
46423
  },
46186
46424
  plans: {
@@ -46288,6 +46526,91 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
46288
46526
  )`
46289
46527
  ];
46290
46528
  }
46529
+ function postgresTodosScopedSlugPreflightSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
46530
+ assertSafeIdentifier(tableName);
46531
+ return `/* todos:scoped-slug-duplicate-audit */ WITH candidates AS (
46532
+ SELECT service, object_type, COALESCE(payload->>'project_id', '') AS scope,
46533
+ jsonb_typeof(payload->'project_id') AS scope_type,
46534
+ payload->>'slug' AS slug, jsonb_typeof(payload->'slug') AS slug_type, object_id
46535
+ FROM ${tableName}
46536
+ WHERE object_type = 'task_lists' AND deleted_at IS NULL
46537
+ UNION ALL
46538
+ SELECT service, object_type, '' AS scope, NULL::text AS scope_type, payload->>'task_list_id' AS slug,
46539
+ jsonb_typeof(payload->'task_list_id') AS slug_type, object_id
46540
+ FROM ${tableName}
46541
+ WHERE object_type = 'projects' AND deleted_at IS NULL
46542
+ ), annotated AS (
46543
+ SELECT *, trim(both '-' from regexp_replace(lower(COALESCE(slug, '')), '[^a-z0-9]+', '-', 'g')) AS normalized_slug
46544
+ FROM candidates
46545
+ ), invalid AS (
46546
+ SELECT service, object_type, scope, COALESCE(slug, '<null>') AS slug,
46547
+ ARRAY[object_id] AS object_ids, 1::integer AS duplicate_count, 'invalid'::text AS issue
46548
+ FROM annotated
46549
+ WHERE slug_type IS DISTINCT FROM 'string'
46550
+ OR slug IS NULL OR slug = '' OR normalized_slug = '' OR slug IS DISTINCT FROM normalized_slug
46551
+ OR (object_type = 'task_lists' AND (
46552
+ (scope_type IS NOT NULL AND scope_type NOT IN ('string', 'null'))
46553
+ OR (scope_type = 'string' AND btrim(scope) = '')
46554
+ ))
46555
+ ), duplicates AS (
46556
+ SELECT service, object_type, scope, slug,
46557
+ array_agg(object_id ORDER BY object_id) AS object_ids,
46558
+ count(*)::integer AS duplicate_count, 'duplicate'::text AS issue
46559
+ FROM annotated
46560
+ WHERE slug = normalized_slug AND slug <> ''
46561
+ GROUP BY service, object_type, scope, slug
46562
+ HAVING count(*) > 1
46563
+ ) SELECT * FROM invalid
46564
+ UNION ALL SELECT * FROM duplicates
46565
+ ORDER BY service, object_type, scope, slug`;
46566
+ }
46567
+ function postgresTodosScopedSlugUniqueIndexSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
46568
+ assertSafeIdentifier(tableName);
46569
+ return [
46570
+ `CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_task_list_scope_slug_uidx
46571
+ ON ${tableName} (service, COALESCE(payload->>'project_id', ''), (payload->>'slug'))
46572
+ WHERE object_type = 'task_lists' AND deleted_at IS NULL AND COALESCE(payload->>'slug', '') <> ''`,
46573
+ `CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_project_task_list_slug_uidx
46574
+ ON ${tableName} (service, (payload->>'task_list_id'))
46575
+ WHERE object_type = 'projects' AND deleted_at IS NULL AND COALESCE(payload->>'task_list_id', '') <> ''`
46576
+ ];
46577
+ }
46578
+ function postgresTodosScopedSlugIndexStatusSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
46579
+ assertSafeIdentifier(tableName);
46580
+ return `/* todos:scoped-slug-index-status */ SELECT index_class.relname AS index_name,
46581
+ index_meta.indisvalid AS is_valid, index_meta.indisready AS is_ready
46582
+ FROM pg_index index_meta
46583
+ JOIN pg_class index_class ON index_class.oid = index_meta.indexrelid
46584
+ WHERE index_meta.indrelid = to_regclass('${tableName}')
46585
+ AND index_class.relname IN (
46586
+ '${tableName}_task_list_scope_slug_uidx',
46587
+ '${tableName}_project_task_list_slug_uidx'
46588
+ )`;
46589
+ }
46590
+ async function ensurePostgresScopedSlugUniqueIndexes(client, tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
46591
+ const audit = await client.query(postgresTodosScopedSlugPreflightSql(tableName));
46592
+ if (audit.rows.length > 0)
46593
+ throw new PostgresScopedSlugMigrationConflictError(audit.rows);
46594
+ for (const sql of postgresTodosScopedSlugUniqueIndexSql(tableName)) {
46595
+ try {
46596
+ await client.query(sql);
46597
+ } catch (error) {
46598
+ const indexName = sql.match(/INDEX CONCURRENTLY IF NOT EXISTS ([a-zA-Z0-9_]+)/)?.[1] ?? "unknown_index";
46599
+ throw new PostgresScopedSlugIndexBuildError(indexName, error);
46600
+ }
46601
+ }
46602
+ const expected = new Set([
46603
+ `${tableName}_task_list_scope_slug_uidx`,
46604
+ `${tableName}_project_task_list_slug_uidx`
46605
+ ]);
46606
+ const status = await client.query(postgresTodosScopedSlugIndexStatusSql(tableName));
46607
+ for (const indexName of expected) {
46608
+ const row = status.rows.find((candidate) => candidate.index_name === indexName);
46609
+ if (!row?.is_valid || !row.is_ready) {
46610
+ throw new PostgresScopedSlugIndexBuildError(indexName, new Error("index is missing, invalid, or not ready"));
46611
+ }
46612
+ }
46613
+ }
46291
46614
  function postgresTodosCommentCursorIndexSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
46292
46615
  assertSafeIdentifier(tableName);
46293
46616
  return `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_comment_task_created_idx
@@ -46316,9 +46639,31 @@ class PostgresTodosSyncStore {
46316
46639
  }
46317
46640
  }
46318
46641
  async pushSnapshot(snapshot, context = {}) {
46642
+ const routingErrors = validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists);
46643
+ if (routingErrors.length > 0) {
46644
+ throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
46645
+ }
46646
+ const existing = await this.client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
46647
+ FROM ${this.tableName}
46648
+ WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
46649
+ const existingProjects = [];
46650
+ const existingTaskLists = [];
46651
+ for (const row of existing.rows) {
46652
+ const payload = payloadRecord(row.payload);
46653
+ if (row.object_type === "projects")
46654
+ existingProjects.push(payload);
46655
+ if (row.object_type === "task_lists")
46656
+ existingTaskLists.push(payload);
46657
+ }
46658
+ const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
46659
+ if (destinationErrors.length > 0) {
46660
+ throw new Error(`Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
46661
+ }
46319
46662
  const result = { records: 0, objectTypes: {} };
46320
46663
  const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
46321
46664
  for (const entry of snapshotEntries(snapshot)) {
46665
+ if (entry.deletedAt === null)
46666
+ assertCanonicalScopedSlugEntry(entry);
46322
46667
  await this.client.query(`INSERT INTO ${this.tableName} (
46323
46668
  service, object_type, object_id, payload, updated_at,
46324
46669
  deleted_at, source_machine_id, version
@@ -46397,6 +46742,22 @@ function snapshotEntries(snapshot) {
46397
46742
  }))
46398
46743
  ];
46399
46744
  }
46745
+ function assertCanonicalScopedSlugEntry(entry) {
46746
+ if (!entry.payload || typeof entry.payload !== "object" || Array.isArray(entry.payload))
46747
+ return;
46748
+ const payload = entry.payload;
46749
+ if (entry.type === "projects" && !isCanonicalSlug(payload["task_list_id"])) {
46750
+ throw new Error("Invalid project task-list slug \u2014 sync requires non-empty canonical kebab-case");
46751
+ }
46752
+ if (entry.type === "task_lists") {
46753
+ if (!isCanonicalSlug(payload["slug"])) {
46754
+ throw new Error("Invalid task-list slug \u2014 sync requires non-empty canonical kebab-case");
46755
+ }
46756
+ if (!isValidTaskListProjectScope(payload["project_id"])) {
46757
+ throw new Error("Invalid task-list project scope \u2014 project_id must be null, missing, or a non-empty string");
46758
+ }
46759
+ }
46760
+ }
46400
46761
  function entry(type, payload, fallbackUpdatedAt) {
46401
46762
  const id = payload["id"];
46402
46763
  if (typeof id !== "string" || !id)
@@ -46479,7 +46840,26 @@ function assertSafeIdentifier(value) {
46479
46840
  if (!/^[a-z_][a-z0-9_]*$/i.test(value))
46480
46841
  throw new Error(`Unsafe Postgres identifier: ${value}`);
46481
46842
  }
46482
- var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records", DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors";
46843
+ var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records", DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors", PostgresScopedSlugMigrationConflictError, PostgresScopedSlugIndexBuildError;
46844
+ var init_postgres_sync = __esm(() => {
46845
+ PostgresScopedSlugMigrationConflictError = class PostgresScopedSlugMigrationConflictError extends Error {
46846
+ conflicts;
46847
+ constructor(conflicts) {
46848
+ const preview = conflicts.slice(0, 5).map((conflict) => `${conflict.object_type}:${conflict.scope || "global"}:${conflict.slug} [${conflict.object_ids.join(", ")}]`).join("; ");
46849
+ 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.");
46850
+ this.conflicts = conflicts;
46851
+ this.name = "PostgresScopedSlugMigrationConflictError";
46852
+ }
46853
+ };
46854
+ PostgresScopedSlugIndexBuildError = class PostgresScopedSlugIndexBuildError extends Error {
46855
+ index_name;
46856
+ constructor(index_name, cause) {
46857
+ 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 });
46858
+ this.index_name = index_name;
46859
+ this.name = "PostgresScopedSlugIndexBuildError";
46860
+ }
46861
+ };
46862
+ });
46483
46863
 
46484
46864
  // src/storage/shadow-outbox.ts
46485
46865
  class TodosShadowOutbox {
@@ -46726,6 +47106,7 @@ function emptySnapshot() {
46726
47106
  var MAX_BACKOFF_MS;
46727
47107
  var init_shadow_outbox = __esm(() => {
46728
47108
  init_local_sqlite();
47109
+ init_postgres_sync();
46729
47110
  init_shadow_outbox_schema();
46730
47111
  init_shadow_outbox_schema();
46731
47112
  MAX_BACKOFF_MS = 5 * 60000;
@@ -48238,6 +48619,7 @@ function createPostgresTodosStorageAdapter(options) {
48238
48619
  getByPath: async (path) => (await store.list("projects")).find((project) => project.path === path) ?? null,
48239
48620
  list: async () => (await store.list("projects")).sort((a, b) => a.name.localeCompare(b.name)),
48240
48621
  update: (id, input) => updateProject2(id, input, store),
48622
+ rename: (id, input, context) => store.renameProject(id, input.new_slug, input.name, context),
48241
48623
  delete: (id, context) => store.delete("projects", id, context)
48242
48624
  },
48243
48625
  plans: {
@@ -48475,9 +48857,22 @@ class PostgresJsonRecordStore {
48475
48857
  });
48476
48858
  }
48477
48859
  async upsert(type, value, context = {}) {
48860
+ if (type === "projects" && !isCanonicalSlug(value.task_list_id)) {
48861
+ throw new Error("Invalid project task-list slug \u2014 imports require non-empty canonical kebab-case");
48862
+ }
48863
+ if (type === "task_lists") {
48864
+ if (!isCanonicalSlug(value.slug)) {
48865
+ throw new Error("Invalid task-list slug \u2014 imports require non-empty canonical kebab-case");
48866
+ }
48867
+ if (!isValidTaskListProjectScope(value.project_id)) {
48868
+ throw new Error("Invalid task-list project scope \u2014 project_id must be null, missing, or a non-empty string");
48869
+ }
48870
+ }
48478
48871
  await this.ensureSchema();
48479
48872
  const updatedAt = stringValue2(value.updated_at) ?? stringValue2(value.created_at) ?? new Date().toISOString();
48480
- const result = await this.options.client.query(`INSERT INTO ${this.tableName} (
48873
+ let result;
48874
+ try {
48875
+ result = await this.options.client.query(`INSERT INTO ${this.tableName} (
48481
48876
  service, object_type, object_id, payload, updated_at,
48482
48877
  deleted_at, source_machine_id, version
48483
48878
  ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, NULL, $6, $7)
@@ -48492,14 +48887,23 @@ class PostgresJsonRecordStore {
48492
48887
  OR (${this.tableName}.updated_at = EXCLUDED.updated_at
48493
48888
  AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
48494
48889
  RETURNING object_id`, [
48495
- this.service,
48496
- type,
48497
- value.id,
48498
- jsonbParam(value),
48499
- updatedAt,
48500
- context.requestId ?? this.sourceMachineId ?? null,
48501
- numberValue3(value.version)
48502
- ]);
48890
+ this.service,
48891
+ type,
48892
+ value.id,
48893
+ jsonbParam(value),
48894
+ updatedAt,
48895
+ context.requestId ?? this.sourceMachineId ?? null,
48896
+ numberValue3(value.version)
48897
+ ]);
48898
+ } catch (error) {
48899
+ if (type === "task_lists" && isPostgresUniqueViolation(error)) {
48900
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${String(value.slug ?? "")}" already exists in this scope`);
48901
+ }
48902
+ if (type === "projects" && isPostgresUniqueViolation(error)) {
48903
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${String(value.task_list_id ?? "")}" already exists`);
48904
+ }
48905
+ throw error;
48906
+ }
48503
48907
  if (result.rows.length === 0) {
48504
48908
  const current = await this.get(type, value.id);
48505
48909
  if (current)
@@ -48507,6 +48911,93 @@ class PostgresJsonRecordStore {
48507
48911
  }
48508
48912
  return value;
48509
48913
  }
48914
+ async renameProject(id, newSlug, name, context = {}) {
48915
+ await this.ensureSchema();
48916
+ const normalizedSlug = slugifyRaw(newSlug);
48917
+ if (!normalizedSlug)
48918
+ throw new Error("Invalid slug \u2014 must be non-empty kebab-case");
48919
+ const timestamp3 = new Date().toISOString();
48920
+ try {
48921
+ const result = await this.options.client.query(`/* todos:rename-project-atomic */ WITH target AS (
48922
+ SELECT payload, payload->>'task_list_id' AS old_slug
48923
+ FROM ${this.tableName}
48924
+ WHERE service = $1 AND object_type = 'projects' AND object_id = $2 AND deleted_at IS NULL
48925
+ FOR UPDATE
48926
+ ), project_conflict AS (
48927
+ SELECT 1 FROM ${this.tableName}
48928
+ WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
48929
+ AND deleted_at IS NULL AND payload->>'task_list_id' = $3 LIMIT 1
48930
+ ), task_list_conflict AS (
48931
+ SELECT 1 FROM ${this.tableName} r, target
48932
+ WHERE r.service = $1 AND r.object_type = 'task_lists' AND r.deleted_at IS NULL
48933
+ AND r.payload->>'project_id' = $2 AND r.payload->>'slug' = $3
48934
+ AND r.payload->>'slug' IS DISTINCT FROM target.old_slug LIMIT 1
48935
+ ), updated_lists AS (
48936
+ UPDATE ${this.tableName} r SET
48937
+ payload = r.payload || jsonb_build_object('slug', $3::text, 'updated_at', $5::text)
48938
+ || CASE WHEN $4::text IS NULL THEN '{}'::jsonb ELSE jsonb_build_object('name', $4::text) END,
48939
+ updated_at = $5::timestamptz, version = COALESCE(r.version, 0) + 1,
48940
+ source_machine_id = COALESCE($6, r.source_machine_id)
48941
+ FROM target
48942
+ WHERE r.service = $1 AND r.object_type = 'task_lists' AND r.deleted_at IS NULL
48943
+ AND r.payload->>'project_id' = $2 AND r.payload->>'slug' = target.old_slug
48944
+ AND NOT EXISTS (SELECT 1 FROM project_conflict)
48945
+ AND NOT EXISTS (SELECT 1 FROM task_list_conflict)
48946
+ AND (target.old_slug IS DISTINCT FROM $3
48947
+ OR ($4::text IS NOT NULL AND r.payload->>'name' IS DISTINCT FROM $4))
48948
+ RETURNING 1
48949
+ ), updated_project AS (
48950
+ UPDATE ${this.tableName} r SET
48951
+ payload = r.payload || jsonb_build_object('task_list_id', $3::text, 'updated_at', $5::text)
48952
+ || CASE WHEN $4::text IS NULL THEN '{}'::jsonb ELSE jsonb_build_object('name', $4::text) END,
48953
+ updated_at = $5::timestamptz, version = COALESCE(r.version, 0) + 1,
48954
+ source_machine_id = COALESCE($6, r.source_machine_id)
48955
+ FROM target
48956
+ WHERE r.service = $1 AND r.object_type = 'projects' AND r.object_id = $2 AND r.deleted_at IS NULL
48957
+ AND NOT EXISTS (SELECT 1 FROM project_conflict)
48958
+ AND NOT EXISTS (SELECT 1 FROM task_list_conflict)
48959
+ AND (target.old_slug IS DISTINCT FROM $3
48960
+ OR ($4::text IS NOT NULL AND target.payload->>'name' IS DISTINCT FROM $4))
48961
+ RETURNING r.payload
48962
+ ) SELECT
48963
+ EXISTS (SELECT 1 FROM target) AS found,
48964
+ EXISTS (SELECT 1 FROM project_conflict) AS project_conflict,
48965
+ EXISTS (SELECT 1 FROM task_list_conflict) AS task_list_conflict,
48966
+ COALESCE((SELECT payload FROM updated_project), (SELECT payload FROM target)) AS project,
48967
+ (SELECT count(*) FROM updated_lists) AS task_lists_updated`, [this.service, id, normalizedSlug, name ?? null, timestamp3, this.machineId(context)]);
48968
+ const row = result.rows[0];
48969
+ if (!row?.found)
48970
+ throw new ProjectNotFoundError(id);
48971
+ if (row.project_conflict) {
48972
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalizedSlug}" is already used by another project`);
48973
+ }
48974
+ if (row.task_list_conflict) {
48975
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalizedSlug}" is already used in this project`);
48976
+ }
48977
+ return {
48978
+ project: payloadRecord2(row.project),
48979
+ task_lists_updated: Number(row.task_lists_updated)
48980
+ };
48981
+ } catch (error) {
48982
+ if (isPostgresUniqueViolation(error)) {
48983
+ const constraintName = postgresConstraintName(error);
48984
+ let projectConflict = constraintName.includes("project_task_list_slug_uidx");
48985
+ if (!constraintName) {
48986
+ const conflict = await this.options.client.query(`/* todos:classify-project-rename-conflict */ SELECT EXISTS (
48987
+ SELECT 1 FROM ${this.tableName}
48988
+ WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
48989
+ AND deleted_at IS NULL AND payload->>'task_list_id' = $3
48990
+ ) AS project_conflict`, [this.service, id, normalizedSlug]);
48991
+ projectConflict = Boolean(conflict.rows[0]?.project_conflict);
48992
+ }
48993
+ if (projectConflict) {
48994
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalizedSlug}" is already used by another project`);
48995
+ }
48996
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalizedSlug}" is already used in this project`);
48997
+ }
48998
+ throw error;
48999
+ }
49000
+ }
48510
49001
  async incrementProjectTaskCounter(projectId, _context = {}) {
48511
49002
  await this.ensureSchema();
48512
49003
  const result = await this.options.client.query(`UPDATE ${this.tableName}
@@ -48926,12 +49417,16 @@ async function getChangedSince(since, filters, store) {
48926
49417
  }
48927
49418
  async function createProject2(input, store, context) {
48928
49419
  const timestamp3 = new Date().toISOString();
49420
+ const derivedSlug = slugifyRaw(input.name);
49421
+ const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugifyRaw(input.task_list_id);
49422
+ if (!derivedSlug || !taskListId)
49423
+ throw new Error("Project name and task-list slug must be non-empty");
48929
49424
  const project = {
48930
49425
  id: randomUUID3(),
48931
49426
  name: input.name,
48932
49427
  path: input.path,
48933
49428
  description: input.description ?? null,
48934
- task_list_id: input.task_list_id ?? `todos-${slugify2(input.name)}`,
49429
+ task_list_id: taskListId,
48935
49430
  task_prefix: input.task_prefix ?? await generateProjectPrefix(input.name, store),
48936
49431
  task_counter: 0,
48937
49432
  created_at: timestamp3,
@@ -48942,6 +49437,9 @@ async function createProject2(input, store, context) {
48942
49437
  return store.upsert("projects", project, context);
48943
49438
  }
48944
49439
  async function updateProject2(id, input, store) {
49440
+ if ("task_list_id" in input) {
49441
+ throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
49442
+ }
48945
49443
  const project = await requireRecord("projects", id, store);
48946
49444
  const updated = { ...project, ...definedPatch(input), updated_at: new Date().toISOString() };
48947
49445
  return store.upsert("projects", updated);
@@ -49050,10 +49548,13 @@ async function releaseAgent2(idOrName, sessionId, store, context) {
49050
49548
  }
49051
49549
  async function createTaskList2(input, store, context) {
49052
49550
  const timestamp3 = new Date().toISOString();
49551
+ const slug = slugifyRaw(input.slug === undefined ? input.name : input.slug);
49552
+ if (!slug)
49553
+ throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
49053
49554
  return store.upsert("task_lists", {
49054
49555
  id: randomUUID3(),
49055
49556
  project_id: input.project_id ?? context?.projectId ?? null,
49056
- slug: input.slug ?? slugify2(input.name),
49557
+ slug,
49057
49558
  name: input.name,
49058
49559
  description: input.description ?? null,
49059
49560
  metadata: input.metadata ?? {},
@@ -49065,9 +49566,20 @@ async function createTaskList2(input, store, context) {
49065
49566
  }
49066
49567
  async function updateTaskList2(id, input, store) {
49067
49568
  const list = await requireRecord("task_lists", id, store);
49569
+ const patch = definedPatch(input);
49570
+ if (input.slug !== undefined) {
49571
+ const slug = slugifyRaw(input.slug);
49572
+ if (!slug)
49573
+ throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
49574
+ const duplicate = (await store.list("task_lists")).find((candidate) => candidate.id !== id && candidate.project_id === list.project_id && candidate.slug === slug);
49575
+ if (duplicate) {
49576
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
49577
+ }
49578
+ patch.slug = slug;
49579
+ }
49068
49580
  return store.upsert("task_lists", {
49069
49581
  ...list,
49070
- ...definedPatch(input),
49582
+ ...patch,
49071
49583
  metadata: input.metadata ?? list.metadata,
49072
49584
  updated_at: new Date().toISOString()
49073
49585
  });
@@ -49151,6 +49663,16 @@ async function exportSnapshot(store) {
49151
49663
  }
49152
49664
  async function importSnapshot(snapshot, store, context) {
49153
49665
  const result = { inserted: 0, updated: 0, deleted: 0, skipped: 0, errors: [] };
49666
+ result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
49667
+ if (result.errors.length > 0)
49668
+ return result;
49669
+ const [existingProjects, existingTaskLists] = await Promise.all([
49670
+ store.list("projects"),
49671
+ store.list("task_lists")
49672
+ ]);
49673
+ result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
49674
+ if (result.errors.length > 0)
49675
+ return result;
49154
49676
  const entries = [
49155
49677
  ...snapshot.tasks.map((row) => ["tasks", row]),
49156
49678
  ...snapshot.projects.map((row) => ["projects", row]),
@@ -49221,10 +49743,7 @@ async function generateProjectPrefix(name, store) {
49221
49743
  return candidate;
49222
49744
  }
49223
49745
  function slugifyRaw(value) {
49224
- return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
49225
- }
49226
- function slugify2(value) {
49227
- return slugifyRaw(value) || "todos";
49746
+ return normalizeSlug(value);
49228
49747
  }
49229
49748
  function normalizePlanSlug2(value) {
49230
49749
  const slug = slugifyRaw(value);
@@ -49278,9 +49797,20 @@ function compareClock(left, right) {
49278
49797
  function numberValue3(value) {
49279
49798
  return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
49280
49799
  }
49800
+ function isPostgresUniqueViolation(error) {
49801
+ return typeof error === "object" && error !== null && error.code === "23505";
49802
+ }
49803
+ function postgresConstraintName(error) {
49804
+ if (typeof error !== "object" || error === null)
49805
+ return "";
49806
+ const candidate = error;
49807
+ const constraint = candidate.constraint ?? candidate.constraint_name;
49808
+ return typeof constraint === "string" ? constraint : "";
49809
+ }
49281
49810
  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";
49282
49811
  var init_postgres_adapter = __esm(() => {
49283
49812
  init_types();
49813
+ init_postgres_sync();
49284
49814
  init_redaction();
49285
49815
  });
49286
49816
 
@@ -49378,6 +49908,7 @@ async function backfillPostgresCommentRedaction(client, options = {}) {
49378
49908
  var COMMENT_REDACTION_BACKFILL_CONFIRMATION = "REDACT_STORED_TODOS_COMMENTS";
49379
49909
  var init_comment_redaction_backfill = __esm(() => {
49380
49910
  init_redaction();
49911
+ init_postgres_sync();
49381
49912
  });
49382
49913
 
49383
49914
  // src/server/cloud.ts
@@ -49391,6 +49922,7 @@ __export(exports_cloud, {
49391
49922
  getCloudVerifier: () => getCloudVerifier,
49392
49923
  getCloudStorageAdapter: () => getCloudStorageAdapter,
49393
49924
  getApiKeyStore: () => getApiKeyStore,
49925
+ ensureCloudScopedSlugUniqueIndexes: () => ensureCloudScopedSlugUniqueIndexes,
49394
49926
  ensureCloudSchema: () => ensureCloudSchema,
49395
49927
  ensureCloudCommentCursorIndex: () => ensureCloudCommentCursorIndex,
49396
49928
  closeCloud: () => closeCloud,
@@ -49475,6 +50007,9 @@ async function ensureCloudSchema() {
49475
50007
  async function ensureCloudCommentCursorIndex() {
49476
50008
  await getClient().query(postgresTodosCommentCursorIndexSql());
49477
50009
  }
50010
+ async function ensureCloudScopedSlugUniqueIndexes() {
50011
+ await ensurePostgresScopedSlugUniqueIndexes(getClient());
50012
+ }
49478
50013
  async function normalizeCloudPayloads() {
49479
50014
  const client = getClient();
49480
50015
  const res = await client.query(`UPDATE todos_sync_records
@@ -49506,6 +50041,7 @@ var init_cloud = __esm(() => {
49506
50041
  init_auth();
49507
50042
  init_cloud_client();
49508
50043
  init_postgres_adapter();
50044
+ init_postgres_sync();
49509
50045
  init_comment_redaction_backfill();
49510
50046
  });
49511
50047
 
@@ -49530,6 +50066,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
49530
50066
  schemas: {
49531
50067
  Task: taskSchema,
49532
50068
  Project: projectSchema,
50069
+ TaskList: taskListSchema,
49533
50070
  TaskComment: taskCommentSchema,
49534
50071
  CreateTaskInput: {
49535
50072
  type: "object",
@@ -49558,12 +50095,65 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
49558
50095
  },
49559
50096
  CreateProjectInput: {
49560
50097
  type: "object",
50098
+ additionalProperties: false,
49561
50099
  required: ["name", "path"],
49562
50100
  properties: {
50101
+ name: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
50102
+ path: { type: "string", minLength: 1 },
50103
+ description: { type: "string" },
50104
+ task_list_id: { type: "string", minLength: 1, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
50105
+ task_prefix: { type: "string", minLength: 1 }
50106
+ }
50107
+ },
50108
+ UpdateProjectInput: {
50109
+ type: "object",
50110
+ additionalProperties: false,
50111
+ minProperties: 1,
50112
+ properties: {
50113
+ name: { type: "string", minLength: 1 },
50114
+ path: { type: "string", minLength: 1 },
50115
+ description: { type: "string", nullable: true }
50116
+ }
50117
+ },
50118
+ RenameProjectInput: {
50119
+ type: "object",
50120
+ additionalProperties: false,
50121
+ required: ["new_slug"],
50122
+ properties: {
50123
+ new_slug: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
50124
+ name: { type: "string", minLength: 1 }
50125
+ }
50126
+ },
50127
+ ErrorResponse: {
50128
+ type: "object",
50129
+ required: ["error"],
50130
+ properties: {
50131
+ error: { type: "string" },
50132
+ code: { type: "string" },
50133
+ conflict: { type: "boolean" }
50134
+ }
50135
+ },
50136
+ CreateTaskListInput: {
50137
+ type: "object",
50138
+ additionalProperties: false,
50139
+ required: ["name"],
50140
+ properties: {
50141
+ name: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
50142
+ slug: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
50143
+ project_id: { type: "string" },
50144
+ description: { type: "string" },
50145
+ metadata: { type: "object", additionalProperties: true }
50146
+ }
50147
+ },
50148
+ UpdateTaskListInput: {
50149
+ type: "object",
50150
+ additionalProperties: false,
50151
+ minProperties: 1,
50152
+ properties: {
50153
+ slug: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
49563
50154
  name: { type: "string" },
49564
- path: { type: "string" },
49565
50155
  description: { type: "string" },
49566
- task_prefix: { type: "string" }
50156
+ metadata: { type: "object", additionalProperties: true }
49567
50157
  }
49568
50158
  },
49569
50159
  CreateTaskCommentInput: {
@@ -49772,7 +50362,10 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
49772
50362
  required: true,
49773
50363
  content: { "application/json": { schema: { $ref: "#/components/schemas/CreateProjectInput" } } }
49774
50364
  },
49775
- responses: { "201": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" } } } } } } }
50365
+ responses: {
50366
+ "201": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" } } } } } },
50367
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
50368
+ }
49776
50369
  }
49777
50370
  },
49778
50371
  "/v1/projects/{id}": {
@@ -49781,6 +50374,86 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
49781
50374
  summary: "Get a project by id",
49782
50375
  parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
49783
50376
  responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" } } } } } } }
50377
+ },
50378
+ patch: {
50379
+ operationId: "updateProject",
50380
+ summary: "Update a project",
50381
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
50382
+ requestBody: {
50383
+ required: true,
50384
+ content: { "application/json": { schema: { $ref: "#/components/schemas/UpdateProjectInput" } } }
50385
+ },
50386
+ responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" } } } } } } }
50387
+ },
50388
+ delete: {
50389
+ operationId: "deleteProject",
50390
+ summary: "Delete a project",
50391
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
50392
+ responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean" }, id: { type: "string" } } } } } } }
50393
+ }
50394
+ },
50395
+ "/v1/projects/{id}/rename": {
50396
+ post: {
50397
+ operationId: "renameProject",
50398
+ summary: "Atomically rename a project and its canonical task list",
50399
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
50400
+ requestBody: {
50401
+ required: true,
50402
+ content: { "application/json": { schema: { $ref: "#/components/schemas/RenameProjectInput" } } }
50403
+ },
50404
+ responses: {
50405
+ "200": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" }, task_lists_updated: { type: "number" } } } } } },
50406
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
50407
+ }
50408
+ }
50409
+ },
50410
+ "/v1/task-lists": {
50411
+ get: {
50412
+ operationId: "listTaskLists",
50413
+ summary: "List task lists",
50414
+ parameters: [{ name: "project_id", in: "query", schema: { type: "string" } }],
50415
+ responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { task_lists: { type: "array", items: { $ref: "#/components/schemas/TaskList" } }, count: { type: "number" } } } } } } }
50416
+ },
50417
+ post: {
50418
+ operationId: "createTaskList",
50419
+ summary: "Create a task list",
50420
+ requestBody: {
50421
+ required: true,
50422
+ content: { "application/json": { schema: { $ref: "#/components/schemas/CreateTaskListInput" } } }
50423
+ },
50424
+ responses: {
50425
+ "201": { content: { "application/json": { schema: { type: "object", properties: { task_list: { $ref: "#/components/schemas/TaskList" } } } } } },
50426
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
50427
+ }
50428
+ }
50429
+ },
50430
+ "/v1/task-lists/{id}": {
50431
+ get: {
50432
+ operationId: "getTaskList",
50433
+ summary: "Get a task list by id",
50434
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
50435
+ responses: {
50436
+ "200": { content: { "application/json": { schema: { type: "object", properties: { task_list: { $ref: "#/components/schemas/TaskList" } } } } } }
50437
+ }
50438
+ },
50439
+ patch: {
50440
+ operationId: "updateTaskList",
50441
+ summary: "Update a task list",
50442
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
50443
+ requestBody: {
50444
+ required: true,
50445
+ content: { "application/json": { schema: { $ref: "#/components/schemas/UpdateTaskListInput" } } }
50446
+ },
50447
+ responses: {
50448
+ "200": { content: { "application/json": { schema: { type: "object", properties: { task_list: { $ref: "#/components/schemas/TaskList" } } } } } },
50449
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
50450
+ }
50451
+ },
50452
+ delete: {
50453
+ operationId: "deleteTaskList",
50454
+ summary: "Delete a task list",
50455
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
50456
+ responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean" }, id: { type: "string" } } } } } } }
49784
50457
  }
49785
50458
  },
49786
50459
  "/v1/stats": {
@@ -49847,7 +50520,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
49847
50520
  }
49848
50521
  };
49849
50522
  }
49850
- var taskSchema, projectSchema, taskCommentSchema;
50523
+ var taskSchema, projectSchema, taskListSchema, taskCommentSchema;
49851
50524
  var init_openapi = __esm(() => {
49852
50525
  init_package_version();
49853
50526
  taskSchema = {
@@ -49874,6 +50547,22 @@ var init_openapi = __esm(() => {
49874
50547
  name: { type: "string" },
49875
50548
  path: { type: "string" },
49876
50549
  description: { type: "string", nullable: true },
50550
+ task_list_id: { type: "string", nullable: true },
50551
+ task_prefix: { type: "string", nullable: true },
50552
+ task_counter: { type: "number" },
50553
+ created_at: { type: "string" },
50554
+ updated_at: { type: "string" }
50555
+ }
50556
+ };
50557
+ taskListSchema = {
50558
+ type: "object",
50559
+ properties: {
50560
+ id: { type: "string" },
50561
+ project_id: { type: "string", nullable: true },
50562
+ slug: { type: "string" },
50563
+ name: { type: "string" },
50564
+ description: { type: "string", nullable: true },
50565
+ metadata: { type: "object", additionalProperties: true },
49877
50566
  created_at: { type: "string" },
49878
50567
  updated_at: { type: "string" }
49879
50568
  }
@@ -49907,6 +50596,48 @@ function json2(body, status = 200) {
49907
50596
  function error(status, message, extra) {
49908
50597
  return json2({ error: message, ...extra ?? {} }, status);
49909
50598
  }
50599
+ function validateProjectPatch(value) {
50600
+ if (!value || typeof value !== "object" || Array.isArray(value))
50601
+ return { ok: false, message: "project patch must be an object" };
50602
+ const body = value;
50603
+ const allowed = new Set(["name", "path", "description"]);
50604
+ const unknown = Object.keys(body).find((key) => !allowed.has(key));
50605
+ if (unknown)
50606
+ return { ok: false, message: `unknown project field: ${unknown}` };
50607
+ if (Object.keys(body).length === 0)
50608
+ return { ok: false, message: "project patch must not be empty" };
50609
+ if (body["name"] !== undefined && (typeof body["name"] !== "string" || !body["name"].trim()))
50610
+ return { ok: false, message: "name must be a non-empty string" };
50611
+ if (body["path"] !== undefined && (typeof body["path"] !== "string" || !body["path"].trim()))
50612
+ return { ok: false, message: "path must be a non-empty string" };
50613
+ if (body["description"] !== undefined && body["description"] !== null && typeof body["description"] !== "string")
50614
+ return { ok: false, message: "description must be a string or null" };
50615
+ return { ok: true, patch: body };
50616
+ }
50617
+ function validateProjectCreate(value) {
50618
+ if (!value || typeof value !== "object" || Array.isArray(value))
50619
+ return { ok: false, message: "project body must be an object" };
50620
+ const body = value;
50621
+ const allowed = new Set(["name", "path", "description", "task_list_id", "task_prefix"]);
50622
+ const unknown = Object.keys(body).find((key) => !allowed.has(key));
50623
+ if (unknown)
50624
+ return { ok: false, message: `unknown project field: ${unknown}` };
50625
+ if (typeof body["name"] !== "string" || !body["name"].trim())
50626
+ return { ok: false, message: "name must be a non-empty string" };
50627
+ if (!normalizeSlug(body["name"]))
50628
+ return { ok: false, message: "name must produce a non-empty canonical slug" };
50629
+ if (typeof body["path"] !== "string" || !body["path"].trim())
50630
+ return { ok: false, message: "path must be a non-empty string" };
50631
+ if (body["description"] !== undefined && typeof body["description"] !== "string")
50632
+ return { ok: false, message: "description must be a string" };
50633
+ if (body["task_list_id"] !== undefined && !isCanonicalSlug(body["task_list_id"])) {
50634
+ return { ok: false, message: "task_list_id must be non-empty canonical kebab-case" };
50635
+ }
50636
+ if (body["task_prefix"] !== undefined && (typeof body["task_prefix"] !== "string" || !body["task_prefix"].trim())) {
50637
+ return { ok: false, message: "task_prefix must be a non-empty string" };
50638
+ }
50639
+ return { ok: true, input: body };
50640
+ }
49910
50641
  async function readJson(req) {
49911
50642
  try {
49912
50643
  const text2 = await req.text();
@@ -50387,14 +51118,31 @@ async function handleV1Request(req, url, dependencies = {}) {
50387
51118
  }
50388
51119
  if (method === "POST") {
50389
51120
  const body = await readJson(req);
50390
- if (!body || typeof body.name !== "string" || typeof body.path !== "string") {
50391
- return error(400, "name and path are required");
50392
- }
50393
- const project = await store.projects.create(body, contextFromPrincipal(principal));
51121
+ if (!body)
51122
+ return error(400, "invalid JSON body");
51123
+ const validated = validateProjectCreate(body);
51124
+ if (!validated.ok)
51125
+ return error(400, validated.message);
51126
+ const project = await store.projects.create(validated.input, contextFromPrincipal(principal));
50394
51127
  return json2({ project }, 201);
50395
51128
  }
50396
51129
  return error(405, `method ${method} not allowed on /v1/projects`);
50397
51130
  }
51131
+ if (action === "rename") {
51132
+ if (method !== "POST")
51133
+ return error(405, `method ${method} not allowed on /v1/projects/:id/rename`);
51134
+ const body = await readJson(req);
51135
+ if (!body || typeof body.new_slug !== "string" || !body.new_slug.trim() || !normalizeSlug(body.new_slug)) {
51136
+ return error(400, "new_slug must be a non-empty string");
51137
+ }
51138
+ if (body.name !== undefined && (typeof body.name !== "string" || !body.name.trim())) {
51139
+ return error(400, "name must be a non-empty string");
51140
+ }
51141
+ const unknownField = Object.keys(body).find((key) => !["new_slug", "name"].includes(key));
51142
+ if (unknownField)
51143
+ return error(400, `unknown project rename field: ${unknownField}`);
51144
+ return json2(await store.projects.rename(id, body, contextFromPrincipal(principal)));
51145
+ }
50398
51146
  if (method === "GET") {
50399
51147
  const project = await store.projects.get(id);
50400
51148
  return project ? json2({ project }) : error(404, "project not found");
@@ -50403,8 +51151,13 @@ async function handleV1Request(req, url, dependencies = {}) {
50403
51151
  const body = await readJson(req);
50404
51152
  if (!body)
50405
51153
  return error(400, "invalid JSON body");
50406
- const project = await store.projects.update(id, body);
50407
- return project ? json2({ project }) : error(404, "project not found");
51154
+ const validated = validateProjectPatch(body);
51155
+ if (!validated.ok)
51156
+ return error(400, validated.message);
51157
+ if (!await store.projects.get(id))
51158
+ return error(404, "project not found");
51159
+ const project = await store.projects.update(id, validated.patch);
51160
+ return json2({ project });
50408
51161
  }
50409
51162
  if (method === "DELETE") {
50410
51163
  await store.projects.delete(id, contextFromPrincipal(principal));
@@ -50491,6 +51244,21 @@ async function handleV1Request(req, url, dependencies = {}) {
50491
51244
  const body = await readJson(req);
50492
51245
  if (!body || typeof body.name !== "string" || !body.name.trim())
50493
51246
  return error(400, "name is required");
51247
+ const unknownField = Object.keys(body).find((key) => !["name", "slug", "project_id", "description", "metadata"].includes(key));
51248
+ if (unknownField)
51249
+ return error(400, `unsupported task-list create field: ${unknownField}`);
51250
+ if (body.slug !== undefined && typeof body.slug !== "string")
51251
+ return error(400, "slug must be a string");
51252
+ if (body.project_id !== undefined && (typeof body.project_id !== "string" || !body.project_id.trim()))
51253
+ return error(400, "project_id must be a non-empty string");
51254
+ if (body.description !== undefined && typeof body.description !== "string")
51255
+ return error(400, "description must be a string");
51256
+ if (body.metadata !== undefined && (!body.metadata || typeof body.metadata !== "object" || Array.isArray(body.metadata))) {
51257
+ return error(400, "metadata must be an object");
51258
+ }
51259
+ if (!normalizeSlug(body.slug === undefined ? body.name : body.slug)) {
51260
+ return error(400, "task-list slug must be non-empty kebab-case");
51261
+ }
50494
51262
  const taskList = await store.taskLists.create(body, contextFromPrincipal(principal));
50495
51263
  return json2({ task_list: taskList }, 201);
50496
51264
  }
@@ -50498,6 +51266,29 @@ async function handleV1Request(req, url, dependencies = {}) {
50498
51266
  const taskList = await store.taskLists.get(id);
50499
51267
  return taskList ? json2({ task_list: taskList }) : error(404, "task list not found");
50500
51268
  }
51269
+ if (id && (method === "PATCH" || method === "PUT")) {
51270
+ const body = await readJson(req);
51271
+ if (!body)
51272
+ return error(400, "invalid JSON body");
51273
+ const unknownField = Object.keys(body).find((key) => !["slug", "name", "description", "metadata"].includes(key));
51274
+ if (unknownField)
51275
+ return error(400, `unsupported task-list update field: ${unknownField}`);
51276
+ if (Object.keys(body).length === 0)
51277
+ return error(400, "task-list update must not be empty");
51278
+ if (body.slug !== undefined && (typeof body.slug !== "string" || !normalizeSlug(body.slug)))
51279
+ return error(400, "slug must be a non-empty string");
51280
+ if (body.name !== undefined && (typeof body.name !== "string" || !body.name.trim()))
51281
+ return error(400, "name must be a non-empty string");
51282
+ if (body.description !== undefined && typeof body.description !== "string")
51283
+ return error(400, "description must be a string");
51284
+ if (body.metadata !== undefined && (!body.metadata || typeof body.metadata !== "object" || Array.isArray(body.metadata))) {
51285
+ return error(400, "metadata must be an object");
51286
+ }
51287
+ if (!await store.taskLists.get(id))
51288
+ return error(404, "task list not found");
51289
+ const taskList = await store.taskLists.update(id, body);
51290
+ return json2({ task_list: taskList });
51291
+ }
50501
51292
  if (id && method === "DELETE") {
50502
51293
  const deleted = await store.taskLists.delete(id, contextFromPrincipal(principal));
50503
51294
  return deleted ? json2({ deleted: true, id }) : error(404, "task list not found");
@@ -50570,6 +51361,10 @@ async function handleV1Request(req, url, dependencies = {}) {
50570
51361
  } catch (e) {
50571
51362
  if (e instanceof LockError)
50572
51363
  return error(409, e.message, { code: LockError.code });
51364
+ if (e instanceof ResourceConflictError)
51365
+ return error(409, e.message, { code: e.code, conflict: true });
51366
+ if (e instanceof ProjectNotFoundError)
51367
+ return error(404, e.message, { code: ProjectNotFoundError.code });
50573
51368
  return error(500, e.message || "internal error");
50574
51369
  }
50575
51370
  }