@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
@@ -702,7 +702,7 @@ var init_cloud_client = __esm(() => {
702
702
  });
703
703
 
704
704
  // src/types/index.ts
705
- var TASK_STATUSES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, PlanNotFoundError, LockError, AgentNotFoundError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
705
+ var TASK_STATUSES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
706
706
  var init_types = __esm(() => {
707
707
  TASK_STATUSES = [
708
708
  "pending",
@@ -745,6 +745,14 @@ var init_types = __esm(() => {
745
745
  this.name = "ProjectNotFoundError";
746
746
  }
747
747
  };
748
+ ResourceConflictError = class ResourceConflictError extends Error {
749
+ code;
750
+ constructor(code, message) {
751
+ super(message);
752
+ this.code = code;
753
+ this.name = "ResourceConflictError";
754
+ }
755
+ };
748
756
  PlanNotFoundError = class PlanNotFoundError extends Error {
749
757
  planId;
750
758
  static code = "PLAN_NOT_FOUND";
@@ -823,6 +831,77 @@ var init_types = __esm(() => {
823
831
  };
824
832
  });
825
833
 
834
+ // src/lib/slugs.ts
835
+ function normalizeSlug(value) {
836
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
837
+ }
838
+ function isCanonicalSlug(value) {
839
+ return typeof value === "string" && value.length > 0 && normalizeSlug(value) === value;
840
+ }
841
+ function isValidTaskListProjectScope(value) {
842
+ return value === undefined || value === null || typeof value === "string" && value.trim().length > 0;
843
+ }
844
+ function validateSnapshotRoutingRecords(projects, taskLists) {
845
+ const errors = [];
846
+ const projectSlugs = new Map;
847
+ const taskListSlugs = new Map;
848
+ for (const project of projects) {
849
+ if (!isCanonicalSlug(project.task_list_id)) {
850
+ errors.push(`project ${project.id}: task_list_id must be non-empty canonical kebab-case`);
851
+ continue;
852
+ }
853
+ const existing = projectSlugs.get(project.task_list_id);
854
+ if (projectSlugs.has(project.task_list_id)) {
855
+ errors.push(`project ${project.id}: task_list_id duplicates project ${existing}: ${project.task_list_id}`);
856
+ } else {
857
+ projectSlugs.set(project.task_list_id, project.id);
858
+ }
859
+ }
860
+ for (const taskList of taskLists) {
861
+ if (!isCanonicalSlug(taskList.slug)) {
862
+ errors.push(`task list ${taskList.id}: slug must be non-empty canonical kebab-case`);
863
+ continue;
864
+ }
865
+ if (!isValidTaskListProjectScope(taskList.project_id)) {
866
+ errors.push(`task list ${taskList.id}: project_id must be null, missing, or a non-empty string`);
867
+ continue;
868
+ }
869
+ const scope = taskList.project_id ?? null;
870
+ const scopedSlugs = taskListSlugs.get(scope) ?? new Map;
871
+ const existing = scopedSlugs.get(taskList.slug);
872
+ if (scopedSlugs.has(taskList.slug)) {
873
+ errors.push(`task list ${taskList.id}: slug duplicates task list ${existing} in the same scope: ${taskList.slug}`);
874
+ } else {
875
+ scopedSlugs.set(taskList.slug, taskList.id);
876
+ taskListSlugs.set(scope, scopedSlugs);
877
+ }
878
+ }
879
+ return errors;
880
+ }
881
+ function validateSnapshotRoutingDestinationConflicts(projects, taskLists, existingProjects, existingTaskLists) {
882
+ const errors = [];
883
+ for (const project of projects) {
884
+ const current = existingProjects.find((candidate) => candidate.id === project.id);
885
+ if (current?.task_list_id === project.task_list_id)
886
+ continue;
887
+ const conflict = existingProjects.find((candidate) => candidate.id !== project.id && candidate.task_list_id === project.task_list_id);
888
+ if (conflict) {
889
+ errors.push(`project ${project.id}: task_list_id conflicts with existing project ${conflict.id}: ${String(project.task_list_id)}`);
890
+ }
891
+ }
892
+ for (const taskList of taskLists) {
893
+ const projectId = taskList.project_id ?? null;
894
+ const current = existingTaskLists.find((candidate) => candidate.id === taskList.id);
895
+ if ((current?.project_id ?? null) === projectId && current?.slug === taskList.slug)
896
+ continue;
897
+ const conflict = existingTaskLists.find((candidate) => candidate.id !== taskList.id && (candidate.project_id ?? null) === projectId && candidate.slug === taskList.slug);
898
+ if (conflict) {
899
+ errors.push(`task list ${taskList.id}: slug conflicts with existing task list ${conflict.id} in the same scope: ${String(taskList.slug)}`);
900
+ }
901
+ }
902
+ return errors;
903
+ }
904
+
826
905
  // src/storage/postgres-sync.ts
827
906
  function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE, cursorTableName = DEFAULT_TODOS_POSTGRES_CURSOR_TABLE) {
828
907
  assertSafeIdentifier(tableName);
@@ -852,6 +931,91 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
852
931
  )`
853
932
  ];
854
933
  }
934
+ function postgresTodosScopedSlugPreflightSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
935
+ assertSafeIdentifier(tableName);
936
+ return `/* todos:scoped-slug-duplicate-audit */ WITH candidates AS (
937
+ SELECT service, object_type, COALESCE(payload->>'project_id', '') AS scope,
938
+ jsonb_typeof(payload->'project_id') AS scope_type,
939
+ payload->>'slug' AS slug, jsonb_typeof(payload->'slug') AS slug_type, object_id
940
+ FROM ${tableName}
941
+ WHERE object_type = 'task_lists' AND deleted_at IS NULL
942
+ UNION ALL
943
+ SELECT service, object_type, '' AS scope, NULL::text AS scope_type, payload->>'task_list_id' AS slug,
944
+ jsonb_typeof(payload->'task_list_id') AS slug_type, object_id
945
+ FROM ${tableName}
946
+ WHERE object_type = 'projects' AND deleted_at IS NULL
947
+ ), annotated AS (
948
+ SELECT *, trim(both '-' from regexp_replace(lower(COALESCE(slug, '')), '[^a-z0-9]+', '-', 'g')) AS normalized_slug
949
+ FROM candidates
950
+ ), invalid AS (
951
+ SELECT service, object_type, scope, COALESCE(slug, '<null>') AS slug,
952
+ ARRAY[object_id] AS object_ids, 1::integer AS duplicate_count, 'invalid'::text AS issue
953
+ FROM annotated
954
+ WHERE slug_type IS DISTINCT FROM 'string'
955
+ OR slug IS NULL OR slug = '' OR normalized_slug = '' OR slug IS DISTINCT FROM normalized_slug
956
+ OR (object_type = 'task_lists' AND (
957
+ (scope_type IS NOT NULL AND scope_type NOT IN ('string', 'null'))
958
+ OR (scope_type = 'string' AND btrim(scope) = '')
959
+ ))
960
+ ), duplicates AS (
961
+ SELECT service, object_type, scope, slug,
962
+ array_agg(object_id ORDER BY object_id) AS object_ids,
963
+ count(*)::integer AS duplicate_count, 'duplicate'::text AS issue
964
+ FROM annotated
965
+ WHERE slug = normalized_slug AND slug <> ''
966
+ GROUP BY service, object_type, scope, slug
967
+ HAVING count(*) > 1
968
+ ) SELECT * FROM invalid
969
+ UNION ALL SELECT * FROM duplicates
970
+ ORDER BY service, object_type, scope, slug`;
971
+ }
972
+ function postgresTodosScopedSlugUniqueIndexSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
973
+ assertSafeIdentifier(tableName);
974
+ return [
975
+ `CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_task_list_scope_slug_uidx
976
+ ON ${tableName} (service, COALESCE(payload->>'project_id', ''), (payload->>'slug'))
977
+ WHERE object_type = 'task_lists' AND deleted_at IS NULL AND COALESCE(payload->>'slug', '') <> ''`,
978
+ `CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_project_task_list_slug_uidx
979
+ ON ${tableName} (service, (payload->>'task_list_id'))
980
+ WHERE object_type = 'projects' AND deleted_at IS NULL AND COALESCE(payload->>'task_list_id', '') <> ''`
981
+ ];
982
+ }
983
+ function postgresTodosScopedSlugIndexStatusSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
984
+ assertSafeIdentifier(tableName);
985
+ return `/* todos:scoped-slug-index-status */ SELECT index_class.relname AS index_name,
986
+ index_meta.indisvalid AS is_valid, index_meta.indisready AS is_ready
987
+ FROM pg_index index_meta
988
+ JOIN pg_class index_class ON index_class.oid = index_meta.indexrelid
989
+ WHERE index_meta.indrelid = to_regclass('${tableName}')
990
+ AND index_class.relname IN (
991
+ '${tableName}_task_list_scope_slug_uidx',
992
+ '${tableName}_project_task_list_slug_uidx'
993
+ )`;
994
+ }
995
+ async function ensurePostgresScopedSlugUniqueIndexes(client, tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
996
+ const audit = await client.query(postgresTodosScopedSlugPreflightSql(tableName));
997
+ if (audit.rows.length > 0)
998
+ throw new PostgresScopedSlugMigrationConflictError(audit.rows);
999
+ for (const sql of postgresTodosScopedSlugUniqueIndexSql(tableName)) {
1000
+ try {
1001
+ await client.query(sql);
1002
+ } catch (error) {
1003
+ const indexName = sql.match(/INDEX CONCURRENTLY IF NOT EXISTS ([a-zA-Z0-9_]+)/)?.[1] ?? "unknown_index";
1004
+ throw new PostgresScopedSlugIndexBuildError(indexName, error);
1005
+ }
1006
+ }
1007
+ const expected = new Set([
1008
+ `${tableName}_task_list_scope_slug_uidx`,
1009
+ `${tableName}_project_task_list_slug_uidx`
1010
+ ]);
1011
+ const status = await client.query(postgresTodosScopedSlugIndexStatusSql(tableName));
1012
+ for (const indexName of expected) {
1013
+ const row = status.rows.find((candidate) => candidate.index_name === indexName);
1014
+ if (!row?.is_valid || !row.is_ready) {
1015
+ throw new PostgresScopedSlugIndexBuildError(indexName, new Error("index is missing, invalid, or not ready"));
1016
+ }
1017
+ }
1018
+ }
855
1019
  function postgresTodosCommentCursorIndexSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
856
1020
  assertSafeIdentifier(tableName);
857
1021
  return `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_comment_task_created_idx
@@ -880,9 +1044,31 @@ class PostgresTodosSyncStore {
880
1044
  }
881
1045
  }
882
1046
  async pushSnapshot(snapshot, context = {}) {
1047
+ const routingErrors = validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists);
1048
+ if (routingErrors.length > 0) {
1049
+ throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
1050
+ }
1051
+ const existing = await this.client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
1052
+ FROM ${this.tableName}
1053
+ WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
1054
+ const existingProjects = [];
1055
+ const existingTaskLists = [];
1056
+ for (const row of existing.rows) {
1057
+ const payload = payloadRecord(row.payload);
1058
+ if (row.object_type === "projects")
1059
+ existingProjects.push(payload);
1060
+ if (row.object_type === "task_lists")
1061
+ existingTaskLists.push(payload);
1062
+ }
1063
+ const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
1064
+ if (destinationErrors.length > 0) {
1065
+ throw new Error(`Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
1066
+ }
883
1067
  const result = { records: 0, objectTypes: {} };
884
1068
  const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
885
1069
  for (const entry of snapshotEntries(snapshot)) {
1070
+ if (entry.deletedAt === null)
1071
+ assertCanonicalScopedSlugEntry(entry);
886
1072
  await this.client.query(`INSERT INTO ${this.tableName} (
887
1073
  service, object_type, object_id, payload, updated_at,
888
1074
  deleted_at, source_machine_id, version
@@ -961,6 +1147,22 @@ function snapshotEntries(snapshot) {
961
1147
  }))
962
1148
  ];
963
1149
  }
1150
+ function assertCanonicalScopedSlugEntry(entry) {
1151
+ if (!entry.payload || typeof entry.payload !== "object" || Array.isArray(entry.payload))
1152
+ return;
1153
+ const payload = entry.payload;
1154
+ if (entry.type === "projects" && !isCanonicalSlug(payload["task_list_id"])) {
1155
+ throw new Error("Invalid project task-list slug \u2014 sync requires non-empty canonical kebab-case");
1156
+ }
1157
+ if (entry.type === "task_lists") {
1158
+ if (!isCanonicalSlug(payload["slug"])) {
1159
+ throw new Error("Invalid task-list slug \u2014 sync requires non-empty canonical kebab-case");
1160
+ }
1161
+ if (!isValidTaskListProjectScope(payload["project_id"])) {
1162
+ throw new Error("Invalid task-list project scope \u2014 project_id must be null, missing, or a non-empty string");
1163
+ }
1164
+ }
1165
+ }
964
1166
  function entry(type, payload, fallbackUpdatedAt) {
965
1167
  const id = payload["id"];
966
1168
  if (typeof id !== "string" || !id)
@@ -1043,7 +1245,26 @@ function assertSafeIdentifier(value) {
1043
1245
  if (!/^[a-z_][a-z0-9_]*$/i.test(value))
1044
1246
  throw new Error(`Unsafe Postgres identifier: ${value}`);
1045
1247
  }
1046
- var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records", DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors";
1248
+ var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records", DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors", PostgresScopedSlugMigrationConflictError, PostgresScopedSlugIndexBuildError;
1249
+ var init_postgres_sync = __esm(() => {
1250
+ PostgresScopedSlugMigrationConflictError = class PostgresScopedSlugMigrationConflictError extends Error {
1251
+ conflicts;
1252
+ constructor(conflicts) {
1253
+ const preview = conflicts.slice(0, 5).map((conflict) => `${conflict.object_type}:${conflict.scope || "global"}:${conflict.slug} [${conflict.object_ids.join(", ")}]`).join("; ");
1254
+ 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.");
1255
+ this.conflicts = conflicts;
1256
+ this.name = "PostgresScopedSlugMigrationConflictError";
1257
+ }
1258
+ };
1259
+ PostgresScopedSlugIndexBuildError = class PostgresScopedSlugIndexBuildError extends Error {
1260
+ index_name;
1261
+ constructor(index_name, cause) {
1262
+ 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 });
1263
+ this.index_name = index_name;
1264
+ this.name = "PostgresScopedSlugIndexBuildError";
1265
+ }
1266
+ };
1267
+ });
1047
1268
 
1048
1269
  // src/lib/sync-utils.ts
1049
1270
  import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, readdirSync, statSync, writeFileSync } from "fs";
@@ -1299,6 +1520,7 @@ function createPostgresTodosStorageAdapter(options) {
1299
1520
  getByPath: async (path) => (await store.list("projects")).find((project) => project.path === path) ?? null,
1300
1521
  list: async () => (await store.list("projects")).sort((a, b) => a.name.localeCompare(b.name)),
1301
1522
  update: (id, input) => updateProject(id, input, store),
1523
+ rename: (id, input, context) => store.renameProject(id, input.new_slug, input.name, context),
1302
1524
  delete: (id, context) => store.delete("projects", id, context)
1303
1525
  },
1304
1526
  plans: {
@@ -1536,9 +1758,22 @@ class PostgresJsonRecordStore {
1536
1758
  });
1537
1759
  }
1538
1760
  async upsert(type, value, context = {}) {
1761
+ if (type === "projects" && !isCanonicalSlug(value.task_list_id)) {
1762
+ throw new Error("Invalid project task-list slug \u2014 imports require non-empty canonical kebab-case");
1763
+ }
1764
+ if (type === "task_lists") {
1765
+ if (!isCanonicalSlug(value.slug)) {
1766
+ throw new Error("Invalid task-list slug \u2014 imports require non-empty canonical kebab-case");
1767
+ }
1768
+ if (!isValidTaskListProjectScope(value.project_id)) {
1769
+ throw new Error("Invalid task-list project scope \u2014 project_id must be null, missing, or a non-empty string");
1770
+ }
1771
+ }
1539
1772
  await this.ensureSchema();
1540
1773
  const updatedAt = stringValue2(value.updated_at) ?? stringValue2(value.created_at) ?? new Date().toISOString();
1541
- const result = await this.options.client.query(`INSERT INTO ${this.tableName} (
1774
+ let result;
1775
+ try {
1776
+ result = await this.options.client.query(`INSERT INTO ${this.tableName} (
1542
1777
  service, object_type, object_id, payload, updated_at,
1543
1778
  deleted_at, source_machine_id, version
1544
1779
  ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, NULL, $6, $7)
@@ -1553,14 +1788,23 @@ class PostgresJsonRecordStore {
1553
1788
  OR (${this.tableName}.updated_at = EXCLUDED.updated_at
1554
1789
  AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
1555
1790
  RETURNING object_id`, [
1556
- this.service,
1557
- type,
1558
- value.id,
1559
- jsonbParam(value),
1560
- updatedAt,
1561
- context.requestId ?? this.sourceMachineId ?? null,
1562
- numberValue2(value.version)
1563
- ]);
1791
+ this.service,
1792
+ type,
1793
+ value.id,
1794
+ jsonbParam(value),
1795
+ updatedAt,
1796
+ context.requestId ?? this.sourceMachineId ?? null,
1797
+ numberValue2(value.version)
1798
+ ]);
1799
+ } catch (error) {
1800
+ if (type === "task_lists" && isPostgresUniqueViolation(error)) {
1801
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${String(value.slug ?? "")}" already exists in this scope`);
1802
+ }
1803
+ if (type === "projects" && isPostgresUniqueViolation(error)) {
1804
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${String(value.task_list_id ?? "")}" already exists`);
1805
+ }
1806
+ throw error;
1807
+ }
1564
1808
  if (result.rows.length === 0) {
1565
1809
  const current = await this.get(type, value.id);
1566
1810
  if (current)
@@ -1568,6 +1812,93 @@ class PostgresJsonRecordStore {
1568
1812
  }
1569
1813
  return value;
1570
1814
  }
1815
+ async renameProject(id, newSlug, name, context = {}) {
1816
+ await this.ensureSchema();
1817
+ const normalizedSlug = slugifyRaw(newSlug);
1818
+ if (!normalizedSlug)
1819
+ throw new Error("Invalid slug \u2014 must be non-empty kebab-case");
1820
+ const timestamp = new Date().toISOString();
1821
+ try {
1822
+ const result = await this.options.client.query(`/* todos:rename-project-atomic */ WITH target AS (
1823
+ SELECT payload, payload->>'task_list_id' AS old_slug
1824
+ FROM ${this.tableName}
1825
+ WHERE service = $1 AND object_type = 'projects' AND object_id = $2 AND deleted_at IS NULL
1826
+ FOR UPDATE
1827
+ ), project_conflict AS (
1828
+ SELECT 1 FROM ${this.tableName}
1829
+ WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
1830
+ AND deleted_at IS NULL AND payload->>'task_list_id' = $3 LIMIT 1
1831
+ ), task_list_conflict AS (
1832
+ SELECT 1 FROM ${this.tableName} r, target
1833
+ WHERE r.service = $1 AND r.object_type = 'task_lists' AND r.deleted_at IS NULL
1834
+ AND r.payload->>'project_id' = $2 AND r.payload->>'slug' = $3
1835
+ AND r.payload->>'slug' IS DISTINCT FROM target.old_slug LIMIT 1
1836
+ ), updated_lists AS (
1837
+ UPDATE ${this.tableName} r SET
1838
+ payload = r.payload || jsonb_build_object('slug', $3::text, 'updated_at', $5::text)
1839
+ || CASE WHEN $4::text IS NULL THEN '{}'::jsonb ELSE jsonb_build_object('name', $4::text) END,
1840
+ updated_at = $5::timestamptz, version = COALESCE(r.version, 0) + 1,
1841
+ source_machine_id = COALESCE($6, r.source_machine_id)
1842
+ FROM target
1843
+ WHERE r.service = $1 AND r.object_type = 'task_lists' AND r.deleted_at IS NULL
1844
+ AND r.payload->>'project_id' = $2 AND r.payload->>'slug' = target.old_slug
1845
+ AND NOT EXISTS (SELECT 1 FROM project_conflict)
1846
+ AND NOT EXISTS (SELECT 1 FROM task_list_conflict)
1847
+ AND (target.old_slug IS DISTINCT FROM $3
1848
+ OR ($4::text IS NOT NULL AND r.payload->>'name' IS DISTINCT FROM $4))
1849
+ RETURNING 1
1850
+ ), updated_project AS (
1851
+ UPDATE ${this.tableName} r SET
1852
+ payload = r.payload || jsonb_build_object('task_list_id', $3::text, 'updated_at', $5::text)
1853
+ || CASE WHEN $4::text IS NULL THEN '{}'::jsonb ELSE jsonb_build_object('name', $4::text) END,
1854
+ updated_at = $5::timestamptz, version = COALESCE(r.version, 0) + 1,
1855
+ source_machine_id = COALESCE($6, r.source_machine_id)
1856
+ FROM target
1857
+ WHERE r.service = $1 AND r.object_type = 'projects' AND r.object_id = $2 AND r.deleted_at IS NULL
1858
+ AND NOT EXISTS (SELECT 1 FROM project_conflict)
1859
+ AND NOT EXISTS (SELECT 1 FROM task_list_conflict)
1860
+ AND (target.old_slug IS DISTINCT FROM $3
1861
+ OR ($4::text IS NOT NULL AND target.payload->>'name' IS DISTINCT FROM $4))
1862
+ RETURNING r.payload
1863
+ ) SELECT
1864
+ EXISTS (SELECT 1 FROM target) AS found,
1865
+ EXISTS (SELECT 1 FROM project_conflict) AS project_conflict,
1866
+ EXISTS (SELECT 1 FROM task_list_conflict) AS task_list_conflict,
1867
+ COALESCE((SELECT payload FROM updated_project), (SELECT payload FROM target)) AS project,
1868
+ (SELECT count(*) FROM updated_lists) AS task_lists_updated`, [this.service, id, normalizedSlug, name ?? null, timestamp, this.machineId(context)]);
1869
+ const row = result.rows[0];
1870
+ if (!row?.found)
1871
+ throw new ProjectNotFoundError(id);
1872
+ if (row.project_conflict) {
1873
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalizedSlug}" is already used by another project`);
1874
+ }
1875
+ if (row.task_list_conflict) {
1876
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalizedSlug}" is already used in this project`);
1877
+ }
1878
+ return {
1879
+ project: payloadRecord2(row.project),
1880
+ task_lists_updated: Number(row.task_lists_updated)
1881
+ };
1882
+ } catch (error) {
1883
+ if (isPostgresUniqueViolation(error)) {
1884
+ const constraintName = postgresConstraintName(error);
1885
+ let projectConflict = constraintName.includes("project_task_list_slug_uidx");
1886
+ if (!constraintName) {
1887
+ const conflict = await this.options.client.query(`/* todos:classify-project-rename-conflict */ SELECT EXISTS (
1888
+ SELECT 1 FROM ${this.tableName}
1889
+ WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
1890
+ AND deleted_at IS NULL AND payload->>'task_list_id' = $3
1891
+ ) AS project_conflict`, [this.service, id, normalizedSlug]);
1892
+ projectConflict = Boolean(conflict.rows[0]?.project_conflict);
1893
+ }
1894
+ if (projectConflict) {
1895
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalizedSlug}" is already used by another project`);
1896
+ }
1897
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalizedSlug}" is already used in this project`);
1898
+ }
1899
+ throw error;
1900
+ }
1901
+ }
1571
1902
  async incrementProjectTaskCounter(projectId, _context = {}) {
1572
1903
  await this.ensureSchema();
1573
1904
  const result = await this.options.client.query(`UPDATE ${this.tableName}
@@ -1987,12 +2318,16 @@ async function getChangedSince(since, filters, store) {
1987
2318
  }
1988
2319
  async function createProject(input, store, context) {
1989
2320
  const timestamp = new Date().toISOString();
2321
+ const derivedSlug = slugifyRaw(input.name);
2322
+ const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugifyRaw(input.task_list_id);
2323
+ if (!derivedSlug || !taskListId)
2324
+ throw new Error("Project name and task-list slug must be non-empty");
1990
2325
  const project = {
1991
2326
  id: randomUUID(),
1992
2327
  name: input.name,
1993
2328
  path: input.path,
1994
2329
  description: input.description ?? null,
1995
- task_list_id: input.task_list_id ?? `todos-${slugify(input.name)}`,
2330
+ task_list_id: taskListId,
1996
2331
  task_prefix: input.task_prefix ?? await generateProjectPrefix(input.name, store),
1997
2332
  task_counter: 0,
1998
2333
  created_at: timestamp,
@@ -2003,6 +2338,9 @@ async function createProject(input, store, context) {
2003
2338
  return store.upsert("projects", project, context);
2004
2339
  }
2005
2340
  async function updateProject(id, input, store) {
2341
+ if ("task_list_id" in input) {
2342
+ throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
2343
+ }
2006
2344
  const project = await requireRecord("projects", id, store);
2007
2345
  const updated = { ...project, ...definedPatch(input), updated_at: new Date().toISOString() };
2008
2346
  return store.upsert("projects", updated);
@@ -2111,10 +2449,13 @@ async function releaseAgent(idOrName, sessionId, store, context) {
2111
2449
  }
2112
2450
  async function createTaskList(input, store, context) {
2113
2451
  const timestamp = new Date().toISOString();
2452
+ const slug = slugifyRaw(input.slug === undefined ? input.name : input.slug);
2453
+ if (!slug)
2454
+ throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
2114
2455
  return store.upsert("task_lists", {
2115
2456
  id: randomUUID(),
2116
2457
  project_id: input.project_id ?? context?.projectId ?? null,
2117
- slug: input.slug ?? slugify(input.name),
2458
+ slug,
2118
2459
  name: input.name,
2119
2460
  description: input.description ?? null,
2120
2461
  metadata: input.metadata ?? {},
@@ -2126,9 +2467,20 @@ async function createTaskList(input, store, context) {
2126
2467
  }
2127
2468
  async function updateTaskList(id, input, store) {
2128
2469
  const list = await requireRecord("task_lists", id, store);
2470
+ const patch = definedPatch(input);
2471
+ if (input.slug !== undefined) {
2472
+ const slug = slugifyRaw(input.slug);
2473
+ if (!slug)
2474
+ throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
2475
+ const duplicate = (await store.list("task_lists")).find((candidate) => candidate.id !== id && candidate.project_id === list.project_id && candidate.slug === slug);
2476
+ if (duplicate) {
2477
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
2478
+ }
2479
+ patch.slug = slug;
2480
+ }
2129
2481
  return store.upsert("task_lists", {
2130
2482
  ...list,
2131
- ...definedPatch(input),
2483
+ ...patch,
2132
2484
  metadata: input.metadata ?? list.metadata,
2133
2485
  updated_at: new Date().toISOString()
2134
2486
  });
@@ -2212,6 +2564,16 @@ async function exportSnapshot(store) {
2212
2564
  }
2213
2565
  async function importSnapshot(snapshot, store, context) {
2214
2566
  const result = { inserted: 0, updated: 0, deleted: 0, skipped: 0, errors: [] };
2567
+ result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
2568
+ if (result.errors.length > 0)
2569
+ return result;
2570
+ const [existingProjects, existingTaskLists] = await Promise.all([
2571
+ store.list("projects"),
2572
+ store.list("task_lists")
2573
+ ]);
2574
+ result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
2575
+ if (result.errors.length > 0)
2576
+ return result;
2215
2577
  const entries = [
2216
2578
  ...snapshot.tasks.map((row) => ["tasks", row]),
2217
2579
  ...snapshot.projects.map((row) => ["projects", row]),
@@ -2282,10 +2644,7 @@ async function generateProjectPrefix(name, store) {
2282
2644
  return candidate;
2283
2645
  }
2284
2646
  function slugifyRaw(value) {
2285
- return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
2286
- }
2287
- function slugify(value) {
2288
- return slugifyRaw(value) || "todos";
2647
+ return normalizeSlug(value);
2289
2648
  }
2290
2649
  function normalizePlanSlug(value) {
2291
2650
  const slug = slugifyRaw(value);
@@ -2339,9 +2698,20 @@ function compareClock(left, right) {
2339
2698
  function numberValue2(value) {
2340
2699
  return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
2341
2700
  }
2701
+ function isPostgresUniqueViolation(error) {
2702
+ return typeof error === "object" && error !== null && error.code === "23505";
2703
+ }
2704
+ function postgresConstraintName(error) {
2705
+ if (typeof error !== "object" || error === null)
2706
+ return "";
2707
+ const candidate = error;
2708
+ const constraint = candidate.constraint ?? candidate.constraint_name;
2709
+ return typeof constraint === "string" ? constraint : "";
2710
+ }
2342
2711
  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";
2343
2712
  var init_postgres_adapter = __esm(() => {
2344
2713
  init_types();
2714
+ init_postgres_sync();
2345
2715
  init_redaction();
2346
2716
  });
2347
2717
 
@@ -2448,6 +2818,7 @@ function isCommentRedactionBackfillComplete(result) {
2448
2818
  var COMMENT_REDACTION_BACKFILL_CONFIRMATION = "REDACT_STORED_TODOS_COMMENTS";
2449
2819
  var init_comment_redaction_backfill = __esm(() => {
2450
2820
  init_redaction();
2821
+ init_postgres_sync();
2451
2822
  });
2452
2823
 
2453
2824
  // src/server/cloud.ts
@@ -2461,6 +2832,7 @@ __export(exports_cloud, {
2461
2832
  getCloudVerifier: () => getCloudVerifier,
2462
2833
  getCloudStorageAdapter: () => getCloudStorageAdapter,
2463
2834
  getApiKeyStore: () => getApiKeyStore,
2835
+ ensureCloudScopedSlugUniqueIndexes: () => ensureCloudScopedSlugUniqueIndexes,
2464
2836
  ensureCloudSchema: () => ensureCloudSchema,
2465
2837
  ensureCloudCommentCursorIndex: () => ensureCloudCommentCursorIndex,
2466
2838
  closeCloud: () => closeCloud,
@@ -2545,6 +2917,9 @@ async function ensureCloudSchema() {
2545
2917
  async function ensureCloudCommentCursorIndex() {
2546
2918
  await getClient().query(postgresTodosCommentCursorIndexSql());
2547
2919
  }
2920
+ async function ensureCloudScopedSlugUniqueIndexes() {
2921
+ await ensurePostgresScopedSlugUniqueIndexes(getClient());
2922
+ }
2548
2923
  async function normalizeCloudPayloads() {
2549
2924
  const client = getClient();
2550
2925
  const res = await client.query(`UPDATE todos_sync_records
@@ -2576,6 +2951,7 @@ var init_cloud = __esm(() => {
2576
2951
  init_auth();
2577
2952
  init_cloud_client();
2578
2953
  init_postgres_adapter();
2954
+ init_postgres_sync();
2579
2955
  init_comment_redaction_backfill();
2580
2956
  });
2581
2957
 
@@ -4288,6 +4664,95 @@ function ensureSchema(db) {
4288
4664
  )`);
4289
4665
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_project_machine_paths_project ON project_machine_paths(project_id)");
4290
4666
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_project_machine_paths_machine ON project_machine_paths(machine_id)");
4667
+ ensureTable("canonical_slug_claims", `
4668
+ CREATE TABLE canonical_slug_claims (
4669
+ kind TEXT NOT NULL CHECK(kind IN ('project', 'task_list')),
4670
+ scope_key TEXT NOT NULL,
4671
+ slug TEXT NOT NULL,
4672
+ object_id TEXT NOT NULL,
4673
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
4674
+ PRIMARY KEY (kind, scope_key, slug)
4675
+ )`);
4676
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_canonical_slug_claims_object ON canonical_slug_claims(kind, object_id)");
4677
+ ensureColumn("projects", "task_list_id", "TEXT");
4678
+ db.exec(`CREATE TRIGGER IF NOT EXISTS claim_project_canonical_slug_insert
4679
+ BEFORE INSERT ON projects
4680
+ WHEN NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> ''
4681
+ BEGIN
4682
+ SELECT CASE WHEN EXISTS (
4683
+ SELECT 1 FROM projects WHERE id <> NEW.id AND task_list_id = NEW.task_list_id
4684
+ ) THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
4685
+ INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
4686
+ VALUES ('project', 'global', NEW.task_list_id, NEW.id);
4687
+ SELECT CASE WHEN (
4688
+ SELECT object_id FROM canonical_slug_claims
4689
+ WHERE kind = 'project' AND scope_key = 'global' AND slug = NEW.task_list_id
4690
+ ) <> NEW.id THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
4691
+ END`);
4692
+ db.exec(`CREATE TRIGGER IF NOT EXISTS claim_project_canonical_slug_update
4693
+ BEFORE UPDATE OF task_list_id ON projects
4694
+ WHEN NEW.task_list_id IS NOT OLD.task_list_id
4695
+ BEGIN
4696
+ DELETE FROM canonical_slug_claims WHERE kind = 'project' AND object_id = NEW.id;
4697
+ SELECT CASE WHEN EXISTS (
4698
+ SELECT 1 FROM projects WHERE id <> NEW.id AND task_list_id = NEW.task_list_id
4699
+ ) AND NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> ''
4700
+ THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
4701
+ INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
4702
+ SELECT 'project', 'global', NEW.task_list_id, NEW.id
4703
+ WHERE NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> '';
4704
+ SELECT CASE WHEN NEW.task_list_id IS NOT NULL AND NEW.task_list_id <> '' AND (
4705
+ SELECT object_id FROM canonical_slug_claims
4706
+ WHERE kind = 'project' AND scope_key = 'global' AND slug = NEW.task_list_id
4707
+ ) <> NEW.id THEN RAISE(ABORT, 'PROJECT_SLUG_CONFLICT') END;
4708
+ END`);
4709
+ db.exec(`CREATE TRIGGER IF NOT EXISTS release_project_canonical_slug_delete
4710
+ AFTER DELETE ON projects
4711
+ BEGIN
4712
+ DELETE FROM canonical_slug_claims WHERE kind = 'project' AND object_id = OLD.id;
4713
+ END`);
4714
+ db.exec(`CREATE TRIGGER IF NOT EXISTS claim_task_list_canonical_slug_insert
4715
+ BEFORE INSERT ON task_lists
4716
+ WHEN NEW.slug IS NOT NULL AND NEW.slug <> ''
4717
+ BEGIN
4718
+ SELECT CASE WHEN EXISTS (
4719
+ SELECT 1 FROM task_lists
4720
+ WHERE id <> NEW.id AND project_id IS NEW.project_id AND slug = NEW.slug
4721
+ ) THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
4722
+ INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
4723
+ VALUES ('task_list', CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END, NEW.slug, NEW.id);
4724
+ SELECT CASE WHEN (
4725
+ SELECT object_id FROM canonical_slug_claims
4726
+ WHERE kind = 'task_list'
4727
+ AND scope_key = CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END
4728
+ AND slug = NEW.slug
4729
+ ) <> NEW.id THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
4730
+ END`);
4731
+ db.exec(`CREATE TRIGGER IF NOT EXISTS claim_task_list_canonical_slug_update
4732
+ BEFORE UPDATE OF slug, project_id ON task_lists
4733
+ WHEN NEW.slug IS NOT OLD.slug OR NEW.project_id IS NOT OLD.project_id
4734
+ BEGIN
4735
+ DELETE FROM canonical_slug_claims WHERE kind = 'task_list' AND object_id = NEW.id;
4736
+ SELECT CASE WHEN EXISTS (
4737
+ SELECT 1 FROM task_lists
4738
+ WHERE id <> NEW.id AND project_id IS NEW.project_id AND slug = NEW.slug
4739
+ ) AND NEW.slug IS NOT NULL AND NEW.slug <> ''
4740
+ THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
4741
+ INSERT OR IGNORE INTO canonical_slug_claims(kind, scope_key, slug, object_id)
4742
+ SELECT 'task_list', CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END, NEW.slug, NEW.id
4743
+ WHERE NEW.slug IS NOT NULL AND NEW.slug <> '';
4744
+ SELECT CASE WHEN NEW.slug IS NOT NULL AND NEW.slug <> '' AND (
4745
+ SELECT object_id FROM canonical_slug_claims
4746
+ WHERE kind = 'task_list'
4747
+ AND scope_key = CASE WHEN NEW.project_id IS NULL THEN 'standalone:' ELSE 'project:' || NEW.project_id END
4748
+ AND slug = NEW.slug
4749
+ ) <> NEW.id THEN RAISE(ABORT, 'TASK_LIST_SLUG_CONFLICT') END;
4750
+ END`);
4751
+ db.exec(`CREATE TRIGGER IF NOT EXISTS release_task_list_canonical_slug_delete
4752
+ AFTER DELETE ON task_lists
4753
+ BEGIN
4754
+ DELETE FROM canonical_slug_claims WHERE kind = 'task_list' AND object_id = OLD.id;
4755
+ END`);
4291
4756
  ensureTable("storage_tombstones", `
4292
4757
  CREATE TABLE storage_tombstones (
4293
4758
  id TEXT PRIMARY KEY,
@@ -5610,11 +6075,25 @@ var init_storage_tombstones = __esm(() => {
5610
6075
  init_machines();
5611
6076
  });
5612
6077
 
6078
+ // src/db/slug-claims.ts
6079
+ function taskListSlugScopeKey(projectId) {
6080
+ return projectId ? `project:${projectId}` : "standalone:";
6081
+ }
6082
+ function claimCanonicalSlug(kind, scopeKey, slug, objectId, db) {
6083
+ db.run(`INSERT OR IGNORE INTO canonical_slug_claims (kind, scope_key, slug, object_id)
6084
+ VALUES (?, ?, ?, ?)`, [kind, scopeKey, slug, objectId]);
6085
+ const claim = db.query("SELECT object_id FROM canonical_slug_claims WHERE kind = ? AND scope_key = ? AND slug = ?").get(kind, scopeKey, slug);
6086
+ return claim?.object_id === objectId;
6087
+ }
6088
+ function releaseCanonicalSlugClaims(kind, objectId, db) {
6089
+ db.run("DELETE FROM canonical_slug_claims WHERE kind = ? AND object_id = ?", [kind, objectId]);
6090
+ }
6091
+
5613
6092
  // src/db/projects.ts
5614
6093
  var exports_projects = {};
5615
6094
  __export(exports_projects, {
5616
6095
  updateProject: () => updateProject2,
5617
- slugify: () => slugify2,
6096
+ slugify: () => slugify,
5618
6097
  setMachineLocalPath: () => setMachineLocalPath,
5619
6098
  renameProject: () => renameProject,
5620
6099
  removeProjectSource: () => removeProjectSource,
@@ -5632,8 +6111,8 @@ __export(exports_projects, {
5632
6111
  createProject: () => createProject2,
5633
6112
  addProjectSource: () => addProjectSource
5634
6113
  });
5635
- function slugify2(name) {
5636
- return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
6114
+ function slugify(name) {
6115
+ return normalizeSlug(name);
5637
6116
  }
5638
6117
  function generatePrefix(name, db) {
5639
6118
  const words = name.replace(/[^a-zA-Z0-9\s]/g, "").trim().split(/\s+/);
@@ -5657,14 +6136,23 @@ function generatePrefix(name, db) {
5657
6136
  }
5658
6137
  function createProject2(input, db) {
5659
6138
  const d = db || getDatabase();
5660
- const id = uuid();
5661
- const timestamp = now();
5662
- const taskListId = input.task_list_id ?? `todos-${slugify2(input.name)}`;
5663
- const taskPrefix = input.task_prefix || generatePrefix(input.name, d);
5664
- const machineId = currentStorageMachineId(d);
5665
- d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, created_at, updated_at, machine_id)
5666
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, timestamp, timestamp, machineId]);
5667
- return getProject(id, d);
6139
+ return d.transaction(() => {
6140
+ const id = uuid();
6141
+ const timestamp = now();
6142
+ const derivedSlug = slugify(input.name);
6143
+ const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugify(input.task_list_id);
6144
+ if (!derivedSlug || !taskListId)
6145
+ throw new Error("Project name and task-list slug must be non-empty");
6146
+ const slugConflict = d.query("SELECT id FROM projects WHERE task_list_id = ? LIMIT 1").get(taskListId);
6147
+ if (slugConflict || !claimCanonicalSlug("project", "global", taskListId, id, d)) {
6148
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${taskListId}" already exists`);
6149
+ }
6150
+ const taskPrefix = input.task_prefix || generatePrefix(input.name, d);
6151
+ const machineId = currentStorageMachineId(d);
6152
+ d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, created_at, updated_at, machine_id)
6153
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, timestamp, timestamp, machineId]);
6154
+ return getProject(id, d);
6155
+ })();
5668
6156
  }
5669
6157
  function getProject(id, db) {
5670
6158
  const d = db || getDatabase();
@@ -5692,6 +6180,9 @@ function updateProject2(id, input, db) {
5692
6180
  const project = getProject(id, d);
5693
6181
  if (!project)
5694
6182
  throw new ProjectNotFoundError(id);
6183
+ if ("task_list_id" in input) {
6184
+ throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
6185
+ }
5695
6186
  const sets = ["updated_at = ?"];
5696
6187
  const params = [now()];
5697
6188
  if (input.name !== undefined) {
@@ -5702,10 +6193,6 @@ function updateProject2(id, input, db) {
5702
6193
  sets.push("description = ?");
5703
6194
  params.push(input.description);
5704
6195
  }
5705
- if (input.task_list_id !== undefined) {
5706
- sets.push("task_list_id = ?");
5707
- params.push(input.task_list_id);
5708
- }
5709
6196
  if (input.path !== undefined) {
5710
6197
  sets.push("path = ?");
5711
6198
  params.push(input.path);
@@ -5716,29 +6203,41 @@ function updateProject2(id, input, db) {
5716
6203
  }
5717
6204
  function renameProject(id, input, db) {
5718
6205
  const d = db || getDatabase();
5719
- const project = getProject(id, d);
5720
- if (!project)
5721
- throw new ProjectNotFoundError(id);
5722
- let taskListsUpdated = 0;
5723
- const ts = now();
5724
- if (input.new_slug !== undefined) {
5725
- const normalised = input.new_slug.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "");
5726
- if (!normalised)
5727
- throw new Error("Invalid slug \u2014 must be non-empty kebab-case");
5728
- const conflict = d.query("SELECT id FROM projects WHERE task_list_id = ? AND id != ?").get(normalised, id);
5729
- if (conflict)
5730
- throw new Error(`Slug "${normalised}" is already used by another project`);
5731
- const oldSlug = project.task_list_id;
5732
- d.run("UPDATE projects SET task_list_id = ?, updated_at = ? WHERE id = ?", [normalised, ts, id]);
5733
- if (oldSlug) {
5734
- 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]);
5735
- taskListsUpdated = result.changes;
5736
- }
5737
- }
5738
- if (input.name !== undefined) {
5739
- d.run("UPDATE projects SET name = ?, updated_at = ? WHERE id = ?", [input.name, ts, id]);
5740
- }
5741
- return { project: getProject(id, d), task_lists_updated: taskListsUpdated };
6206
+ return d.transaction(() => {
6207
+ const project = getProject(id, d);
6208
+ if (!project)
6209
+ throw new ProjectNotFoundError(id);
6210
+ let taskListsUpdated = 0;
6211
+ const ts = now();
6212
+ if (input.new_slug !== undefined) {
6213
+ const normalised = normalizeSlug(input.new_slug);
6214
+ if (!normalised)
6215
+ throw new Error("Invalid slug \u2014 must be non-empty kebab-case");
6216
+ const oldSlug = project.task_list_id;
6217
+ if (normalised !== oldSlug) {
6218
+ const conflict = d.query("SELECT id FROM projects WHERE task_list_id = ? AND id != ?").get(normalised, id);
6219
+ if (conflict) {
6220
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalised}" is already used by another project`);
6221
+ }
6222
+ const taskListConflict = d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ? AND slug != COALESCE(?, '') LIMIT 1").get(id, normalised, oldSlug);
6223
+ if (taskListConflict) {
6224
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task-list slug "${normalised}" is already used in project "${project.name}"`);
6225
+ }
6226
+ releaseCanonicalSlugClaims("project", id, d);
6227
+ if (!claimCanonicalSlug("project", "global", normalised, id, d)) {
6228
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Slug "${normalised}" is already used by another project`);
6229
+ }
6230
+ d.run("UPDATE projects SET task_list_id = ?, updated_at = ? WHERE id = ?", [normalised, ts, id]);
6231
+ }
6232
+ if (oldSlug && (normalised !== oldSlug || input.name !== undefined && input.name !== project.name)) {
6233
+ 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;
6234
+ }
6235
+ }
6236
+ if (input.name !== undefined && input.name !== project.name) {
6237
+ d.run("UPDATE projects SET name = ?, updated_at = ? WHERE id = ?", [input.name, ts, id]);
6238
+ }
6239
+ return { project: getProject(id, d), task_lists_updated: taskListsUpdated };
6240
+ })();
5742
6241
  }
5743
6242
  function deleteProject(id, db) {
5744
6243
  const d = db || getDatabase();
@@ -5750,8 +6249,10 @@ function deleteProject(id, db) {
5750
6249
  object_id: id,
5751
6250
  payload: project
5752
6251
  }, d);
5753
- const result = d.run("DELETE FROM projects WHERE id = ?", [id]);
5754
- return result.changes > 0;
6252
+ return d.transaction(() => {
6253
+ releaseCanonicalSlugClaims("project", id, d);
6254
+ return d.run("DELETE FROM projects WHERE id = ?", [id]).changes > 0;
6255
+ })();
5755
6256
  }
5756
6257
  function rowToSource(row) {
5757
6258
  return {
@@ -7205,19 +7706,22 @@ function rowToTaskList(row) {
7205
7706
  }
7206
7707
  function createTaskList2(input, db) {
7207
7708
  const d = db || getDatabase();
7208
- const id = uuid();
7209
- const timestamp = now();
7210
- const slug = input.slug || slugify2(input.name);
7211
- const machineId = currentStorageMachineId(d);
7212
- if (!input.project_id) {
7213
- const existing = d.query("SELECT id FROM task_lists WHERE project_id IS NULL AND slug = ?").get(slug);
7214
- if (existing) {
7215
- throw new Error(`Standalone task list with slug "${slug}" already exists`);
7216
- }
7217
- }
7218
- d.run(`INSERT INTO task_lists (id, project_id, slug, name, description, metadata, created_at, updated_at, machine_id)
7219
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, input.project_id || null, slug, input.name, input.description || null, JSON.stringify(input.metadata || {}), timestamp, timestamp, machineId]);
7220
- return getTaskList(id, d);
7709
+ return d.transaction(() => {
7710
+ const id = uuid();
7711
+ const timestamp = now();
7712
+ const slug = normalizeSlug(input.slug === undefined ? input.name : input.slug);
7713
+ if (!slug)
7714
+ throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
7715
+ const machineId = currentStorageMachineId(d);
7716
+ const scopeKey = taskListSlugScopeKey(input.project_id);
7717
+ 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);
7718
+ if (existing || !claimCanonicalSlug("task_list", scopeKey, slug, id, d)) {
7719
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
7720
+ }
7721
+ d.run(`INSERT INTO task_lists (id, project_id, slug, name, description, metadata, created_at, updated_at, machine_id)
7722
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, input.project_id || null, slug, input.name, input.description || null, JSON.stringify(input.metadata || {}), timestamp, timestamp, machineId]);
7723
+ return getTaskList(id, d);
7724
+ })();
7221
7725
  }
7222
7726
  function getTaskList(id, db) {
7223
7727
  const d = db || getDatabase();
@@ -7243,26 +7747,45 @@ function listTaskLists(projectId, db) {
7243
7747
  }
7244
7748
  function updateTaskList2(id, input, db) {
7245
7749
  const d = db || getDatabase();
7246
- const existing = getTaskList(id, d);
7247
- if (!existing)
7248
- throw new TaskListNotFoundError(id);
7249
- const sets = ["updated_at = ?"];
7250
- const params = [now()];
7251
- if (input.name !== undefined) {
7252
- sets.push("name = ?");
7253
- params.push(input.name);
7254
- }
7255
- if (input.description !== undefined) {
7256
- sets.push("description = ?");
7257
- params.push(input.description);
7258
- }
7259
- if (input.metadata !== undefined) {
7260
- sets.push("metadata = ?");
7261
- params.push(JSON.stringify(input.metadata));
7262
- }
7263
- params.push(id);
7264
- d.run(`UPDATE task_lists SET ${sets.join(", ")} WHERE id = ?`, params);
7265
- return getTaskList(id, d);
7750
+ return d.transaction(() => {
7751
+ const existing = getTaskList(id, d);
7752
+ if (!existing)
7753
+ throw new TaskListNotFoundError(id);
7754
+ const sets = ["updated_at = ?"];
7755
+ const params = [now()];
7756
+ if (input.slug !== undefined) {
7757
+ const slug = slugify(input.slug);
7758
+ if (!slug)
7759
+ throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
7760
+ 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);
7761
+ if (duplicate) {
7762
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
7763
+ }
7764
+ if (slug !== existing.slug) {
7765
+ releaseCanonicalSlugClaims("task_list", id, d);
7766
+ if (!claimCanonicalSlug("task_list", taskListSlugScopeKey(existing.project_id), slug, id, d)) {
7767
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
7768
+ }
7769
+ }
7770
+ sets.push("slug = ?");
7771
+ params.push(slug);
7772
+ }
7773
+ if (input.name !== undefined) {
7774
+ sets.push("name = ?");
7775
+ params.push(input.name);
7776
+ }
7777
+ if (input.description !== undefined) {
7778
+ sets.push("description = ?");
7779
+ params.push(input.description);
7780
+ }
7781
+ if (input.metadata !== undefined) {
7782
+ sets.push("metadata = ?");
7783
+ params.push(JSON.stringify(input.metadata));
7784
+ }
7785
+ params.push(id);
7786
+ d.run(`UPDATE task_lists SET ${sets.join(", ")} WHERE id = ?`, params);
7787
+ return getTaskList(id, d);
7788
+ })();
7266
7789
  }
7267
7790
  function deleteTaskList(id, db) {
7268
7791
  const d = db || getDatabase();
@@ -7274,7 +7797,10 @@ function deleteTaskList(id, db) {
7274
7797
  object_id: id,
7275
7798
  payload: list
7276
7799
  }, d);
7277
- return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
7800
+ return d.transaction(() => {
7801
+ releaseCanonicalSlugClaims("task_list", id, d);
7802
+ return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
7803
+ })();
7278
7804
  }
7279
7805
  function ensureTaskList(name, slug, projectId, db) {
7280
7806
  const d = db || getDatabase();
@@ -10605,10 +11131,10 @@ var init_task_relations = __esm(() => {
10605
11131
 
10606
11132
  // src/db/plans.ts
10607
11133
  function planSlugBase3(value) {
10608
- return slugify2(value) || "plan";
11134
+ return slugify(value) || "plan";
10609
11135
  }
10610
11136
  function normalizePlanSlug2(value) {
10611
- const slug = slugify2(value);
11137
+ const slug = slugify(value);
10612
11138
  if (!slug)
10613
11139
  throw new Error("Invalid plan slug");
10614
11140
  return slug;
@@ -14789,6 +15315,14 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
14789
15315
  skipped: 0,
14790
15316
  errors: []
14791
15317
  };
15318
+ result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
15319
+ if (result.errors.length === 0) {
15320
+ const existingProjects = d.query("SELECT id, task_list_id FROM projects").all();
15321
+ const existingTaskLists = d.query("SELECT id, project_id, slug FROM task_lists").all();
15322
+ result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
15323
+ }
15324
+ if (result.errors.length > 0)
15325
+ return result;
14792
15326
  const applyRows = (objectType, table, columns, rows, updateClockColumn, afterUpsert) => {
14793
15327
  for (const row of rows) {
14794
15328
  try {
@@ -15161,6 +15695,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
15161
15695
  getByPath: (path) => getProjectByPath(path, database()),
15162
15696
  list: () => listProjects(database()),
15163
15697
  update: (id, input) => updateProject2(id, input, database()),
15698
+ rename: (id, input) => renameProject(id, input, database()),
15164
15699
  delete: (id) => deleteProject(id, database())
15165
15700
  },
15166
15701
  plans: {
@@ -15484,6 +16019,7 @@ function emptySnapshot() {
15484
16019
  var MAX_BACKOFF_MS;
15485
16020
  var init_shadow_outbox = __esm(() => {
15486
16021
  init_local_sqlite();
16022
+ init_postgres_sync();
15487
16023
  init_shadow_outbox_schema();
15488
16024
  init_shadow_outbox_schema();
15489
16025
  MAX_BACKOFF_MS = 5 * 60000;
@@ -15598,6 +16134,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
15598
16134
  schemas: {
15599
16135
  Task: taskSchema,
15600
16136
  Project: projectSchema,
16137
+ TaskList: taskListSchema,
15601
16138
  TaskComment: taskCommentSchema,
15602
16139
  CreateTaskInput: {
15603
16140
  type: "object",
@@ -15626,12 +16163,65 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
15626
16163
  },
15627
16164
  CreateProjectInput: {
15628
16165
  type: "object",
16166
+ additionalProperties: false,
15629
16167
  required: ["name", "path"],
15630
16168
  properties: {
16169
+ name: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
16170
+ path: { type: "string", minLength: 1 },
16171
+ description: { type: "string" },
16172
+ task_list_id: { type: "string", minLength: 1, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
16173
+ task_prefix: { type: "string", minLength: 1 }
16174
+ }
16175
+ },
16176
+ UpdateProjectInput: {
16177
+ type: "object",
16178
+ additionalProperties: false,
16179
+ minProperties: 1,
16180
+ properties: {
16181
+ name: { type: "string", minLength: 1 },
16182
+ path: { type: "string", minLength: 1 },
16183
+ description: { type: "string", nullable: true }
16184
+ }
16185
+ },
16186
+ RenameProjectInput: {
16187
+ type: "object",
16188
+ additionalProperties: false,
16189
+ required: ["new_slug"],
16190
+ properties: {
16191
+ new_slug: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
16192
+ name: { type: "string", minLength: 1 }
16193
+ }
16194
+ },
16195
+ ErrorResponse: {
16196
+ type: "object",
16197
+ required: ["error"],
16198
+ properties: {
16199
+ error: { type: "string" },
16200
+ code: { type: "string" },
16201
+ conflict: { type: "boolean" }
16202
+ }
16203
+ },
16204
+ CreateTaskListInput: {
16205
+ type: "object",
16206
+ additionalProperties: false,
16207
+ required: ["name"],
16208
+ properties: {
16209
+ name: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
16210
+ slug: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
16211
+ project_id: { type: "string" },
16212
+ description: { type: "string" },
16213
+ metadata: { type: "object", additionalProperties: true }
16214
+ }
16215
+ },
16216
+ UpdateTaskListInput: {
16217
+ type: "object",
16218
+ additionalProperties: false,
16219
+ minProperties: 1,
16220
+ properties: {
16221
+ slug: { type: "string", minLength: 1, pattern: ".*[A-Za-z0-9].*" },
15631
16222
  name: { type: "string" },
15632
- path: { type: "string" },
15633
16223
  description: { type: "string" },
15634
- task_prefix: { type: "string" }
16224
+ metadata: { type: "object", additionalProperties: true }
15635
16225
  }
15636
16226
  },
15637
16227
  CreateTaskCommentInput: {
@@ -15840,7 +16430,10 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
15840
16430
  required: true,
15841
16431
  content: { "application/json": { schema: { $ref: "#/components/schemas/CreateProjectInput" } } }
15842
16432
  },
15843
- responses: { "201": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" } } } } } } }
16433
+ responses: {
16434
+ "201": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" } } } } } },
16435
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
16436
+ }
15844
16437
  }
15845
16438
  },
15846
16439
  "/v1/projects/{id}": {
@@ -15849,6 +16442,86 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
15849
16442
  summary: "Get a project by id",
15850
16443
  parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
15851
16444
  responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" } } } } } } }
16445
+ },
16446
+ patch: {
16447
+ operationId: "updateProject",
16448
+ summary: "Update a project",
16449
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
16450
+ requestBody: {
16451
+ required: true,
16452
+ content: { "application/json": { schema: { $ref: "#/components/schemas/UpdateProjectInput" } } }
16453
+ },
16454
+ responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" } } } } } } }
16455
+ },
16456
+ delete: {
16457
+ operationId: "deleteProject",
16458
+ summary: "Delete a project",
16459
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
16460
+ responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean" }, id: { type: "string" } } } } } } }
16461
+ }
16462
+ },
16463
+ "/v1/projects/{id}/rename": {
16464
+ post: {
16465
+ operationId: "renameProject",
16466
+ summary: "Atomically rename a project and its canonical task list",
16467
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
16468
+ requestBody: {
16469
+ required: true,
16470
+ content: { "application/json": { schema: { $ref: "#/components/schemas/RenameProjectInput" } } }
16471
+ },
16472
+ responses: {
16473
+ "200": { content: { "application/json": { schema: { type: "object", properties: { project: { $ref: "#/components/schemas/Project" }, task_lists_updated: { type: "number" } } } } } },
16474
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
16475
+ }
16476
+ }
16477
+ },
16478
+ "/v1/task-lists": {
16479
+ get: {
16480
+ operationId: "listTaskLists",
16481
+ summary: "List task lists",
16482
+ parameters: [{ name: "project_id", in: "query", schema: { type: "string" } }],
16483
+ responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { task_lists: { type: "array", items: { $ref: "#/components/schemas/TaskList" } }, count: { type: "number" } } } } } } }
16484
+ },
16485
+ post: {
16486
+ operationId: "createTaskList",
16487
+ summary: "Create a task list",
16488
+ requestBody: {
16489
+ required: true,
16490
+ content: { "application/json": { schema: { $ref: "#/components/schemas/CreateTaskListInput" } } }
16491
+ },
16492
+ responses: {
16493
+ "201": { content: { "application/json": { schema: { type: "object", properties: { task_list: { $ref: "#/components/schemas/TaskList" } } } } } },
16494
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
16495
+ }
16496
+ }
16497
+ },
16498
+ "/v1/task-lists/{id}": {
16499
+ get: {
16500
+ operationId: "getTaskList",
16501
+ summary: "Get a task list by id",
16502
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
16503
+ responses: {
16504
+ "200": { content: { "application/json": { schema: { type: "object", properties: { task_list: { $ref: "#/components/schemas/TaskList" } } } } } }
16505
+ }
16506
+ },
16507
+ patch: {
16508
+ operationId: "updateTaskList",
16509
+ summary: "Update a task list",
16510
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
16511
+ requestBody: {
16512
+ required: true,
16513
+ content: { "application/json": { schema: { $ref: "#/components/schemas/UpdateTaskListInput" } } }
16514
+ },
16515
+ responses: {
16516
+ "200": { content: { "application/json": { schema: { type: "object", properties: { task_list: { $ref: "#/components/schemas/TaskList" } } } } } },
16517
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
16518
+ }
16519
+ },
16520
+ delete: {
16521
+ operationId: "deleteTaskList",
16522
+ summary: "Delete a task list",
16523
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
16524
+ responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean" }, id: { type: "string" } } } } } } }
15852
16525
  }
15853
16526
  },
15854
16527
  "/v1/stats": {
@@ -15915,7 +16588,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
15915
16588
  }
15916
16589
  };
15917
16590
  }
15918
- var taskSchema, projectSchema, taskCommentSchema;
16591
+ var taskSchema, projectSchema, taskListSchema, taskCommentSchema;
15919
16592
  var init_openapi = __esm(() => {
15920
16593
  init_package_version();
15921
16594
  taskSchema = {
@@ -15942,6 +16615,22 @@ var init_openapi = __esm(() => {
15942
16615
  name: { type: "string" },
15943
16616
  path: { type: "string" },
15944
16617
  description: { type: "string", nullable: true },
16618
+ task_list_id: { type: "string", nullable: true },
16619
+ task_prefix: { type: "string", nullable: true },
16620
+ task_counter: { type: "number" },
16621
+ created_at: { type: "string" },
16622
+ updated_at: { type: "string" }
16623
+ }
16624
+ };
16625
+ taskListSchema = {
16626
+ type: "object",
16627
+ properties: {
16628
+ id: { type: "string" },
16629
+ project_id: { type: "string", nullable: true },
16630
+ slug: { type: "string" },
16631
+ name: { type: "string" },
16632
+ description: { type: "string", nullable: true },
16633
+ metadata: { type: "object", additionalProperties: true },
15945
16634
  created_at: { type: "string" },
15946
16635
  updated_at: { type: "string" }
15947
16636
  }
@@ -15975,6 +16664,48 @@ function json2(body, status = 200) {
15975
16664
  function error(status, message, extra) {
15976
16665
  return json2({ error: message, ...extra ?? {} }, status);
15977
16666
  }
16667
+ function validateProjectPatch(value) {
16668
+ if (!value || typeof value !== "object" || Array.isArray(value))
16669
+ return { ok: false, message: "project patch must be an object" };
16670
+ const body = value;
16671
+ const allowed = new Set(["name", "path", "description"]);
16672
+ const unknown = Object.keys(body).find((key) => !allowed.has(key));
16673
+ if (unknown)
16674
+ return { ok: false, message: `unknown project field: ${unknown}` };
16675
+ if (Object.keys(body).length === 0)
16676
+ return { ok: false, message: "project patch must not be empty" };
16677
+ if (body["name"] !== undefined && (typeof body["name"] !== "string" || !body["name"].trim()))
16678
+ return { ok: false, message: "name must be a non-empty string" };
16679
+ if (body["path"] !== undefined && (typeof body["path"] !== "string" || !body["path"].trim()))
16680
+ return { ok: false, message: "path must be a non-empty string" };
16681
+ if (body["description"] !== undefined && body["description"] !== null && typeof body["description"] !== "string")
16682
+ return { ok: false, message: "description must be a string or null" };
16683
+ return { ok: true, patch: body };
16684
+ }
16685
+ function validateProjectCreate(value) {
16686
+ if (!value || typeof value !== "object" || Array.isArray(value))
16687
+ return { ok: false, message: "project body must be an object" };
16688
+ const body = value;
16689
+ const allowed = new Set(["name", "path", "description", "task_list_id", "task_prefix"]);
16690
+ const unknown = Object.keys(body).find((key) => !allowed.has(key));
16691
+ if (unknown)
16692
+ return { ok: false, message: `unknown project field: ${unknown}` };
16693
+ if (typeof body["name"] !== "string" || !body["name"].trim())
16694
+ return { ok: false, message: "name must be a non-empty string" };
16695
+ if (!normalizeSlug(body["name"]))
16696
+ return { ok: false, message: "name must produce a non-empty canonical slug" };
16697
+ if (typeof body["path"] !== "string" || !body["path"].trim())
16698
+ return { ok: false, message: "path must be a non-empty string" };
16699
+ if (body["description"] !== undefined && typeof body["description"] !== "string")
16700
+ return { ok: false, message: "description must be a string" };
16701
+ if (body["task_list_id"] !== undefined && !isCanonicalSlug(body["task_list_id"])) {
16702
+ return { ok: false, message: "task_list_id must be non-empty canonical kebab-case" };
16703
+ }
16704
+ if (body["task_prefix"] !== undefined && (typeof body["task_prefix"] !== "string" || !body["task_prefix"].trim())) {
16705
+ return { ok: false, message: "task_prefix must be a non-empty string" };
16706
+ }
16707
+ return { ok: true, input: body };
16708
+ }
15978
16709
  async function readJson(req) {
15979
16710
  try {
15980
16711
  const text = await req.text();
@@ -16455,14 +17186,31 @@ async function handleV1Request(req, url, dependencies = {}) {
16455
17186
  }
16456
17187
  if (method === "POST") {
16457
17188
  const body = await readJson(req);
16458
- if (!body || typeof body.name !== "string" || typeof body.path !== "string") {
16459
- return error(400, "name and path are required");
16460
- }
16461
- const project = await store.projects.create(body, contextFromPrincipal(principal));
17189
+ if (!body)
17190
+ return error(400, "invalid JSON body");
17191
+ const validated = validateProjectCreate(body);
17192
+ if (!validated.ok)
17193
+ return error(400, validated.message);
17194
+ const project = await store.projects.create(validated.input, contextFromPrincipal(principal));
16462
17195
  return json2({ project }, 201);
16463
17196
  }
16464
17197
  return error(405, `method ${method} not allowed on /v1/projects`);
16465
17198
  }
17199
+ if (action === "rename") {
17200
+ if (method !== "POST")
17201
+ return error(405, `method ${method} not allowed on /v1/projects/:id/rename`);
17202
+ const body = await readJson(req);
17203
+ if (!body || typeof body.new_slug !== "string" || !body.new_slug.trim() || !normalizeSlug(body.new_slug)) {
17204
+ return error(400, "new_slug must be a non-empty string");
17205
+ }
17206
+ if (body.name !== undefined && (typeof body.name !== "string" || !body.name.trim())) {
17207
+ return error(400, "name must be a non-empty string");
17208
+ }
17209
+ const unknownField = Object.keys(body).find((key) => !["new_slug", "name"].includes(key));
17210
+ if (unknownField)
17211
+ return error(400, `unknown project rename field: ${unknownField}`);
17212
+ return json2(await store.projects.rename(id, body, contextFromPrincipal(principal)));
17213
+ }
16466
17214
  if (method === "GET") {
16467
17215
  const project = await store.projects.get(id);
16468
17216
  return project ? json2({ project }) : error(404, "project not found");
@@ -16471,8 +17219,13 @@ async function handleV1Request(req, url, dependencies = {}) {
16471
17219
  const body = await readJson(req);
16472
17220
  if (!body)
16473
17221
  return error(400, "invalid JSON body");
16474
- const project = await store.projects.update(id, body);
16475
- return project ? json2({ project }) : error(404, "project not found");
17222
+ const validated = validateProjectPatch(body);
17223
+ if (!validated.ok)
17224
+ return error(400, validated.message);
17225
+ if (!await store.projects.get(id))
17226
+ return error(404, "project not found");
17227
+ const project = await store.projects.update(id, validated.patch);
17228
+ return json2({ project });
16476
17229
  }
16477
17230
  if (method === "DELETE") {
16478
17231
  await store.projects.delete(id, contextFromPrincipal(principal));
@@ -16559,6 +17312,21 @@ async function handleV1Request(req, url, dependencies = {}) {
16559
17312
  const body = await readJson(req);
16560
17313
  if (!body || typeof body.name !== "string" || !body.name.trim())
16561
17314
  return error(400, "name is required");
17315
+ const unknownField = Object.keys(body).find((key) => !["name", "slug", "project_id", "description", "metadata"].includes(key));
17316
+ if (unknownField)
17317
+ return error(400, `unsupported task-list create field: ${unknownField}`);
17318
+ if (body.slug !== undefined && typeof body.slug !== "string")
17319
+ return error(400, "slug must be a string");
17320
+ if (body.project_id !== undefined && (typeof body.project_id !== "string" || !body.project_id.trim()))
17321
+ return error(400, "project_id must be a non-empty string");
17322
+ if (body.description !== undefined && typeof body.description !== "string")
17323
+ return error(400, "description must be a string");
17324
+ if (body.metadata !== undefined && (!body.metadata || typeof body.metadata !== "object" || Array.isArray(body.metadata))) {
17325
+ return error(400, "metadata must be an object");
17326
+ }
17327
+ if (!normalizeSlug(body.slug === undefined ? body.name : body.slug)) {
17328
+ return error(400, "task-list slug must be non-empty kebab-case");
17329
+ }
16562
17330
  const taskList = await store.taskLists.create(body, contextFromPrincipal(principal));
16563
17331
  return json2({ task_list: taskList }, 201);
16564
17332
  }
@@ -16566,6 +17334,29 @@ async function handleV1Request(req, url, dependencies = {}) {
16566
17334
  const taskList = await store.taskLists.get(id);
16567
17335
  return taskList ? json2({ task_list: taskList }) : error(404, "task list not found");
16568
17336
  }
17337
+ if (id && (method === "PATCH" || method === "PUT")) {
17338
+ const body = await readJson(req);
17339
+ if (!body)
17340
+ return error(400, "invalid JSON body");
17341
+ const unknownField = Object.keys(body).find((key) => !["slug", "name", "description", "metadata"].includes(key));
17342
+ if (unknownField)
17343
+ return error(400, `unsupported task-list update field: ${unknownField}`);
17344
+ if (Object.keys(body).length === 0)
17345
+ return error(400, "task-list update must not be empty");
17346
+ if (body.slug !== undefined && (typeof body.slug !== "string" || !normalizeSlug(body.slug)))
17347
+ return error(400, "slug must be a non-empty string");
17348
+ if (body.name !== undefined && (typeof body.name !== "string" || !body.name.trim()))
17349
+ return error(400, "name must be a non-empty string");
17350
+ if (body.description !== undefined && typeof body.description !== "string")
17351
+ return error(400, "description must be a string");
17352
+ if (body.metadata !== undefined && (!body.metadata || typeof body.metadata !== "object" || Array.isArray(body.metadata))) {
17353
+ return error(400, "metadata must be an object");
17354
+ }
17355
+ if (!await store.taskLists.get(id))
17356
+ return error(404, "task list not found");
17357
+ const taskList = await store.taskLists.update(id, body);
17358
+ return json2({ task_list: taskList });
17359
+ }
16569
17360
  if (id && method === "DELETE") {
16570
17361
  const deleted = await store.taskLists.delete(id, contextFromPrincipal(principal));
16571
17362
  return deleted ? json2({ deleted: true, id }) : error(404, "task list not found");
@@ -16638,6 +17429,10 @@ async function handleV1Request(req, url, dependencies = {}) {
16638
17429
  } catch (e) {
16639
17430
  if (e instanceof LockError)
16640
17431
  return error(409, e.message, { code: LockError.code });
17432
+ if (e instanceof ResourceConflictError)
17433
+ return error(409, e.message, { code: e.code, conflict: true });
17434
+ if (e instanceof ProjectNotFoundError)
17435
+ return error(404, e.message, { code: ProjectNotFoundError.code });
16641
17436
  return error(500, e.message || "internal error");
16642
17437
  }
16643
17438
  }
@@ -44933,7 +45728,7 @@ function addSourceOnce(projectId, type, name, uri, metadata, db) {
44933
45728
  function bootstrapProject(options = {}, db) {
44934
45729
  const d = db || getDatabase();
44935
45730
  const discovery = discoverProjectWorkspace(options.path);
44936
- const taskListSlug = options.taskListSlug || `todos-${slugify2(options.name || discovery.projectName)}`;
45731
+ const taskListSlug = options.taskListSlug || `todos-${slugify(options.name || discovery.projectName)}`;
44937
45732
  if (options.dryRun) {
44938
45733
  return {
44939
45734
  dryRun: true,
@@ -44948,10 +45743,10 @@ function bootstrapProject(options = {}, db) {
44948
45743
  let project = ensureProject(options.name || discovery.projectName, discovery.projectPath, d);
44949
45744
  const createdProject = !beforeProject;
44950
45745
  if (project.task_list_id !== taskListSlug || options.name && project.name !== options.name) {
44951
- project = updateProject2(project.id, {
45746
+ project = renameProject(project.id, {
44952
45747
  name: options.name ?? project.name,
44953
- task_list_id: taskListSlug
44954
- }, d);
45748
+ new_slug: taskListSlug
45749
+ }, d).project;
44955
45750
  }
44956
45751
  setMachineLocalPath(project.id, discovery.projectPath, d);
44957
45752
  const beforeTaskList = d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ?").get(project.id, taskListSlug);
@@ -91555,6 +92350,7 @@ async function runMigrate() {
91555
92350
  const {
91556
92351
  ensureCloudSchema: ensureCloudSchema2,
91557
92352
  ensureCloudCommentCursorIndex: ensureCloudCommentCursorIndex2,
92353
+ ensureCloudScopedSlugUniqueIndexes: ensureCloudScopedSlugUniqueIndexes2,
91558
92354
  normalizeCloudPayloads: normalizeCloudPayloads2,
91559
92355
  pingCloud: pingCloud2,
91560
92356
  resolveCloudDatabaseUrl: resolveCloudDatabaseUrl2,
@@ -91573,6 +92369,8 @@ async function runMigrate() {
91573
92369
  console.log(`migrate: normalized ${normalized} payload row(s)`);
91574
92370
  console.log("migrate: prebuilding comment cursor index concurrently\u2026");
91575
92371
  await ensureCloudCommentCursorIndex2();
92372
+ console.log("migrate: auditing scoped slug duplicates and building unique indexes concurrently\u2026");
92373
+ await ensureCloudScopedSlugUniqueIndexes2();
91576
92374
  console.log("migrate: done");
91577
92375
  await closeCloud2();
91578
92376
  process.exit(0);