@hasna/todos 0.15.34 → 0.15.41

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 (93) hide show
  1. package/dist/cli/cloud-router.d.ts +53 -19
  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/plan-template-commands.d.ts.map +1 -1
  5. package/dist/cli/commands/project-commands.d.ts.map +1 -1
  6. package/dist/cli/commands/task-commands.d.ts.map +1 -1
  7. package/dist/cli/commands/task-subtree-transfer-commands.d.ts +3 -0
  8. package/dist/cli/commands/task-subtree-transfer-commands.d.ts.map +1 -0
  9. package/dist/cli/index.js +3551 -836
  10. package/dist/cli/stage-a.d.ts.map +1 -1
  11. package/dist/contracts.js +147 -57
  12. package/dist/db/migrations.d.ts.map +1 -1
  13. package/dist/db/plan-comments.d.ts +14 -0
  14. package/dist/db/plan-comments.d.ts.map +1 -0
  15. package/dist/db/projects.d.ts +23 -0
  16. package/dist/db/projects.d.ts.map +1 -1
  17. package/dist/db/schema.d.ts.map +1 -1
  18. package/dist/db/task-crud.d.ts.map +1 -1
  19. package/dist/db/task-lifecycle.d.ts.map +1 -1
  20. package/dist/db/task-lists.d.ts.map +1 -1
  21. package/dist/db/task-relations.d.ts.map +1 -1
  22. package/dist/index.d.ts +3 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1994 -346
  25. package/dist/json-contracts.d.ts.map +1 -1
  26. package/dist/lib/comment-cursor.d.ts +19 -0
  27. package/dist/lib/comment-cursor.d.ts.map +1 -1
  28. package/dist/lib/import-export-bridge.d.ts.map +1 -1
  29. package/dist/lib/onboarding-fixtures.d.ts.map +1 -1
  30. package/dist/mcp/index.js +2491 -507
  31. package/dist/mcp/tools/task-project-tools.d.ts.map +1 -1
  32. package/dist/mcp.js +11 -4
  33. package/dist/project-registration/authority.d.ts.map +1 -1
  34. package/dist/project-registration/sqlite.d.ts.map +1 -1
  35. package/dist/project-registration.js +362 -79
  36. package/dist/registry.d.ts +1 -1
  37. package/dist/registry.d.ts.map +1 -1
  38. package/dist/registry.js +154 -57
  39. package/dist/release-provenance.json +5 -5
  40. package/dist/sdk/index.d.ts +1 -1
  41. package/dist/sdk/index.d.ts.map +1 -1
  42. package/dist/sdk/index.js +35 -0
  43. package/dist/sdk/v1.generated.d.ts +144 -0
  44. package/dist/sdk/v1.generated.d.ts.map +1 -1
  45. package/dist/server/cloud.d.ts +12 -2
  46. package/dist/server/cloud.d.ts.map +1 -1
  47. package/dist/server/index.js +5247 -8429
  48. package/dist/server/openapi.d.ts +2104 -1314
  49. package/dist/server/openapi.d.ts.map +1 -1
  50. package/dist/server/v1.d.ts +2 -1
  51. package/dist/server/v1.d.ts.map +1 -1
  52. package/dist/storage/config.d.ts +12 -30
  53. package/dist/storage/config.d.ts.map +1 -1
  54. package/dist/storage/index.d.ts +2 -2
  55. package/dist/storage/index.d.ts.map +1 -1
  56. package/dist/storage/interfaces.d.ts +13 -2
  57. package/dist/storage/interfaces.d.ts.map +1 -1
  58. package/dist/storage/local-sqlite.d.ts.map +1 -1
  59. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  60. package/dist/storage/timestamp-backfill.d.ts +62 -0
  61. package/dist/storage/timestamp-backfill.d.ts.map +1 -0
  62. package/dist/storage.d.ts +2 -2
  63. package/dist/storage.d.ts.map +1 -1
  64. package/dist/storage.js +327 -75
  65. package/dist/task-subtree-transfer/authority.d.ts +27 -0
  66. package/dist/task-subtree-transfer/authority.d.ts.map +1 -0
  67. package/dist/task-subtree-transfer/backend.d.ts +9 -0
  68. package/dist/task-subtree-transfer/backend.d.ts.map +1 -0
  69. package/dist/task-subtree-transfer/canonical.d.ts +14 -0
  70. package/dist/task-subtree-transfer/canonical.d.ts.map +1 -0
  71. package/dist/task-subtree-transfer/http.d.ts +16 -0
  72. package/dist/task-subtree-transfer/http.d.ts.map +1 -0
  73. package/dist/task-subtree-transfer/index.d.ts +6 -0
  74. package/dist/task-subtree-transfer/index.d.ts.map +1 -0
  75. package/dist/task-subtree-transfer/planner.d.ts +24 -0
  76. package/dist/task-subtree-transfer/planner.d.ts.map +1 -0
  77. package/dist/task-subtree-transfer/postgres.d.ts +19 -0
  78. package/dist/task-subtree-transfer/postgres.d.ts.map +1 -0
  79. package/dist/task-subtree-transfer/schema-sql.d.ts +5 -0
  80. package/dist/task-subtree-transfer/schema-sql.d.ts.map +1 -0
  81. package/dist/task-subtree-transfer/sqlite.d.ts +16 -0
  82. package/dist/task-subtree-transfer/sqlite.d.ts.map +1 -0
  83. package/dist/task-subtree-transfer/types.d.ts +148 -0
  84. package/dist/task-subtree-transfer/types.d.ts.map +1 -0
  85. package/dist/task-subtree-transfer.d.ts +2 -0
  86. package/dist/task-subtree-transfer.d.ts.map +1 -0
  87. package/dist/task-subtree-transfer.js +1869 -0
  88. package/dist/testing.d.ts +41 -7
  89. package/dist/testing.d.ts.map +1 -1
  90. package/dist/testing.js +44 -1
  91. package/dist/types/index.d.ts +29 -2
  92. package/dist/types/index.d.ts.map +1 -1
  93. package/package.json +11 -4
@@ -0,0 +1,1869 @@
1
+ // @bun
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ function __accessProp(key) {
7
+ return this[key];
8
+ }
9
+ var __toCommonJS = (from) => {
10
+ var entry = (__moduleCache ??= new WeakMap).get(from), desc;
11
+ if (entry)
12
+ return entry;
13
+ entry = __defProp({}, "__esModule", { value: true });
14
+ if (from && typeof from === "object" || typeof from === "function") {
15
+ for (var key of __getOwnPropNames(from))
16
+ if (!__hasOwnProp.call(entry, key))
17
+ __defProp(entry, key, {
18
+ get: __accessProp.bind(from, key),
19
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
20
+ });
21
+ }
22
+ __moduleCache.set(from, entry);
23
+ return entry;
24
+ };
25
+ var __moduleCache;
26
+ var __returnValue = (v) => v;
27
+ function __exportSetter(name, newValue) {
28
+ this[name] = __returnValue.bind(null, newValue);
29
+ }
30
+ var __export = (target, all) => {
31
+ for (var name in all)
32
+ __defProp(target, name, {
33
+ get: all[name],
34
+ enumerable: true,
35
+ configurable: true,
36
+ set: __exportSetter.bind(all, name)
37
+ });
38
+ };
39
+ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
40
+ var __require = import.meta.require;
41
+
42
+ // src/lib/slugs.ts
43
+ function normalizeSlug(value) {
44
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
45
+ }
46
+ function isCanonicalSlug(value) {
47
+ return typeof value === "string" && value.length > 0 && normalizeSlug(value) === value;
48
+ }
49
+ function isValidTaskListProjectScope(value) {
50
+ return value === undefined || value === null || typeof value === "string" && value.trim().length > 0;
51
+ }
52
+ function validateSnapshotRoutingRecords(projects, taskLists) {
53
+ const errors = [];
54
+ const projectSlugs = new Map;
55
+ const taskListSlugs = new Map;
56
+ for (const project of projects) {
57
+ if (!isCanonicalSlug(project.task_list_id)) {
58
+ errors.push(`project ${project.id}: task_list_id must be non-empty canonical kebab-case`);
59
+ continue;
60
+ }
61
+ const existing = projectSlugs.get(project.task_list_id);
62
+ if (projectSlugs.has(project.task_list_id)) {
63
+ errors.push(`project ${project.id}: task_list_id duplicates project ${existing}: ${project.task_list_id}`);
64
+ } else {
65
+ projectSlugs.set(project.task_list_id, project.id);
66
+ }
67
+ }
68
+ for (const taskList of taskLists) {
69
+ if (!isCanonicalSlug(taskList.slug)) {
70
+ errors.push(`task list ${taskList.id}: slug must be non-empty canonical kebab-case`);
71
+ continue;
72
+ }
73
+ if (!isValidTaskListProjectScope(taskList.project_id)) {
74
+ errors.push(`task list ${taskList.id}: project_id must be null, missing, or a non-empty string`);
75
+ continue;
76
+ }
77
+ const scope = taskList.project_id ?? null;
78
+ const scopedSlugs = taskListSlugs.get(scope) ?? new Map;
79
+ const existing = scopedSlugs.get(taskList.slug);
80
+ if (scopedSlugs.has(taskList.slug)) {
81
+ errors.push(`task list ${taskList.id}: slug duplicates task list ${existing} in the same scope: ${taskList.slug}`);
82
+ } else {
83
+ scopedSlugs.set(taskList.slug, taskList.id);
84
+ taskListSlugs.set(scope, scopedSlugs);
85
+ }
86
+ }
87
+ return errors;
88
+ }
89
+ function validateSnapshotRoutingDestinationConflicts(projects, taskLists, existingProjects, existingTaskLists) {
90
+ const errors = [];
91
+ for (const project of projects) {
92
+ const current = existingProjects.find((candidate) => candidate.id === project.id);
93
+ if (current?.task_list_id === project.task_list_id)
94
+ continue;
95
+ const conflict = existingProjects.find((candidate) => candidate.id !== project.id && candidate.task_list_id === project.task_list_id);
96
+ if (conflict) {
97
+ errors.push(`project ${project.id}: task_list_id conflicts with existing project ${conflict.id}: ${String(project.task_list_id)}`);
98
+ }
99
+ }
100
+ for (const taskList of taskLists) {
101
+ const projectId = taskList.project_id ?? null;
102
+ const current = existingTaskLists.find((candidate) => candidate.id === taskList.id);
103
+ if ((current?.project_id ?? null) === projectId && current?.slug === taskList.slug)
104
+ continue;
105
+ const conflict = existingTaskLists.find((candidate) => candidate.id !== taskList.id && (candidate.project_id ?? null) === projectId && candidate.slug === taskList.slug);
106
+ if (conflict) {
107
+ errors.push(`task list ${taskList.id}: slug conflicts with existing task list ${conflict.id} in the same scope: ${String(taskList.slug)}`);
108
+ }
109
+ }
110
+ return errors;
111
+ }
112
+
113
+ // src/task-manifest/canonical.ts
114
+ import { createHash } from "crypto";
115
+ function canonicalize(value) {
116
+ if (Array.isArray(value))
117
+ return value.map(canonicalize);
118
+ if (value !== null && typeof value === "object") {
119
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, canonicalize(entry)]));
120
+ }
121
+ return value;
122
+ }
123
+ function canonicalJson(value) {
124
+ return JSON.stringify(canonicalize(value));
125
+ }
126
+ function canonicalDigest(value) {
127
+ return createHash("sha256").update(canonicalJson(value)).digest("hex");
128
+ }
129
+ function deterministicUuid(namespace, ...parts) {
130
+ const bytes = createHash("sha256").update([namespace, ...parts].join("\x1F")).digest().subarray(0, 16);
131
+ bytes[6] = bytes[6] & 15 | 80;
132
+ bytes[8] = bytes[8] & 63 | 128;
133
+ const hex = bytes.toString("hex");
134
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
135
+ }
136
+
137
+ // src/task-subtree-transfer/types.ts
138
+ var TODOS_TASK_SUBTREE_TRANSFER_ROUTE = "todos.task-subtree-transfer.v1";
139
+ var TODOS_TASK_SUBTREE_TRANSFER_SCHEMA_VERSION = 1;
140
+
141
+ class TodosTaskSubtreeTransferError extends Error {
142
+ code;
143
+ details;
144
+ constructor(code, message, details = {}) {
145
+ super(message);
146
+ this.code = code;
147
+ this.details = details;
148
+ this.name = "TodosTaskSubtreeTransferError";
149
+ }
150
+ }
151
+
152
+ // src/task-subtree-transfer/canonical.ts
153
+ function taskSubtreeTransferRequestDigest(input) {
154
+ const { idempotency_key: _idempotencyKey, ...request } = input;
155
+ return canonicalDigest(request);
156
+ }
157
+ function taskSubtreeTransferRollbackRequestDigest(input) {
158
+ const { idempotency_key: _idempotencyKey, ...request } = input;
159
+ return canonicalDigest(request);
160
+ }
161
+ function deriveTodosTaskSubtreeTransferIdempotencyKey(input) {
162
+ return `tstk_${canonicalDigest({
163
+ route: TODOS_TASK_SUBTREE_TRANSFER_ROUTE,
164
+ ...input
165
+ }).slice(0, 48)}`;
166
+ }
167
+
168
+ // src/storage/postgres-sync.ts
169
+ var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records";
170
+ var DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors";
171
+ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE, cursorTableName = DEFAULT_TODOS_POSTGRES_CURSOR_TABLE) {
172
+ assertSafeIdentifier(tableName);
173
+ assertSafeIdentifier(cursorTableName);
174
+ return [
175
+ `CREATE TABLE IF NOT EXISTS ${tableName} (
176
+ service text NOT NULL,
177
+ object_type text NOT NULL,
178
+ object_id text NOT NULL,
179
+ payload jsonb NOT NULL,
180
+ updated_at timestamptz NOT NULL,
181
+ deleted_at timestamptz,
182
+ source_machine_id text,
183
+ version integer,
184
+ PRIMARY KEY (service, object_type, object_id)
185
+ )`,
186
+ `CREATE INDEX IF NOT EXISTS ${tableName}_updated_idx ON ${tableName} (service, updated_at)`,
187
+ `CREATE INDEX IF NOT EXISTS ${tableName}_task_status_idx ON ${tableName} ((payload->>'status')) WHERE object_type = 'tasks' AND deleted_at IS NULL`,
188
+ `CREATE INDEX IF NOT EXISTS ${tableName}_task_project_idx ON ${tableName} ((payload->>'project_id')) WHERE object_type = 'tasks' AND deleted_at IS NULL`,
189
+ `CREATE INDEX IF NOT EXISTS ${tableName}_payload_gin ON ${tableName} USING gin (payload jsonb_path_ops)`,
190
+ `CREATE EXTENSION IF NOT EXISTS pg_trgm`,
191
+ `CREATE EXTENSION IF NOT EXISTS unaccent`,
192
+ `CREATE OR REPLACE FUNCTION todos_immutable_unaccent(text)
193
+ RETURNS text
194
+ LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
195
+ AS $$ SELECT unaccent('unaccent', $1) $$`,
196
+ `CREATE OR REPLACE FUNCTION todos_try_timestamptz(text)
197
+ RETURNS timestamptz
198
+ LANGUAGE plpgsql IMMUTABLE PARALLEL UNSAFE
199
+ SET DateStyle TO 'ISO, YMD'
200
+ AS $$
201
+ BEGIN
202
+ RETURN CASE
203
+ WHEN $1 ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN $1::timestamptz
204
+ ELSE ($1::timestamp AT TIME ZONE 'UTC')
205
+ END;
206
+ EXCEPTION WHEN others THEN RETURN NULL; END $$`,
207
+ `DROP INDEX IF EXISTS ${tableName}_task_updated_at_idx`,
208
+ `CREATE INDEX IF NOT EXISTS ${tableName}_task_updated_at_utc_idx
209
+ ON ${tableName} (todos_try_timestamptz(payload->>'updated_at'))
210
+ WHERE object_type = 'tasks' AND deleted_at IS NULL`,
211
+ `ALTER TABLE ${tableName}
212
+ ADD COLUMN IF NOT EXISTS task_search_tsv tsvector
213
+ GENERATED ALWAYS AS (
214
+ setweight(to_tsvector('simple', todos_immutable_unaccent(COALESCE(payload->>'title', ''))), 'A')
215
+ || setweight(to_tsvector('simple', todos_immutable_unaccent(COALESCE(payload->>'description', ''))), 'B')
216
+ || setweight(to_tsvector('simple', todos_immutable_unaccent(translate(COALESCE(payload->>'tags', '[]'), '[]",', ' '))), 'C')
217
+ ) STORED`,
218
+ `CREATE INDEX IF NOT EXISTS ${tableName}_task_search_tsv_idx
219
+ ON ${tableName} USING gin (task_search_tsv)
220
+ WHERE object_type = 'tasks' AND deleted_at IS NULL`,
221
+ `CREATE INDEX IF NOT EXISTS ${tableName}_task_search_trgm_idx
222
+ ON ${tableName} USING gin (
223
+ todos_immutable_unaccent(COALESCE(payload->>'title', '') || ' ' || COALESCE(payload->>'description', '')) gin_trgm_ops
224
+ )
225
+ WHERE object_type = 'tasks' AND deleted_at IS NULL`,
226
+ `CREATE TABLE IF NOT EXISTS ${cursorTableName} (
227
+ service text NOT NULL,
228
+ cursor_name text NOT NULL,
229
+ value text NOT NULL,
230
+ updated_at timestamptz NOT NULL DEFAULT now(),
231
+ PRIMARY KEY (service, cursor_name)
232
+ )`
233
+ ];
234
+ }
235
+
236
+ class PostgresScopedSlugMigrationConflictError extends Error {
237
+ conflicts;
238
+ constructor(conflicts) {
239
+ const preview = conflicts.slice(0, 5).map((conflict) => `${conflict.object_type}:${conflict.scope || "global"}:${conflict.slug} [${conflict.object_ids.join(", ")}]`).join("; ");
240
+ 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.");
241
+ this.conflicts = conflicts;
242
+ this.name = "PostgresScopedSlugMigrationConflictError";
243
+ }
244
+ }
245
+
246
+ class PostgresScopedSlugIndexBuildError extends Error {
247
+ index_name;
248
+ constructor(index_name, cause) {
249
+ 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 });
250
+ this.index_name = index_name;
251
+ this.name = "PostgresScopedSlugIndexBuildError";
252
+ }
253
+ }
254
+ function postgresTodosScopedSlugPreflightSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
255
+ assertSafeIdentifier(tableName);
256
+ return `/* todos:scoped-slug-duplicate-audit */ WITH candidates AS (
257
+ SELECT service, object_type, COALESCE(payload->>'project_id', '') AS scope,
258
+ jsonb_typeof(payload->'project_id') AS scope_type,
259
+ payload->>'slug' AS slug, jsonb_typeof(payload->'slug') AS slug_type, object_id
260
+ FROM ${tableName}
261
+ WHERE object_type = 'task_lists' AND deleted_at IS NULL
262
+ UNION ALL
263
+ SELECT service, object_type, '' AS scope, NULL::text AS scope_type, payload->>'task_list_id' AS slug,
264
+ jsonb_typeof(payload->'task_list_id') AS slug_type, object_id
265
+ FROM ${tableName}
266
+ WHERE object_type = 'projects' AND deleted_at IS NULL
267
+ ), annotated AS (
268
+ SELECT *, trim(both '-' from regexp_replace(lower(COALESCE(slug, '')), '[^a-z0-9]+', '-', 'g')) AS normalized_slug
269
+ FROM candidates
270
+ ), invalid AS (
271
+ SELECT service, object_type, scope, COALESCE(slug, '<null>') AS slug,
272
+ ARRAY[object_id] AS object_ids, 1::integer AS duplicate_count, 'invalid'::text AS issue
273
+ FROM annotated
274
+ WHERE slug_type IS DISTINCT FROM 'string'
275
+ OR slug IS NULL OR slug = '' OR normalized_slug = '' OR slug IS DISTINCT FROM normalized_slug
276
+ OR (object_type = 'task_lists' AND (
277
+ (scope_type IS NOT NULL AND scope_type NOT IN ('string', 'null'))
278
+ OR (scope_type = 'string' AND btrim(scope) = '')
279
+ ))
280
+ ), duplicates AS (
281
+ SELECT service, object_type, scope, slug,
282
+ array_agg(object_id ORDER BY object_id) AS object_ids,
283
+ count(*)::integer AS duplicate_count, 'duplicate'::text AS issue
284
+ FROM annotated
285
+ WHERE slug = normalized_slug AND slug <> ''
286
+ GROUP BY service, object_type, scope, slug
287
+ HAVING count(*) > 1
288
+ ) SELECT * FROM invalid
289
+ UNION ALL SELECT * FROM duplicates
290
+ ORDER BY service, object_type, scope, slug`;
291
+ }
292
+ function postgresTodosScopedSlugUniqueIndexSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
293
+ assertSafeIdentifier(tableName);
294
+ return [
295
+ `CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_task_list_scope_slug_uidx
296
+ ON ${tableName} (service, COALESCE(payload->>'project_id', ''), (payload->>'slug'))
297
+ WHERE object_type = 'task_lists' AND deleted_at IS NULL AND COALESCE(payload->>'slug', '') <> ''`,
298
+ `CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_project_task_list_slug_uidx
299
+ ON ${tableName} (service, (payload->>'task_list_id'))
300
+ WHERE object_type = 'projects' AND deleted_at IS NULL AND COALESCE(payload->>'task_list_id', '') <> ''`
301
+ ];
302
+ }
303
+ function postgresTodosScopedSlugIndexStatusSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
304
+ assertSafeIdentifier(tableName);
305
+ return `/* todos:scoped-slug-index-status */ SELECT index_class.relname AS index_name,
306
+ index_meta.indisvalid AS is_valid, index_meta.indisready AS is_ready
307
+ FROM pg_index index_meta
308
+ JOIN pg_class index_class ON index_class.oid = index_meta.indexrelid
309
+ WHERE index_meta.indrelid = to_regclass('${tableName}')
310
+ AND index_class.relname IN (
311
+ '${tableName}_task_list_scope_slug_uidx',
312
+ '${tableName}_project_task_list_slug_uidx'
313
+ )`;
314
+ }
315
+ async function ensurePostgresScopedSlugUniqueIndexes(client, tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
316
+ const audit = await client.query(postgresTodosScopedSlugPreflightSql(tableName));
317
+ if (audit.rows.length > 0)
318
+ throw new PostgresScopedSlugMigrationConflictError(audit.rows);
319
+ for (const sql of postgresTodosScopedSlugUniqueIndexSql(tableName)) {
320
+ try {
321
+ await client.query(sql);
322
+ } catch (error) {
323
+ const indexName = sql.match(/INDEX CONCURRENTLY IF NOT EXISTS ([a-zA-Z0-9_]+)/)?.[1] ?? "unknown_index";
324
+ throw new PostgresScopedSlugIndexBuildError(indexName, error);
325
+ }
326
+ }
327
+ const expected = new Set([
328
+ `${tableName}_task_list_scope_slug_uidx`,
329
+ `${tableName}_project_task_list_slug_uidx`
330
+ ]);
331
+ const status = await client.query(postgresTodosScopedSlugIndexStatusSql(tableName));
332
+ for (const indexName of expected) {
333
+ const row = status.rows.find((candidate) => candidate.index_name === indexName);
334
+ if (!row?.is_valid || !row.is_ready) {
335
+ throw new PostgresScopedSlugIndexBuildError(indexName, new Error("index is missing, invalid, or not ready"));
336
+ }
337
+ }
338
+ }
339
+ function postgresTodosCommentCursorIndexSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
340
+ assertSafeIdentifier(tableName);
341
+ return `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_comment_task_created_idx
342
+ ON ${tableName} (service, (payload->>'task_id'), (payload->>'created_at'), object_id)
343
+ WHERE object_type = 'comments' AND deleted_at IS NULL`;
344
+ }
345
+ class PostgresTodosSyncStore {
346
+ client;
347
+ service;
348
+ sourceMachineId;
349
+ tableName;
350
+ cursorTableName;
351
+ constructor(client, options = {}) {
352
+ this.client = client;
353
+ this.service = options.service ?? "todos";
354
+ this.sourceMachineId = options.sourceMachineId;
355
+ this.tableName = options.tableName ?? DEFAULT_TODOS_POSTGRES_SYNC_TABLE;
356
+ this.cursorTableName = options.cursorTableName ?? DEFAULT_TODOS_POSTGRES_CURSOR_TABLE;
357
+ assertSafeIdentifier(this.tableName);
358
+ assertSafeIdentifier(this.cursorTableName);
359
+ }
360
+ async ensureSchema() {
361
+ for (const sql of postgresTodosSyncSchemaSql(this.tableName, this.cursorTableName)) {
362
+ await this.client.query(sql);
363
+ }
364
+ }
365
+ async pushSnapshot(snapshot, context = {}) {
366
+ const routingErrors = validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists);
367
+ if (routingErrors.length > 0) {
368
+ throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
369
+ }
370
+ const existing = await this.client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
371
+ FROM ${this.tableName}
372
+ WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
373
+ const existingProjects = [];
374
+ const existingTaskLists = [];
375
+ for (const row of existing.rows) {
376
+ const payload = payloadRecord(row.payload);
377
+ if (row.object_type === "projects")
378
+ existingProjects.push(payload);
379
+ if (row.object_type === "task_lists")
380
+ existingTaskLists.push(payload);
381
+ }
382
+ const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
383
+ if (destinationErrors.length > 0) {
384
+ throw new Error(`Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
385
+ }
386
+ const result = { records: 0, objectTypes: {} };
387
+ const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
388
+ for (const entry of snapshotEntries(snapshot)) {
389
+ if (entry.deletedAt === null)
390
+ assertCanonicalScopedSlugEntry(entry);
391
+ await this.client.query(`INSERT INTO ${this.tableName} (
392
+ service, object_type, object_id, payload, updated_at,
393
+ deleted_at, source_machine_id, version
394
+ ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $6::timestamptz, $7, $8)
395
+ ON CONFLICT (service, object_type, object_id) DO UPDATE SET
396
+ payload = EXCLUDED.payload,
397
+ updated_at = EXCLUDED.updated_at,
398
+ deleted_at = EXCLUDED.deleted_at,
399
+ source_machine_id = EXCLUDED.source_machine_id,
400
+ version = EXCLUDED.version
401
+ WHERE ${this.tableName}.updated_at < EXCLUDED.updated_at
402
+ OR (${this.tableName}.updated_at = EXCLUDED.updated_at
403
+ AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))`, [
404
+ this.service,
405
+ entry.type,
406
+ entry.id,
407
+ entry.payload,
408
+ entry.updatedAt,
409
+ entry.deletedAt,
410
+ sourceMachineId,
411
+ entry.version
412
+ ]);
413
+ result.records += 1;
414
+ result.objectTypes[entry.type] = (result.objectTypes[entry.type] ?? 0) + 1;
415
+ }
416
+ return result;
417
+ }
418
+ async pullSnapshot(options = {}) {
419
+ const params = [this.service];
420
+ const filters = ["service = $1"];
421
+ if (options.since) {
422
+ params.push(options.since);
423
+ filters.push(`updated_at > $${params.length}::timestamptz`);
424
+ }
425
+ if (options.objectTypes?.length) {
426
+ params.push(options.objectTypes);
427
+ filters.push(`object_type = ANY($${params.length}::text[])`);
428
+ }
429
+ const response = await this.client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version FROM ${this.tableName}
430
+ WHERE ${filters.join(" AND ")}
431
+ ORDER BY updated_at ASC, object_type ASC, object_id ASC`, params);
432
+ return rowsToSnapshot(response.rows);
433
+ }
434
+ async getCursor(name) {
435
+ const result = await this.client.query(`SELECT value FROM ${this.cursorTableName} WHERE service = $1 AND cursor_name = $2`, [this.service, name]);
436
+ return result.rows[0]?.value ?? null;
437
+ }
438
+ async setCursor(name, value) {
439
+ await this.client.query(`INSERT INTO ${this.cursorTableName} (service, cursor_name, value, updated_at)
440
+ VALUES ($1, $2, $3, now())
441
+ ON CONFLICT (service, cursor_name) DO UPDATE SET
442
+ value = EXCLUDED.value,
443
+ updated_at = EXCLUDED.updated_at`, [this.service, name, value]);
444
+ }
445
+ }
446
+ function createPostgresTodosSyncStore(client, options = {}) {
447
+ return new PostgresTodosSyncStore(client, options);
448
+ }
449
+ function snapshotEntries(snapshot) {
450
+ return [
451
+ ...snapshot.tasks.map((payload) => entry("tasks", payload, snapshot.exportedAt)),
452
+ ...snapshot.projects.map((payload) => entry("projects", payload, snapshot.exportedAt)),
453
+ ...(snapshot.projectMachinePaths ?? []).map((payload) => entry("project_machine_paths", payload, snapshot.exportedAt)),
454
+ ...snapshot.plans.map((payload) => entry("plans", payload, snapshot.exportedAt)),
455
+ ...snapshot.agents.map((payload) => entry("agents", payload, snapshot.exportedAt)),
456
+ ...snapshot.taskLists.map((payload) => entry("task_lists", payload, snapshot.exportedAt)),
457
+ ...snapshot.templates.map((payload) => entry("templates", payload, snapshot.exportedAt)),
458
+ ...(snapshot.templateTasks ?? []).map((payload) => entry("template_tasks", payload, snapshot.exportedAt)),
459
+ ...snapshot.auditHistory.map((payload) => entry("audit_history", payload, snapshot.exportedAt)),
460
+ ...(snapshot.tombstones ?? []).map((tombstone) => ({
461
+ type: tombstone.object_type,
462
+ id: tombstone.object_id,
463
+ payload: tombstone.payload ?? { id: tombstone.object_id, deleted_at: tombstone.deleted_at },
464
+ updatedAt: tombstone.updated_at || tombstone.deleted_at,
465
+ deletedAt: tombstone.deleted_at,
466
+ version: tombstone.version ?? null
467
+ }))
468
+ ];
469
+ }
470
+ function assertCanonicalScopedSlugEntry(entry) {
471
+ if (!entry.payload || typeof entry.payload !== "object" || Array.isArray(entry.payload))
472
+ return;
473
+ const payload = entry.payload;
474
+ if (entry.type === "projects" && !isCanonicalSlug(payload["task_list_id"])) {
475
+ throw new Error("Invalid project task-list slug \u2014 sync requires non-empty canonical kebab-case");
476
+ }
477
+ if (entry.type === "task_lists") {
478
+ if (!isCanonicalSlug(payload["slug"])) {
479
+ throw new Error("Invalid task-list slug \u2014 sync requires non-empty canonical kebab-case");
480
+ }
481
+ if (!isValidTaskListProjectScope(payload["project_id"])) {
482
+ throw new Error("Invalid task-list project scope \u2014 project_id must be null, missing, or a non-empty string");
483
+ }
484
+ }
485
+ }
486
+ function entry(type, payload, fallbackUpdatedAt) {
487
+ const id = payload["id"];
488
+ if (typeof id !== "string" || !id)
489
+ throw new Error(`${type} record is missing a stable id`);
490
+ return {
491
+ type,
492
+ id,
493
+ payload,
494
+ updatedAt: stringValue(payload["updated_at"]) ?? stringValue(payload["created_at"]) ?? fallbackUpdatedAt,
495
+ deletedAt: stringValue(payload["deleted_at"]),
496
+ version: numberValue(payload["version"])
497
+ };
498
+ }
499
+ function rowsToSnapshot(rows) {
500
+ const snapshot = {
501
+ exportedAt: new Date().toISOString(),
502
+ source: "postgres",
503
+ tasks: [],
504
+ projects: [],
505
+ projectMachinePaths: [],
506
+ plans: [],
507
+ agents: [],
508
+ taskLists: [],
509
+ templates: [],
510
+ templateTasks: [],
511
+ auditHistory: [],
512
+ tombstones: []
513
+ };
514
+ for (const row of rows) {
515
+ const payload = payloadRecord(row.payload);
516
+ const deletedAt = stringValue(row.deleted_at);
517
+ if (deletedAt) {
518
+ snapshot.tombstones ??= [];
519
+ snapshot.tombstones.push({
520
+ object_type: row.object_type,
521
+ object_id: stringValue(row.object_id) ?? stringValue(payload["id"]) ?? "",
522
+ deleted_at: deletedAt,
523
+ updated_at: stringValue(row.updated_at) ?? deletedAt,
524
+ source_machine_id: stringValue(row.source_machine_id),
525
+ payload,
526
+ version: numberValue(row.version)
527
+ });
528
+ continue;
529
+ }
530
+ if (row.object_type === "tasks")
531
+ snapshot.tasks.push(payload);
532
+ else if (row.object_type === "projects")
533
+ snapshot.projects.push(payload);
534
+ else if (row.object_type === "project_machine_paths") {
535
+ snapshot.projectMachinePaths ??= [];
536
+ snapshot.projectMachinePaths.push(payload);
537
+ } else if (row.object_type === "plans")
538
+ snapshot.plans.push(payload);
539
+ else if (row.object_type === "agents")
540
+ snapshot.agents.push(payload);
541
+ else if (row.object_type === "task_lists")
542
+ snapshot.taskLists.push(payload);
543
+ else if (row.object_type === "templates")
544
+ snapshot.templates.push(payload);
545
+ else if (row.object_type === "template_tasks")
546
+ snapshot.templateTasks.push(payload);
547
+ else if (row.object_type === "audit_history")
548
+ snapshot.auditHistory.push(payload);
549
+ }
550
+ return snapshot;
551
+ }
552
+ function payloadRecord(value) {
553
+ if (typeof value === "string")
554
+ return JSON.parse(value);
555
+ if (value && typeof value === "object" && !Array.isArray(value))
556
+ return value;
557
+ throw new Error("Postgres sync payload must be a JSON object");
558
+ }
559
+ function stringValue(value) {
560
+ if (value instanceof Date)
561
+ return value.toISOString();
562
+ return typeof value === "string" && value ? value : null;
563
+ }
564
+ function numberValue(value) {
565
+ return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
566
+ }
567
+ function assertSafeIdentifier(value) {
568
+ if (!/^[a-z_][a-z0-9_]*$/i.test(value))
569
+ throw new Error(`Unsafe Postgres identifier: ${value}`);
570
+ }
571
+
572
+ // src/task-subtree-transfer/planner.ts
573
+ function sortedTaskImage(task) {
574
+ return {
575
+ task_id: task.task_id,
576
+ project_id: task.project_id,
577
+ parent_id: task.parent_id,
578
+ plan_id: task.plan_id,
579
+ task_list_id: task.task_list_id,
580
+ version: task.version,
581
+ updated_at: task.updated_at
582
+ };
583
+ }
584
+ function sourcePopulationDigest(tasks) {
585
+ return canonicalDigest(tasks.map((task) => ({
586
+ ...sortedTaskImage(task),
587
+ archived_at: task.archived_at ?? null
588
+ })).sort((left, right) => left.task_id.localeCompare(right.task_id)));
589
+ }
590
+ function closure(tasks, rootTaskId) {
591
+ const byId = new Map(tasks.map((task) => [task.task_id, task]));
592
+ if (!byId.has(rootTaskId)) {
593
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_NOT_FOUND", `Root task not found in the exact source project: ${rootTaskId}`, { root_task_id: rootTaskId });
594
+ }
595
+ const children = new Map;
596
+ for (const task of tasks) {
597
+ if (!task.parent_id)
598
+ continue;
599
+ const siblings = children.get(task.parent_id) ?? [];
600
+ siblings.push(task.task_id);
601
+ children.set(task.parent_id, siblings);
602
+ }
603
+ for (const ids of children.values())
604
+ ids.sort();
605
+ const visiting = new Set;
606
+ const visited = new Set;
607
+ const result = [];
608
+ const visit = (id) => {
609
+ if (visiting.has(id)) {
610
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_HIERARCHY_CYCLE", "Task hierarchy contains a cycle in the requested descendant closure", { task_id: id });
611
+ }
612
+ if (visited.has(id))
613
+ return;
614
+ const task = byId.get(id);
615
+ if (!task) {
616
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_FOREIGN_REFERENCE", "Task hierarchy references a parent outside the exact source population", { task_id: id });
617
+ }
618
+ visiting.add(id);
619
+ result.push(task);
620
+ for (const child of children.get(id) ?? [])
621
+ visit(child);
622
+ visiting.delete(id);
623
+ visited.add(id);
624
+ };
625
+ visit(rootTaskId);
626
+ return result.sort((left, right) => left.task_id.localeCompare(right.task_id));
627
+ }
628
+ function prepareTransfer(snapshot, input) {
629
+ if (!snapshot.destination_project_found || !snapshot.destination_task_list_found || !snapshot.destination_parent_found) {
630
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_FOREIGN_REFERENCE", "Destination project, task list, or parent does not match the exact requested target");
631
+ }
632
+ const moved = closure(snapshot.source_tasks, input.root_task_id);
633
+ const movedIds = new Set(moved.map((task) => task.task_id));
634
+ if (input.destination_parent_id && movedIds.has(input.destination_parent_id)) {
635
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_HIERARCHY_CYCLE", "Destination parent cannot be inside the transferred subtree", { destination_parent_id: input.destination_parent_id });
636
+ }
637
+ const planIds = [...new Set(moved.map((task) => task.plan_id).filter((id) => Boolean(id)))].sort();
638
+ const allPlanTasks = new Map;
639
+ for (const task of snapshot.plan_tasks) {
640
+ if (!task.plan_id)
641
+ continue;
642
+ const rows = allPlanTasks.get(task.plan_id) ?? [];
643
+ rows.push(task);
644
+ allPlanTasks.set(task.plan_id, rows);
645
+ }
646
+ const contained = [];
647
+ const shared = [];
648
+ const planById = new Map(snapshot.plans.map((plan) => [plan.plan_id, plan]));
649
+ for (const planId of planIds) {
650
+ const plan = planById.get(planId);
651
+ if (!plan || plan.project_id !== input.source_project_id) {
652
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_PLAN_CONFLICT", "A moved task references a plan absent from the exact source project", { plan_id: planId });
653
+ }
654
+ const members = allPlanTasks.get(planId) ?? [];
655
+ if (members.length === 0) {
656
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_PLAN_CONFLICT", "A moved task references a plan with no authoritative task membership", { plan_id: planId });
657
+ }
658
+ if (members.every((task) => movedIds.has(task.task_id)))
659
+ contained.push(planId);
660
+ else
661
+ shared.push(planId);
662
+ }
663
+ const inspection = {
664
+ source_project_id: input.source_project_id,
665
+ destination_project_id: input.destination_project_id,
666
+ destination_task_list_id: input.destination_task_list_id,
667
+ root_task_id: input.root_task_id,
668
+ destination_parent_id: input.destination_parent_id,
669
+ expected_root_parent_id: moved.find((task) => task.task_id === input.root_task_id).parent_id,
670
+ source_population_digest: sourcePopulationDigest(snapshot.source_tasks),
671
+ expected_tasks: moved.map((task) => ({ task_id: task.task_id, version: task.version })),
672
+ contained_plan_ids: contained,
673
+ shared_plan_ids: shared,
674
+ complete: true
675
+ };
676
+ return {
677
+ inspection,
678
+ prior_tasks: moved.map(sortedTaskImage),
679
+ prior_plans: contained.map((id) => planById.get(id)),
680
+ task_plan_targets: new Map(moved.map((task) => [task.task_id, task.plan_id]))
681
+ };
682
+ }
683
+ function validateApplySnapshot(prepared, request, plans) {
684
+ if (request.expected_root_parent_id !== prepared.inspection.expected_root_parent_id) {
685
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_CAS_CONFLICT", "Root parent changed since inspection");
686
+ }
687
+ if (request.source_population_digest !== prepared.inspection.source_population_digest) {
688
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_POPULATION_DRIFT", "Complete source-project task population changed since inspection");
689
+ }
690
+ const expected = request.expected_tasks.map((task) => ({ task_id: task.task_id, version: task.version })).sort((left, right) => left.task_id.localeCompare(right.task_id));
691
+ if (JSON.stringify(expected) !== JSON.stringify(prepared.inspection.expected_tasks)) {
692
+ const actualById = new Map(prepared.inspection.expected_tasks.map((task) => [task.task_id, task.version]));
693
+ const sameIds = expected.length === prepared.inspection.expected_tasks.length && expected.every((task) => actualById.has(task.task_id));
694
+ throw new TodosTaskSubtreeTransferError(sameIds ? "TODOS_TASK_SUBTREE_TRANSFER_CAS_CONFLICT" : "TODOS_TASK_SUBTREE_TRANSFER_CLOSURE_DRIFT", sameIds ? "At least one exact task version changed since inspection" : "Exact descendant closure changed since inspection");
695
+ }
696
+ const requestedSources = request.shared_plan_splits.map((mapping) => mapping.source_plan_id).sort();
697
+ if (JSON.stringify(requestedSources) !== JSON.stringify(prepared.inspection.shared_plan_ids)) {
698
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_PARTIAL_PLAN", "Shared plans require one exact explicit destination-plan mapping each", {
699
+ expected_shared_plan_ids: prepared.inspection.shared_plan_ids,
700
+ received_shared_plan_ids: requestedSources
701
+ });
702
+ }
703
+ const planById = new Map(plans.map((plan) => [plan.plan_id, plan]));
704
+ const destinationIds = new Set;
705
+ for (const mapping of request.shared_plan_splits) {
706
+ if (destinationIds.has(mapping.destination_plan_id)) {
707
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_PLAN_CONFLICT", "Two shared source plans cannot collapse into one destination plan");
708
+ }
709
+ destinationIds.add(mapping.destination_plan_id);
710
+ const destination = planById.get(mapping.destination_plan_id);
711
+ if (!destination || destination.project_id !== request.destination_project_id || destination.task_list_id !== request.destination_task_list_id) {
712
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_PLAN_CONFLICT", "Shared-plan destination does not match the exact destination project and task list", { destination_plan_id: mapping.destination_plan_id });
713
+ }
714
+ for (const [taskId, planId] of prepared.task_plan_targets) {
715
+ if (planId === mapping.source_plan_id) {
716
+ prepared.task_plan_targets.set(taskId, mapping.destination_plan_id);
717
+ }
718
+ }
719
+ }
720
+ }
721
+
722
+ // src/task-subtree-transfer/schema-sql.ts
723
+ function sqliteTodosTaskSubtreeTransferSchemaSql() {
724
+ return `
725
+ CREATE TABLE IF NOT EXISTS todos_task_subtree_transfer_receipts (
726
+ receipt_id TEXT PRIMARY KEY,
727
+ tenant_id TEXT NOT NULL,
728
+ kind TEXT NOT NULL CHECK(kind IN ('apply', 'rollback')),
729
+ operation_id TEXT NOT NULL,
730
+ step_id TEXT NOT NULL,
731
+ idempotency_key TEXT NOT NULL,
732
+ request_digest TEXT NOT NULL,
733
+ precondition_digest TEXT NOT NULL,
734
+ result_digest TEXT NOT NULL,
735
+ apply_receipt_id TEXT,
736
+ request_json TEXT NOT NULL,
737
+ result_json TEXT NOT NULL,
738
+ created_at TEXT NOT NULL,
739
+ UNIQUE(tenant_id, kind, operation_id, step_id),
740
+ UNIQUE(tenant_id, kind, idempotency_key)
741
+ );
742
+ CREATE TRIGGER IF NOT EXISTS todos_task_subtree_transfer_receipts_immutable_update
743
+ BEFORE UPDATE ON todos_task_subtree_transfer_receipts BEGIN
744
+ SELECT RAISE(ABORT, 'todos task subtree transfer receipts are immutable');
745
+ END;
746
+ CREATE TRIGGER IF NOT EXISTS todos_task_subtree_transfer_receipts_immutable_delete
747
+ BEFORE DELETE ON todos_task_subtree_transfer_receipts BEGIN
748
+ SELECT RAISE(ABORT, 'todos task subtree transfer receipts are immutable');
749
+ END;
750
+ `;
751
+ }
752
+ function ensureSqliteTodosTaskSubtreeTransferSchema(db) {
753
+ db.exec(sqliteTodosTaskSubtreeTransferSchemaSql());
754
+ }
755
+ function postgresTodosTaskSubtreeTransferSchemaSql() {
756
+ return [
757
+ `CREATE TABLE IF NOT EXISTS todos_task_subtree_transfer_receipts (
758
+ receipt_id text PRIMARY KEY,
759
+ tenant_id text NOT NULL,
760
+ kind text NOT NULL CHECK(kind IN ('apply', 'rollback')),
761
+ operation_id text NOT NULL,
762
+ step_id text NOT NULL,
763
+ idempotency_key text NOT NULL,
764
+ request_digest text NOT NULL,
765
+ precondition_digest text NOT NULL,
766
+ result_digest text NOT NULL,
767
+ apply_receipt_id text,
768
+ request_json jsonb NOT NULL,
769
+ result_json jsonb NOT NULL,
770
+ created_at timestamptz NOT NULL,
771
+ UNIQUE(tenant_id, kind, operation_id, step_id),
772
+ UNIQUE(tenant_id, kind, idempotency_key)
773
+ )`,
774
+ `CREATE OR REPLACE FUNCTION reject_todos_task_subtree_transfer_receipt_mutation()
775
+ RETURNS trigger LANGUAGE plpgsql AS $$
776
+ BEGIN
777
+ RAISE EXCEPTION 'todos task subtree transfer receipts are immutable';
778
+ END $$`,
779
+ `DROP TRIGGER IF EXISTS todos_task_subtree_transfer_receipts_immutable_update
780
+ ON todos_task_subtree_transfer_receipts`,
781
+ `CREATE TRIGGER todos_task_subtree_transfer_receipts_immutable_update
782
+ BEFORE UPDATE OR DELETE ON todos_task_subtree_transfer_receipts
783
+ FOR EACH ROW EXECUTE FUNCTION reject_todos_task_subtree_transfer_receipt_mutation()`
784
+ ];
785
+ }
786
+
787
+ // src/task-subtree-transfer/postgres.ts
788
+ function safeIdentifier(value, fallback) {
789
+ const candidate = value ?? fallback;
790
+ if (!/^[a-z_][a-z0-9_]*$/.test(candidate)) {
791
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_INVALID_INPUT", "PostgreSQL table name must be a safe identifier");
792
+ }
793
+ return candidate;
794
+ }
795
+ function timestamp(value) {
796
+ return value instanceof Date ? value.toISOString() : new Date(String(value)).toISOString();
797
+ }
798
+ function payloadOf(row) {
799
+ return typeof row.payload === "string" ? JSON.parse(row.payload) : row.payload;
800
+ }
801
+ function taskRecord(row) {
802
+ const payload = payloadOf(row);
803
+ return {
804
+ task_id: String(payload.id ?? row.object_id),
805
+ project_id: payload.project_id == null ? null : String(payload.project_id),
806
+ parent_id: payload.parent_id == null ? null : String(payload.parent_id),
807
+ plan_id: payload.plan_id == null ? null : String(payload.plan_id),
808
+ task_list_id: payload.task_list_id == null ? null : String(payload.task_list_id),
809
+ version: Number(payload.version ?? row.version ?? 1),
810
+ updated_at: timestamp(payload.updated_at ?? row.updated_at),
811
+ archived_at: payload.archived_at == null ? null : String(payload.archived_at)
812
+ };
813
+ }
814
+ function planRecord(row) {
815
+ const payload = payloadOf(row);
816
+ return {
817
+ plan_id: String(payload.id ?? row.object_id),
818
+ project_id: payload.project_id == null ? null : String(payload.project_id),
819
+ task_list_id: payload.task_list_id == null ? null : String(payload.task_list_id),
820
+ updated_at: timestamp(payload.updated_at ?? row.updated_at)
821
+ };
822
+ }
823
+
824
+ class PostgresTodosTaskSubtreeTransferBackend {
825
+ client;
826
+ kind = "postgresql";
827
+ service;
828
+ tableName;
829
+ tenantId;
830
+ schemaReady = null;
831
+ constructor(client, options = {}) {
832
+ this.client = client;
833
+ this.service = options.service ?? "todos";
834
+ this.tableName = safeIdentifier(options.tableName, DEFAULT_TODOS_POSTGRES_SYNC_TABLE);
835
+ this.tenantId = options.tenantId ?? "default";
836
+ }
837
+ async ensureSchema() {
838
+ this.schemaReady ??= (async () => {
839
+ for (const sql of postgresTodosSyncSchemaSql(this.tableName))
840
+ await this.client.query(sql);
841
+ for (const sql of postgresTodosTaskSubtreeTransferSchemaSql())
842
+ await this.client.query(sql);
843
+ })();
844
+ await this.schemaReady;
845
+ }
846
+ async snapshot(client, input, forUpdate = false) {
847
+ const lock = forUpdate ? " FOR UPDATE" : "";
848
+ const source = await client.query(`SELECT object_id, payload, updated_at, version FROM ${this.tableName}
849
+ WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
850
+ AND payload->>'project_id' = $2 ORDER BY object_id${lock}`, [this.service, input.source_project_id]);
851
+ const allTasks = await client.query(`SELECT object_id, payload, updated_at, version FROM ${this.tableName}
852
+ WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL ORDER BY object_id${lock}`, [this.service]);
853
+ const plans = await client.query(`SELECT object_id, payload, updated_at, version FROM ${this.tableName}
854
+ WHERE service = $1 AND object_type = 'plans' AND deleted_at IS NULL ORDER BY object_id${lock}`, [this.service]);
855
+ const destinationProject = await client.query(`SELECT 1 FROM ${this.tableName}
856
+ WHERE service = $1 AND object_type = 'projects' AND object_id = $2
857
+ AND deleted_at IS NULL LIMIT 1${lock}`, [this.service, input.destination_project_id]);
858
+ const destinationList = await client.query(`SELECT 1 FROM ${this.tableName}
859
+ WHERE service = $1 AND object_type = 'task_lists' AND object_id = $2
860
+ AND deleted_at IS NULL AND payload->>'project_id' = $3 LIMIT 1`, [this.service, input.destination_task_list_id, input.destination_project_id]);
861
+ const destinationParent = input.destination_parent_id === null ? true : (await client.query(`SELECT 1 FROM ${this.tableName}
862
+ WHERE service = $1 AND object_type = 'tasks' AND object_id = $2
863
+ AND deleted_at IS NULL AND payload->>'project_id' = $3 LIMIT 1`, [this.service, input.destination_parent_id, input.destination_project_id])).rows.length === 1;
864
+ return {
865
+ source_tasks: source.rows.map(taskRecord),
866
+ plan_tasks: allTasks.rows.map(taskRecord),
867
+ plans: plans.rows.map(planRecord),
868
+ destination_project_found: destinationProject.rows.length === 1,
869
+ destination_task_list_found: destinationList.rows.length === 1,
870
+ destination_parent_found: destinationParent
871
+ };
872
+ }
873
+ async inspect(input) {
874
+ await this.ensureSchema();
875
+ return prepareTransfer(await this.snapshot(this.client, input), input).inspection;
876
+ }
877
+ async insertPayload(client, task, update, now) {
878
+ const current = await client.query(`SELECT payload FROM ${this.tableName}
879
+ WHERE service = $1 AND object_type = 'tasks' AND object_id = $2
880
+ AND deleted_at IS NULL FOR UPDATE`, [this.service, task.task_id]);
881
+ const row = current.rows[0];
882
+ if (!row)
883
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_NOT_FOUND", `Task not found: ${task.task_id}`);
884
+ const payload = payloadOf(row);
885
+ if (Number(payload.version ?? 1) !== task.version) {
886
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_CAS_CONFLICT", "Task changed during transfer", { task_id: task.task_id });
887
+ }
888
+ const next = {
889
+ ...payload,
890
+ ...update,
891
+ version: task.version + 1,
892
+ updated_at: now
893
+ };
894
+ const result = await client.query(`UPDATE ${this.tableName} SET payload = $1::jsonb, updated_at = $2, version = $3
895
+ WHERE service = $4 AND object_type = 'tasks' AND object_id = $5
896
+ AND deleted_at IS NULL AND version = $6
897
+ RETURNING object_id`, [canonicalJson(next), now, task.version + 1, this.service, task.task_id, task.version]);
898
+ if (result.rows.length !== 1) {
899
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_CAS_CONFLICT", "Task CAS failed", { task_id: task.task_id });
900
+ }
901
+ }
902
+ async apply(input, options) {
903
+ await this.ensureSchema();
904
+ if (!this.client.transaction) {
905
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_ATOMICITY_UNAVAILABLE", "PostgreSQL subtree transfer requires an authoritative transaction-capable client");
906
+ }
907
+ return this.client.transaction(async (tx) => {
908
+ await tx.query("SELECT pg_advisory_xact_lock(hashtextextended('todos:task-parent-integrity', 0))");
909
+ const requestDigest = taskSubtreeTransferRequestDigest(input);
910
+ const existing = await tx.query(`SELECT result_json, request_digest, precondition_digest, idempotency_key
911
+ FROM todos_task_subtree_transfer_receipts
912
+ WHERE tenant_id = $1 AND kind = 'apply'
913
+ AND (idempotency_key = $2 OR (operation_id = $3 AND step_id = $4))
914
+ ORDER BY created_at ASC LIMIT 1`, [this.tenantId, input.idempotency_key, input.operation_id, input.step_id]);
915
+ if (existing.rows[0]) {
916
+ const row = existing.rows[0];
917
+ if (row.request_digest !== requestDigest || row.precondition_digest !== input.precondition_digest || row.idempotency_key !== input.idempotency_key) {
918
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_IDEMPOTENCY_CONFLICT", "The operation was already accepted for a different request");
919
+ }
920
+ return { ...typeof row.result_json === "string" ? JSON.parse(row.result_json) : row.result_json, duplicate: true };
921
+ }
922
+ const snapshot = await this.snapshot(tx, input, true);
923
+ const prepared = prepareTransfer(snapshot, input);
924
+ validateApplySnapshot(prepared, input, snapshot.plans);
925
+ const now = options.now?.() ?? new Date().toISOString();
926
+ const priorImage = {
927
+ tasks: prepared.prior_tasks,
928
+ plans: prepared.prior_plans
929
+ };
930
+ for (const task of prepared.prior_tasks) {
931
+ await this.insertPayload(tx, task, {
932
+ project_id: input.destination_project_id,
933
+ task_list_id: input.destination_task_list_id,
934
+ plan_id: prepared.task_plan_targets.get(task.task_id) ?? null,
935
+ parent_id: task.task_id === input.root_task_id ? input.destination_parent_id : task.parent_id
936
+ }, now);
937
+ }
938
+ if (options.faultInjector && await options.faultInjector("after_task_writes"))
939
+ throw new Error("Injected task-subtree-transfer fault at after_task_writes");
940
+ for (const plan of prepared.prior_plans) {
941
+ const row = await tx.query(`SELECT payload FROM ${this.tableName}
942
+ WHERE service = $1 AND object_type = 'plans' AND object_id = $2
943
+ AND deleted_at IS NULL FOR UPDATE`, [this.service, plan.plan_id]);
944
+ const current = row.rows[0] ? payloadOf(row.rows[0]) : null;
945
+ if (!current || String(current.project_id ?? "") !== String(plan.project_id ?? "") || (current.task_list_id ?? null) !== plan.task_list_id || timestamp(current.updated_at ?? row.rows[0]?.updated_at) !== plan.updated_at) {
946
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_CAS_CONFLICT", "Contained plan changed during transfer", { plan_id: plan.plan_id });
947
+ }
948
+ const result2 = await tx.query(`UPDATE ${this.tableName} SET payload = jsonb_set(jsonb_set(jsonb_set(payload, '{project_id}', to_jsonb($1::text)), '{task_list_id}', to_jsonb($2::text)), '{updated_at}', to_jsonb($3::text)), updated_at = $3
949
+ WHERE service = $4 AND object_type = 'plans' AND object_id = $5 AND deleted_at IS NULL
950
+ RETURNING object_id`, [input.destination_project_id, input.destination_task_list_id, now, this.service, plan.plan_id]);
951
+ if (result2.rows.length !== 1) {
952
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_CAS_CONFLICT", "Contained plan update did not affect exactly one row", { plan_id: plan.plan_id });
953
+ }
954
+ }
955
+ if (options.faultInjector && await options.faultInjector("after_plan_writes"))
956
+ throw new Error("Injected task-subtree-transfer fault at after_plan_writes");
957
+ const postImage = {
958
+ tasks: prepared.prior_tasks.map((task) => ({
959
+ ...task,
960
+ project_id: input.destination_project_id,
961
+ task_list_id: input.destination_task_list_id,
962
+ plan_id: prepared.task_plan_targets.get(task.task_id) ?? null,
963
+ parent_id: task.task_id === input.root_task_id ? input.destination_parent_id : task.parent_id,
964
+ version: task.version + 1,
965
+ updated_at: now
966
+ })),
967
+ plans: prepared.prior_plans.map((plan) => ({ ...plan, project_id: input.destination_project_id, task_list_id: input.destination_task_list_id, updated_at: now }))
968
+ };
969
+ const resultDigest = canonicalDigest({ route: TODOS_TASK_SUBTREE_TRANSFER_ROUTE, request_digest: requestDigest, prior_image: priorImage, post_image: postImage });
970
+ const receipt = {
971
+ receipt_id: deterministicUuid(TODOS_TASK_SUBTREE_TRANSFER_ROUTE, "apply", input.operation_id, input.step_id, input.idempotency_key, requestDigest),
972
+ authority: "todos",
973
+ route: TODOS_TASK_SUBTREE_TRANSFER_ROUTE,
974
+ schema_version: 1,
975
+ kind: "apply",
976
+ operation_id: input.operation_id,
977
+ step_id: input.step_id,
978
+ idempotency_key: input.idempotency_key,
979
+ request_digest: requestDigest,
980
+ precondition_digest: input.precondition_digest,
981
+ result_digest: resultDigest,
982
+ apply_receipt_id: null,
983
+ source_project_id: input.source_project_id,
984
+ destination_project_id: input.destination_project_id,
985
+ destination_task_list_id: input.destination_task_list_id,
986
+ root_task_id: input.root_task_id,
987
+ source_population_digest: input.source_population_digest,
988
+ prior_image: priorImage,
989
+ post_image: postImage,
990
+ shared_plan_splits: input.shared_plan_splits,
991
+ created_at: now
992
+ };
993
+ const result = { duplicate: false, receipt, moved_task_ids: prepared.prior_tasks.map((task) => task.task_id), moved_plan_ids: prepared.prior_plans.map((plan) => plan.plan_id), complete: true };
994
+ await tx.query(`INSERT INTO todos_task_subtree_transfer_receipts (
995
+ receipt_id, tenant_id, kind, operation_id, step_id, idempotency_key,
996
+ request_digest, precondition_digest, result_digest, apply_receipt_id,
997
+ request_json, result_json, created_at
998
+ ) VALUES ($1, $2, 'apply', $3, $4, $5, $6, $7, $8, NULL, $9::jsonb, $10::jsonb, $11)`, [receipt.receipt_id, this.tenantId, receipt.operation_id, receipt.step_id, receipt.idempotency_key, receipt.request_digest, receipt.precondition_digest, receipt.result_digest, canonicalJson(input), canonicalJson(result), now]);
999
+ if (options.faultInjector && await options.faultInjector("after_receipt_write"))
1000
+ throw new Error("Injected task-subtree-transfer fault at after_receipt_write");
1001
+ return result;
1002
+ });
1003
+ }
1004
+ async readExact(receiptId) {
1005
+ await this.ensureSchema();
1006
+ const row = await this.client.query(`SELECT result_json FROM todos_task_subtree_transfer_receipts WHERE tenant_id = $1 AND receipt_id = $2 LIMIT 1`, [this.tenantId, receiptId]);
1007
+ if (!row.rows[0])
1008
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_RECEIPT_NOT_FOUND", `Transfer receipt not found: ${receiptId}`);
1009
+ return { ...typeof row.rows[0].result_json === "string" ? JSON.parse(String(row.rows[0].result_json)) : row.rows[0].result_json, duplicate: false };
1010
+ }
1011
+ async rollback(input, options) {
1012
+ await this.ensureSchema();
1013
+ if (!this.client.transaction)
1014
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_ATOMICITY_UNAVAILABLE", "PostgreSQL subtree rollback requires a transaction-capable client");
1015
+ return this.client.transaction(async (tx) => {
1016
+ await tx.query("SELECT pg_advisory_xact_lock(hashtextextended('todos:task-parent-integrity', 0))");
1017
+ const requestDigest = taskSubtreeTransferRollbackRequestDigest(input);
1018
+ const existing = await tx.query(`SELECT result_json, request_digest, precondition_digest, idempotency_key
1019
+ FROM todos_task_subtree_transfer_receipts
1020
+ WHERE tenant_id = $1 AND kind = 'rollback'
1021
+ AND (idempotency_key = $2 OR (operation_id = $3 AND step_id = $4))
1022
+ ORDER BY created_at ASC LIMIT 1`, [this.tenantId, input.idempotency_key, input.operation_id, input.step_id]);
1023
+ if (existing.rows[0]) {
1024
+ const row = existing.rows[0];
1025
+ if (row.request_digest !== requestDigest || row.precondition_digest !== input.precondition_digest || row.idempotency_key !== input.idempotency_key)
1026
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_IDEMPOTENCY_CONFLICT", "The rollback operation was already accepted for a different request");
1027
+ return { ...typeof row.result_json === "string" ? JSON.parse(String(row.result_json)) : row.result_json, duplicate: true };
1028
+ }
1029
+ const applyRow = await tx.query(`SELECT result_json FROM todos_task_subtree_transfer_receipts WHERE tenant_id = $1 AND kind = 'apply' AND receipt_id = $2 LIMIT 1`, [this.tenantId, input.receipt_id]);
1030
+ if (!applyRow.rows[0])
1031
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_RECEIPT_NOT_FOUND", `Apply receipt not found: ${input.receipt_id}`);
1032
+ const applied = typeof applyRow.rows[0].result_json === "string" ? JSON.parse(String(applyRow.rows[0].result_json)) : applyRow.rows[0].result_json;
1033
+ if (applied.receipt.operation_id !== input.operation_id) {
1034
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_ROLLBACK_CONFLICT", "Rollback operation does not match the apply receipt");
1035
+ }
1036
+ const expectedPrecondition = canonicalDigest({ route: TODOS_TASK_SUBTREE_TRANSFER_ROUTE, direction: "rollback", operation_id: input.operation_id, step_id: input.step_id, apply_receipt_id: input.receipt_id, apply_result_digest: applied.receipt.result_digest });
1037
+ if (input.precondition_digest !== expectedPrecondition)
1038
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_DIGEST_MISMATCH", "Rollback precondition digest does not match the exact apply receipt", { expected_precondition_digest: expectedPrecondition });
1039
+ const now = options.now?.() ?? new Date().toISOString();
1040
+ for (const task of applied.receipt.post_image.tasks) {
1041
+ const row = await tx.query(`SELECT payload FROM ${this.tableName} WHERE service = $1 AND object_type = 'tasks' AND object_id = $2 AND deleted_at IS NULL FOR UPDATE`, [this.service, task.task_id]);
1042
+ if (!row.rows[0])
1043
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_ROLLBACK_CONFLICT", "Transferred task disappeared", { task_id: task.task_id });
1044
+ const payload = payloadOf(row.rows[0]);
1045
+ if (Number(payload.version ?? 1) !== task.version || String(payload.project_id ?? "") !== String(task.project_id ?? "") || (payload.parent_id ?? null) !== task.parent_id || (payload.plan_id ?? null) !== task.plan_id || (payload.task_list_id ?? null) !== task.task_list_id)
1046
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_ROLLBACK_CONFLICT", "Transferred task changed after apply", { task_id: task.task_id });
1047
+ }
1048
+ for (const task of applied.receipt.prior_image.tasks) {
1049
+ const row = await tx.query(`SELECT payload FROM ${this.tableName} WHERE service = $1 AND object_type = 'tasks' AND object_id = $2 AND deleted_at IS NULL`, [this.service, task.task_id]);
1050
+ const payload = row.rows[0] ? payloadOf(row.rows[0]) : null;
1051
+ if (!payload)
1052
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_ROLLBACK_CONFLICT", "Task disappeared during rollback", { task_id: task.task_id });
1053
+ const next = { ...payload, project_id: task.project_id, task_list_id: task.task_list_id, plan_id: task.plan_id, parent_id: task.parent_id, version: task.version + 2, updated_at: now };
1054
+ const result2 = await tx.query(`UPDATE ${this.tableName} SET payload = $1::jsonb, updated_at = $2, version = $3
1055
+ WHERE service = $4 AND object_type = 'tasks' AND object_id = $5 AND deleted_at IS NULL AND version = $6
1056
+ RETURNING object_id`, [canonicalJson(next), now, task.version + 2, this.service, task.task_id, task.version + 1]);
1057
+ if (result2.rows.length !== 1) {
1058
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_ROLLBACK_CONFLICT", "Task rollback did not affect exactly one row", { task_id: task.task_id });
1059
+ }
1060
+ }
1061
+ if (options.faultInjector && await options.faultInjector("after_rollback_task_writes"))
1062
+ throw new Error("Injected task-subtree-transfer fault at after_rollback_task_writes");
1063
+ for (const plan of applied.receipt.prior_image.plans) {
1064
+ const post = applied.receipt.post_image.plans.find((candidate) => candidate.plan_id === plan.plan_id);
1065
+ const result2 = await tx.query(`UPDATE ${this.tableName}
1066
+ SET payload = jsonb_set(
1067
+ jsonb_set(
1068
+ jsonb_set(payload, '{project_id}', to_jsonb($1::text)),
1069
+ '{task_list_id}',
1070
+ COALESCE(to_jsonb($2::text), 'null'::jsonb)
1071
+ ),
1072
+ '{updated_at}',
1073
+ to_jsonb($3::text)
1074
+ ), updated_at = $3
1075
+ WHERE service = $4 AND object_type = 'plans' AND object_id = $5 AND deleted_at IS NULL
1076
+ AND payload->>'project_id' = $6 AND COALESCE(payload->>'task_list_id', '') = COALESCE($7, '') AND payload->>'updated_at' = $8
1077
+ RETURNING object_id`, [plan.project_id, plan.task_list_id, now, this.service, plan.plan_id, post.project_id, post.task_list_id, post.updated_at]);
1078
+ if (result2.rows.length !== 1) {
1079
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_ROLLBACK_CONFLICT", "Contained plan rollback did not affect exactly one row", { plan_id: plan.plan_id });
1080
+ }
1081
+ }
1082
+ if (options.faultInjector && await options.faultInjector("after_rollback_plan_writes"))
1083
+ throw new Error("Injected task-subtree-transfer fault at after_rollback_plan_writes");
1084
+ const restored = {
1085
+ tasks: applied.receipt.prior_image.tasks.map((task) => ({ ...task, version: task.version + 2, updated_at: now })),
1086
+ plans: applied.receipt.prior_image.plans.map((plan) => ({ ...plan, updated_at: now }))
1087
+ };
1088
+ const resultDigest = canonicalDigest({ route: TODOS_TASK_SUBTREE_TRANSFER_ROUTE, direction: "rollback", apply_receipt_id: input.receipt_id, prior_image: applied.receipt.post_image, post_image: restored });
1089
+ const receipt = { ...applied.receipt, receipt_id: deterministicUuid(TODOS_TASK_SUBTREE_TRANSFER_ROUTE, "rollback", input.operation_id, input.step_id, input.idempotency_key, input.receipt_id), kind: "rollback", operation_id: input.operation_id, step_id: input.step_id, idempotency_key: input.idempotency_key, request_digest: requestDigest, precondition_digest: input.precondition_digest, result_digest: resultDigest, apply_receipt_id: input.receipt_id, prior_image: applied.receipt.post_image, post_image: restored, created_at: now };
1090
+ const result = { duplicate: false, receipt, moved_task_ids: applied.moved_task_ids, moved_plan_ids: applied.moved_plan_ids, complete: true };
1091
+ await tx.query(`INSERT INTO todos_task_subtree_transfer_receipts (
1092
+ receipt_id, tenant_id, kind, operation_id, step_id, idempotency_key,
1093
+ request_digest, precondition_digest, result_digest, apply_receipt_id,
1094
+ request_json, result_json, created_at
1095
+ ) VALUES ($1, $2, 'rollback', $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb, $12)`, [receipt.receipt_id, this.tenantId, receipt.operation_id, receipt.step_id, receipt.idempotency_key, receipt.request_digest, receipt.precondition_digest, receipt.result_digest, receipt.apply_receipt_id, canonicalJson(input), canonicalJson(result), now]);
1096
+ if (options.faultInjector && await options.faultInjector("after_rollback_receipt_write"))
1097
+ throw new Error("Injected task-subtree-transfer fault at after_rollback_receipt_write");
1098
+ return result;
1099
+ });
1100
+ }
1101
+ }
1102
+
1103
+ // src/task-subtree-transfer/sqlite.ts
1104
+ var sqliteTails = new WeakMap;
1105
+ async function fault(options, point) {
1106
+ if (options.faultInjector && await options.faultInjector(point) === true) {
1107
+ throw new Error(`Injected task-subtree-transfer fault at ${point}`);
1108
+ }
1109
+ }
1110
+ function parseResult(value, duplicate) {
1111
+ return { ...JSON.parse(value), duplicate };
1112
+ }
1113
+ function rowImage(row) {
1114
+ return {
1115
+ task_id: String(row.id),
1116
+ project_id: row.project_id == null ? null : String(row.project_id),
1117
+ parent_id: row.parent_id == null ? null : String(row.parent_id),
1118
+ plan_id: row.plan_id == null ? null : String(row.plan_id),
1119
+ task_list_id: row.task_list_id == null ? null : String(row.task_list_id),
1120
+ version: Number(row.version),
1121
+ updated_at: String(row.updated_at),
1122
+ archived_at: row.archived_at == null ? null : String(row.archived_at)
1123
+ };
1124
+ }
1125
+ function planImage(row) {
1126
+ return {
1127
+ plan_id: String(row.id),
1128
+ project_id: row.project_id == null ? null : String(row.project_id),
1129
+ task_list_id: row.task_list_id == null ? null : String(row.task_list_id),
1130
+ updated_at: String(row.updated_at)
1131
+ };
1132
+ }
1133
+ function imageFromTaskRow(row) {
1134
+ const image = rowImage(row);
1135
+ const { archived_at: _archivedAt, ...withoutArchive } = image;
1136
+ return withoutArchive;
1137
+ }
1138
+ function imageFromPlanRow(row) {
1139
+ return planImage(row);
1140
+ }
1141
+ function receiptId(input) {
1142
+ return deterministicUuid(TODOS_TASK_SUBTREE_TRANSFER_ROUTE, "apply", input.operation_id, input.step_id, input.idempotency_key, taskSubtreeTransferRequestDigest(input));
1143
+ }
1144
+ function rollbackReceiptId(input) {
1145
+ return deterministicUuid(TODOS_TASK_SUBTREE_TRANSFER_ROUTE, "rollback", input.operation_id, input.step_id, input.idempotency_key, input.receipt_id);
1146
+ }
1147
+ function taskWhere(db, taskId) {
1148
+ const row = db.query(`SELECT id, project_id, parent_id, plan_id, task_list_id, version, updated_at, archived_at
1149
+ FROM tasks WHERE id = ? LIMIT 1`).get(taskId);
1150
+ if (!row) {
1151
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_NOT_FOUND", `Task not found: ${taskId}`, { task_id: taskId });
1152
+ }
1153
+ return row;
1154
+ }
1155
+
1156
+ class SqliteTodosTaskSubtreeTransferBackend {
1157
+ db;
1158
+ tenantId;
1159
+ kind = "sqlite";
1160
+ constructor(db, tenantId = "default") {
1161
+ this.db = db;
1162
+ this.tenantId = tenantId;
1163
+ ensureSqliteTodosTaskSubtreeTransferSchema(db);
1164
+ }
1165
+ async serialized(run) {
1166
+ const previous = sqliteTails.get(this.db) ?? Promise.resolve();
1167
+ let release;
1168
+ const current = new Promise((resolve) => {
1169
+ release = resolve;
1170
+ });
1171
+ const tail = previous.then(() => current);
1172
+ sqliteTails.set(this.db, tail);
1173
+ await previous;
1174
+ try {
1175
+ this.db.exec("BEGIN IMMEDIATE");
1176
+ try {
1177
+ const result = await run();
1178
+ this.db.exec("COMMIT");
1179
+ return result;
1180
+ } catch (error) {
1181
+ this.db.exec("ROLLBACK");
1182
+ throw error;
1183
+ }
1184
+ } finally {
1185
+ release();
1186
+ if (sqliteTails.get(this.db) === tail)
1187
+ sqliteTails.delete(this.db);
1188
+ }
1189
+ }
1190
+ snapshot(input) {
1191
+ const sourceTasks = this.db.query(`SELECT id, project_id, parent_id, plan_id, task_list_id, version, updated_at, archived_at
1192
+ FROM tasks WHERE project_id = ? ORDER BY id`).all(input.source_project_id).map(rowImage);
1193
+ const allTasks = this.db.query(`SELECT id, project_id, parent_id, plan_id, task_list_id, version, updated_at, archived_at
1194
+ FROM tasks ORDER BY id`).all().map(rowImage);
1195
+ const plans = this.db.query(`SELECT id, project_id, task_list_id, updated_at
1196
+ FROM plans ORDER BY id`).all().map(planImage);
1197
+ const destinationProjectFound = Boolean(this.db.query("SELECT id FROM projects WHERE id = ? LIMIT 1").get(input.destination_project_id));
1198
+ const destinationTaskList = this.db.query("SELECT id FROM task_lists WHERE id = ? AND project_id = ? LIMIT 1").get(input.destination_task_list_id, input.destination_project_id);
1199
+ const destinationParentFound = input.destination_parent_id === null || Boolean(this.db.query("SELECT id FROM tasks WHERE id = ? AND project_id = ? LIMIT 1").get(input.destination_parent_id, input.destination_project_id));
1200
+ return {
1201
+ source_tasks: sourceTasks,
1202
+ plan_tasks: allTasks,
1203
+ plans,
1204
+ destination_project_found: destinationProjectFound,
1205
+ destination_task_list_found: Boolean(destinationTaskList),
1206
+ destination_parent_found: destinationParentFound
1207
+ };
1208
+ }
1209
+ async inspect(input) {
1210
+ return this.serialized(() => prepareTransfer(this.snapshot(input), input).inspection);
1211
+ }
1212
+ async apply(input, options) {
1213
+ return this.serialized(async () => {
1214
+ const requestDigest = taskSubtreeTransferRequestDigest(input);
1215
+ const existing = this.db.query(`SELECT result_json, request_digest, precondition_digest, idempotency_key
1216
+ FROM todos_task_subtree_transfer_receipts
1217
+ WHERE tenant_id = ? AND kind = 'apply'
1218
+ AND (idempotency_key = ? OR (operation_id = ? AND step_id = ?))
1219
+ ORDER BY created_at ASC LIMIT 1`).get(this.tenantId, input.idempotency_key, input.operation_id, input.step_id);
1220
+ if (existing) {
1221
+ if (existing.request_digest !== requestDigest || existing.precondition_digest !== input.precondition_digest || existing.idempotency_key !== input.idempotency_key) {
1222
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_IDEMPOTENCY_CONFLICT", "The operation/step or idempotency key was already accepted for a different request");
1223
+ }
1224
+ return parseResult(String(existing.result_json), true);
1225
+ }
1226
+ const snapshot = this.snapshot(input);
1227
+ const prepared = prepareTransfer(snapshot, input);
1228
+ validateApplySnapshot(prepared, input, snapshot.plans);
1229
+ const priorImage = {
1230
+ tasks: prepared.prior_tasks,
1231
+ plans: prepared.prior_plans
1232
+ };
1233
+ const now = options.now?.() ?? new Date().toISOString();
1234
+ const movedTaskIds = prepared.prior_tasks.map((task) => task.task_id);
1235
+ for (const task of prepared.prior_tasks) {
1236
+ const current = imageFromTaskRow(taskWhere(this.db, task.task_id));
1237
+ if (canonicalJson(current) !== canonicalJson(task)) {
1238
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_CAS_CONFLICT", "A task changed after inspection", { task_id: task.task_id });
1239
+ }
1240
+ }
1241
+ for (const task of prepared.prior_tasks) {
1242
+ const targetPlan = prepared.task_plan_targets.get(task.task_id) ?? null;
1243
+ const parentId = task.task_id === input.root_task_id ? input.destination_parent_id : task.parent_id;
1244
+ const result2 = this.db.query(`UPDATE tasks
1245
+ SET project_id = ?, task_list_id = ?, plan_id = ?, parent_id = ?, version = version + 1, updated_at = ?
1246
+ WHERE id = ? AND version = ?`).run(input.destination_project_id, input.destination_task_list_id, targetPlan, parentId, now, task.task_id, task.version);
1247
+ if (result2.changes !== 1) {
1248
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_CAS_CONFLICT", "A task version changed during the atomic transfer", { task_id: task.task_id });
1249
+ }
1250
+ }
1251
+ await fault(options, "after_task_writes");
1252
+ for (const plan of prepared.prior_plans) {
1253
+ const result2 = this.db.query(`UPDATE plans SET project_id = ?, task_list_id = ?, updated_at = ?
1254
+ WHERE id = ? AND project_id IS ? AND task_list_id IS ? AND updated_at = ?`).run(input.destination_project_id, input.destination_task_list_id, now, plan.plan_id, plan.project_id, plan.task_list_id, plan.updated_at);
1255
+ if (result2.changes !== 1) {
1256
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_CAS_CONFLICT", "A contained plan changed during the atomic transfer", { plan_id: plan.plan_id });
1257
+ }
1258
+ }
1259
+ await fault(options, "after_plan_writes");
1260
+ const postImage = {
1261
+ tasks: prepared.prior_tasks.map((task) => ({
1262
+ ...task,
1263
+ project_id: input.destination_project_id,
1264
+ task_list_id: input.destination_task_list_id,
1265
+ plan_id: prepared.task_plan_targets.get(task.task_id) ?? null,
1266
+ parent_id: task.task_id === input.root_task_id ? input.destination_parent_id : task.parent_id,
1267
+ version: task.version + 1,
1268
+ updated_at: now
1269
+ })),
1270
+ plans: prepared.prior_plans.map((plan) => ({
1271
+ ...plan,
1272
+ project_id: input.destination_project_id,
1273
+ task_list_id: input.destination_task_list_id,
1274
+ updated_at: now
1275
+ }))
1276
+ };
1277
+ const resultDigest = canonicalDigest({
1278
+ route: TODOS_TASK_SUBTREE_TRANSFER_ROUTE,
1279
+ request_digest: requestDigest,
1280
+ prior_image: priorImage,
1281
+ post_image: postImage
1282
+ });
1283
+ const receipt = {
1284
+ receipt_id: receiptId(input),
1285
+ authority: "todos",
1286
+ route: TODOS_TASK_SUBTREE_TRANSFER_ROUTE,
1287
+ schema_version: 1,
1288
+ kind: "apply",
1289
+ operation_id: input.operation_id,
1290
+ step_id: input.step_id,
1291
+ idempotency_key: input.idempotency_key,
1292
+ request_digest: requestDigest,
1293
+ precondition_digest: input.precondition_digest,
1294
+ result_digest: resultDigest,
1295
+ apply_receipt_id: null,
1296
+ source_project_id: input.source_project_id,
1297
+ destination_project_id: input.destination_project_id,
1298
+ destination_task_list_id: input.destination_task_list_id,
1299
+ root_task_id: input.root_task_id,
1300
+ source_population_digest: input.source_population_digest,
1301
+ prior_image: priorImage,
1302
+ post_image: postImage,
1303
+ shared_plan_splits: input.shared_plan_splits,
1304
+ created_at: now
1305
+ };
1306
+ const result = {
1307
+ duplicate: false,
1308
+ receipt,
1309
+ moved_task_ids: movedTaskIds,
1310
+ moved_plan_ids: prepared.prior_plans.map((plan) => plan.plan_id),
1311
+ complete: true
1312
+ };
1313
+ this.db.query(`INSERT INTO todos_task_subtree_transfer_receipts (
1314
+ receipt_id, tenant_id, kind, operation_id, step_id, idempotency_key,
1315
+ request_digest, precondition_digest, result_digest, apply_receipt_id,
1316
+ request_json, result_json, created_at
1317
+ ) VALUES (?, ?, 'apply', ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)`).run(receipt.receipt_id, this.tenantId, receipt.operation_id, receipt.step_id, receipt.idempotency_key, receipt.request_digest, receipt.precondition_digest, receipt.result_digest, canonicalJson(input), canonicalJson(result), now);
1318
+ await fault(options, "after_receipt_write");
1319
+ return result;
1320
+ });
1321
+ }
1322
+ async readExact(receiptIdValue) {
1323
+ const row = this.db.query(`SELECT result_json FROM todos_task_subtree_transfer_receipts
1324
+ WHERE tenant_id = ? AND receipt_id = ? LIMIT 1`).get(this.tenantId, receiptIdValue);
1325
+ if (!row) {
1326
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_RECEIPT_NOT_FOUND", `Transfer receipt not found: ${receiptIdValue}`);
1327
+ }
1328
+ return parseResult(String(row.result_json), false);
1329
+ }
1330
+ async rollback(input, options) {
1331
+ return this.serialized(async () => {
1332
+ const requestDigest = taskSubtreeTransferRollbackRequestDigest(input);
1333
+ const existing = this.db.query(`SELECT result_json, request_digest, precondition_digest, idempotency_key
1334
+ FROM todos_task_subtree_transfer_receipts
1335
+ WHERE tenant_id = ? AND kind = 'rollback'
1336
+ AND (idempotency_key = ? OR (operation_id = ? AND step_id = ?))
1337
+ ORDER BY created_at ASC LIMIT 1`).get(this.tenantId, input.idempotency_key, input.operation_id, input.step_id);
1338
+ if (existing) {
1339
+ if (existing.request_digest !== requestDigest || existing.precondition_digest !== input.precondition_digest || existing.idempotency_key !== input.idempotency_key) {
1340
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_IDEMPOTENCY_CONFLICT", "The rollback operation/step or idempotency key was already accepted for a different request");
1341
+ }
1342
+ return parseResult(String(existing.result_json), true);
1343
+ }
1344
+ const applyRow = this.db.query(`SELECT result_json FROM todos_task_subtree_transfer_receipts
1345
+ WHERE tenant_id = ? AND kind = 'apply' AND receipt_id = ? LIMIT 1`).get(this.tenantId, input.receipt_id);
1346
+ if (!applyRow) {
1347
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_RECEIPT_NOT_FOUND", `Apply receipt not found: ${input.receipt_id}`);
1348
+ }
1349
+ const applied = parseResult(String(applyRow.result_json), false);
1350
+ if (applied.receipt.operation_id !== input.operation_id) {
1351
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_ROLLBACK_CONFLICT", "Rollback operation does not match the apply receipt");
1352
+ }
1353
+ const expectedPrecondition = canonicalDigest({
1354
+ route: TODOS_TASK_SUBTREE_TRANSFER_ROUTE,
1355
+ direction: "rollback",
1356
+ operation_id: input.operation_id,
1357
+ step_id: input.step_id,
1358
+ apply_receipt_id: input.receipt_id,
1359
+ apply_result_digest: applied.receipt.result_digest
1360
+ });
1361
+ if (input.precondition_digest !== expectedPrecondition) {
1362
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_DIGEST_MISMATCH", "Rollback precondition digest does not match the exact apply receipt", { expected_precondition_digest: expectedPrecondition });
1363
+ }
1364
+ const now = options.now?.() ?? new Date().toISOString();
1365
+ for (const task of applied.receipt.post_image.tasks) {
1366
+ const current = imageFromTaskRow(taskWhere(this.db, task.task_id));
1367
+ if (canonicalJson(current) !== canonicalJson(task)) {
1368
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_ROLLBACK_CONFLICT", "Rollback refused because a transferred task changed after apply", { task_id: task.task_id });
1369
+ }
1370
+ }
1371
+ for (const plan of applied.receipt.post_image.plans) {
1372
+ const row = this.db.query("SELECT id, project_id, task_list_id, updated_at FROM plans WHERE id = ? LIMIT 1").get(plan.plan_id);
1373
+ if (!row || canonicalJson(imageFromPlanRow(row)) !== canonicalJson(plan)) {
1374
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_ROLLBACK_CONFLICT", "Rollback refused because a transferred plan changed after apply", { plan_id: plan.plan_id });
1375
+ }
1376
+ }
1377
+ for (const task of applied.receipt.prior_image.tasks) {
1378
+ const result2 = this.db.query(`UPDATE tasks SET project_id = ?, task_list_id = ?, plan_id = ?, parent_id = ?,
1379
+ version = version + 1, updated_at = ?
1380
+ WHERE id = ? AND version = ?`).run(task.project_id, task.task_list_id, task.plan_id, task.parent_id, now, task.task_id, applied.receipt.post_image.tasks.find((candidate) => candidate.task_id === task.task_id).version);
1381
+ if (result2.changes !== 1) {
1382
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_ROLLBACK_CONFLICT", "Rollback task CAS failed", { task_id: task.task_id });
1383
+ }
1384
+ }
1385
+ await fault(options, "after_rollback_task_writes");
1386
+ for (const plan of applied.receipt.prior_image.plans) {
1387
+ const post = applied.receipt.post_image.plans.find((candidate) => candidate.plan_id === plan.plan_id);
1388
+ const result2 = this.db.query(`UPDATE plans SET project_id = ?, task_list_id = ?, updated_at = ?
1389
+ WHERE id = ? AND project_id IS ? AND task_list_id IS ? AND updated_at = ?`).run(plan.project_id, plan.task_list_id, now, plan.plan_id, post.project_id, post.task_list_id, post.updated_at);
1390
+ if (result2.changes !== 1) {
1391
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_ROLLBACK_CONFLICT", "Rollback plan CAS failed", { plan_id: plan.plan_id });
1392
+ }
1393
+ }
1394
+ await fault(options, "after_rollback_plan_writes");
1395
+ const restored = {
1396
+ tasks: applied.receipt.prior_image.tasks.map((task) => ({
1397
+ ...task,
1398
+ version: applied.receipt.post_image.tasks.find((candidate) => candidate.task_id === task.task_id).version + 1,
1399
+ updated_at: now
1400
+ })),
1401
+ plans: applied.receipt.prior_image.plans.map((plan) => ({ ...plan, updated_at: now }))
1402
+ };
1403
+ const resultDigest = canonicalDigest({
1404
+ route: TODOS_TASK_SUBTREE_TRANSFER_ROUTE,
1405
+ direction: "rollback",
1406
+ apply_receipt_id: input.receipt_id,
1407
+ prior_image: applied.receipt.post_image,
1408
+ post_image: restored
1409
+ });
1410
+ const receipt = {
1411
+ ...applied.receipt,
1412
+ receipt_id: rollbackReceiptId(input),
1413
+ kind: "rollback",
1414
+ step_id: input.step_id,
1415
+ idempotency_key: input.idempotency_key,
1416
+ request_digest: requestDigest,
1417
+ precondition_digest: input.precondition_digest,
1418
+ result_digest: resultDigest,
1419
+ apply_receipt_id: input.receipt_id,
1420
+ prior_image: applied.receipt.post_image,
1421
+ post_image: restored,
1422
+ created_at: now
1423
+ };
1424
+ const result = {
1425
+ duplicate: false,
1426
+ receipt,
1427
+ moved_task_ids: applied.moved_task_ids,
1428
+ moved_plan_ids: applied.moved_plan_ids,
1429
+ complete: true
1430
+ };
1431
+ this.db.query(`INSERT INTO todos_task_subtree_transfer_receipts (
1432
+ receipt_id, tenant_id, kind, operation_id, step_id, idempotency_key,
1433
+ request_digest, precondition_digest, result_digest, apply_receipt_id,
1434
+ request_json, result_json, created_at
1435
+ ) VALUES (?, ?, 'rollback', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(receipt.receipt_id, this.tenantId, receipt.operation_id, receipt.step_id, receipt.idempotency_key, receipt.request_digest, receipt.precondition_digest, receipt.result_digest, receipt.apply_receipt_id, canonicalJson(input), canonicalJson(result), now);
1436
+ await fault(options, "after_rollback_receipt_write");
1437
+ return result;
1438
+ });
1439
+ }
1440
+ }
1441
+
1442
+ // src/task-subtree-transfer/authority.ts
1443
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
1444
+ var DIGEST = /^[0-9a-f]{64}$/i;
1445
+ function boundedString(value, field, max = 200) {
1446
+ if (typeof value !== "string" || value.length === 0 || value.length > max) {
1447
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_INVALID_INPUT", `${field} must be a non-empty bounded string`);
1448
+ }
1449
+ return value;
1450
+ }
1451
+ function uuid(value, field) {
1452
+ const parsed = boundedString(value, field, 80);
1453
+ if (!UUID.test(parsed)) {
1454
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_INVALID_INPUT", `${field} must be a UUID`);
1455
+ }
1456
+ return parsed;
1457
+ }
1458
+ function digest(value, field) {
1459
+ const parsed = boundedString(value, field, 64);
1460
+ if (!DIGEST.test(parsed)) {
1461
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_INVALID_INPUT", `${field} must be a SHA-256 digest`);
1462
+ }
1463
+ return parsed.toLowerCase();
1464
+ }
1465
+ function nullableUuid(value, field) {
1466
+ if (value === null)
1467
+ return null;
1468
+ return uuid(value, field);
1469
+ }
1470
+ function strictObject(input, fields) {
1471
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
1472
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_INVALID_INPUT", "Request must be a JSON object");
1473
+ }
1474
+ const object = input;
1475
+ const allowed = new Set(fields);
1476
+ for (const key of Object.keys(object)) {
1477
+ if (!allowed.has(key)) {
1478
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_INVALID_INPUT", `Unknown field: ${key}`);
1479
+ }
1480
+ }
1481
+ return object;
1482
+ }
1483
+ var INSPECT_FIELDS = [
1484
+ "source_project_id",
1485
+ "destination_project_id",
1486
+ "destination_task_list_id",
1487
+ "root_task_id",
1488
+ "destination_parent_id"
1489
+ ];
1490
+ function parseTodosTaskSubtreeTransferInspect(input) {
1491
+ const value = strictObject(input, INSPECT_FIELDS);
1492
+ const request = {
1493
+ source_project_id: uuid(value.source_project_id, "source_project_id"),
1494
+ destination_project_id: uuid(value.destination_project_id, "destination_project_id"),
1495
+ destination_task_list_id: uuid(value.destination_task_list_id, "destination_task_list_id"),
1496
+ root_task_id: uuid(value.root_task_id, "root_task_id"),
1497
+ destination_parent_id: nullableUuid(value.destination_parent_id, "destination_parent_id")
1498
+ };
1499
+ if (request.source_project_id === request.destination_project_id) {
1500
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_INVALID_INPUT", "source_project_id and destination_project_id must identify different Projects");
1501
+ }
1502
+ return request;
1503
+ }
1504
+ var APPLY_FIELDS = [
1505
+ ...INSPECT_FIELDS,
1506
+ "version",
1507
+ "operation_id",
1508
+ "step_id",
1509
+ "idempotency_key",
1510
+ "precondition_digest",
1511
+ "expected_root_parent_id",
1512
+ "source_population_digest",
1513
+ "expected_tasks",
1514
+ "shared_plan_splits"
1515
+ ];
1516
+ function parseTodosTaskSubtreeTransferApply(input) {
1517
+ const value = strictObject(input, APPLY_FIELDS);
1518
+ if (value.version !== 1) {
1519
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_INVALID_INPUT", "version must be 1");
1520
+ }
1521
+ if (!Array.isArray(value.expected_tasks) || value.expected_tasks.length === 0) {
1522
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_INVALID_INPUT", "expected_tasks must be a non-empty array");
1523
+ }
1524
+ const expected_tasks = value.expected_tasks.map((item, index) => {
1525
+ const row = strictObject(item, ["task_id", "version"]);
1526
+ const version = row.version;
1527
+ if (!Number.isSafeInteger(version) || Number(version) < 1) {
1528
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_INVALID_INPUT", `expected_tasks.${index}.version must be a positive integer`);
1529
+ }
1530
+ return { task_id: uuid(row.task_id, `expected_tasks.${index}.task_id`), version: Number(version) };
1531
+ });
1532
+ const ids = new Set;
1533
+ for (const row of expected_tasks) {
1534
+ if (ids.has(row.task_id)) {
1535
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_INVALID_INPUT", "expected_tasks must not contain duplicate task IDs");
1536
+ }
1537
+ ids.add(row.task_id);
1538
+ }
1539
+ if (!Array.isArray(value.shared_plan_splits)) {
1540
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_INVALID_INPUT", "shared_plan_splits must be an array");
1541
+ }
1542
+ const shared_plan_splits = value.shared_plan_splits.map((item, index) => {
1543
+ const row = strictObject(item, ["source_plan_id", "destination_plan_id"]);
1544
+ return {
1545
+ source_plan_id: uuid(row.source_plan_id, `shared_plan_splits.${index}.source_plan_id`),
1546
+ destination_plan_id: uuid(row.destination_plan_id, `shared_plan_splits.${index}.destination_plan_id`)
1547
+ };
1548
+ });
1549
+ return {
1550
+ ...parseTodosTaskSubtreeTransferInspect({
1551
+ source_project_id: value.source_project_id,
1552
+ destination_project_id: value.destination_project_id,
1553
+ destination_task_list_id: value.destination_task_list_id,
1554
+ root_task_id: value.root_task_id,
1555
+ destination_parent_id: value.destination_parent_id
1556
+ }),
1557
+ version: 1,
1558
+ operation_id: boundedString(value.operation_id, "operation_id"),
1559
+ step_id: boundedString(value.step_id, "step_id"),
1560
+ idempotency_key: boundedString(value.idempotency_key, "idempotency_key", 240),
1561
+ precondition_digest: digest(value.precondition_digest, "precondition_digest"),
1562
+ expected_root_parent_id: nullableUuid(value.expected_root_parent_id, "expected_root_parent_id"),
1563
+ source_population_digest: digest(value.source_population_digest, "source_population_digest"),
1564
+ expected_tasks,
1565
+ shared_plan_splits
1566
+ };
1567
+ }
1568
+ function parseTodosTaskSubtreeTransferRollback(input) {
1569
+ const value = strictObject(input, [
1570
+ "receipt_id",
1571
+ "operation_id",
1572
+ "step_id",
1573
+ "idempotency_key",
1574
+ "precondition_digest"
1575
+ ]);
1576
+ return {
1577
+ receipt_id: uuid(value.receipt_id, "receipt_id"),
1578
+ operation_id: boundedString(value.operation_id, "operation_id"),
1579
+ step_id: boundedString(value.step_id, "step_id"),
1580
+ idempotency_key: boundedString(value.idempotency_key, "idempotency_key", 240),
1581
+ precondition_digest: digest(value.precondition_digest, "precondition_digest")
1582
+ };
1583
+ }
1584
+ function deriveTodosTaskSubtreeTransferApplyPreconditionDigest(input) {
1585
+ const {
1586
+ source_project_id,
1587
+ destination_project_id,
1588
+ destination_task_list_id,
1589
+ root_task_id,
1590
+ expected_root_parent_id,
1591
+ destination_parent_id,
1592
+ source_population_digest,
1593
+ expected_tasks,
1594
+ shared_plan_splits
1595
+ } = input;
1596
+ return canonicalDigest({
1597
+ route: TODOS_TASK_SUBTREE_TRANSFER_ROUTE,
1598
+ direction: "apply",
1599
+ source_project_id,
1600
+ destination_project_id,
1601
+ destination_task_list_id,
1602
+ root_task_id,
1603
+ expected_root_parent_id,
1604
+ destination_parent_id,
1605
+ source_population_digest,
1606
+ expected_tasks,
1607
+ shared_plan_splits
1608
+ });
1609
+ }
1610
+ function deriveTodosTaskSubtreeTransferRollbackPreconditionDigest(input) {
1611
+ return canonicalDigest({
1612
+ route: TODOS_TASK_SUBTREE_TRANSFER_ROUTE,
1613
+ direction: "rollback",
1614
+ apply_receipt_id: input.receipt_id,
1615
+ operation_id: input.operation_id,
1616
+ step_id: input.step_id,
1617
+ apply_result_digest: input.apply_result_digest
1618
+ });
1619
+ }
1620
+ function resolveTenantId(value) {
1621
+ const tenant = value ?? "default";
1622
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(tenant)) {
1623
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_INVALID_INPUT", "tenantId must be a bounded exact authority identifier");
1624
+ }
1625
+ return tenant;
1626
+ }
1627
+
1628
+ class PackageOwnedTodosTaskSubtreeTransferAuthority {
1629
+ backend;
1630
+ tenantId;
1631
+ backendOptions;
1632
+ constructor(backend, options = {}) {
1633
+ this.backend = backend;
1634
+ this.tenantId = resolveTenantId(options.tenantId);
1635
+ this.backendOptions = { ...options, tenantId: this.tenantId };
1636
+ }
1637
+ async capability() {
1638
+ return {
1639
+ authority: "todos",
1640
+ route: TODOS_TASK_SUBTREE_TRANSFER_ROUTE,
1641
+ schema_version: TODOS_TASK_SUBTREE_TRANSFER_SCHEMA_VERSION,
1642
+ tenant_id: this.tenantId,
1643
+ backend: this.backend.kind,
1644
+ exact_descendant_closure: true,
1645
+ complete_source_population_digest: true,
1646
+ per_task_version_cas: true,
1647
+ explicit_shared_plan_splits: true,
1648
+ atomic_apply: true,
1649
+ immutable_forward_inverse_receipts: true,
1650
+ prior_image_receipts: true,
1651
+ cas_protected_rollback: true,
1652
+ preserves_descendant_parent_ids: true,
1653
+ preserves_task_and_relation_identities: true
1654
+ };
1655
+ }
1656
+ async inspect(input) {
1657
+ return this.backend.inspect(parseTodosTaskSubtreeTransferInspect(input));
1658
+ }
1659
+ async apply(input) {
1660
+ const request = parseTodosTaskSubtreeTransferApply(input);
1661
+ const expected = deriveTodosTaskSubtreeTransferApplyPreconditionDigest(request);
1662
+ if (request.precondition_digest !== expected) {
1663
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_DIGEST_MISMATCH", "precondition_digest does not match the exact transfer target and source snapshot", { expected_precondition_digest: expected });
1664
+ }
1665
+ const requestDigest = taskSubtreeTransferRequestDigest(request);
1666
+ const expectedKey = deriveTodosTaskSubtreeTransferIdempotencyKey({
1667
+ operation_id: request.operation_id,
1668
+ step_id: request.step_id,
1669
+ direction: "apply",
1670
+ target_selector: request.root_task_id,
1671
+ request_digest: requestDigest,
1672
+ precondition_digest: request.precondition_digest
1673
+ });
1674
+ if (request.idempotency_key !== expectedKey) {
1675
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_IDEMPOTENCY_MISMATCH", "idempotency_key does not match the exact transfer request", { expected_idempotency_key: expectedKey });
1676
+ }
1677
+ return this.backend.apply(request, this.backendOptions);
1678
+ }
1679
+ async readExact(receiptIdValue) {
1680
+ return this.backend.readExact(uuid(receiptIdValue, "receipt_id"));
1681
+ }
1682
+ async rollback(input) {
1683
+ const request = parseTodosTaskSubtreeTransferRollback(input);
1684
+ const requestDigest = taskSubtreeTransferRollbackRequestDigest(request);
1685
+ const expectedKey = deriveTodosTaskSubtreeTransferIdempotencyKey({
1686
+ operation_id: request.operation_id,
1687
+ step_id: request.step_id,
1688
+ direction: "rollback",
1689
+ target_selector: request.receipt_id,
1690
+ request_digest: requestDigest,
1691
+ precondition_digest: request.precondition_digest
1692
+ });
1693
+ if (request.idempotency_key !== expectedKey) {
1694
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_IDEMPOTENCY_MISMATCH", "rollback idempotency_key does not match the exact rollback request", { expected_idempotency_key: expectedKey });
1695
+ }
1696
+ return this.backend.rollback(request, this.backendOptions);
1697
+ }
1698
+ }
1699
+ function createSqliteTodosTaskSubtreeTransferAuthority(options) {
1700
+ return new PackageOwnedTodosTaskSubtreeTransferAuthority(new SqliteTodosTaskSubtreeTransferBackend(options.database, resolveTenantId(options.tenantId)), options);
1701
+ }
1702
+ function createPostgresTodosTaskSubtreeTransferAuthority(client, options = {}) {
1703
+ return new PackageOwnedTodosTaskSubtreeTransferAuthority(new PostgresTodosTaskSubtreeTransferBackend(client, options), options);
1704
+ }
1705
+ // src/task-subtree-transfer/http.ts
1706
+ var JSON_HEADERS = { "Content-Type": "application/json" };
1707
+ var REQUEST_BYTES = 2 * 1024 * 1024;
1708
+ var RESPONSE_BYTES = 8 * 1024 * 1024;
1709
+ function json(body, status = 200) {
1710
+ return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
1711
+ }
1712
+ function status(error) {
1713
+ switch (error.code) {
1714
+ case "TODOS_TASK_SUBTREE_TRANSFER_INVALID_INPUT":
1715
+ case "TODOS_TASK_SUBTREE_TRANSFER_FOREIGN_REFERENCE":
1716
+ case "TODOS_TASK_SUBTREE_TRANSFER_HIERARCHY_CYCLE":
1717
+ return 400;
1718
+ case "TODOS_TASK_SUBTREE_TRANSFER_RECEIPT_NOT_FOUND":
1719
+ case "TODOS_TASK_SUBTREE_TRANSFER_NOT_FOUND":
1720
+ return 404;
1721
+ case "TODOS_TASK_SUBTREE_TRANSFER_ATOMICITY_UNAVAILABLE":
1722
+ return 503;
1723
+ default:
1724
+ return 409;
1725
+ }
1726
+ }
1727
+ async function boundedText(message, limit) {
1728
+ if (!message.body)
1729
+ return "";
1730
+ const reader = message.body.getReader();
1731
+ const decoder = new TextDecoder;
1732
+ let bytes = 0;
1733
+ let text = "";
1734
+ try {
1735
+ while (true) {
1736
+ const { done, value } = await reader.read();
1737
+ if (done)
1738
+ break;
1739
+ bytes += value.byteLength;
1740
+ if (bytes > limit) {
1741
+ await reader.cancel();
1742
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_INVALID_INPUT", "Task-subtree-transfer HTTP body exceeds the byte bound");
1743
+ }
1744
+ text += decoder.decode(value, { stream: true });
1745
+ }
1746
+ return text + decoder.decode();
1747
+ } finally {
1748
+ reader.releaseLock();
1749
+ }
1750
+ }
1751
+ async function readBody(request) {
1752
+ const declaredLength = Number(request.headers.get("content-length") ?? 0);
1753
+ if (Number.isFinite(declaredLength) && declaredLength > REQUEST_BYTES) {
1754
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_INVALID_INPUT", "Task-subtree-transfer request exceeds the byte bound");
1755
+ }
1756
+ const text = await boundedText(request, REQUEST_BYTES);
1757
+ try {
1758
+ return JSON.parse(text);
1759
+ } catch {
1760
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_INVALID_INPUT", "Invalid JSON body");
1761
+ }
1762
+ }
1763
+ async function handleTodosTaskSubtreeTransferHttpRequest(request, url, authority, basePath = "/v1/task-subtree-transfer") {
1764
+ if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`))
1765
+ return null;
1766
+ const action = url.pathname.slice(basePath.length).split("/").filter(Boolean).join("/");
1767
+ try {
1768
+ if ((action === "" || action === "capability") && request.method === "GET") {
1769
+ return json({ capability: await authority.capability() });
1770
+ }
1771
+ if (request.method !== "POST")
1772
+ return json({ error: "method not allowed" }, 405);
1773
+ if (action === "inspect")
1774
+ return json({ inspection: await authority.inspect(await readBody(request)) });
1775
+ if (action === "apply")
1776
+ return json({ result: await authority.apply(await readBody(request)) }, 201);
1777
+ if (action === "rollback")
1778
+ return json({ result: await authority.rollback(await readBody(request)) }, 201);
1779
+ if (action === "read-exact") {
1780
+ const input = await readBody(request);
1781
+ if (!input || typeof input.receipt_id !== "string") {
1782
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_INVALID_INPUT", "receipt_id is required");
1783
+ }
1784
+ return json({ result: await authority.readExact(input.receipt_id) });
1785
+ }
1786
+ return json({ error: "unknown task-subtree-transfer route" }, 404);
1787
+ } catch (cause) {
1788
+ if (cause instanceof TodosTaskSubtreeTransferError) {
1789
+ return json({
1790
+ error: cause.message,
1791
+ code: cause.code,
1792
+ details: cause.details,
1793
+ authoritative: true
1794
+ }, status(cause));
1795
+ }
1796
+ return json({ error: "internal task-subtree-transfer error" }, 500);
1797
+ }
1798
+ }
1799
+
1800
+ class TodosTaskSubtreeTransferHttpClient {
1801
+ baseUrl;
1802
+ fetchImpl;
1803
+ headers;
1804
+ constructor(options) {
1805
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
1806
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
1807
+ this.headers = {
1808
+ ...options.headers,
1809
+ ...options.apiKey ? { Authorization: `Bearer ${options.apiKey}` } : {},
1810
+ "Content-Type": "application/json"
1811
+ };
1812
+ }
1813
+ async request(action, init = {}) {
1814
+ const response = await this.fetchImpl(`${this.baseUrl}/v1/task-subtree-transfer${action}`, {
1815
+ ...init,
1816
+ headers: { ...this.headers, ...init.headers ?? {} }
1817
+ });
1818
+ const text = await boundedText(response, RESPONSE_BYTES);
1819
+ let payload;
1820
+ try {
1821
+ payload = JSON.parse(text);
1822
+ } catch {
1823
+ throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_HTTP_ERROR", `Task-subtree-transfer HTTP ${response.status} returned invalid JSON`);
1824
+ }
1825
+ if (!response.ok) {
1826
+ throw new TodosTaskSubtreeTransferError(typeof payload.code === "string" ? payload.code : "TODOS_TASK_SUBTREE_TRANSFER_HTTP_ERROR", typeof payload.error === "string" ? payload.error : `Task-subtree-transfer HTTP ${response.status}`, payload.details && typeof payload.details === "object" ? payload.details : {});
1827
+ }
1828
+ return payload;
1829
+ }
1830
+ async capability() {
1831
+ return (await this.request("/capability")).capability;
1832
+ }
1833
+ async inspect(input) {
1834
+ return (await this.request("/inspect", { method: "POST", body: JSON.stringify(input) })).inspection;
1835
+ }
1836
+ async apply(input) {
1837
+ return (await this.request("/apply", { method: "POST", body: JSON.stringify(input) })).result;
1838
+ }
1839
+ async readExact(receiptId2) {
1840
+ return (await this.request("/read-exact", { method: "POST", body: JSON.stringify({ receipt_id: receiptId2 }) })).result;
1841
+ }
1842
+ async rollback(input) {
1843
+ return (await this.request("/rollback", { method: "POST", body: JSON.stringify(input) })).result;
1844
+ }
1845
+ }
1846
+ function createTodosTaskSubtreeTransferHttpClient(options) {
1847
+ return new TodosTaskSubtreeTransferHttpClient(options);
1848
+ }
1849
+ export {
1850
+ taskSubtreeTransferRollbackRequestDigest,
1851
+ taskSubtreeTransferRequestDigest,
1852
+ sqliteTodosTaskSubtreeTransferSchemaSql,
1853
+ postgresTodosTaskSubtreeTransferSchemaSql,
1854
+ parseTodosTaskSubtreeTransferRollback,
1855
+ parseTodosTaskSubtreeTransferInspect,
1856
+ parseTodosTaskSubtreeTransferApply,
1857
+ handleTodosTaskSubtreeTransferHttpRequest,
1858
+ deriveTodosTaskSubtreeTransferRollbackPreconditionDigest,
1859
+ deriveTodosTaskSubtreeTransferIdempotencyKey,
1860
+ deriveTodosTaskSubtreeTransferApplyPreconditionDigest,
1861
+ createTodosTaskSubtreeTransferHttpClient,
1862
+ createSqliteTodosTaskSubtreeTransferAuthority,
1863
+ createPostgresTodosTaskSubtreeTransferAuthority,
1864
+ TodosTaskSubtreeTransferHttpClient,
1865
+ TodosTaskSubtreeTransferError,
1866
+ TODOS_TASK_SUBTREE_TRANSFER_SCHEMA_VERSION,
1867
+ TODOS_TASK_SUBTREE_TRANSFER_ROUTE,
1868
+ PackageOwnedTodosTaskSubtreeTransferAuthority
1869
+ };