@hasna/todos 0.15.7 → 0.15.9

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.
package/dist/contracts.js CHANGED
@@ -6747,6 +6747,40 @@ function deleteTaskList(id, db) {
6747
6747
  return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
6748
6748
  })();
6749
6749
  }
6750
+ function deleteTaskListIfUnchangedAndUnused(id, expected, db) {
6751
+ const d = db || getDatabase();
6752
+ return d.transaction(() => {
6753
+ const current = getTaskList(id, d);
6754
+ if (!current) {
6755
+ return { status: "not_found", task_dependents: 0, plan_dependents: 0 };
6756
+ }
6757
+ const changed = current.project_id !== expected.project_id || current.slug !== expected.slug || current.name !== expected.name || current.description !== expected.description || current.updated_at !== expected.updated_at || JSON.stringify(current.metadata) !== JSON.stringify(expected.metadata);
6758
+ if (changed) {
6759
+ return { status: "changed", task_dependents: 0, plan_dependents: 0 };
6760
+ }
6761
+ const taskDependents = Number(d.query("SELECT COUNT(*) AS count FROM tasks WHERE task_list_id = ?").get(id).count);
6762
+ const planDependents = Number(d.query("SELECT COUNT(*) AS count FROM plans WHERE task_list_id = ?").get(id).count);
6763
+ if (taskDependents > 0 || planDependents > 0) {
6764
+ return {
6765
+ status: "has_dependents",
6766
+ task_dependents: taskDependents,
6767
+ plan_dependents: planDependents
6768
+ };
6769
+ }
6770
+ recordStorageTombstone({
6771
+ object_type: "task_lists",
6772
+ object_id: id,
6773
+ payload: current
6774
+ }, d);
6775
+ releaseCanonicalSlugClaims("task_list", id, d);
6776
+ const deleted = d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
6777
+ return {
6778
+ status: deleted ? "deleted" : "not_found",
6779
+ task_dependents: 0,
6780
+ plan_dependents: 0
6781
+ };
6782
+ })();
6783
+ }
6750
6784
  function ensureTaskList(name, slug, projectId, db) {
6751
6785
  const d = db || getDatabase();
6752
6786
  const existing = getTaskListBySlug(slug, projectId, d);
@@ -12250,7 +12284,7 @@ var init_tasks = __esm(() => {
12250
12284
  // package.json
12251
12285
  var package_default = {
12252
12286
  name: "@hasna/todos",
12253
- version: "0.15.7",
12287
+ version: "0.15.9",
12254
12288
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
12255
12289
  type: "module",
12256
12290
  main: "dist/index.js",
@@ -6,5 +6,10 @@ export declare function getTaskListBySlug(slug: string, projectId?: string, db?:
6
6
  export declare function listTaskLists(projectId?: string, db?: Database): TaskList[];
7
7
  export declare function updateTaskList(id: string, input: UpdateTaskListInput, db?: Database): TaskList;
8
8
  export declare function deleteTaskList(id: string, db?: Database): boolean;
9
+ export declare function deleteTaskListIfUnchangedAndUnused(id: string, expected: Pick<TaskList, "project_id" | "slug" | "name" | "description" | "metadata" | "updated_at">, db?: Database): {
10
+ status: "deleted" | "not_found" | "changed" | "has_dependents";
11
+ task_dependents: number;
12
+ plan_dependents: number;
13
+ };
9
14
  export declare function ensureTaskList(name: string, slug: string, projectId?: string, db?: Database): TaskList;
10
15
  //# sourceMappingURL=task-lists.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"task-lists.d.ts","sourceRoot":"","sources":["../../src/db/task-lists.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,KAAK,EAAE,mBAAmB,EAAE,QAAQ,EAAe,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAezG,wBAAgB,cAAc,CAAC,KAAK,EAAE,mBAAmB,EAAE,EAAE,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAwBlF;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,QAAQ,GAAG,QAAQ,GAAG,IAAI,CAItE;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,QAAQ,GAAG,QAAQ,GAAG,IAAI,CASlG;AAED,wBAAgB,aAAa,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,QAAQ,GAAG,QAAQ,EAAE,CAM3E;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,mBAAmB,EAAE,EAAE,CAAC,EAAE,QAAQ,GAAG,QAAQ,CA4C9F;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,QAAQ,GAAG,OAAO,CAajE;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAKtG"}
1
+ {"version":3,"file":"task-lists.d.ts","sourceRoot":"","sources":["../../src/db/task-lists.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,KAAK,EAAE,mBAAmB,EAAE,QAAQ,EAAe,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAezG,wBAAgB,cAAc,CAAC,KAAK,EAAE,mBAAmB,EAAE,EAAE,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAwBlF;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,QAAQ,GAAG,QAAQ,GAAG,IAAI,CAItE;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,QAAQ,GAAG,QAAQ,GAAG,IAAI,CASlG;AAED,wBAAgB,aAAa,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,QAAQ,GAAG,QAAQ,EAAE,CAM3E;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,mBAAmB,EAAE,EAAE,CAAC,EAAE,QAAQ,GAAG,QAAQ,CA4C9F;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,QAAQ,GAAG,OAAO,CAajE;AAED,wBAAgB,kCAAkC,CAChD,EAAE,EAAE,MAAM,EACV,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,YAAY,GAAG,MAAM,GAAG,MAAM,GAAG,aAAa,GAAG,UAAU,GAAG,YAAY,CAAC,EACpG,EAAE,CAAC,EAAE,QAAQ,GACZ;IACD,MAAM,EAAE,SAAS,GAAG,WAAW,GAAG,SAAS,GAAG,gBAAgB,CAAC;IAC/D,eAAe,EAAE,MAAM,CAAC;IACxB,eAAe,EAAE,MAAM,CAAC;CACzB,CA8CA;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAKtG"}
package/dist/index.js CHANGED
@@ -6747,6 +6747,40 @@ function deleteTaskList(id, db) {
6747
6747
  return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
6748
6748
  })();
6749
6749
  }
6750
+ function deleteTaskListIfUnchangedAndUnused(id, expected, db) {
6751
+ const d = db || getDatabase();
6752
+ return d.transaction(() => {
6753
+ const current = getTaskList(id, d);
6754
+ if (!current) {
6755
+ return { status: "not_found", task_dependents: 0, plan_dependents: 0 };
6756
+ }
6757
+ const changed = current.project_id !== expected.project_id || current.slug !== expected.slug || current.name !== expected.name || current.description !== expected.description || current.updated_at !== expected.updated_at || JSON.stringify(current.metadata) !== JSON.stringify(expected.metadata);
6758
+ if (changed) {
6759
+ return { status: "changed", task_dependents: 0, plan_dependents: 0 };
6760
+ }
6761
+ const taskDependents = Number(d.query("SELECT COUNT(*) AS count FROM tasks WHERE task_list_id = ?").get(id).count);
6762
+ const planDependents = Number(d.query("SELECT COUNT(*) AS count FROM plans WHERE task_list_id = ?").get(id).count);
6763
+ if (taskDependents > 0 || planDependents > 0) {
6764
+ return {
6765
+ status: "has_dependents",
6766
+ task_dependents: taskDependents,
6767
+ plan_dependents: planDependents
6768
+ };
6769
+ }
6770
+ recordStorageTombstone({
6771
+ object_type: "task_lists",
6772
+ object_id: id,
6773
+ payload: current
6774
+ }, d);
6775
+ releaseCanonicalSlugClaims("task_list", id, d);
6776
+ const deleted = d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
6777
+ return {
6778
+ status: deleted ? "deleted" : "not_found",
6779
+ task_dependents: 0,
6780
+ plan_dependents: 0
6781
+ };
6782
+ })();
6783
+ }
6750
6784
  function ensureTaskList(name, slug, projectId, db) {
6751
6785
  const d = db || getDatabase();
6752
6786
  const existing = getTaskListBySlug(slug, projectId, d);
@@ -12363,7 +12397,7 @@ var init_dispatches = __esm(() => {
12363
12397
  // package.json
12364
12398
  var package_default = {
12365
12399
  name: "@hasna/todos",
12366
- version: "0.15.7",
12400
+ version: "0.15.9",
12367
12401
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
12368
12402
  type: "module",
12369
12403
  main: "dist/index.js",
@@ -25696,7 +25730,8 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
25696
25730
  getBySlug: (slug, projectId) => getTaskListBySlug(slug, projectId, database()),
25697
25731
  list: (projectId) => listTaskLists(projectId, database()),
25698
25732
  update: (id, input) => updateTaskList(id, input, database()),
25699
- delete: (id) => deleteTaskList(id, database())
25733
+ delete: (id) => deleteTaskList(id, database()),
25734
+ deleteIfUnchangedAndUnused: (id, expected) => deleteTaskListIfUnchangedAndUnused(id, expected, database())
25700
25735
  },
25701
25736
  templates: {
25702
25737
  create: (input) => createTemplate(input, database()),
@@ -32511,6 +32546,8 @@ class PostgresTodosProjectRegistrationBackend {
32511
32546
  }
32512
32547
 
32513
32548
  // src/project-registration/sqlite.ts
32549
+ init_database();
32550
+ init_storage_tombstones();
32514
32551
  var sqliteTransactionTails2 = new WeakMap;
32515
32552
  var PROJECT_REFERENCE_COLUMNS = new Set([
32516
32553
  "project_id",
@@ -32519,9 +32556,75 @@ var PROJECT_REFERENCE_COLUMNS = new Set([
32519
32556
  "external_project_id"
32520
32557
  ]);
32521
32558
  var TASK_LIST_REFERENCE_COLUMNS = new Set(["task_list_id"]);
32559
+ var SQLITE_TRANSACTION_RETRY_LIMIT = 8;
32560
+
32561
+ class SqliteRegistrationOptimisticConflict extends Error {
32562
+ constructor(message, options = {}) {
32563
+ super(message, options);
32564
+ this.name = "SqliteRegistrationOptimisticConflict";
32565
+ }
32566
+ }
32567
+ function sameSqliteValue(left, right) {
32568
+ return JSON.stringify(left) === JSON.stringify(right);
32569
+ }
32570
+ function taskListFromRow(row) {
32571
+ return {
32572
+ ...row,
32573
+ metadata: JSON.parse(row.metadata || "{}")
32574
+ };
32575
+ }
32576
+ function selectProject(db, id) {
32577
+ return db.query("SELECT * FROM projects WHERE id = ? LIMIT 1").get(id);
32578
+ }
32579
+ function selectTaskList(db, id) {
32580
+ const row = db.query("SELECT * FROM task_lists WHERE id = ? LIMIT 1").get(id);
32581
+ return row ? taskListFromRow(row) : null;
32582
+ }
32583
+ function selectProjectConflict(db, path, taskListSlug) {
32584
+ return db.query(`
32585
+ SELECT * FROM projects
32586
+ WHERE path = ? OR task_list_id = ?
32587
+ ORDER BY created_at ASC, id ASC
32588
+ LIMIT 1
32589
+ `).get(path, taskListSlug);
32590
+ }
32591
+ function selectTaskListConflict(db, projectId, slug) {
32592
+ const row = db.query(`
32593
+ SELECT * FROM task_lists
32594
+ WHERE project_id = ? AND slug = ?
32595
+ LIMIT 1
32596
+ `).get(projectId, slug);
32597
+ return row ? taskListFromRow(row) : null;
32598
+ }
32522
32599
  function quoteSqliteIdentifier(value) {
32523
32600
  return `"${value.replaceAll('"', '""')}"`;
32524
32601
  }
32602
+ function hasSqliteDependents(db, resourceKind, targetId) {
32603
+ const targetTable = resourceKind === "project" ? "projects" : "task_lists";
32604
+ const semanticColumns = resourceKind === "project" ? PROJECT_REFERENCE_COLUMNS : TASK_LIST_REFERENCE_COLUMNS;
32605
+ const tables = db.query(`
32606
+ SELECT name FROM sqlite_schema
32607
+ WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
32608
+ ORDER BY name
32609
+ `).all();
32610
+ for (const { name: tableName } of tables) {
32611
+ const quotedTable = quoteSqliteIdentifier(tableName);
32612
+ const columns = db.query(`PRAGMA table_info(${quotedTable})`).all();
32613
+ const foreignKeys = db.query(`PRAGMA foreign_key_list(${quotedTable})`).all();
32614
+ const referenceColumns = columns.map((column) => column.name).filter((columnName) => semanticColumns.has(columnName) || foreignKeys.some((foreignKey) => foreignKey.from === columnName && foreignKey.table === targetTable));
32615
+ for (const columnName of referenceColumns) {
32616
+ const row = db.query(`
32617
+ SELECT 1 AS found
32618
+ FROM ${quotedTable}
32619
+ WHERE ${quoteSqliteIdentifier(columnName)} = ?
32620
+ LIMIT 1
32621
+ `).get(targetId);
32622
+ if (row)
32623
+ return true;
32624
+ }
32625
+ }
32626
+ return false;
32627
+ }
32525
32628
  function receiptFromRow2(row) {
32526
32629
  return {
32527
32630
  ...row,
@@ -32664,30 +32767,7 @@ class SqliteTodosProjectRegistrationTransaction {
32664
32767
  }
32665
32768
  async lockCompensationWrites() {}
32666
32769
  async hasDependents(resourceKind, targetId) {
32667
- const targetTable = resourceKind === "project" ? "projects" : "task_lists";
32668
- const semanticColumns = resourceKind === "project" ? PROJECT_REFERENCE_COLUMNS : TASK_LIST_REFERENCE_COLUMNS;
32669
- const tables = this.db.query(`
32670
- SELECT name FROM sqlite_schema
32671
- WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
32672
- ORDER BY name
32673
- `).all();
32674
- for (const { name: tableName } of tables) {
32675
- const quotedTable = quoteSqliteIdentifier(tableName);
32676
- const columns = this.db.query(`PRAGMA table_info(${quotedTable})`).all();
32677
- const foreignKeys = this.db.query(`PRAGMA foreign_key_list(${quotedTable})`).all();
32678
- const referenceColumns = columns.map((column) => column.name).filter((columnName) => semanticColumns.has(columnName) || foreignKeys.some((foreignKey) => foreignKey.from === columnName && foreignKey.table === targetTable));
32679
- for (const columnName of referenceColumns) {
32680
- const row = this.db.query(`
32681
- SELECT 1 AS found
32682
- FROM ${quotedTable}
32683
- WHERE ${quoteSqliteIdentifier(columnName)} = ?
32684
- LIMIT 1
32685
- `).get(targetId);
32686
- if (row)
32687
- return true;
32688
- }
32689
- }
32690
- return false;
32770
+ return hasSqliteDependents(this.db, resourceKind, targetId);
32691
32771
  }
32692
32772
  async deleteProject(id) {
32693
32773
  return await this.storage.projects.delete(id);
@@ -32697,6 +32777,376 @@ class SqliteTodosProjectRegistrationTransaction {
32697
32777
  }
32698
32778
  }
32699
32779
 
32780
+ class StagedSqliteTodosProjectRegistrationTransaction {
32781
+ db;
32782
+ direct;
32783
+ validators = [];
32784
+ mutations = [];
32785
+ receipts = new Map;
32786
+ bindings = new Map;
32787
+ projects = new Map;
32788
+ taskLists = new Map;
32789
+ constructor(db) {
32790
+ this.db = db;
32791
+ this.direct = new SqliteTodosProjectRegistrationTransaction(db);
32792
+ }
32793
+ commit() {
32794
+ this.db.exec("BEGIN IMMEDIATE");
32795
+ try {
32796
+ for (const validate of this.validators) {
32797
+ if (!validate()) {
32798
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration input changed before SQLite commit");
32799
+ }
32800
+ }
32801
+ for (const mutate of this.mutations)
32802
+ mutate();
32803
+ this.db.exec("COMMIT");
32804
+ } catch (error) {
32805
+ try {
32806
+ this.db.exec("ROLLBACK");
32807
+ } catch {}
32808
+ throw error;
32809
+ }
32810
+ }
32811
+ async lockStep(_identity) {}
32812
+ async getReceiptForLookup(identity) {
32813
+ const staged = [...this.receipts.values()].filter((receipt) => receipt.authority_id === identity.authority_id && receipt.tenant_id === identity.tenant_id && receipt.corpus_id === identity.corpus_id && receipt.operation_id === identity.operation_id && receipt.step_id === identity.step_id && receipt.resource_kind === identity.resource_kind && receipt.direction === identity.direction && receipt.idempotency_key === identity.idempotency_key && receipt.target_selector === identity.target_selector);
32814
+ const stored = await this.direct.getReceiptForLookup(identity);
32815
+ if (stored)
32816
+ staged.push(stored);
32817
+ const outcomeRank = (receipt) => receipt.outcome === "terminal_nonacceptance" ? 0 : receipt.outcome === "duplicate_of_accepted" ? 1 : 2;
32818
+ return staged.sort((left, right) => outcomeRank(left) - outcomeRank(right) || right.created_at.localeCompare(left.created_at) || right.receipt_id.localeCompare(left.receipt_id))[0] ?? null;
32819
+ }
32820
+ async getReceiptById(receiptId) {
32821
+ return this.receipts.get(receiptId) ?? this.direct.getReceiptById(receiptId);
32822
+ }
32823
+ async getAcceptedReceiptForStep(identity) {
32824
+ const staged = [...this.receipts.values()].filter((receipt) => receipt.authority_id === identity.authority_id && receipt.tenant_id === identity.tenant_id && receipt.corpus_id === identity.corpus_id && receipt.operation_id === identity.operation_id && receipt.step_id === identity.step_id && receipt.resource_kind === identity.resource_kind && receipt.direction === identity.direction && receipt.outcome === "accepted");
32825
+ const stored = await this.direct.getAcceptedReceiptForStep(identity);
32826
+ if (stored)
32827
+ staged.push(stored);
32828
+ return staged.sort((left, right) => left.created_at.localeCompare(right.created_at) || left.receipt_id.localeCompare(right.receipt_id))[0] ?? null;
32829
+ }
32830
+ async insertReceipt(receipt) {
32831
+ if (this.receipts.has(receipt.receipt_id))
32832
+ return false;
32833
+ if (await this.direct.getReceiptById(receipt.receipt_id))
32834
+ return false;
32835
+ const planned = { ...receipt };
32836
+ this.receipts.set(planned.receipt_id, planned);
32837
+ this.mutations.push(() => {
32838
+ try {
32839
+ const result = this.db.query(`
32840
+ INSERT OR IGNORE INTO todos_project_registration_receipts (
32841
+ receipt_id, authority, route, package_version, authority_id, tenant_id,
32842
+ corpus_id, operation_id, step_id, resource_kind, direction,
32843
+ target_selector, idempotency_key, request_digest, precondition_digest,
32844
+ normalized_call_digest, outcome, reason, target_id, result_revision,
32845
+ result_digest, duplicate_of_receipt_id, accepted_receipt_id,
32846
+ created_by_operation, created_at
32847
+ ) VALUES (
32848
+ ?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?
32849
+ )
32850
+ `).run(planned.receipt_id, planned.authority, planned.route, planned.package_version, planned.authority_id, planned.tenant_id, planned.corpus_id, planned.operation_id, planned.step_id, planned.resource_kind, planned.direction, planned.target_selector, planned.idempotency_key, planned.request_digest, planned.precondition_digest, planned.normalized_call_digest, planned.outcome, planned.reason, planned.target_id, planned.result_revision, planned.result_digest, planned.duplicate_of_receipt_id, planned.accepted_receipt_id, planned.created_by_operation ? 1 : 0, planned.created_at);
32851
+ if (result.changes !== 1) {
32852
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration receipt changed before SQLite commit");
32853
+ }
32854
+ } catch (error) {
32855
+ if (error instanceof SqliteRegistrationOptimisticConflict)
32856
+ throw error;
32857
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration receipt conflicted at SQLite commit", { cause: error });
32858
+ }
32859
+ });
32860
+ return true;
32861
+ }
32862
+ async getBinding(scope, resourceKind, targetSelector) {
32863
+ const key = this.bindingKey(scope, resourceKind, targetSelector);
32864
+ return this.bindings.get(key) ?? this.direct.getBinding(scope, resourceKind, targetSelector);
32865
+ }
32866
+ async claimBinding(binding) {
32867
+ const key = this.bindingKey(binding, binding.resource_kind, binding.target_selector);
32868
+ if (this.bindings.has(key))
32869
+ return false;
32870
+ if (await this.direct.getBinding(binding, binding.resource_kind, binding.target_selector)) {
32871
+ return false;
32872
+ }
32873
+ const planned = { ...binding };
32874
+ this.bindings.set(key, planned);
32875
+ this.mutations.push(() => {
32876
+ try {
32877
+ const result = this.db.query(`
32878
+ INSERT OR IGNORE INTO todos_project_registration_bindings (
32879
+ authority_id, tenant_id, corpus_id, resource_kind, target_selector,
32880
+ operation_id, step_id, direction, idempotency_key, request_digest,
32881
+ precondition_digest, normalized_call_digest, state, target_id,
32882
+ accepted_receipt_id, result_revision, result_digest, removed_receipt_id,
32883
+ created_at, updated_at
32884
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
32885
+ `).run(planned.authority_id, planned.tenant_id, planned.corpus_id, planned.resource_kind, planned.target_selector, planned.operation_id, planned.step_id, planned.direction, planned.idempotency_key, planned.request_digest, planned.precondition_digest, planned.normalized_call_digest, planned.state, planned.target_id, planned.accepted_receipt_id, planned.result_revision, planned.result_digest, planned.removed_receipt_id, planned.created_at, planned.updated_at);
32886
+ if (result.changes !== 1) {
32887
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration binding changed before SQLite commit");
32888
+ }
32889
+ } catch (error) {
32890
+ if (error instanceof SqliteRegistrationOptimisticConflict)
32891
+ throw error;
32892
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration binding conflicted at SQLite commit", { cause: error });
32893
+ }
32894
+ });
32895
+ return true;
32896
+ }
32897
+ async setBindingAccepted(scope, resourceKind, targetSelector, update) {
32898
+ const binding = await this.requireBinding(scope, resourceKind, targetSelector, "pending");
32899
+ this.bindings.set(this.bindingKey(scope, resourceKind, targetSelector), {
32900
+ ...binding,
32901
+ state: "accepted",
32902
+ target_id: update.target_id,
32903
+ accepted_receipt_id: update.accepted_receipt_id,
32904
+ result_revision: update.result_revision,
32905
+ result_digest: update.result_digest,
32906
+ updated_at: update.updated_at
32907
+ });
32908
+ this.mutations.push(() => {
32909
+ const result = this.db.query(`
32910
+ UPDATE todos_project_registration_bindings
32911
+ SET state = 'accepted', target_id = ?, accepted_receipt_id = ?,
32912
+ result_revision = ?, result_digest = ?, updated_at = ?
32913
+ WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
32914
+ AND resource_kind = ? AND target_selector = ? AND state = 'pending'
32915
+ `).run(update.target_id, update.accepted_receipt_id, update.result_revision, update.result_digest, update.updated_at, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
32916
+ if (result.changes !== 1) {
32917
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration binding was no longer pending at SQLite commit");
32918
+ }
32919
+ });
32920
+ }
32921
+ async setBindingTerminal(scope, resourceKind, targetSelector, updatedAt) {
32922
+ const binding = await this.requireBinding(scope, resourceKind, targetSelector, "pending");
32923
+ this.bindings.set(this.bindingKey(scope, resourceKind, targetSelector), {
32924
+ ...binding,
32925
+ state: "terminal_nonacceptance",
32926
+ updated_at: updatedAt
32927
+ });
32928
+ this.mutations.push(() => {
32929
+ const result = this.db.query(`
32930
+ UPDATE todos_project_registration_bindings
32931
+ SET state = 'terminal_nonacceptance', updated_at = ?
32932
+ WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
32933
+ AND resource_kind = ? AND target_selector = ? AND state = 'pending'
32934
+ `).run(updatedAt, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
32935
+ if (result.changes !== 1) {
32936
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration binding was no longer pending at SQLite commit");
32937
+ }
32938
+ });
32939
+ }
32940
+ async setBindingRemoved(scope, resourceKind, targetSelector, removedReceiptId, updatedAt) {
32941
+ const binding = await this.requireBinding(scope, resourceKind, targetSelector, "accepted");
32942
+ this.bindings.set(this.bindingKey(scope, resourceKind, targetSelector), {
32943
+ ...binding,
32944
+ state: "removed",
32945
+ removed_receipt_id: removedReceiptId,
32946
+ updated_at: updatedAt
32947
+ });
32948
+ this.mutations.push(() => {
32949
+ const result = this.db.query(`
32950
+ UPDATE todos_project_registration_bindings
32951
+ SET state = 'removed', removed_receipt_id = ?, updated_at = ?
32952
+ WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
32953
+ AND resource_kind = ? AND target_selector = ? AND state = 'accepted'
32954
+ `).run(removedReceiptId, updatedAt, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
32955
+ if (result.changes !== 1) {
32956
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration binding was no longer accepted at SQLite commit");
32957
+ }
32958
+ });
32959
+ }
32960
+ async findProjectConflict(path, taskListSlug) {
32961
+ const planned = [...this.projects.values()].find((project) => project?.path === path || project?.task_list_id === taskListSlug);
32962
+ if (planned)
32963
+ return planned;
32964
+ const observed = selectProjectConflict(this.db, path, taskListSlug);
32965
+ this.validators.push(() => sameSqliteValue(selectProjectConflict(this.db, path, taskListSlug), observed));
32966
+ return observed;
32967
+ }
32968
+ async findTaskListConflict(projectId, slug) {
32969
+ const planned = [...this.taskLists.values()].find((taskList) => taskList?.project_id === projectId && taskList.slug === slug);
32970
+ if (planned)
32971
+ return planned;
32972
+ const observed = selectTaskListConflict(this.db, projectId, slug);
32973
+ this.validators.push(() => sameSqliteValue(selectTaskListConflict(this.db, projectId, slug), observed));
32974
+ return observed;
32975
+ }
32976
+ async createProject(input) {
32977
+ const derivedSlug = normalizeSlug(input.name);
32978
+ const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : normalizeSlug(input.task_list_id);
32979
+ if (!derivedSlug || !taskListId) {
32980
+ throw new Error("Project name and task-list slug must be non-empty");
32981
+ }
32982
+ const project = {
32983
+ id: uuid(),
32984
+ name: input.name,
32985
+ path: input.path,
32986
+ description: input.description || null,
32987
+ task_list_id: taskListId,
32988
+ task_prefix: input.task_prefix ?? this.availableProjectPrefix(input.name),
32989
+ task_counter: 0,
32990
+ created_at: now(),
32991
+ updated_at: now(),
32992
+ machine_id: currentStorageMachineId(this.db)
32993
+ };
32994
+ project.updated_at = project.created_at;
32995
+ this.projects.set(project.id, project);
32996
+ this.mutations.push(() => {
32997
+ try {
32998
+ const result = this.db.run(`INSERT INTO projects (
32999
+ id, name, path, description, task_list_id, task_prefix,
33000
+ task_counter, created_at, updated_at, machine_id
33001
+ ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [
33002
+ project.id,
33003
+ project.name,
33004
+ project.path,
33005
+ project.description,
33006
+ project.task_list_id,
33007
+ project.task_prefix,
33008
+ project.created_at,
33009
+ project.updated_at,
33010
+ project.machine_id ?? null
33011
+ ]);
33012
+ if (result.changes < 1) {
33013
+ throw new SqliteRegistrationOptimisticConflict("Todos project changed before SQLite registration commit");
33014
+ }
33015
+ } catch (error) {
33016
+ if (error instanceof SqliteRegistrationOptimisticConflict)
33017
+ throw error;
33018
+ throw new SqliteRegistrationOptimisticConflict("Todos project conflicted at SQLite registration commit", { cause: error });
33019
+ }
33020
+ });
33021
+ return project;
33022
+ }
33023
+ async createTaskList(input) {
33024
+ const slug = normalizeSlug(input.slug === undefined ? input.name : input.slug);
33025
+ if (!slug)
33026
+ throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
33027
+ const taskList = {
33028
+ id: uuid(),
33029
+ project_id: input.project_id || null,
33030
+ slug,
33031
+ name: input.name,
33032
+ description: input.description || null,
33033
+ metadata: input.metadata ?? {},
33034
+ created_at: now(),
33035
+ updated_at: now(),
33036
+ machine_id: currentStorageMachineId(this.db)
33037
+ };
33038
+ taskList.updated_at = taskList.created_at;
33039
+ this.taskLists.set(taskList.id, taskList);
33040
+ this.mutations.push(() => {
33041
+ try {
33042
+ const result = this.db.run(`INSERT INTO task_lists (
33043
+ id, project_id, slug, name, description, metadata,
33044
+ created_at, updated_at, machine_id
33045
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
33046
+ taskList.id,
33047
+ taskList.project_id,
33048
+ taskList.slug,
33049
+ taskList.name,
33050
+ taskList.description,
33051
+ JSON.stringify(taskList.metadata),
33052
+ taskList.created_at,
33053
+ taskList.updated_at,
33054
+ taskList.machine_id ?? null
33055
+ ]);
33056
+ if (result.changes < 1) {
33057
+ throw new SqliteRegistrationOptimisticConflict("Todos task list changed before SQLite registration commit");
33058
+ }
33059
+ } catch (error) {
33060
+ if (error instanceof SqliteRegistrationOptimisticConflict)
33061
+ throw error;
33062
+ throw new SqliteRegistrationOptimisticConflict("Todos task list conflicted at SQLite registration commit", { cause: error });
33063
+ }
33064
+ });
33065
+ return taskList;
33066
+ }
33067
+ async getProject(id) {
33068
+ if (this.projects.has(id))
33069
+ return this.projects.get(id) ?? null;
33070
+ const observed = selectProject(this.db, id);
33071
+ this.validators.push(() => sameSqliteValue(selectProject(this.db, id), observed));
33072
+ return observed;
33073
+ }
33074
+ async getTaskList(id) {
33075
+ if (this.taskLists.has(id))
33076
+ return this.taskLists.get(id) ?? null;
33077
+ const observed = selectTaskList(this.db, id);
33078
+ this.validators.push(() => sameSqliteValue(selectTaskList(this.db, id), observed));
33079
+ return observed;
33080
+ }
33081
+ async lockCompensationWrites() {}
33082
+ async hasDependents(resourceKind, targetId) {
33083
+ const observed = hasSqliteDependents(this.db, resourceKind, targetId);
33084
+ this.validators.push(() => hasSqliteDependents(this.db, resourceKind, targetId) === observed);
33085
+ return observed;
33086
+ }
33087
+ async deleteProject(id) {
33088
+ const project = await this.getProject(id);
33089
+ if (!project)
33090
+ return false;
33091
+ this.projects.set(id, null);
33092
+ this.mutations.push(() => {
33093
+ recordStorageTombstone({
33094
+ object_type: "projects",
33095
+ object_id: id,
33096
+ payload: project
33097
+ }, this.db);
33098
+ if (this.db.run("DELETE FROM projects WHERE id = ?", [id]).changes < 1) {
33099
+ throw new SqliteRegistrationOptimisticConflict("Todos project changed before SQLite compensation commit");
33100
+ }
33101
+ });
33102
+ return true;
33103
+ }
33104
+ async deleteTaskList(id) {
33105
+ const taskList = await this.getTaskList(id);
33106
+ if (!taskList)
33107
+ return false;
33108
+ this.taskLists.set(id, null);
33109
+ this.mutations.push(() => {
33110
+ recordStorageTombstone({
33111
+ object_type: "task_lists",
33112
+ object_id: id,
33113
+ payload: taskList
33114
+ }, this.db);
33115
+ if (this.db.run("DELETE FROM task_lists WHERE id = ?", [id]).changes < 1) {
33116
+ throw new SqliteRegistrationOptimisticConflict("Todos task list changed before SQLite compensation commit");
33117
+ }
33118
+ });
33119
+ return true;
33120
+ }
33121
+ bindingKey(scope, resourceKind, targetSelector) {
33122
+ return JSON.stringify([
33123
+ scope.authority_id,
33124
+ scope.tenant_id,
33125
+ scope.corpus_id,
33126
+ resourceKind,
33127
+ targetSelector
33128
+ ]);
33129
+ }
33130
+ async requireBinding(scope, resourceKind, targetSelector, state) {
33131
+ const binding = await this.getBinding(scope, resourceKind, targetSelector);
33132
+ if (!binding || binding.state !== state) {
33133
+ throw new Error(`Todos project registration binding was not ${state}`);
33134
+ }
33135
+ return binding;
33136
+ }
33137
+ availableProjectPrefix(name) {
33138
+ const words = name.replace(/[^a-zA-Z0-9\s]/g, "").trim().split(/\s+/);
33139
+ const prefix = words.length >= 3 ? words.slice(0, 3).map((word) => word[0].toUpperCase()).join("") : words.length === 2 ? (words[0].slice(0, 2) + words[1][0]).toUpperCase() : words[0].slice(0, 3).toUpperCase();
33140
+ let candidate = prefix;
33141
+ let suffix = 1;
33142
+ while (this.db.query("SELECT id FROM projects WHERE task_prefix = ? LIMIT 1").get(candidate) || [...this.projects.values()].some((project) => project?.task_prefix === candidate)) {
33143
+ suffix += 1;
33144
+ candidate = `${prefix}${suffix}`;
33145
+ }
33146
+ return candidate;
33147
+ }
33148
+ }
33149
+
32700
33150
  class SqliteTodosProjectRegistrationBackend {
32701
33151
  db;
32702
33152
  kind = "sqlite";
@@ -32715,15 +33165,19 @@ class SqliteTodosProjectRegistrationBackend {
32715
33165
  sqliteTransactionTails2.set(this.db, current);
32716
33166
  await previous;
32717
33167
  try {
32718
- this.db.exec("BEGIN IMMEDIATE");
32719
- const result = await fn(new SqliteTodosProjectRegistrationTransaction(this.db));
32720
- this.db.exec("COMMIT");
32721
- return result;
32722
- } catch (error) {
32723
- try {
32724
- this.db.exec("ROLLBACK");
32725
- } catch {}
32726
- throw error;
33168
+ for (let attempt = 0;attempt < SQLITE_TRANSACTION_RETRY_LIMIT; attempt += 1) {
33169
+ const transaction = new StagedSqliteTodosProjectRegistrationTransaction(this.db);
33170
+ const result = await fn(transaction);
33171
+ try {
33172
+ transaction.commit();
33173
+ return result;
33174
+ } catch (error) {
33175
+ if (!(error instanceof SqliteRegistrationOptimisticConflict) || attempt === SQLITE_TRANSACTION_RETRY_LIMIT - 1) {
33176
+ throw error;
33177
+ }
33178
+ }
33179
+ }
33180
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration exhausted SQLite optimistic retries");
32727
33181
  } finally {
32728
33182
  release();
32729
33183
  if (sqliteTransactionTails2.get(this.db) === current) {