@hasna/todos 0.15.46 → 0.15.49

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 (58) hide show
  1. package/dashboard/dist/assets/index-BNQ4gJua.js +342 -0
  2. package/dashboard/dist/assets/index-DjzvHUWt.css +1 -0
  3. package/dashboard/dist/index.html +2 -2
  4. package/dist/cli/commands/query-commands.d.ts.map +1 -1
  5. package/dist/cli/components/Dashboard.d.ts +1 -1
  6. package/dist/cli/components/Dashboard.d.ts.map +1 -1
  7. package/dist/cli/components/Header.d.ts +1 -1
  8. package/dist/cli/components/Header.d.ts.map +1 -1
  9. package/dist/cli/components/ProjectList.d.ts +1 -1
  10. package/dist/cli/components/ProjectList.d.ts.map +1 -1
  11. package/dist/cli/components/SearchView.d.ts +1 -1
  12. package/dist/cli/components/SearchView.d.ts.map +1 -1
  13. package/dist/cli/components/TaskDetail.d.ts +1 -1
  14. package/dist/cli/components/TaskDetail.d.ts.map +1 -1
  15. package/dist/cli/components/TaskForm.d.ts +1 -1
  16. package/dist/cli/components/TaskForm.d.ts.map +1 -1
  17. package/dist/cli/components/TaskList.d.ts +1 -1
  18. package/dist/cli/components/TaskList.d.ts.map +1 -1
  19. package/dist/cli/index.js +671 -155
  20. package/dist/contracts.js +396 -82
  21. package/dist/db/task-crud.d.ts +6 -0
  22. package/dist/db/task-crud.d.ts.map +1 -1
  23. package/dist/db/tasks.d.ts +1 -1
  24. package/dist/db/tasks.d.ts.map +1 -1
  25. package/dist/db/webhooks.d.ts.map +1 -1
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +561 -143
  29. package/dist/lib/dedupe-projection.d.ts +48 -0
  30. package/dist/lib/dedupe-projection.d.ts.map +1 -0
  31. package/dist/lib/redaction.d.ts.map +1 -1
  32. package/dist/lib/task-dedupe.d.ts +5 -9
  33. package/dist/lib/task-dedupe.d.ts.map +1 -1
  34. package/dist/mcp/index.js +621 -154
  35. package/dist/mcp/tools/task-crud.d.ts.map +1 -1
  36. package/dist/mcp.js +6 -4
  37. package/dist/project-registration.js +502 -141
  38. package/dist/registry.js +396 -82
  39. package/dist/release-provenance.json +5 -5
  40. package/dist/server/cloud.d.ts.map +1 -1
  41. package/dist/server/index.js +1278 -342
  42. package/dist/server/routes.d.ts.map +1 -1
  43. package/dist/server/v1.d.ts.map +1 -1
  44. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  45. package/dist/storage/postgres-errors.d.ts +15 -0
  46. package/dist/storage/postgres-errors.d.ts.map +1 -0
  47. package/dist/storage/postgres-sync.d.ts +7 -0
  48. package/dist/storage/postgres-sync.d.ts.map +1 -1
  49. package/dist/storage/shadow-outbox.d.ts.map +1 -1
  50. package/dist/storage/shadow.d.ts.map +1 -1
  51. package/dist/storage.js +510 -137
  52. package/dist/task-manifest.js +118 -47
  53. package/dist/task-subtree-transfer.js +326 -44
  54. package/dist/types/index.d.ts +2 -2
  55. package/dist/types/index.d.ts.map +1 -1
  56. package/package.json +6 -4
  57. package/dashboard/dist/assets/index-DJm6m6Yy.css +0 -1
  58. package/dashboard/dist/assets/index-DVotjwab.js +0 -346
@@ -623,13 +623,15 @@ function upsertSecretSafetyConfig(input) {
623
623
  saveConfig({ ...config, secret_safety: next });
624
624
  return next;
625
625
  }
626
- var DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
626
+ var XAI_PREFIX, DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
627
627
  var init_redaction = __esm(() => {
628
628
  init_config();
629
+ XAI_PREFIX = ["x", "ai", "-"].join("");
629
630
  DEFAULT_SECRET_PATTERNS = [
630
631
  { name: "aws-access-key", regex: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: "[REDACTED_AWS_KEY]" },
631
632
  { name: "private-key", regex: /-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |)PRIVATE KEY-----/g, replacement: "[REDACTED_PRIVATE_KEY]" },
632
633
  { name: "openai-token", regex: /\bsk-[A-Za-z0-9_-]{12,}\b/g, replacement: "[REDACTED_TOKEN]" },
634
+ { name: `${XAI_PREFIX}token`, regex: /\bxai[-][A-Za-z0-9]{20,80}\b/g, replacement: "[REDACTED_TOKEN]" },
633
635
  { name: "npm-token", regex: /\bnpm_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_NPM_TOKEN]" },
634
636
  { name: "github-fine-grained-token", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
635
637
  { name: "github-token", regex: /\bgh[opsu]_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
@@ -6607,8 +6609,9 @@ var init_event_hooks = __esm(() => {
6607
6609
  VALID_TARGETS = new Set(["stdout", "file", "socket", "script"]);
6608
6610
  });
6609
6611
 
6610
- // node_modules/.bun/@hasna+events@0.1.11/node_modules/@hasna/events/dist/index.js
6612
+ // node_modules/.bun/@hasna+events@0.1.16/node_modules/@hasna/events/dist/index.js
6611
6613
  import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
6614
+ import { Buffer as Buffer2 } from "buffer";
6612
6615
  import { existsSync as existsSync6 } from "fs";
6613
6616
  import { homedir as homedir2 } from "os";
6614
6617
  import { join as join4 } from "path";
@@ -6717,11 +6720,13 @@ function getEventsDataDir(override) {
6717
6720
 
6718
6721
  class JsonEventsStore {
6719
6722
  dataDir;
6723
+ runtime;
6720
6724
  channelsPath;
6721
6725
  eventsPath;
6722
6726
  deliveriesPath;
6723
6727
  constructor(dataDir = getEventsDataDir()) {
6724
6728
  this.dataDir = dataDir;
6729
+ this.runtime = localJsonRuntime(dataDir);
6725
6730
  this.channelsPath = join4(dataDir, "channels.json");
6726
6731
  this.eventsPath = join4(dataDir, "events.json");
6727
6732
  this.deliveriesPath = join4(dataDir, "deliveries.json");
@@ -6769,13 +6774,58 @@ class JsonEventsStore {
6769
6774
  await this.writeJson(this.eventsPath, events);
6770
6775
  return event;
6771
6776
  }
6772
- async listEvents() {
6777
+ async appendEventOnce(event, options = {}) {
6773
6778
  await this.init();
6774
- return this.readJson(this.eventsPath, []);
6779
+ const events = await this.readJson(this.eventsPath, []);
6780
+ const dedupe = options.dedupe !== false;
6781
+ if (dedupe) {
6782
+ const existing = findEventByIdentity(events, { id: event.id, dedupeKey: event.dedupeKey });
6783
+ if (existing) {
6784
+ return {
6785
+ event: existing,
6786
+ stored: false,
6787
+ deduped: true,
6788
+ identity: { id: existing.id, dedupeKey: existing.dedupeKey }
6789
+ };
6790
+ }
6791
+ }
6792
+ events.push(event);
6793
+ await this.writeJson(this.eventsPath, events);
6794
+ return {
6795
+ event,
6796
+ stored: true,
6797
+ deduped: false,
6798
+ identity: { id: event.id, dedupeKey: event.dedupeKey }
6799
+ };
6800
+ }
6801
+ async listEvents(options = {}) {
6802
+ await this.init();
6803
+ const events = await this.readJson(this.eventsPath, []);
6804
+ return queryEvents(events, options);
6805
+ }
6806
+ async listEventsPage(options = {}) {
6807
+ await this.init();
6808
+ const events = await this.readJson(this.eventsPath, []);
6809
+ const queried = queryEvents(events, {
6810
+ eventId: options.eventId,
6811
+ source: options.source,
6812
+ type: options.type
6813
+ });
6814
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
6815
+ const limit = normalizeEventPageLimit(options.limit);
6816
+ const pageEvents = queried.slice(offset, offset + limit);
6817
+ const nextOffset = offset + pageEvents.length;
6818
+ const hasMore = nextOffset < queried.length;
6819
+ return {
6820
+ events: pageEvents,
6821
+ cursor: options.cursor,
6822
+ nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
6823
+ hasMore
6824
+ };
6775
6825
  }
6776
6826
  async findEventByIdentity(identity) {
6777
6827
  const events = await this.listEvents();
6778
- return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
6828
+ return findEventByIdentity(events, identity);
6779
6829
  }
6780
6830
  async appendDelivery(result) {
6781
6831
  await this.init();
@@ -6826,6 +6876,83 @@ class JsonEventsStore {
6826
6876
  });
6827
6877
  }
6828
6878
  }
6879
+ function localJsonRuntime(dataDir = getEventsDataDir()) {
6880
+ return {
6881
+ mode: "local-files",
6882
+ name: "json-events-store",
6883
+ remote: false,
6884
+ localFiles: true,
6885
+ localSqlite: false,
6886
+ postgres: false,
6887
+ s3: false,
6888
+ aws: false,
6889
+ durable: true,
6890
+ idempotency: "best-effort-local",
6891
+ replayCursors: true,
6892
+ description: `Local JSON files in ${dataDir}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
6893
+ };
6894
+ }
6895
+ function encodeLocalJsonEventCursor(offset, options = {}) {
6896
+ if (!Number.isInteger(offset) || offset < 0)
6897
+ throw new Error(`Invalid event cursor offset: ${offset}`);
6898
+ const payload = {
6899
+ offset,
6900
+ eventId: options.eventId,
6901
+ source: options.source,
6902
+ type: options.type
6903
+ };
6904
+ return `${LOCAL_JSON_EVENT_CURSOR_PREFIX}${Buffer2.from(JSON.stringify(payload), "utf-8").toString("base64url")}`;
6905
+ }
6906
+ function decodeLocalJsonEventCursor(cursor, options = {}) {
6907
+ if (!cursor)
6908
+ return 0;
6909
+ if (!cursor.startsWith(LOCAL_JSON_EVENT_CURSOR_PREFIX))
6910
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
6911
+ const rawPayload = cursor.slice(LOCAL_JSON_EVENT_CURSOR_PREFIX.length);
6912
+ let payload;
6913
+ try {
6914
+ payload = JSON.parse(Buffer2.from(rawPayload, "base64url").toString("utf-8"));
6915
+ } catch {
6916
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
6917
+ }
6918
+ const offset = payload.offset;
6919
+ if (!Number.isInteger(offset) || offset < 0)
6920
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
6921
+ assertCursorFilter("eventId", payload.eventId, options.eventId);
6922
+ assertCursorFilter("source", payload.source, options.source);
6923
+ assertCursorFilter("type", payload.type, options.type);
6924
+ return offset;
6925
+ }
6926
+ function normalizeEventPageLimit(limit) {
6927
+ if (limit === undefined)
6928
+ return DEFAULT_EVENT_PAGE_LIMIT;
6929
+ if (!Number.isInteger(limit) || limit < 1)
6930
+ throw new Error(`Event page limit must be a positive integer, got ${limit}`);
6931
+ return Math.min(limit, MAX_EVENT_PAGE_LIMIT);
6932
+ }
6933
+ function queryEvents(events, options) {
6934
+ let rows = events;
6935
+ if (options.eventId)
6936
+ rows = rows.filter((event) => event.id === options.eventId);
6937
+ if (options.source)
6938
+ rows = rows.filter((event) => event.source === options.source);
6939
+ if (options.type)
6940
+ rows = rows.filter((event) => event.type === options.type);
6941
+ if (options.cursor) {
6942
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
6943
+ rows = rows.slice(offset);
6944
+ }
6945
+ if (options.limit !== undefined)
6946
+ rows = rows.slice(0, normalizeEventPageLimit(options.limit));
6947
+ return rows;
6948
+ }
6949
+ function assertCursorFilter(name, cursorValue, optionValue) {
6950
+ if (cursorValue !== optionValue)
6951
+ throw new Error(`Local JSON event cursor ${name} filter mismatch`);
6952
+ }
6953
+ function findEventByIdentity(events, identity) {
6954
+ return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
6955
+ }
6829
6956
  function buildSignatureBase(timestamp2, body) {
6830
6957
  return `${timestamp2}.${body}`;
6831
6958
  }
@@ -6839,21 +6966,27 @@ function now2() {
6839
6966
  function truncate(value, max = 4096) {
6840
6967
  return value.length > max ? `${value.slice(0, max)}...` : value;
6841
6968
  }
6842
- function buildWebhookRequest(event, channel) {
6969
+ function buildWebhookRequest(event, channel, options = {}) {
6843
6970
  if (!channel.webhook)
6844
6971
  throw new Error(`Channel ${channel.id} has no webhook config`);
6972
+ for (const name of Object.keys(channel.webhook.headers ?? {})) {
6973
+ if (/^x-hasna-/i.test(name)) {
6974
+ throw new Error(`Webhook header ${name} is reserved for signed delivery metadata`);
6975
+ }
6976
+ }
6845
6977
  const body = JSON.stringify(event);
6846
- const timestamp2 = event.time;
6978
+ const timestamp2 = options.timestamp ?? new Date().toISOString();
6847
6979
  const headers = {
6848
6980
  "Content-Type": "application/json",
6849
6981
  "User-Agent": "@hasna/events",
6850
6982
  "X-Hasna-Event-Id": event.id,
6851
6983
  "X-Hasna-Event-Type": event.type,
6852
- "X-Hasna-Timestamp": timestamp2,
6853
- ...channel.webhook.headers
6984
+ ...channel.webhook.headers,
6985
+ "X-Hasna-Timestamp": timestamp2
6854
6986
  };
6855
- if (channel.webhook.secret) {
6856
- headers["X-Hasna-Signature"] = signPayload(channel.webhook.secret, timestamp2, body);
6987
+ const secret = options.secret ?? channel.webhook.secret;
6988
+ if (secret) {
6989
+ headers["X-Hasna-Signature"] = signPayload(secret, timestamp2, body);
6857
6990
  }
6858
6991
  return { body, headers };
6859
6992
  }
@@ -6861,7 +6994,21 @@ async function dispatchWebhook(event, channel, options = {}) {
6861
6994
  if (!channel.webhook)
6862
6995
  throw new Error(`Channel ${channel.id} has no webhook config`);
6863
6996
  const startedAt = now2();
6864
- const { body, headers } = buildWebhookRequest(event, channel);
6997
+ let secret = channel.webhook.secret;
6998
+ if (channel.webhook.secretRef) {
6999
+ if (!options.secretResolver) {
7000
+ return failedAttempt(startedAt, "Webhook secret reference has no runtime resolver");
7001
+ }
7002
+ try {
7003
+ secret = await options.secretResolver(channel.webhook.secretRef);
7004
+ } catch {
7005
+ return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
7006
+ }
7007
+ if (!secret)
7008
+ return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
7009
+ }
7010
+ const timestamp2 = (options.now?.() ?? new Date).toISOString();
7011
+ const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp: timestamp2 });
6865
7012
  const controller = new AbortController;
6866
7013
  const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
6867
7014
  try {
@@ -6893,6 +7040,15 @@ async function dispatchWebhook(event, channel, options = {}) {
6893
7040
  clearTimeout(timeout);
6894
7041
  }
6895
7042
  }
7043
+ function failedAttempt(startedAt, error) {
7044
+ return {
7045
+ attempt: 1,
7046
+ status: "failed",
7047
+ startedAt,
7048
+ completedAt: now2(),
7049
+ error
7050
+ };
7051
+ }
6896
7052
  async function dispatchCommand(event, channel) {
6897
7053
  if (!channel.command)
6898
7054
  throw new Error(`Channel ${channel.id} has no command config`);
@@ -6981,6 +7137,76 @@ function createDeliveryResult(event, channel, attempts) {
6981
7137
  completedAt: attempts.at(-1)?.completedAt ?? now2()
6982
7138
  };
6983
7139
  }
7140
+
7141
+ class EventTypeCatalog {
7142
+ definitions = new Map;
7143
+ register(definition) {
7144
+ this.definitions.set(definition.type, definition);
7145
+ return this;
7146
+ }
7147
+ unregister(type) {
7148
+ return this.definitions.delete(type);
7149
+ }
7150
+ has(type) {
7151
+ return this.definitions.has(type);
7152
+ }
7153
+ get(type) {
7154
+ return this.definitions.get(type);
7155
+ }
7156
+ list() {
7157
+ return [...this.definitions.values()];
7158
+ }
7159
+ validateEvent(event) {
7160
+ const definition = this.definitions.get(event.type);
7161
+ if (!definition)
7162
+ return { ok: true };
7163
+ return definition.validate(event.data, event);
7164
+ }
7165
+ assertEventValid(event) {
7166
+ const result = this.validateEvent(event);
7167
+ if (!result.ok) {
7168
+ throw new EventValidationError(event.type, result.issues);
7169
+ }
7170
+ }
7171
+ }
7172
+ function redactPaths(event, paths, replacement = "[REDACTED]") {
7173
+ if (paths.length === 0)
7174
+ return event;
7175
+ const copy = structuredClone(event);
7176
+ for (const path of paths) {
7177
+ setPath(copy, path, replacement);
7178
+ }
7179
+ return copy;
7180
+ }
7181
+ function redactSensitiveKeys(event, replacement = "[REDACTED]") {
7182
+ return redactValue2(event, replacement);
7183
+ }
7184
+ function shouldRedactKey(key) {
7185
+ return /secret|token|password|api[_-]?key|authorization/i.test(key);
7186
+ }
7187
+ function redactValue2(value, replacement) {
7188
+ if (Array.isArray(value))
7189
+ return value.map((item) => redactValue2(item, replacement));
7190
+ if (!value || typeof value !== "object")
7191
+ return value;
7192
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
7193
+ key,
7194
+ shouldRedactKey(key) ? replacement : redactValue2(item, replacement)
7195
+ ]));
7196
+ }
7197
+ function setPath(input, path, replacement) {
7198
+ const parts = path.split(".");
7199
+ let cursor = input;
7200
+ for (const part of parts.slice(0, -1)) {
7201
+ const next = cursor[part];
7202
+ if (!next || typeof next !== "object")
7203
+ return;
7204
+ cursor = next;
7205
+ }
7206
+ const last = parts.at(-1);
7207
+ if (last && last in cursor)
7208
+ cursor[last] = replacement;
7209
+ }
6984
7210
  function createEvent(input) {
6985
7211
  return {
6986
7212
  id: input.id ?? randomUUID22(),
@@ -7001,10 +7227,18 @@ class EventsClient {
7001
7227
  store;
7002
7228
  redactors;
7003
7229
  transportOptions;
7230
+ catalog;
7231
+ validateCatalogTypes;
7004
7232
  constructor(options = {}) {
7005
7233
  this.store = options.store ?? new JsonEventsStore(options.dataDir);
7006
7234
  this.redactors = options.redactors ?? [];
7007
- this.transportOptions = { fetchImpl: options.fetchImpl };
7235
+ this.transportOptions = {
7236
+ fetchImpl: options.fetchImpl,
7237
+ secretResolver: options.secretResolver,
7238
+ now: options.now
7239
+ };
7240
+ this.catalog = options.catalog ?? defaultEventTypeCatalog;
7241
+ this.validateCatalogTypes = options.validateCatalogTypes ?? false;
7008
7242
  }
7009
7243
  async addChannel(input) {
7010
7244
  const timestamp2 = new Date().toISOString();
@@ -7022,18 +7256,40 @@ class EventsClient {
7022
7256
  }
7023
7257
  async emit(input, options = {}) {
7024
7258
  const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
7025
- if (options.dedupe !== false) {
7026
- const existing = await this.store.findEventByIdentity({ id: input.id, dedupeKey: event.dedupeKey });
7027
- if (existing) {
7028
- return { event: existing, deliveries: [], deduped: true };
7029
- }
7030
- }
7031
- await this.store.appendEvent(event);
7032
- const deliveries = options.deliver === false ? [] : await this.deliver(event);
7033
- return { event, deliveries, deduped: false };
7034
- }
7035
- async listEvents() {
7036
- return this.store.listEvents();
7259
+ if (options.validate ?? this.validateCatalogTypes) {
7260
+ this.catalog.assertEventValid(event);
7261
+ }
7262
+ const append = await this.appendEvent(event, { dedupe: options.dedupe !== false });
7263
+ if (append.deduped) {
7264
+ return { event: append.event, deliveries: [], deduped: true };
7265
+ }
7266
+ const deliveries = options.deliver === false ? [] : await this.deliver(append.event);
7267
+ return { event: append.event, deliveries, deduped: false };
7268
+ }
7269
+ async listEvents(options = {}) {
7270
+ if (Object.keys(options).length === 0)
7271
+ return this.store.listEvents();
7272
+ return queryClientEvents(await this.store.listEvents(), options);
7273
+ }
7274
+ async listEventsPage(options = {}) {
7275
+ if (this.store.listEventsPage)
7276
+ return this.store.listEventsPage(options);
7277
+ const events = queryClientEvents(await this.store.listEvents(), {
7278
+ eventId: options.eventId,
7279
+ source: options.source,
7280
+ type: options.type
7281
+ });
7282
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
7283
+ const limit = normalizeEventPageLimit(options.limit);
7284
+ const pageEvents = events.slice(offset, offset + limit);
7285
+ const nextOffset = offset + pageEvents.length;
7286
+ const hasMore = nextOffset < events.length;
7287
+ return {
7288
+ events: pageEvents,
7289
+ cursor: options.cursor,
7290
+ nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
7291
+ hasMore
7292
+ };
7037
7293
  }
7038
7294
  async listDeliveries() {
7039
7295
  return this.store.listDeliveries();
@@ -7101,22 +7357,37 @@ class EventsClient {
7101
7357
  return result;
7102
7358
  }
7103
7359
  async replay(options = {}) {
7104
- const events = (await this.store.listEvents()).filter((event) => {
7105
- if (options.eventId && event.id !== options.eventId)
7106
- return false;
7107
- if (options.source && event.source !== options.source)
7108
- return false;
7109
- if (options.type && event.type !== options.type)
7110
- return false;
7111
- return true;
7112
- });
7360
+ const page = options.cursor || options.limit !== undefined ? await this.listEventsPage(options) : { events: await this.listEvents(options), hasMore: false };
7113
7361
  if (options.dryRun)
7114
- return { events, deliveries: [] };
7362
+ return { events: page.events, deliveries: [], cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
7115
7363
  const deliveries = [];
7116
- for (const event of events) {
7364
+ for (const event of page.events) {
7117
7365
  deliveries.push(...await this.deliver(event));
7118
7366
  }
7119
- return { events, deliveries };
7367
+ return { events: page.events, deliveries, cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
7368
+ }
7369
+ async appendEvent(event, options) {
7370
+ if (this.store.appendEventOnce) {
7371
+ return this.store.appendEventOnce(event, { dedupe: options.dedupe });
7372
+ }
7373
+ if (options.dedupe) {
7374
+ const existing = await this.store.findEventByIdentity({ id: event.id, dedupeKey: event.dedupeKey });
7375
+ if (existing) {
7376
+ return {
7377
+ event: existing,
7378
+ stored: false,
7379
+ deduped: true,
7380
+ identity: { id: existing.id, dedupeKey: existing.dedupeKey }
7381
+ };
7382
+ }
7383
+ }
7384
+ const stored = await this.store.appendEvent(event);
7385
+ return {
7386
+ event: stored,
7387
+ stored: true,
7388
+ deduped: false,
7389
+ identity: { id: stored.id, dedupeKey: stored.dedupeKey }
7390
+ };
7120
7391
  }
7121
7392
  async applyRedaction(event, channel) {
7122
7393
  let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
@@ -7143,43 +7414,19 @@ class EventsClient {
7143
7414
  return createDeliveryResult(event, channel, attempts);
7144
7415
  }
7145
7416
  }
7146
- function redactPaths(event, paths, replacement = "[REDACTED]") {
7147
- if (paths.length === 0)
7148
- return event;
7149
- const copy = structuredClone(event);
7150
- for (const path of paths) {
7151
- setPath(copy, path, replacement);
7152
- }
7153
- return copy;
7154
- }
7155
- function redactSensitiveKeys(event, replacement = "[REDACTED]") {
7156
- return redactValue2(event, replacement);
7157
- }
7158
- function shouldRedactKey(key) {
7159
- return /secret|token|password|api[_-]?key|authorization/i.test(key);
7160
- }
7161
- function redactValue2(value, replacement) {
7162
- if (Array.isArray(value))
7163
- return value.map((item) => redactValue2(item, replacement));
7164
- if (!value || typeof value !== "object")
7165
- return value;
7166
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [
7167
- key,
7168
- shouldRedactKey(key) ? replacement : redactValue2(item, replacement)
7169
- ]));
7170
- }
7171
- function setPath(input, path, replacement) {
7172
- const parts = path.split(".");
7173
- let cursor = input;
7174
- for (const part of parts.slice(0, -1)) {
7175
- const next = cursor[part];
7176
- if (!next || typeof next !== "object")
7177
- return;
7178
- cursor = next;
7179
- }
7180
- const last = parts.at(-1);
7181
- if (last && last in cursor)
7182
- cursor[last] = replacement;
7417
+ function queryClientEvents(events, options) {
7418
+ let rows = events;
7419
+ if (options.eventId)
7420
+ rows = rows.filter((event) => event.id === options.eventId);
7421
+ if (options.source)
7422
+ rows = rows.filter((event) => event.source === options.source);
7423
+ if (options.type)
7424
+ rows = rows.filter((event) => event.type === options.type);
7425
+ if (options.cursor)
7426
+ rows = rows.slice(decodeLocalJsonEventCursor(options.cursor, options));
7427
+ if (options.limit !== undefined)
7428
+ rows = rows.slice(0, normalizeEventPageLimit(options.limit));
7429
+ return rows;
7183
7430
  }
7184
7431
  function normalizeTime(value) {
7185
7432
  if (!value)
@@ -7193,9 +7440,22 @@ function normalizeRetryPolicy(policy) {
7193
7440
  multiplier: Math.max(1, policy?.multiplier ?? 2)
7194
7441
  };
7195
7442
  }
7196
- var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR", HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME", DEFAULT_SIGNATURE_TOLERANCE_MS;
7443
+ var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR", HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME", LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:", DEFAULT_EVENT_PAGE_LIMIT = 100, MAX_EVENT_PAGE_LIMIT = 1000, DEFAULT_SIGNATURE_TOLERANCE_MS, EventValidationError, defaultEventTypeCatalog, APP_EVENT_V1_MAX_DATA_BYTES;
7197
7444
  var init_dist = __esm(() => {
7198
7445
  DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
7446
+ EventValidationError = class EventValidationError extends Error {
7447
+ eventType;
7448
+ issues;
7449
+ constructor(eventType, issues) {
7450
+ const detail = issues.map((issue) => `${issue.path || "<root>"}: ${issue.message}`).join("; ");
7451
+ super(`Event validation failed for type "${eventType}": ${detail}`);
7452
+ this.name = "EventValidationError";
7453
+ this.eventType = eventType;
7454
+ this.issues = issues;
7455
+ }
7456
+ };
7457
+ defaultEventTypeCatalog = new EventTypeCatalog;
7458
+ APP_EVENT_V1_MAX_DATA_BYTES = 32 * 1024;
7199
7459
  });
7200
7460
 
7201
7461
  // src/db/task-lists.ts
@@ -8070,8 +8330,12 @@ async function deliverWebhook(wh, event, body, attempt, db) {
8070
8330
  const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
8071
8331
  headers["X-Webhook-Signature"] = Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
8072
8332
  }
8073
- const resp = await fetch(wh.url, { method: "POST", headers, body });
8333
+ const resp = await fetch(wh.url, { method: "POST", headers, body, redirect: "manual" });
8074
8334
  const respText = await resp.text().catch(() => "");
8335
+ if (resp.status >= 300 && resp.status < 400) {
8336
+ logDelivery(db, wh.id, event, body, resp.status, "Blocked: webhook URL returned a 3xx redirect; redirects are never followed (SSRF prevention)", attempt);
8337
+ return;
8338
+ }
8075
8339
  logDelivery(db, wh.id, event, body, resp.status, respText.slice(0, 1000), attempt);
8076
8340
  if (resp.status >= 400 && attempt < MAX_RETRY_ATTEMPTS) {
8077
8341
  const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
@@ -9671,6 +9935,12 @@ function listTasks2(filter = {}, db) {
9671
9935
  const d = db || getDatabase();
9672
9936
  const { clearExpiredLocks: clearExpiredLocks2 } = (init_database(), __toCommonJS(exports_database));
9673
9937
  clearExpiredLocks2(d);
9938
+ if (filter.limit !== undefined) {
9939
+ const limit = filter.limit;
9940
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > MAX_TASK_LIST_LIMIT) {
9941
+ throw new TypeError(`listTasks limit must be a positive integer between 1 and ${MAX_TASK_LIST_LIMIT}; got ${String(limit)}`);
9942
+ }
9943
+ }
9674
9944
  const conditions = [];
9675
9945
  const params = [];
9676
9946
  if (filter.project_id) {
@@ -10204,6 +10474,7 @@ function deleteTask(id, db) {
10204
10474
  }
10205
10475
  return result.changes > 0;
10206
10476
  }
10477
+ var MAX_TASK_LIST_LIMIT = 1e5;
10207
10478
  var init_task_crud = __esm(() => {
10208
10479
  init_types();
10209
10480
  init_database();
@@ -12812,7 +13083,7 @@ import { createHash as createHash7 } from "crypto";
12812
13083
  // package.json
12813
13084
  var package_default = {
12814
13085
  name: "@hasna/todos",
12815
- version: "0.15.46",
13086
+ version: "0.15.49",
12816
13087
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
12817
13088
  type: "module",
12818
13089
  main: "dist/index.js",
@@ -12876,6 +13147,7 @@ var package_default = {
12876
13147
  ],
12877
13148
  scripts: {
12878
13149
  build: "rm -rf dist dashboard/dist && cd dashboard && bun install --frozen-lockfile && bun run build && cd .. && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts src/project-registration.ts src/task-manifest.ts src/task-subtree-transfer.ts --outdir dist --root src --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && tsc --emitDeclarationOnly --outDir dist",
13150
+ "build:js": "rm -rf dist && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts src/project-registration.ts src/task-manifest.ts src/task-subtree-transfer.ts --outdir dist --root src --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && tsc --emitDeclarationOnly --outDir dist",
12879
13151
  "build:server": "rm -rf dist && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts src/project-registration.ts src/task-manifest.ts src/task-subtree-transfer.ts --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*'",
12880
13152
  migrate: "bun run src/server/index.ts migrate",
12881
13153
  "backfill:comment-redaction": "bun run src/server/index.ts redact-comments",
@@ -12930,18 +13202,19 @@ var package_default = {
12930
13202
  author: "Andrei Hasna <andrei@hasna.com>",
12931
13203
  license: "Apache-2.0",
12932
13204
  dependencies: {
12933
- "@hasna/contracts": "0.13.4",
13205
+ "@hasna/contracts": "0.14.0",
12934
13206
  "@hasna/events": "^0.1.11",
12935
13207
  "@modelcontextprotocol/sdk": "^1.12.1",
12936
13208
  chalk: "^5.4.1",
12937
13209
  commander: "^13.1.0",
12938
13210
  ink: "^5.2.0",
12939
13211
  react: "^18.3.1",
12940
- zod: "^3.24.2"
13212
+ zod: "3.25.76"
12941
13213
  },
12942
13214
  overrides: {
12943
13215
  ajv: "8.20.0",
12944
- "fast-uri": "3.1.2"
13216
+ "fast-uri": "3.1.2",
13217
+ zod: "3.25.76"
12945
13218
  },
12946
13219
  devDependencies: {
12947
13220
  "@types/bun": "^1.2.4",
@@ -13078,6 +13351,30 @@ function assertPlanProjectLinkReceipt(value) {
13078
13351
  // src/storage/postgres-adapter.ts
13079
13352
  init_stale_lock_handoff();
13080
13353
 
13354
+ // src/storage/postgres-sync.ts
13355
+ init_types();
13356
+
13357
+ // src/storage/postgres-errors.ts
13358
+ function isPostgresUniqueViolation(error) {
13359
+ if (typeof error !== "object" || error === null)
13360
+ return false;
13361
+ const candidate = error;
13362
+ const states = [candidate.code, candidate.errno, candidate.sqlState, candidate.sqlstate];
13363
+ if (typeof candidate.cause === "object" && candidate.cause !== null) {
13364
+ const cause = candidate.cause;
13365
+ states.push(cause.code, cause.errno, cause.sqlState, cause.sqlstate);
13366
+ }
13367
+ return states.some((state) => String(state) === "23505");
13368
+ }
13369
+ function postgresConstraintName(error) {
13370
+ if (typeof error !== "object" || error === null)
13371
+ return "";
13372
+ const candidate = error;
13373
+ const cause = typeof candidate.cause === "object" && candidate.cause !== null ? candidate.cause : undefined;
13374
+ const constraint = candidate.constraint ?? candidate.constraint_name ?? cause?.constraint ?? cause?.constraint_name;
13375
+ return typeof constraint === "string" ? constraint : "";
13376
+ }
13377
+
13081
13378
  // src/storage/postgres-sync.ts
13082
13379
  var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records";
13083
13380
  var DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors";
@@ -13280,53 +13577,98 @@ class PostgresTodosSyncStore {
13280
13577
  if (routingErrors.length > 0) {
13281
13578
  throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
13282
13579
  }
13283
- const existing = await this.client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
13284
- FROM ${this.tableName}
13285
- WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
13286
- const existingProjects = [];
13287
- const existingTaskLists = [];
13288
- for (const row of existing.rows) {
13289
- const payload = payloadRecord(row.payload);
13290
- if (row.object_type === "projects")
13291
- existingProjects.push(payload);
13292
- if (row.object_type === "task_lists")
13293
- existingTaskLists.push(payload);
13294
- }
13295
- const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
13296
- if (destinationErrors.length > 0) {
13297
- throw new Error(`Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
13298
- }
13299
- const result = { records: 0, objectTypes: {} };
13300
- const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
13301
- for (const entry of snapshotEntries(snapshot)) {
13302
- if (entry.deletedAt === null)
13303
- assertCanonicalScopedSlugEntry(entry);
13304
- await this.client.query(`INSERT INTO ${this.tableName} (
13305
- service, object_type, object_id, payload, updated_at,
13306
- deleted_at, source_machine_id, version
13307
- ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $6::timestamptz, $7, $8)
13308
- ON CONFLICT (service, object_type, object_id) DO UPDATE SET
13309
- payload = EXCLUDED.payload,
13310
- updated_at = EXCLUDED.updated_at,
13311
- deleted_at = EXCLUDED.deleted_at,
13312
- source_machine_id = EXCLUDED.source_machine_id,
13313
- version = EXCLUDED.version
13314
- WHERE ${this.tableName}.updated_at < EXCLUDED.updated_at
13315
- OR (${this.tableName}.updated_at = EXCLUDED.updated_at
13316
- AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))`, [
13317
- this.service,
13318
- entry.type,
13319
- entry.id,
13320
- entry.payload,
13321
- entry.updatedAt,
13322
- entry.deletedAt,
13323
- sourceMachineId,
13324
- entry.version
13325
- ]);
13326
- result.records += 1;
13327
- result.objectTypes[entry.type] = (result.objectTypes[entry.type] ?? 0) + 1;
13580
+ const push = async (client) => {
13581
+ const existing = await client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
13582
+ FROM ${this.tableName}
13583
+ WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
13584
+ const existingProjects = [];
13585
+ const existingTaskLists = [];
13586
+ for (const row of existing.rows) {
13587
+ const payload = payloadRecord(row.payload);
13588
+ if (row.object_type === "projects")
13589
+ existingProjects.push(payload);
13590
+ if (row.object_type === "task_lists")
13591
+ existingTaskLists.push(payload);
13592
+ }
13593
+ const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
13594
+ if (destinationErrors.length > 0) {
13595
+ throw new ResourceConflictError("SNAPSHOT_DESTINATION_CONFLICT", `Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
13596
+ }
13597
+ const result = { records: 0, objectTypes: {} };
13598
+ const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
13599
+ for (const entry of snapshotEntries(snapshot)) {
13600
+ if (entry.deletedAt === null)
13601
+ assertCanonicalScopedSlugEntry(entry);
13602
+ try {
13603
+ await client.query(`INSERT INTO ${this.tableName} (
13604
+ service, object_type, object_id, payload, updated_at,
13605
+ deleted_at, source_machine_id, version
13606
+ ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $6::timestamptz, $7, $8)
13607
+ ON CONFLICT (service, object_type, object_id) DO UPDATE SET
13608
+ payload = EXCLUDED.payload,
13609
+ updated_at = EXCLUDED.updated_at,
13610
+ deleted_at = EXCLUDED.deleted_at,
13611
+ source_machine_id = EXCLUDED.source_machine_id,
13612
+ version = EXCLUDED.version
13613
+ WHERE ${this.tableName}.updated_at < EXCLUDED.updated_at
13614
+ OR (${this.tableName}.updated_at = EXCLUDED.updated_at
13615
+ AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))`, [
13616
+ this.service,
13617
+ entry.type,
13618
+ entry.id,
13619
+ entry.payload,
13620
+ entry.updatedAt,
13621
+ entry.deletedAt,
13622
+ sourceMachineId,
13623
+ entry.version
13624
+ ]);
13625
+ } catch (error) {
13626
+ await this.classifySyncInsertConflict(error, entry);
13627
+ }
13628
+ result.records += 1;
13629
+ result.objectTypes[entry.type] = (result.objectTypes[entry.type] ?? 0) + 1;
13630
+ }
13631
+ return result;
13632
+ };
13633
+ if (typeof this.client.transaction === "function") {
13634
+ return this.client.transaction((client) => push(client));
13328
13635
  }
13329
- return result;
13636
+ return push(this.client);
13637
+ }
13638
+ async classifySyncInsertConflict(error, entry) {
13639
+ if (!isPostgresUniqueViolation(error))
13640
+ throw error;
13641
+ const payload = entry.payload;
13642
+ const taskListSlug = String(payload["slug"] ?? "");
13643
+ const projectSlug = String(payload["task_list_id"] ?? "");
13644
+ const constraintName = postgresConstraintName(error);
13645
+ if (entry.type === "projects" && constraintName.includes("project_task_list_slug_uidx")) {
13646
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${projectSlug}" already exists`);
13647
+ }
13648
+ if (entry.type === "task_lists" && constraintName.includes("task_list_scope_slug_uidx")) {
13649
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${taskListSlug}" already exists in this scope`);
13650
+ }
13651
+ if (entry.type === "task_lists") {
13652
+ const scope = typeof payload["project_id"] === "string" ? payload["project_id"] : "";
13653
+ const conflict = await this.client.query(`/* todos:classify-sync-task-list-conflict */ SELECT EXISTS (
13654
+ SELECT 1 FROM ${this.tableName}
13655
+ WHERE service = $1 AND object_type = 'task_lists' AND object_id <> $2
13656
+ AND deleted_at IS NULL AND COALESCE(payload->>'project_id','') = $3 AND payload->>'slug' = $4
13657
+ ) AS conflict`, [this.service, entry.id, scope, taskListSlug]);
13658
+ if (conflict.rows[0]?.conflict) {
13659
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${taskListSlug}" already exists in this scope`);
13660
+ }
13661
+ } else if (entry.type === "projects") {
13662
+ const conflict = await this.client.query(`/* todos:classify-sync-project-conflict */ SELECT EXISTS (
13663
+ SELECT 1 FROM ${this.tableName}
13664
+ WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
13665
+ AND deleted_at IS NULL AND payload->>'task_list_id' = $3
13666
+ ) AS conflict`, [this.service, entry.id, projectSlug]);
13667
+ if (conflict.rows[0]?.conflict) {
13668
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${projectSlug}" already exists`);
13669
+ }
13670
+ }
13671
+ throw error;
13330
13672
  }
13331
13673
  async pullSnapshot(options = {}) {
13332
13674
  const params = [this.service];
@@ -13957,9 +14299,11 @@ class PostgresJsonRecordStore {
13957
14299
  async ensureSchema() {
13958
14300
  if (!this.schemaReady) {
13959
14301
  this.schemaReady = (async () => {
13960
- for (const sql of postgresTodosSyncSchemaSql(this.tableName, this.cursorTableName)) {
13961
- await this.options.client.query(sql);
13962
- }
14302
+ await retryOnTransientPostgresError(async () => {
14303
+ for (const sql of postgresTodosSyncSchemaSql(this.tableName, this.cursorTableName)) {
14304
+ await this.options.client.query(sql);
14305
+ }
14306
+ });
13963
14307
  })().catch((error) => {
13964
14308
  this.schemaReady = null;
13965
14309
  throw error;
@@ -14373,13 +14717,14 @@ class PostgresJsonRecordStore {
14373
14717
  throw new Error(divergentAuditHistoryReplayError(value.id));
14374
14718
  }
14375
14719
  async withTaskParentIntegrityTransaction(fn) {
14376
- if (typeof this.options.client.transaction !== "function") {
14720
+ const transaction = this.options.client.transaction;
14721
+ if (typeof transaction !== "function") {
14377
14722
  throw new Error("TASK_PARENT_ATOMICITY_UNAVAILABLE: PostgreSQL parent writes and task deletion require transaction(callback)");
14378
14723
  }
14379
- return this.options.client.transaction(async (client) => {
14724
+ return retryOnTransientPostgresError(() => transaction(async (client) => {
14380
14725
  await client.query("/* todos:task-parent-integrity-lock */ SELECT pg_advisory_xact_lock(hashtextextended($1 || ':task-parent-integrity', 0))", [this.service]);
14381
14726
  return fn(client);
14382
- });
14727
+ }));
14383
14728
  }
14384
14729
  async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}, parentGuard, queryClient) {
14385
14730
  const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
@@ -16265,7 +16610,13 @@ function compareClock(left, right) {
16265
16610
  function numberValue2(value) {
16266
16611
  return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
16267
16612
  }
16268
- function isPostgresUniqueViolation(error) {
16613
+ var TRANSIENT_POSTGRES_SQLSTATES = new Set(["55P03", "40P01", "40001"]);
16614
+ var TRANSIENT_POSTGRES_MESSAGE_MARKERS = [
16615
+ "canceling statement due to lock timeout",
16616
+ "deadlock detected",
16617
+ "could not serialize access"
16618
+ ];
16619
+ function isTransientPostgresError(error) {
16269
16620
  if (typeof error !== "object" || error === null)
16270
16621
  return false;
16271
16622
  const candidate = error;
@@ -16274,15 +16625,25 @@ function isPostgresUniqueViolation(error) {
16274
16625
  const cause = candidate.cause;
16275
16626
  states.push(cause.code, cause.errno, cause.sqlState, cause.sqlstate);
16276
16627
  }
16277
- return states.some((state) => String(state) === "23505");
16628
+ if (states.some((state) => typeof state === "string" && TRANSIENT_POSTGRES_SQLSTATES.has(state))) {
16629
+ return true;
16630
+ }
16631
+ const message = typeof candidate.message === "string" ? candidate.message : candidate.cause?.message;
16632
+ return typeof message === "string" && TRANSIENT_POSTGRES_MESSAGE_MARKERS.some((marker) => message.includes(marker));
16278
16633
  }
16279
- function postgresConstraintName(error) {
16280
- if (typeof error !== "object" || error === null)
16281
- return "";
16282
- const candidate = error;
16283
- const cause = typeof candidate.cause === "object" && candidate.cause !== null ? candidate.cause : undefined;
16284
- const constraint = candidate.constraint ?? candidate.constraint_name ?? cause?.constraint ?? cause?.constraint_name;
16285
- return typeof constraint === "string" ? constraint : "";
16634
+ async function retryOnTransientPostgresError(fn, attempts = 2, delayMs = 150) {
16635
+ let lastError;
16636
+ for (let attempt = 1;attempt <= attempts; attempt += 1) {
16637
+ try {
16638
+ return await fn();
16639
+ } catch (error) {
16640
+ lastError = error;
16641
+ if (!isTransientPostgresError(error) || attempt === attempts)
16642
+ throw error;
16643
+ await new Promise((resolve) => setTimeout(resolve, delayMs * attempt));
16644
+ }
16645
+ }
16646
+ throw lastError;
16286
16647
  }
16287
16648
  // src/project-registration/types.ts
16288
16649
  var TODOS_PROJECT_REGISTRATION_ROUTE = "todos.project-registration.v1";