@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
package/dist/storage.js CHANGED
@@ -5531,13 +5531,15 @@ function upsertSecretSafetyConfig(input) {
5531
5531
  saveConfig({ ...config, secret_safety: next });
5532
5532
  return next;
5533
5533
  }
5534
- var DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
5534
+ var XAI_PREFIX, DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
5535
5535
  var init_redaction = __esm(() => {
5536
5536
  init_config2();
5537
+ XAI_PREFIX = ["x", "ai", "-"].join("");
5537
5538
  DEFAULT_SECRET_PATTERNS = [
5538
5539
  { name: "aws-access-key", regex: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: "[REDACTED_AWS_KEY]" },
5539
5540
  { name: "private-key", regex: /-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |)PRIVATE KEY-----/g, replacement: "[REDACTED_PRIVATE_KEY]" },
5540
5541
  { name: "openai-token", regex: /\bsk-[A-Za-z0-9_-]{12,}\b/g, replacement: "[REDACTED_TOKEN]" },
5542
+ { name: `${XAI_PREFIX}token`, regex: /\bxai[-][A-Za-z0-9]{20,80}\b/g, replacement: "[REDACTED_TOKEN]" },
5541
5543
  { name: "npm-token", regex: /\bnpm_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_NPM_TOKEN]" },
5542
5544
  { name: "github-fine-grained-token", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
5543
5545
  { name: "github-token", regex: /\bgh[opsu]_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
@@ -6194,8 +6196,9 @@ var init_event_hooks = __esm(() => {
6194
6196
  VALID_TARGETS = new Set(["stdout", "file", "socket", "script"]);
6195
6197
  });
6196
6198
 
6197
- // node_modules/.bun/@hasna+events@0.1.11/node_modules/@hasna/events/dist/index.js
6199
+ // node_modules/.bun/@hasna+events@0.1.16/node_modules/@hasna/events/dist/index.js
6198
6200
  import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
6201
+ import { Buffer as Buffer2 } from "buffer";
6199
6202
  import { existsSync as existsSync5 } from "fs";
6200
6203
  import { homedir as homedir2 } from "os";
6201
6204
  import { join as join4 } from "path";
@@ -6304,11 +6307,13 @@ function getEventsDataDir(override) {
6304
6307
 
6305
6308
  class JsonEventsStore {
6306
6309
  dataDir;
6310
+ runtime;
6307
6311
  channelsPath;
6308
6312
  eventsPath;
6309
6313
  deliveriesPath;
6310
6314
  constructor(dataDir = getEventsDataDir()) {
6311
6315
  this.dataDir = dataDir;
6316
+ this.runtime = localJsonRuntime(dataDir);
6312
6317
  this.channelsPath = join4(dataDir, "channels.json");
6313
6318
  this.eventsPath = join4(dataDir, "events.json");
6314
6319
  this.deliveriesPath = join4(dataDir, "deliveries.json");
@@ -6356,13 +6361,58 @@ class JsonEventsStore {
6356
6361
  await this.writeJson(this.eventsPath, events);
6357
6362
  return event;
6358
6363
  }
6359
- async listEvents() {
6364
+ async appendEventOnce(event, options = {}) {
6360
6365
  await this.init();
6361
- return this.readJson(this.eventsPath, []);
6366
+ const events = await this.readJson(this.eventsPath, []);
6367
+ const dedupe = options.dedupe !== false;
6368
+ if (dedupe) {
6369
+ const existing = findEventByIdentity(events, { id: event.id, dedupeKey: event.dedupeKey });
6370
+ if (existing) {
6371
+ return {
6372
+ event: existing,
6373
+ stored: false,
6374
+ deduped: true,
6375
+ identity: { id: existing.id, dedupeKey: existing.dedupeKey }
6376
+ };
6377
+ }
6378
+ }
6379
+ events.push(event);
6380
+ await this.writeJson(this.eventsPath, events);
6381
+ return {
6382
+ event,
6383
+ stored: true,
6384
+ deduped: false,
6385
+ identity: { id: event.id, dedupeKey: event.dedupeKey }
6386
+ };
6387
+ }
6388
+ async listEvents(options = {}) {
6389
+ await this.init();
6390
+ const events = await this.readJson(this.eventsPath, []);
6391
+ return queryEvents(events, options);
6392
+ }
6393
+ async listEventsPage(options = {}) {
6394
+ await this.init();
6395
+ const events = await this.readJson(this.eventsPath, []);
6396
+ const queried = queryEvents(events, {
6397
+ eventId: options.eventId,
6398
+ source: options.source,
6399
+ type: options.type
6400
+ });
6401
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
6402
+ const limit = normalizeEventPageLimit(options.limit);
6403
+ const pageEvents = queried.slice(offset, offset + limit);
6404
+ const nextOffset = offset + pageEvents.length;
6405
+ const hasMore = nextOffset < queried.length;
6406
+ return {
6407
+ events: pageEvents,
6408
+ cursor: options.cursor,
6409
+ nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
6410
+ hasMore
6411
+ };
6362
6412
  }
6363
6413
  async findEventByIdentity(identity) {
6364
6414
  const events = await this.listEvents();
6365
- return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
6415
+ return findEventByIdentity(events, identity);
6366
6416
  }
6367
6417
  async appendDelivery(result) {
6368
6418
  await this.init();
@@ -6413,6 +6463,83 @@ class JsonEventsStore {
6413
6463
  });
6414
6464
  }
6415
6465
  }
6466
+ function localJsonRuntime(dataDir = getEventsDataDir()) {
6467
+ return {
6468
+ mode: "local-files",
6469
+ name: "json-events-store",
6470
+ remote: false,
6471
+ localFiles: true,
6472
+ localSqlite: false,
6473
+ postgres: false,
6474
+ s3: false,
6475
+ aws: false,
6476
+ durable: true,
6477
+ idempotency: "best-effort-local",
6478
+ replayCursors: true,
6479
+ description: `Local JSON files in ${dataDir}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
6480
+ };
6481
+ }
6482
+ function encodeLocalJsonEventCursor(offset, options = {}) {
6483
+ if (!Number.isInteger(offset) || offset < 0)
6484
+ throw new Error(`Invalid event cursor offset: ${offset}`);
6485
+ const payload = {
6486
+ offset,
6487
+ eventId: options.eventId,
6488
+ source: options.source,
6489
+ type: options.type
6490
+ };
6491
+ return `${LOCAL_JSON_EVENT_CURSOR_PREFIX}${Buffer2.from(JSON.stringify(payload), "utf-8").toString("base64url")}`;
6492
+ }
6493
+ function decodeLocalJsonEventCursor(cursor, options = {}) {
6494
+ if (!cursor)
6495
+ return 0;
6496
+ if (!cursor.startsWith(LOCAL_JSON_EVENT_CURSOR_PREFIX))
6497
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
6498
+ const rawPayload = cursor.slice(LOCAL_JSON_EVENT_CURSOR_PREFIX.length);
6499
+ let payload;
6500
+ try {
6501
+ payload = JSON.parse(Buffer2.from(rawPayload, "base64url").toString("utf-8"));
6502
+ } catch {
6503
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
6504
+ }
6505
+ const offset = payload.offset;
6506
+ if (!Number.isInteger(offset) || offset < 0)
6507
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
6508
+ assertCursorFilter("eventId", payload.eventId, options.eventId);
6509
+ assertCursorFilter("source", payload.source, options.source);
6510
+ assertCursorFilter("type", payload.type, options.type);
6511
+ return offset;
6512
+ }
6513
+ function normalizeEventPageLimit(limit) {
6514
+ if (limit === undefined)
6515
+ return DEFAULT_EVENT_PAGE_LIMIT;
6516
+ if (!Number.isInteger(limit) || limit < 1)
6517
+ throw new Error(`Event page limit must be a positive integer, got ${limit}`);
6518
+ return Math.min(limit, MAX_EVENT_PAGE_LIMIT);
6519
+ }
6520
+ function queryEvents(events, options) {
6521
+ let rows = events;
6522
+ if (options.eventId)
6523
+ rows = rows.filter((event) => event.id === options.eventId);
6524
+ if (options.source)
6525
+ rows = rows.filter((event) => event.source === options.source);
6526
+ if (options.type)
6527
+ rows = rows.filter((event) => event.type === options.type);
6528
+ if (options.cursor) {
6529
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
6530
+ rows = rows.slice(offset);
6531
+ }
6532
+ if (options.limit !== undefined)
6533
+ rows = rows.slice(0, normalizeEventPageLimit(options.limit));
6534
+ return rows;
6535
+ }
6536
+ function assertCursorFilter(name, cursorValue, optionValue) {
6537
+ if (cursorValue !== optionValue)
6538
+ throw new Error(`Local JSON event cursor ${name} filter mismatch`);
6539
+ }
6540
+ function findEventByIdentity(events, identity) {
6541
+ return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
6542
+ }
6416
6543
  function buildSignatureBase(timestamp2, body) {
6417
6544
  return `${timestamp2}.${body}`;
6418
6545
  }
@@ -6426,21 +6553,27 @@ function now2() {
6426
6553
  function truncate(value, max = 4096) {
6427
6554
  return value.length > max ? `${value.slice(0, max)}...` : value;
6428
6555
  }
6429
- function buildWebhookRequest(event, channel) {
6556
+ function buildWebhookRequest(event, channel, options = {}) {
6430
6557
  if (!channel.webhook)
6431
6558
  throw new Error(`Channel ${channel.id} has no webhook config`);
6559
+ for (const name of Object.keys(channel.webhook.headers ?? {})) {
6560
+ if (/^x-hasna-/i.test(name)) {
6561
+ throw new Error(`Webhook header ${name} is reserved for signed delivery metadata`);
6562
+ }
6563
+ }
6432
6564
  const body = JSON.stringify(event);
6433
- const timestamp2 = event.time;
6565
+ const timestamp2 = options.timestamp ?? new Date().toISOString();
6434
6566
  const headers = {
6435
6567
  "Content-Type": "application/json",
6436
6568
  "User-Agent": "@hasna/events",
6437
6569
  "X-Hasna-Event-Id": event.id,
6438
6570
  "X-Hasna-Event-Type": event.type,
6439
- "X-Hasna-Timestamp": timestamp2,
6440
- ...channel.webhook.headers
6571
+ ...channel.webhook.headers,
6572
+ "X-Hasna-Timestamp": timestamp2
6441
6573
  };
6442
- if (channel.webhook.secret) {
6443
- headers["X-Hasna-Signature"] = signPayload(channel.webhook.secret, timestamp2, body);
6574
+ const secret = options.secret ?? channel.webhook.secret;
6575
+ if (secret) {
6576
+ headers["X-Hasna-Signature"] = signPayload(secret, timestamp2, body);
6444
6577
  }
6445
6578
  return { body, headers };
6446
6579
  }
@@ -6448,7 +6581,21 @@ async function dispatchWebhook(event, channel, options = {}) {
6448
6581
  if (!channel.webhook)
6449
6582
  throw new Error(`Channel ${channel.id} has no webhook config`);
6450
6583
  const startedAt = now2();
6451
- const { body, headers } = buildWebhookRequest(event, channel);
6584
+ let secret = channel.webhook.secret;
6585
+ if (channel.webhook.secretRef) {
6586
+ if (!options.secretResolver) {
6587
+ return failedAttempt(startedAt, "Webhook secret reference has no runtime resolver");
6588
+ }
6589
+ try {
6590
+ secret = await options.secretResolver(channel.webhook.secretRef);
6591
+ } catch {
6592
+ return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
6593
+ }
6594
+ if (!secret)
6595
+ return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
6596
+ }
6597
+ const timestamp2 = (options.now?.() ?? new Date).toISOString();
6598
+ const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp: timestamp2 });
6452
6599
  const controller = new AbortController;
6453
6600
  const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
6454
6601
  try {
@@ -6480,6 +6627,15 @@ async function dispatchWebhook(event, channel, options = {}) {
6480
6627
  clearTimeout(timeout);
6481
6628
  }
6482
6629
  }
6630
+ function failedAttempt(startedAt, error) {
6631
+ return {
6632
+ attempt: 1,
6633
+ status: "failed",
6634
+ startedAt,
6635
+ completedAt: now2(),
6636
+ error
6637
+ };
6638
+ }
6483
6639
  async function dispatchCommand(event, channel) {
6484
6640
  if (!channel.command)
6485
6641
  throw new Error(`Channel ${channel.id} has no command config`);
@@ -6568,6 +6724,76 @@ function createDeliveryResult(event, channel, attempts) {
6568
6724
  completedAt: attempts.at(-1)?.completedAt ?? now2()
6569
6725
  };
6570
6726
  }
6727
+
6728
+ class EventTypeCatalog {
6729
+ definitions = new Map;
6730
+ register(definition) {
6731
+ this.definitions.set(definition.type, definition);
6732
+ return this;
6733
+ }
6734
+ unregister(type) {
6735
+ return this.definitions.delete(type);
6736
+ }
6737
+ has(type) {
6738
+ return this.definitions.has(type);
6739
+ }
6740
+ get(type) {
6741
+ return this.definitions.get(type);
6742
+ }
6743
+ list() {
6744
+ return [...this.definitions.values()];
6745
+ }
6746
+ validateEvent(event) {
6747
+ const definition = this.definitions.get(event.type);
6748
+ if (!definition)
6749
+ return { ok: true };
6750
+ return definition.validate(event.data, event);
6751
+ }
6752
+ assertEventValid(event) {
6753
+ const result = this.validateEvent(event);
6754
+ if (!result.ok) {
6755
+ throw new EventValidationError(event.type, result.issues);
6756
+ }
6757
+ }
6758
+ }
6759
+ function redactPaths(event, paths, replacement = "[REDACTED]") {
6760
+ if (paths.length === 0)
6761
+ return event;
6762
+ const copy = structuredClone(event);
6763
+ for (const path of paths) {
6764
+ setPath(copy, path, replacement);
6765
+ }
6766
+ return copy;
6767
+ }
6768
+ function redactSensitiveKeys(event, replacement = "[REDACTED]") {
6769
+ return redactValue2(event, replacement);
6770
+ }
6771
+ function shouldRedactKey(key) {
6772
+ return /secret|token|password|api[_-]?key|authorization/i.test(key);
6773
+ }
6774
+ function redactValue2(value, replacement) {
6775
+ if (Array.isArray(value))
6776
+ return value.map((item) => redactValue2(item, replacement));
6777
+ if (!value || typeof value !== "object")
6778
+ return value;
6779
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
6780
+ key,
6781
+ shouldRedactKey(key) ? replacement : redactValue2(item, replacement)
6782
+ ]));
6783
+ }
6784
+ function setPath(input, path, replacement) {
6785
+ const parts = path.split(".");
6786
+ let cursor = input;
6787
+ for (const part of parts.slice(0, -1)) {
6788
+ const next = cursor[part];
6789
+ if (!next || typeof next !== "object")
6790
+ return;
6791
+ cursor = next;
6792
+ }
6793
+ const last = parts.at(-1);
6794
+ if (last && last in cursor)
6795
+ cursor[last] = replacement;
6796
+ }
6571
6797
  function createEvent(input) {
6572
6798
  return {
6573
6799
  id: input.id ?? randomUUID22(),
@@ -6588,10 +6814,18 @@ class EventsClient {
6588
6814
  store;
6589
6815
  redactors;
6590
6816
  transportOptions;
6817
+ catalog;
6818
+ validateCatalogTypes;
6591
6819
  constructor(options = {}) {
6592
6820
  this.store = options.store ?? new JsonEventsStore(options.dataDir);
6593
6821
  this.redactors = options.redactors ?? [];
6594
- this.transportOptions = { fetchImpl: options.fetchImpl };
6822
+ this.transportOptions = {
6823
+ fetchImpl: options.fetchImpl,
6824
+ secretResolver: options.secretResolver,
6825
+ now: options.now
6826
+ };
6827
+ this.catalog = options.catalog ?? defaultEventTypeCatalog;
6828
+ this.validateCatalogTypes = options.validateCatalogTypes ?? false;
6595
6829
  }
6596
6830
  async addChannel(input) {
6597
6831
  const timestamp2 = new Date().toISOString();
@@ -6609,18 +6843,40 @@ class EventsClient {
6609
6843
  }
6610
6844
  async emit(input, options = {}) {
6611
6845
  const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
6612
- if (options.dedupe !== false) {
6613
- const existing = await this.store.findEventByIdentity({ id: input.id, dedupeKey: event.dedupeKey });
6614
- if (existing) {
6615
- return { event: existing, deliveries: [], deduped: true };
6616
- }
6617
- }
6618
- await this.store.appendEvent(event);
6619
- const deliveries = options.deliver === false ? [] : await this.deliver(event);
6620
- return { event, deliveries, deduped: false };
6621
- }
6622
- async listEvents() {
6623
- return this.store.listEvents();
6846
+ if (options.validate ?? this.validateCatalogTypes) {
6847
+ this.catalog.assertEventValid(event);
6848
+ }
6849
+ const append = await this.appendEvent(event, { dedupe: options.dedupe !== false });
6850
+ if (append.deduped) {
6851
+ return { event: append.event, deliveries: [], deduped: true };
6852
+ }
6853
+ const deliveries = options.deliver === false ? [] : await this.deliver(append.event);
6854
+ return { event: append.event, deliveries, deduped: false };
6855
+ }
6856
+ async listEvents(options = {}) {
6857
+ if (Object.keys(options).length === 0)
6858
+ return this.store.listEvents();
6859
+ return queryClientEvents(await this.store.listEvents(), options);
6860
+ }
6861
+ async listEventsPage(options = {}) {
6862
+ if (this.store.listEventsPage)
6863
+ return this.store.listEventsPage(options);
6864
+ const events = queryClientEvents(await this.store.listEvents(), {
6865
+ eventId: options.eventId,
6866
+ source: options.source,
6867
+ type: options.type
6868
+ });
6869
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
6870
+ const limit = normalizeEventPageLimit(options.limit);
6871
+ const pageEvents = events.slice(offset, offset + limit);
6872
+ const nextOffset = offset + pageEvents.length;
6873
+ const hasMore = nextOffset < events.length;
6874
+ return {
6875
+ events: pageEvents,
6876
+ cursor: options.cursor,
6877
+ nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
6878
+ hasMore
6879
+ };
6624
6880
  }
6625
6881
  async listDeliveries() {
6626
6882
  return this.store.listDeliveries();
@@ -6688,22 +6944,37 @@ class EventsClient {
6688
6944
  return result;
6689
6945
  }
6690
6946
  async replay(options = {}) {
6691
- const events = (await this.store.listEvents()).filter((event) => {
6692
- if (options.eventId && event.id !== options.eventId)
6693
- return false;
6694
- if (options.source && event.source !== options.source)
6695
- return false;
6696
- if (options.type && event.type !== options.type)
6697
- return false;
6698
- return true;
6699
- });
6947
+ const page = options.cursor || options.limit !== undefined ? await this.listEventsPage(options) : { events: await this.listEvents(options), hasMore: false };
6700
6948
  if (options.dryRun)
6701
- return { events, deliveries: [] };
6949
+ return { events: page.events, deliveries: [], cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
6702
6950
  const deliveries = [];
6703
- for (const event of events) {
6951
+ for (const event of page.events) {
6704
6952
  deliveries.push(...await this.deliver(event));
6705
6953
  }
6706
- return { events, deliveries };
6954
+ return { events: page.events, deliveries, cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
6955
+ }
6956
+ async appendEvent(event, options) {
6957
+ if (this.store.appendEventOnce) {
6958
+ return this.store.appendEventOnce(event, { dedupe: options.dedupe });
6959
+ }
6960
+ if (options.dedupe) {
6961
+ const existing = await this.store.findEventByIdentity({ id: event.id, dedupeKey: event.dedupeKey });
6962
+ if (existing) {
6963
+ return {
6964
+ event: existing,
6965
+ stored: false,
6966
+ deduped: true,
6967
+ identity: { id: existing.id, dedupeKey: existing.dedupeKey }
6968
+ };
6969
+ }
6970
+ }
6971
+ const stored = await this.store.appendEvent(event);
6972
+ return {
6973
+ event: stored,
6974
+ stored: true,
6975
+ deduped: false,
6976
+ identity: { id: stored.id, dedupeKey: stored.dedupeKey }
6977
+ };
6707
6978
  }
6708
6979
  async applyRedaction(event, channel) {
6709
6980
  let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
@@ -6730,43 +7001,19 @@ class EventsClient {
6730
7001
  return createDeliveryResult(event, channel, attempts);
6731
7002
  }
6732
7003
  }
6733
- function redactPaths(event, paths, replacement = "[REDACTED]") {
6734
- if (paths.length === 0)
6735
- return event;
6736
- const copy = structuredClone(event);
6737
- for (const path of paths) {
6738
- setPath(copy, path, replacement);
6739
- }
6740
- return copy;
6741
- }
6742
- function redactSensitiveKeys(event, replacement = "[REDACTED]") {
6743
- return redactValue2(event, replacement);
6744
- }
6745
- function shouldRedactKey(key) {
6746
- return /secret|token|password|api[_-]?key|authorization/i.test(key);
6747
- }
6748
- function redactValue2(value, replacement) {
6749
- if (Array.isArray(value))
6750
- return value.map((item) => redactValue2(item, replacement));
6751
- if (!value || typeof value !== "object")
6752
- return value;
6753
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [
6754
- key,
6755
- shouldRedactKey(key) ? replacement : redactValue2(item, replacement)
6756
- ]));
6757
- }
6758
- function setPath(input, path, replacement) {
6759
- const parts = path.split(".");
6760
- let cursor = input;
6761
- for (const part of parts.slice(0, -1)) {
6762
- const next = cursor[part];
6763
- if (!next || typeof next !== "object")
6764
- return;
6765
- cursor = next;
6766
- }
6767
- const last = parts.at(-1);
6768
- if (last && last in cursor)
6769
- cursor[last] = replacement;
7004
+ function queryClientEvents(events, options) {
7005
+ let rows = events;
7006
+ if (options.eventId)
7007
+ rows = rows.filter((event) => event.id === options.eventId);
7008
+ if (options.source)
7009
+ rows = rows.filter((event) => event.source === options.source);
7010
+ if (options.type)
7011
+ rows = rows.filter((event) => event.type === options.type);
7012
+ if (options.cursor)
7013
+ rows = rows.slice(decodeLocalJsonEventCursor(options.cursor, options));
7014
+ if (options.limit !== undefined)
7015
+ rows = rows.slice(0, normalizeEventPageLimit(options.limit));
7016
+ return rows;
6770
7017
  }
6771
7018
  function normalizeTime(value) {
6772
7019
  if (!value)
@@ -6780,9 +7027,22 @@ function normalizeRetryPolicy(policy) {
6780
7027
  multiplier: Math.max(1, policy?.multiplier ?? 2)
6781
7028
  };
6782
7029
  }
6783
- var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR", HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME", DEFAULT_SIGNATURE_TOLERANCE_MS;
7030
+ 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;
6784
7031
  var init_dist = __esm(() => {
6785
7032
  DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
7033
+ EventValidationError = class EventValidationError extends Error {
7034
+ eventType;
7035
+ issues;
7036
+ constructor(eventType, issues) {
7037
+ const detail = issues.map((issue) => `${issue.path || "<root>"}: ${issue.message}`).join("; ");
7038
+ super(`Event validation failed for type "${eventType}": ${detail}`);
7039
+ this.name = "EventValidationError";
7040
+ this.eventType = eventType;
7041
+ this.issues = issues;
7042
+ }
7043
+ };
7044
+ defaultEventTypeCatalog = new EventTypeCatalog;
7045
+ APP_EVENT_V1_MAX_DATA_BYTES = 32 * 1024;
6786
7046
  });
6787
7047
 
6788
7048
  // src/db/task-lists.ts
@@ -7886,8 +8146,12 @@ async function deliverWebhook(wh, event, body, attempt, db) {
7886
8146
  const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
7887
8147
  headers["X-Webhook-Signature"] = Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
7888
8148
  }
7889
- const resp = await fetch(wh.url, { method: "POST", headers, body });
8149
+ const resp = await fetch(wh.url, { method: "POST", headers, body, redirect: "manual" });
7890
8150
  const respText = await resp.text().catch(() => "");
8151
+ if (resp.status >= 300 && resp.status < 400) {
8152
+ logDelivery(db, wh.id, event, body, resp.status, "Blocked: webhook URL returned a 3xx redirect; redirects are never followed (SSRF prevention)", attempt);
8153
+ return;
8154
+ }
7891
8155
  logDelivery(db, wh.id, event, body, resp.status, respText.slice(0, 1000), attempt);
7892
8156
  if (resp.status >= 400 && attempt < MAX_RETRY_ATTEMPTS) {
7893
8157
  const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
@@ -9671,6 +9935,12 @@ function listTasks(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();
@@ -14915,6 +15186,30 @@ function changedSinceStampNewer(stamp, since) {
14915
15186
  // src/storage/postgres-adapter.ts
14916
15187
  init_stale_lock_handoff();
14917
15188
 
15189
+ // src/storage/postgres-sync.ts
15190
+ init_types();
15191
+
15192
+ // src/storage/postgres-errors.ts
15193
+ function isPostgresUniqueViolation(error) {
15194
+ if (typeof error !== "object" || error === null)
15195
+ return false;
15196
+ const candidate = error;
15197
+ const states = [candidate.code, candidate.errno, candidate.sqlState, candidate.sqlstate];
15198
+ if (typeof candidate.cause === "object" && candidate.cause !== null) {
15199
+ const cause = candidate.cause;
15200
+ states.push(cause.code, cause.errno, cause.sqlState, cause.sqlstate);
15201
+ }
15202
+ return states.some((state) => String(state) === "23505");
15203
+ }
15204
+ function postgresConstraintName(error) {
15205
+ if (typeof error !== "object" || error === null)
15206
+ return "";
15207
+ const candidate = error;
15208
+ const cause = typeof candidate.cause === "object" && candidate.cause !== null ? candidate.cause : undefined;
15209
+ const constraint = candidate.constraint ?? candidate.constraint_name ?? cause?.constraint ?? cause?.constraint_name;
15210
+ return typeof constraint === "string" ? constraint : "";
15211
+ }
15212
+
14918
15213
  // src/storage/postgres-sync.ts
14919
15214
  var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records";
14920
15215
  var DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors";
@@ -15117,53 +15412,98 @@ class PostgresTodosSyncStore {
15117
15412
  if (routingErrors.length > 0) {
15118
15413
  throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
15119
15414
  }
15120
- const existing = await this.client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
15121
- FROM ${this.tableName}
15122
- WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
15123
- const existingProjects = [];
15124
- const existingTaskLists = [];
15125
- for (const row of existing.rows) {
15126
- const payload = payloadRecord(row.payload);
15127
- if (row.object_type === "projects")
15128
- existingProjects.push(payload);
15129
- if (row.object_type === "task_lists")
15130
- existingTaskLists.push(payload);
15131
- }
15132
- const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
15133
- if (destinationErrors.length > 0) {
15134
- throw new Error(`Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
15135
- }
15136
- const result = { records: 0, objectTypes: {} };
15137
- const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
15138
- for (const entry of snapshotEntries(snapshot)) {
15139
- if (entry.deletedAt === null)
15140
- assertCanonicalScopedSlugEntry(entry);
15141
- await this.client.query(`INSERT INTO ${this.tableName} (
15142
- service, object_type, object_id, payload, updated_at,
15143
- deleted_at, source_machine_id, version
15144
- ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $6::timestamptz, $7, $8)
15145
- ON CONFLICT (service, object_type, object_id) DO UPDATE SET
15146
- payload = EXCLUDED.payload,
15147
- updated_at = EXCLUDED.updated_at,
15148
- deleted_at = EXCLUDED.deleted_at,
15149
- source_machine_id = EXCLUDED.source_machine_id,
15150
- version = EXCLUDED.version
15151
- WHERE ${this.tableName}.updated_at < EXCLUDED.updated_at
15152
- OR (${this.tableName}.updated_at = EXCLUDED.updated_at
15153
- AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))`, [
15154
- this.service,
15155
- entry.type,
15156
- entry.id,
15157
- entry.payload,
15158
- entry.updatedAt,
15159
- entry.deletedAt,
15160
- sourceMachineId,
15161
- entry.version
15162
- ]);
15163
- result.records += 1;
15164
- result.objectTypes[entry.type] = (result.objectTypes[entry.type] ?? 0) + 1;
15415
+ const push = async (client) => {
15416
+ const existing = await client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
15417
+ FROM ${this.tableName}
15418
+ WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
15419
+ const existingProjects = [];
15420
+ const existingTaskLists = [];
15421
+ for (const row of existing.rows) {
15422
+ const payload = payloadRecord(row.payload);
15423
+ if (row.object_type === "projects")
15424
+ existingProjects.push(payload);
15425
+ if (row.object_type === "task_lists")
15426
+ existingTaskLists.push(payload);
15427
+ }
15428
+ const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
15429
+ if (destinationErrors.length > 0) {
15430
+ throw new ResourceConflictError("SNAPSHOT_DESTINATION_CONFLICT", `Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
15431
+ }
15432
+ const result = { records: 0, objectTypes: {} };
15433
+ const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
15434
+ for (const entry of snapshotEntries(snapshot)) {
15435
+ if (entry.deletedAt === null)
15436
+ assertCanonicalScopedSlugEntry(entry);
15437
+ try {
15438
+ await client.query(`INSERT INTO ${this.tableName} (
15439
+ service, object_type, object_id, payload, updated_at,
15440
+ deleted_at, source_machine_id, version
15441
+ ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $6::timestamptz, $7, $8)
15442
+ ON CONFLICT (service, object_type, object_id) DO UPDATE SET
15443
+ payload = EXCLUDED.payload,
15444
+ updated_at = EXCLUDED.updated_at,
15445
+ deleted_at = EXCLUDED.deleted_at,
15446
+ source_machine_id = EXCLUDED.source_machine_id,
15447
+ version = EXCLUDED.version
15448
+ WHERE ${this.tableName}.updated_at < EXCLUDED.updated_at
15449
+ OR (${this.tableName}.updated_at = EXCLUDED.updated_at
15450
+ AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))`, [
15451
+ this.service,
15452
+ entry.type,
15453
+ entry.id,
15454
+ entry.payload,
15455
+ entry.updatedAt,
15456
+ entry.deletedAt,
15457
+ sourceMachineId,
15458
+ entry.version
15459
+ ]);
15460
+ } catch (error) {
15461
+ await this.classifySyncInsertConflict(error, entry);
15462
+ }
15463
+ result.records += 1;
15464
+ result.objectTypes[entry.type] = (result.objectTypes[entry.type] ?? 0) + 1;
15465
+ }
15466
+ return result;
15467
+ };
15468
+ if (typeof this.client.transaction === "function") {
15469
+ return this.client.transaction((client) => push(client));
15165
15470
  }
15166
- return result;
15471
+ return push(this.client);
15472
+ }
15473
+ async classifySyncInsertConflict(error, entry) {
15474
+ if (!isPostgresUniqueViolation(error))
15475
+ throw error;
15476
+ const payload = entry.payload;
15477
+ const taskListSlug = String(payload["slug"] ?? "");
15478
+ const projectSlug = String(payload["task_list_id"] ?? "");
15479
+ const constraintName = postgresConstraintName(error);
15480
+ if (entry.type === "projects" && constraintName.includes("project_task_list_slug_uidx")) {
15481
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${projectSlug}" already exists`);
15482
+ }
15483
+ if (entry.type === "task_lists" && constraintName.includes("task_list_scope_slug_uidx")) {
15484
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${taskListSlug}" already exists in this scope`);
15485
+ }
15486
+ if (entry.type === "task_lists") {
15487
+ const scope = typeof payload["project_id"] === "string" ? payload["project_id"] : "";
15488
+ const conflict = await this.client.query(`/* todos:classify-sync-task-list-conflict */ SELECT EXISTS (
15489
+ SELECT 1 FROM ${this.tableName}
15490
+ WHERE service = $1 AND object_type = 'task_lists' AND object_id <> $2
15491
+ AND deleted_at IS NULL AND COALESCE(payload->>'project_id','') = $3 AND payload->>'slug' = $4
15492
+ ) AS conflict`, [this.service, entry.id, scope, taskListSlug]);
15493
+ if (conflict.rows[0]?.conflict) {
15494
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${taskListSlug}" already exists in this scope`);
15495
+ }
15496
+ } else if (entry.type === "projects") {
15497
+ const conflict = await this.client.query(`/* todos:classify-sync-project-conflict */ SELECT EXISTS (
15498
+ SELECT 1 FROM ${this.tableName}
15499
+ WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
15500
+ AND deleted_at IS NULL AND payload->>'task_list_id' = $3
15501
+ ) AS conflict`, [this.service, entry.id, projectSlug]);
15502
+ if (conflict.rows[0]?.conflict) {
15503
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${projectSlug}" already exists`);
15504
+ }
15505
+ }
15506
+ throw error;
15167
15507
  }
15168
15508
  async pullSnapshot(options = {}) {
15169
15509
  const params = [this.service];
@@ -15529,9 +15869,11 @@ class PostgresJsonRecordStore {
15529
15869
  async ensureSchema() {
15530
15870
  if (!this.schemaReady) {
15531
15871
  this.schemaReady = (async () => {
15532
- for (const sql of postgresTodosSyncSchemaSql(this.tableName, this.cursorTableName)) {
15533
- await this.options.client.query(sql);
15534
- }
15872
+ await retryOnTransientPostgresError(async () => {
15873
+ for (const sql of postgresTodosSyncSchemaSql(this.tableName, this.cursorTableName)) {
15874
+ await this.options.client.query(sql);
15875
+ }
15876
+ });
15535
15877
  })().catch((error) => {
15536
15878
  this.schemaReady = null;
15537
15879
  throw error;
@@ -15945,13 +16287,14 @@ class PostgresJsonRecordStore {
15945
16287
  throw new Error(divergentAuditHistoryReplayError(value.id));
15946
16288
  }
15947
16289
  async withTaskParentIntegrityTransaction(fn) {
15948
- if (typeof this.options.client.transaction !== "function") {
16290
+ const transaction = this.options.client.transaction;
16291
+ if (typeof transaction !== "function") {
15949
16292
  throw new Error("TASK_PARENT_ATOMICITY_UNAVAILABLE: PostgreSQL parent writes and task deletion require transaction(callback)");
15950
16293
  }
15951
- return this.options.client.transaction(async (client) => {
16294
+ return retryOnTransientPostgresError(() => transaction(async (client) => {
15952
16295
  await client.query("/* todos:task-parent-integrity-lock */ SELECT pg_advisory_xact_lock(hashtextextended($1 || ':task-parent-integrity', 0))", [this.service]);
15953
16296
  return fn(client);
15954
- });
16297
+ }));
15955
16298
  }
15956
16299
  async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}, parentGuard, queryClient) {
15957
16300
  const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
@@ -17837,7 +18180,13 @@ function compareClock(left, right) {
17837
18180
  function numberValue2(value) {
17838
18181
  return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
17839
18182
  }
17840
- function isPostgresUniqueViolation(error) {
18183
+ var TRANSIENT_POSTGRES_SQLSTATES = new Set(["55P03", "40P01", "40001"]);
18184
+ var TRANSIENT_POSTGRES_MESSAGE_MARKERS = [
18185
+ "canceling statement due to lock timeout",
18186
+ "deadlock detected",
18187
+ "could not serialize access"
18188
+ ];
18189
+ function isTransientPostgresError(error) {
17841
18190
  if (typeof error !== "object" || error === null)
17842
18191
  return false;
17843
18192
  const candidate = error;
@@ -17846,18 +18195,29 @@ function isPostgresUniqueViolation(error) {
17846
18195
  const cause = candidate.cause;
17847
18196
  states.push(cause.code, cause.errno, cause.sqlState, cause.sqlstate);
17848
18197
  }
17849
- return states.some((state) => String(state) === "23505");
18198
+ if (states.some((state) => typeof state === "string" && TRANSIENT_POSTGRES_SQLSTATES.has(state))) {
18199
+ return true;
18200
+ }
18201
+ const message = typeof candidate.message === "string" ? candidate.message : candidate.cause?.message;
18202
+ return typeof message === "string" && TRANSIENT_POSTGRES_MESSAGE_MARKERS.some((marker) => message.includes(marker));
17850
18203
  }
17851
- function postgresConstraintName(error) {
17852
- if (typeof error !== "object" || error === null)
17853
- return "";
17854
- const candidate = error;
17855
- const cause = typeof candidate.cause === "object" && candidate.cause !== null ? candidate.cause : undefined;
17856
- const constraint = candidate.constraint ?? candidate.constraint_name ?? cause?.constraint ?? cause?.constraint_name;
17857
- return typeof constraint === "string" ? constraint : "";
18204
+ async function retryOnTransientPostgresError(fn, attempts = 2, delayMs = 150) {
18205
+ let lastError;
18206
+ for (let attempt = 1;attempt <= attempts; attempt += 1) {
18207
+ try {
18208
+ return await fn();
18209
+ } catch (error) {
18210
+ lastError = error;
18211
+ if (!isTransientPostgresError(error) || attempt === attempts)
18212
+ throw error;
18213
+ await new Promise((resolve8) => setTimeout(resolve8, delayMs * attempt));
18214
+ }
18215
+ }
18216
+ throw lastError;
17858
18217
  }
17859
18218
 
17860
18219
  // src/storage/shadow.ts
18220
+ init_types();
17861
18221
  var SNAPSHOT_TO_OBJECT_TYPE = {
17862
18222
  tasks: "tasks",
17863
18223
  projects: "projects",
@@ -17999,6 +18359,12 @@ class TodosShadowMirror {
17999
18359
  const message = error instanceof Error ? error.message : String(error);
18000
18360
  const objectType = op.kind === "upsert" ? SNAPSHOT_TO_OBJECT_TYPE[op.key] : op.objectType;
18001
18361
  const id = op.kind === "upsert" ? String(op.record["id"]) : op.id;
18362
+ if (error instanceof ResourceConflictError) {
18363
+ this.metrics.failed += 1;
18364
+ this.metrics.lastError = message;
18365
+ this.onEvent?.({ type: "dropped", objectType, id, error: message });
18366
+ return;
18367
+ }
18002
18368
  this.metrics.retries += 1;
18003
18369
  this.metrics.lastError = message;
18004
18370
  op.attempts += 1;
@@ -18349,6 +18715,7 @@ function assertPostgresAdapterCapabilities(adapter) {
18349
18715
  }
18350
18716
  }
18351
18717
  // src/storage/shadow-outbox.ts
18718
+ init_types();
18352
18719
  init_shadow_outbox_schema();
18353
18720
  init_shadow_outbox_schema();
18354
18721
  var MAX_BACKOFF_MS = 5 * 60000;
@@ -18478,6 +18845,12 @@ class TodosShadowOutbox {
18478
18845
  this.onEvent?.({ type: "mirrored", objectType: row.object_type, id: row.object_id, lagMs });
18479
18846
  } catch (error) {
18480
18847
  const message = error instanceof Error ? error.message : String(error);
18848
+ if (error instanceof ResourceConflictError) {
18849
+ this.metrics.lastError = message;
18850
+ this.db.run(`UPDATE shadow_outbox SET attempts=?, last_error=?, status='failed' WHERE seq=? AND revision=?`, [row.attempts + 1, message, row.seq, row.revision]);
18851
+ this.onEvent?.({ type: "parked", objectType: row.object_type, id: row.object_id, error: message });
18852
+ return;
18853
+ }
18481
18854
  this.metrics.retries += 1;
18482
18855
  this.metrics.lastError = message;
18483
18856
  const attempts = row.attempts + 1;