@happyvertical/smrt-web 0.51.27 → 0.51.29

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.
@@ -372,6 +372,82 @@ async function executeSmrtWebDataSurfaceAction(transport, request, options) {
372
372
  if (result.requestId !== request.requestId || result.actionId !== request.actionId || result.phase !== request.phase || !sameIdentity(result.identity, request.identity)) throw new TypeError("Data surface action result does not match its request");
373
373
  return result;
374
374
  }
375
+ var RETRYABLE_ACTION_REASONS = /* @__PURE__ */ new Set([
376
+ "idempotency_in_progress",
377
+ "background_unavailable",
378
+ "execution_failed"
379
+ ]);
380
+ var AUTH_ACTION_REASONS = /* @__PURE__ */ new Set([
381
+ "auth_required",
382
+ "unauthenticated",
383
+ "unauthorized"
384
+ ]);
385
+ var STALE_ACTION_REASONS = /* @__PURE__ */ new Set(["stale_revision", "stale_preview"]);
386
+ var UNUSABLE_CONFIRMATION_REASONS = /* @__PURE__ */ new Set([
387
+ "invalid_or_expired_confirmation",
388
+ "confirmation_mismatch",
389
+ "confirmation_replayed",
390
+ "confirmation_required",
391
+ "stale_preview"
392
+ ]);
393
+ function classifySmrtWebDataSurfaceActionResult(result) {
394
+ if (result.ok) return { status: "applied" };
395
+ const reason = result.reason ?? "rejected";
396
+ if (RETRYABLE_ACTION_REASONS.has(reason)) return {
397
+ status: "rejected",
398
+ reason: "write_failed"
399
+ };
400
+ if (AUTH_ACTION_REASONS.has(reason)) return {
401
+ status: "rejected",
402
+ reason: "auth_required"
403
+ };
404
+ if (STALE_ACTION_REASONS.has(reason)) return {
405
+ status: "conflict",
406
+ reason: "stale_write"
407
+ };
408
+ return {
409
+ status: "rejected",
410
+ reason
411
+ };
412
+ }
413
+ function dataSurfaceActionCommandTransport(options) {
414
+ const classify = options.classify ?? classifySmrtWebDataSurfaceActionResult;
415
+ return async (command) => {
416
+ let pinned = command.pinned;
417
+ if (pinned === void 0) {
418
+ pinned = { request: await options.request(command) };
419
+ await command.pin(pinned);
420
+ }
421
+ const base = pinned.request;
422
+ const requestId = (phase) => `${command.idempotencyKey}:${command.attempt}:${phase}`;
423
+ const apply = (confirmationToken) => executeSmrtWebDataSurfaceAction(options.transport, {
424
+ ...base,
425
+ version: 1,
426
+ phase: "apply",
427
+ requestId: requestId("apply"),
428
+ idempotencyKey: command.idempotencyKey,
429
+ ...confirmationToken === void 0 ? {} : { confirmationToken }
430
+ });
431
+ if (!options.preview) return classify(await apply(void 0));
432
+ if (pinned.confirmationToken !== void 0) {
433
+ const applied = await apply(pinned.confirmationToken);
434
+ if (applied.ok || !UNUSABLE_CONFIRMATION_REASONS.has(applied.reason ?? "")) return classify(applied);
435
+ }
436
+ const preview = await executeSmrtWebDataSurfaceAction(options.transport, {
437
+ ...base,
438
+ version: 1,
439
+ phase: "preview",
440
+ requestId: requestId("preview")
441
+ });
442
+ if (!preview.ok) return classify(preview);
443
+ pinned = {
444
+ request: base,
445
+ ...preview.confirmationToken === void 0 ? {} : { confirmationToken: preview.confirmationToken }
446
+ };
447
+ await command.pin(pinned);
448
+ return classify(await apply(preview.confirmationToken));
449
+ };
450
+ }
375
451
  //#endregion
376
452
  //#region src/durable-store.ts
377
453
  function durableStoreNamespace(key) {
@@ -461,6 +537,7 @@ var DurableOutboxQueue = class {
461
537
  id: input.id,
462
538
  payload: input.payload,
463
539
  baseUpdatedAt: input.baseUpdatedAt,
540
+ ...input.transport === void 0 ? {} : { transport: input.transport },
464
541
  state: "pending",
465
542
  attempts: 0,
466
543
  nextAttemptAt: 0,
@@ -626,6 +703,10 @@ function computeBackoffDelay(attempts, backoff, random = Math.random) {
626
703
  function envelopeKindToOp(kind) {
627
704
  return kind === "insert" ? "create" : kind;
628
705
  }
706
+ var SYNC_APPLY_ROUTE = "sync-apply";
707
+ function routeOf(row) {
708
+ return row.transport === void 0 ? SYNC_APPLY_ROUTE : `transport:${row.transport}`;
709
+ }
629
710
  function newItemId() {
630
711
  const cryptoRef = globalThis.crypto;
631
712
  if (cryptoRef?.randomUUID) return cryptoRef.randomUUID();
@@ -646,6 +727,12 @@ var OutboxEngine = class {
646
727
  * collection per object, but the shared-engine model does not forbid more).
647
728
  */
648
729
  listenersByObject = /* @__PURE__ */ new Map();
730
+ /**
731
+ * The sync-apply endpoint, adopted from the first binding that declares one.
732
+ * A tab without one never leads the sync-apply route, so its rows wait for a
733
+ * tab that does (never dropped) — see `drainOnce`.
734
+ */
735
+ syncApply;
649
736
  /** Ref count: number of collections currently attached to this engine. */
650
737
  refCount = 0;
651
738
  /** The durable queue, once opened. undefined while opening / if IDB absent. */
@@ -654,10 +741,12 @@ var OutboxEngine = class {
654
741
  ready;
655
742
  /** True when IndexedDB was unavailable and the engine is a durable no-op. */
656
743
  degraded = false;
657
- /** Leadership handle; set once we've requested the leader lock. */
658
- leadership;
659
- /** True while this tab holds leadership. */
660
- isLeader = false;
744
+ /**
745
+ * Per-route leadership, requested when the first binding serving a route
746
+ * attaches and released when the last one detaches. Exactly one tab replays a
747
+ * given route; a tab never leads a route it has no binding for.
748
+ */
749
+ leaders = /* @__PURE__ */ new Map();
661
750
  /** Unregister fn from the durable-store registry. */
662
751
  unregisterResource;
663
752
  /** True once dispose() ran — guards late async continuations. */
@@ -668,8 +757,14 @@ var OutboxEngine = class {
668
757
  * explicit retry wakes it. Items stay queued.
669
758
  */
670
759
  paused = false;
760
+ /** Wipes issued but not yet cleared; replay is suspended meanwhile. */
761
+ wipesPending = 0;
762
+ /** Those wipes, so `dispose()` can let them finish before closing. */
763
+ pendingWipes = /* @__PURE__ */ new Set();
671
764
  /** True while a drain pass is running, to coalesce concurrent triggers. */
672
765
  draining = false;
766
+ /** The running drain, while `draining`. */
767
+ activeDrain;
673
768
  /** A drain requested while one was in flight — run one more pass after. */
674
769
  drainQueued = false;
675
770
  /** Timer for the next backoff-scheduled drain, if any. */
@@ -679,8 +774,8 @@ var OutboxEngine = class {
679
774
  constructor(config) {
680
775
  this.config = config;
681
776
  this.ready = this.open();
777
+ this.registerForWipe();
682
778
  this.wireOnlineListener();
683
- this.requestLeadership();
684
779
  this.ready.then(() => {
685
780
  if (!this.disposed) this.drain();
686
781
  });
@@ -694,21 +789,39 @@ var OutboxEngine = class {
694
789
  }
695
790
  try {
696
791
  this.queue = await openDurableOutboxQueue(this.config.namespace);
697
- this.unregisterResource = this.config.registerResource(async () => {
698
- await this.queue?.clear();
699
- });
700
- if (this.disposed) {
701
- this.queue.close();
702
- this.queue = void 0;
703
- this.unregisterResource?.();
704
- this.unregisterResource = void 0;
705
- return;
706
- }
707
792
  } catch (error) {
708
793
  this.degraded = true;
709
794
  console.warn("[smrt-web] failed to open the offline outbox", error);
710
795
  }
711
796
  }
797
+ /**
798
+ * Register the queue as a durable resource. A wipe drops the namespace's
799
+ * registrations, so the clear callback forgets ours and the next enqueue
800
+ * registers again — otherwise a second wipe would miss rows written after
801
+ * the first.
802
+ */
803
+ registerForWipe() {
804
+ if (this.unregisterResource || this.disposed) return;
805
+ this.unregisterResource = this.config.registerResource(async () => {
806
+ this.unregisterResource = void 0;
807
+ this.wipesPending += 1;
808
+ const wipe = (async () => {
809
+ try {
810
+ await this.ready;
811
+ await this.queue?.clear();
812
+ } finally {
813
+ this.wipesPending -= 1;
814
+ if (!this.disposed) this.drain();
815
+ }
816
+ })();
817
+ this.pendingWipes.add(wipe);
818
+ try {
819
+ await wipe;
820
+ } finally {
821
+ this.pendingWipes.delete(wipe);
822
+ }
823
+ });
824
+ }
712
825
  /** Wake the drain loop immediately when connectivity returns. */
713
826
  wireOnlineListener() {
714
827
  const target = globalThis;
@@ -719,16 +832,46 @@ var OutboxEngine = class {
719
832
  target.addEventListener("online", listener);
720
833
  this.onlineListener = listener;
721
834
  }
722
- /** Request cross-tab leadership; drain whenever we hold it. */
723
- requestLeadership() {
724
- const lockName = `smrt-web-outbox-leader:${this.config.namespace}`;
725
- this.leadership = acquireLeadership(lockName, () => {
726
- this.isLeader = true;
835
+ /**
836
+ * Request cross-tab leadership for `route` (ref-counted per tab). The
837
+ * sync-apply route keeps the pre-#3021 lock name, so it stays mutually
838
+ * exclusive with tabs running an older build; each transport route has its
839
+ * own lock.
840
+ */
841
+ acquireRoute(route) {
842
+ const existing = this.leaders.get(route);
843
+ if (existing) {
844
+ existing.refs += 1;
845
+ return;
846
+ }
847
+ const root = `smrt-web-outbox-leader:${this.config.namespace}`;
848
+ const lockName = route === SYNC_APPLY_ROUTE ? root : `${root}:${route}`;
849
+ const entry = {
850
+ release: () => {},
851
+ isLeader: false,
852
+ refs: 1
853
+ };
854
+ this.leaders.set(route, entry);
855
+ entry.release = acquireLeadership(lockName, () => {
856
+ entry.isLeader = true;
727
857
  this.drain();
728
858
  }, () => {
729
- this.isLeader = false;
859
+ entry.isLeader = false;
730
860
  });
731
861
  }
862
+ /** Drop one reference to `route`; the last one releases its lock. */
863
+ releaseRoute(route) {
864
+ const entry = this.leaders.get(route);
865
+ if (!entry) return;
866
+ entry.refs -= 1;
867
+ if (entry.refs > 0) return;
868
+ this.leaders.delete(route);
869
+ this.settled().then(() => entry.release());
870
+ }
871
+ /** Does this tab currently lead `route`? */
872
+ leads(route) {
873
+ return this.leaders.get(route)?.isLeader === true;
874
+ }
732
875
  /**
733
876
  * Attach a collection: register its per-object callbacks and bump the ref
734
877
  * count. Returns the exact callback record registered so the caller can pass
@@ -739,9 +882,12 @@ var OutboxEngine = class {
739
882
  */
740
883
  registerCollection(binding) {
741
884
  this.refCount += 1;
885
+ const route = binding.transport ? routeOf({ transport: binding.object }) : binding.syncApply ? SYNC_APPLY_ROUTE : void 0;
742
886
  const record = {
743
887
  onSyncStateChange: binding.onSyncStateChange,
744
- onConflict: binding.onConflict
888
+ onConflict: binding.onConflict,
889
+ transport: binding.transport,
890
+ route
745
891
  };
746
892
  let set = this.listenersByObject.get(binding.object);
747
893
  if (!set) {
@@ -749,8 +895,16 @@ var OutboxEngine = class {
749
895
  this.listenersByObject.set(binding.object, set);
750
896
  }
751
897
  set.add(record);
898
+ if (binding.syncApply && !this.syncApply) this.syncApply = binding.syncApply;
899
+ if (route && !this.disposed) this.acquireRoute(route);
752
900
  return record;
753
901
  }
902
+ /** The first live transport registered under `name`, if any. */
903
+ transportFor(name) {
904
+ const set = this.listenersByObject.get(name);
905
+ if (!set) return void 0;
906
+ for (const record of set) if (record.transport) return record.transport;
907
+ }
754
908
  /**
755
909
  * Detach a collection: remove its callback record and decrement the ref count;
756
910
  * when it reaches zero, dispose the engine (release the lock, unregister from
@@ -760,9 +914,10 @@ var OutboxEngine = class {
760
914
  */
761
915
  async unregisterCollection(object, record) {
762
916
  const set = this.listenersByObject.get(object);
763
- if (set) {
764
- set.delete(record);
917
+ if (set?.delete(record)) {
765
918
  if (set.size === 0) this.listenersByObject.delete(object);
919
+ const route = record.route;
920
+ if (route) this.releaseRoute(route);
766
921
  }
767
922
  this.refCount = Math.max(0, this.refCount - 1);
768
923
  if (this.refCount > 0) return false;
@@ -788,20 +943,23 @@ var OutboxEngine = class {
788
943
  async enqueue(request) {
789
944
  await this.ready;
790
945
  if (!this.queue || this.degraded) return void 0;
946
+ this.registerForWipe();
791
947
  const itemId = newItemId();
792
948
  const op = envelopeKindToOp(request.kind);
793
- const payload = op === "delete" ? void 0 : request.data;
949
+ const rowId = request.rowId || itemId;
950
+ const payload = op === "delete" && request.transport === void 0 ? void 0 : request.data;
794
951
  await this.queue.enqueue({
795
952
  itemId,
796
953
  object: request.object,
797
954
  op,
798
- id: request.rowId,
955
+ id: rowId,
799
956
  payload,
800
- baseUpdatedAt: request.baseUpdatedAt
957
+ baseUpdatedAt: request.baseUpdatedAt,
958
+ transport: request.transport
801
959
  });
802
960
  this.emit({
803
961
  itemId,
804
- rowId: request.rowId,
962
+ rowId,
805
963
  object: request.object,
806
964
  state: "pending",
807
965
  attempts: 0
@@ -842,7 +1000,8 @@ var OutboxEngine = class {
842
1000
  state: row.state,
843
1001
  attempts: row.attempts,
844
1002
  nextAttemptAt: row.nextAttemptAt,
845
- lastError: row.lastError
1003
+ lastError: row.lastError,
1004
+ ...row.transport === void 0 ? {} : { transport: row.transport }
846
1005
  }));
847
1006
  }
848
1007
  /**
@@ -871,53 +1030,124 @@ var OutboxEngine = class {
871
1030
  }
872
1031
  }
873
1032
  /**
874
- * The replay loop. Gated on: (a) holding leadership, (b) not paused by an
1033
+ * The replay loop. Gated on: (a) leading at least one route, (b) not paused by an
875
1034
  * auth failure, (c) `navigator.onLine !== false`, (d) IndexedDB usable. Drains
876
- * all rows due now (`nextAttemptAt <= now`), oldest-first, chunked into
877
- * batches of ≤1000 per POST, one POST at a time to preserve FIFO across
878
- * chunks. Concurrency-coalesced: a drain requested while one runs sets a flag
1035
+ * every led route's rows due now (`nextAttemptAt <= now`), oldest-first per
1036
+ * route; sync-apply chunks into batches of ≤1000 per POST, one send at a time
1037
+ * to preserve each route's FIFO. Concurrency-coalesced: a drain requested while one runs sets a flag
879
1038
  * to run exactly one more pass, so overlapping triggers never interleave.
880
1039
  */
881
- async drain() {
1040
+ drain() {
882
1041
  if (this.draining) {
883
1042
  this.drainQueued = true;
884
- return;
1043
+ return this.activeDrain ?? Promise.resolve();
885
1044
  }
886
1045
  this.draining = true;
887
- try {
888
- for (;;) {
889
- this.drainQueued = false;
890
- await this.drainOnce();
891
- if (!this.drainQueued) break;
1046
+ this.activeDrain = (async () => {
1047
+ try {
1048
+ for (;;) {
1049
+ this.drainQueued = false;
1050
+ await this.drainOnce();
1051
+ if (!this.drainQueued) break;
1052
+ }
1053
+ } finally {
1054
+ this.draining = false;
1055
+ this.activeDrain = void 0;
892
1056
  }
893
- } finally {
894
- this.draining = false;
895
- }
1057
+ })();
1058
+ return this.activeDrain;
1059
+ }
1060
+ /**
1061
+ * Settles once the drain in flight (if any) has finished its request AND its
1062
+ * durable result transition. A route's lock is released only after this, so
1063
+ * another tab can never replay a row this tab is still settling.
1064
+ */
1065
+ settled() {
1066
+ return (this.activeDrain ?? Promise.resolve()).catch(() => void 0);
896
1067
  }
897
1068
  /** One drain pass: send every currently-due batch, then schedule backoff. */
898
1069
  async drainOnce() {
899
1070
  if (this.disposed) return;
900
- if (!this.isLeader) return;
1071
+ if (this.wipesPending > 0) return;
901
1072
  if (this.paused) return;
902
1073
  if (this.degraded || !this.queue) return;
903
1074
  if (isDefinitelyOffline()) return;
904
- const pending = (await this.queue.all()).filter((row) => row.state === "pending");
905
- if (pending.length === 0) return;
906
- const now = Date.now();
907
- const firstBlocked = pending.findIndex((row) => row.nextAttemptAt > now);
908
- const due = firstBlocked === -1 ? pending : pending.slice(0, firstBlocked);
909
- if (due.length === 0) {
910
- await this.scheduleNextBackoff();
911
- return;
1075
+ const byRoute = /* @__PURE__ */ new Map();
1076
+ for (const row of await this.queue.all()) {
1077
+ if (row.state !== "pending") continue;
1078
+ const route = routeOf(row);
1079
+ if (!this.leads(route)) continue;
1080
+ const rows = byRoute.get(route);
1081
+ if (rows) rows.push(row);
1082
+ else byRoute.set(route, [row]);
912
1083
  }
913
- for (let i = 0; i < due.length; i += MAX_SYNC_APPLY_BATCH_SIZE) {
914
- if (this.disposed || this.paused || !this.isLeader) break;
915
- const chunk = due.slice(i, i + MAX_SYNC_APPLY_BATCH_SIZE);
916
- if (!await this.sendBatch(chunk)) break;
1084
+ const now = Date.now();
1085
+ for (const [route, rows] of byRoute) {
1086
+ const firstBlocked = rows.findIndex((row) => row.nextAttemptAt > now);
1087
+ const due = firstBlocked === -1 ? rows : rows.slice(0, firstBlocked);
1088
+ if (route === SYNC_APPLY_ROUTE) {
1089
+ const syncApply = this.syncApply;
1090
+ if (!syncApply) continue;
1091
+ for (let i = 0; i < due.length; i += MAX_SYNC_APPLY_BATCH_SIZE) {
1092
+ if (this.disposed || this.paused || !this.leads(route)) break;
1093
+ const chunk = due.slice(i, i + MAX_SYNC_APPLY_BATCH_SIZE);
1094
+ if (!await this.sendBatch(chunk, syncApply)) break;
1095
+ }
1096
+ } else for (const row of due) {
1097
+ if (this.disposed || this.paused || !this.leads(route)) break;
1098
+ const transport = this.transportFor(row.transport ?? row.object);
1099
+ if (!transport) break;
1100
+ if (!await this.sendCommand(row, transport)) break;
1101
+ }
1102
+ if (this.disposed || this.paused) return;
917
1103
  }
918
1104
  await this.scheduleNextBackoff();
919
1105
  }
920
1106
  /**
1107
+ * Replay one transport row through its consumer-declared transport (#3021)
1108
+ * and map the outcome through the SAME transitions as a sync-apply result. A
1109
+ * throw is the ambiguous path (request may or may not have landed): the row
1110
+ * stays `pending` with backoff and is resent under the same idempotency key.
1111
+ * Returns false when the row remains pending, stopping this route's pass
1112
+ * (FIFO per route).
1113
+ */
1114
+ async sendCommand(row, transport) {
1115
+ this.emit({
1116
+ itemId: row.itemId,
1117
+ rowId: row.id,
1118
+ object: row.object,
1119
+ state: "uploading",
1120
+ attempts: row.attempts
1121
+ });
1122
+ let outcome;
1123
+ try {
1124
+ outcome = await transport({
1125
+ idempotencyKey: row.itemId,
1126
+ name: row.transport ?? row.object,
1127
+ op: row.op,
1128
+ rowId: row.id,
1129
+ ...row.payload === void 0 ? {} : { payload: row.payload },
1130
+ ...row.baseUpdatedAt === void 0 ? {} : { baseUpdatedAt: row.baseUpdatedAt },
1131
+ attempt: row.attempts + 1,
1132
+ ...row.pinned === void 0 ? {} : { pinned: row.pinned },
1133
+ pin: async (value) => {
1134
+ if (row.seq === void 0 || !this.queue) throw new Error("[smrt-web] outbox row is no longer queued");
1135
+ await this.queue.markState(row.seq, { pinned: value });
1136
+ row.pinned = value;
1137
+ }
1138
+ });
1139
+ } catch {
1140
+ await this.requeueRow(row, "network error during command replay");
1141
+ return false;
1142
+ }
1143
+ const result = commandResultToApplyResult(row, outcome);
1144
+ if (!result) {
1145
+ await this.requeueRow(row, "unexpected command result shape");
1146
+ return false;
1147
+ }
1148
+ return this.applyResult(row, result);
1149
+ }
1150
+ /**
921
1151
  * Send one chunk through `POST {basePath}/sync/apply` and map results back
922
1152
  * onto durable transitions. On a network reject / non-200 / lost/mismatched
923
1153
  * response the WHOLE chunk stays `pending` (blind replay is safe) — every row
@@ -925,7 +1155,7 @@ var OutboxEngine = class {
925
1155
  * doesn't hot-spin. Returns false when a retryable row remains pending, which
926
1156
  * stops this drain pass so newer FIFO chunks do not overtake it.
927
1157
  */
928
- async sendBatch(chunk) {
1158
+ async sendBatch(chunk, syncApply) {
929
1159
  for (const row of chunk) this.emit({
930
1160
  itemId: row.itemId,
931
1161
  rowId: row.id,
@@ -943,7 +1173,7 @@ var OutboxEngine = class {
943
1173
  }));
944
1174
  let results;
945
1175
  try {
946
- results = await this.postBatch(items);
1176
+ results = await this.postBatch(items, syncApply);
947
1177
  } catch {
948
1178
  await this.requeueBatch(chunk, "network error during sync");
949
1179
  return false;
@@ -971,9 +1201,9 @@ var OutboxEngine = class {
971
1201
  * error (the caller treats a throw as "response lost → keep pending"). Returns
972
1202
  * the positional `results` array, or `undefined` on a malformed 200 body.
973
1203
  */
974
- async postBatch(items) {
975
- const url = `${this.config.syncApplyBasePath}/${SYNC_APPLY_ROUTE_SEGMENTS.join("/")}`;
976
- const response = await this.config.fetchFn(url, {
1204
+ async postBatch(items, syncApply) {
1205
+ const url = `${syncApply.basePath}/${SYNC_APPLY_ROUTE_SEGMENTS.join("/")}`;
1206
+ const response = await syncApply.fetchFn(url, {
977
1207
  method: "POST",
978
1208
  headers: { "Content-Type": "application/json" },
979
1209
  body: JSON.stringify({ items })
@@ -1079,10 +1309,18 @@ var OutboxEngine = class {
1079
1309
  */
1080
1310
  async scheduleNextBackoff() {
1081
1311
  if (this.disposed || this.paused || !this.queue) return;
1082
- const firstPending = (await this.queue.all()).find((r) => r.state === "pending");
1083
- if (!firstPending) return;
1084
- const now = Date.now();
1085
- const delay = Math.max(0, firstPending.nextAttemptAt - now);
1312
+ let nextAt;
1313
+ const seen = /* @__PURE__ */ new Set();
1314
+ for (const row of await this.queue.all()) {
1315
+ if (row.state !== "pending") continue;
1316
+ const route = routeOf(row);
1317
+ if (seen.has(route)) continue;
1318
+ seen.add(route);
1319
+ if (!this.leads(route)) continue;
1320
+ nextAt = nextAt === void 0 ? row.nextAttemptAt : Math.min(nextAt, row.nextAttemptAt);
1321
+ }
1322
+ if (nextAt === void 0) return;
1323
+ const delay = Math.max(0, nextAt - Date.now());
1086
1324
  if (this.backoffTimer) clearTimeout(this.backoffTimer);
1087
1325
  const timers = globalThis;
1088
1326
  if (typeof timers.setTimeout !== "function") return;
@@ -1110,11 +1348,14 @@ var OutboxEngine = class {
1110
1348
  target.removeEventListener("online", this.onlineListener);
1111
1349
  this.onlineListener = void 0;
1112
1350
  }
1113
- this.leadership?.();
1114
- this.leadership = void 0;
1351
+ const leaders = [...this.leaders.values()];
1352
+ this.leaders.clear();
1353
+ await this.settled();
1354
+ for (const entry of leaders) entry.release();
1115
1355
  this.unregisterResource?.();
1116
1356
  this.unregisterResource = void 0;
1117
1357
  await this.ready.catch(() => void 0);
1358
+ await Promise.allSettled([...this.pendingWipes]);
1118
1359
  this.queue?.close();
1119
1360
  this.queue = void 0;
1120
1361
  this.listenersByObject.clear();
@@ -1129,6 +1370,29 @@ function getOrCreateOutboxEngine(config) {
1129
1370
  }
1130
1371
  return engine;
1131
1372
  }
1373
+ function commandResultToApplyResult(row, outcome) {
1374
+ if (!outcome || typeof outcome !== "object") return void 0;
1375
+ const { status, reason, updatedAt } = outcome;
1376
+ const base = {
1377
+ itemId: row.itemId,
1378
+ id: row.id,
1379
+ ...typeof updatedAt === "string" ? { updatedAt } : {}
1380
+ };
1381
+ if (status === "applied") return {
1382
+ ...base,
1383
+ status
1384
+ };
1385
+ if (status === "conflict") return {
1386
+ ...base,
1387
+ status,
1388
+ reason: reason === "create_conflict" ? "create_conflict" : "stale_write"
1389
+ };
1390
+ if (status === "rejected" && typeof reason === "string" && reason) return {
1391
+ ...base,
1392
+ status,
1393
+ reason
1394
+ };
1395
+ }
1132
1396
  function acquireOutboxEngine(config, binding) {
1133
1397
  const engine = getOrCreateOutboxEngine(config);
1134
1398
  return {
@@ -1149,6 +1413,11 @@ function resolveBackoff(backoff) {
1149
1413
  };
1150
1414
  }
1151
1415
  var handlesByNamespace = /* @__PURE__ */ new Map();
1416
+ async function releaseBinding(namespace, engine, object, record) {
1417
+ const before = engine.referenceCount;
1418
+ await releaseOutboxEngine(namespace, engine, object, record);
1419
+ if (before <= 1 && handlesByNamespace.get(namespace) === engine) handlesByNamespace.delete(namespace);
1420
+ }
1152
1421
  function getPayloadUpdatedAt(data) {
1153
1422
  const value = data.updatedAt ?? data.updated_at;
1154
1423
  if (typeof value === "string") return value;
@@ -1159,6 +1428,7 @@ function offlineOutbox(config) {
1159
1428
  const syncApplyBasePath = config.syncApplyBasePath ?? "/api/v1";
1160
1429
  const fetchFn = config.fetchFn ?? ((...args) => globalThis.fetch(...args));
1161
1430
  const backoff = resolveBackoff(config.backoff);
1431
+ const transport = config.transport;
1162
1432
  const object = config.object.name;
1163
1433
  let engine;
1164
1434
  let record;
@@ -1167,8 +1437,6 @@ function offlineOutbox(config) {
1167
1437
  onAttach() {
1168
1438
  const acquired = acquireOutboxEngine({
1169
1439
  namespace,
1170
- syncApplyBasePath,
1171
- fetchFn,
1172
1440
  backoff,
1173
1441
  random: config.random,
1174
1442
  registerResource: (clear) => registerDurableResource(namespace, {
@@ -1178,7 +1446,11 @@ function offlineOutbox(config) {
1178
1446
  }, {
1179
1447
  object,
1180
1448
  onSyncStateChange: config.onSyncStateChange,
1181
- onConflict: config.onConflict
1449
+ onConflict: config.onConflict,
1450
+ ...transport ? { transport } : { syncApply: {
1451
+ basePath: syncApplyBasePath,
1452
+ fetchFn
1453
+ } }
1182
1454
  });
1183
1455
  engine = acquired.engine;
1184
1456
  record = acquired.record;
@@ -1191,7 +1463,8 @@ function offlineOutbox(config) {
1191
1463
  object,
1192
1464
  rowId: envelope.key,
1193
1465
  data: envelope.data,
1194
- baseUpdatedAt: envelope.baseUpdatedAt ?? getPayloadUpdatedAt(envelope.data)
1466
+ baseUpdatedAt: envelope.baseUpdatedAt ?? getPayloadUpdatedAt(envelope.data),
1467
+ ...transport ? { transport: object } : {}
1195
1468
  })) return { handled: false };
1196
1469
  return {
1197
1470
  handled: true,
@@ -1204,9 +1477,53 @@ function offlineOutbox(config) {
1204
1477
  const currentRecord = record;
1205
1478
  engine = void 0;
1206
1479
  record = void 0;
1207
- const before = current.referenceCount;
1208
- await releaseOutboxEngine(namespace, current, object, currentRecord);
1209
- if (before <= 1 && handlesByNamespace.get(namespace) === current) handlesByNamespace.delete(namespace);
1480
+ await releaseBinding(namespace, current, object, currentRecord);
1481
+ }
1482
+ };
1483
+ }
1484
+ function offlineCommandQueue(config) {
1485
+ if (typeof config.name !== "string" || config.name.length === 0) throw new TypeError("offlineCommandQueue requires a non-empty name");
1486
+ if (typeof config.transport !== "function") throw new TypeError("offlineCommandQueue requires a transport function");
1487
+ const namespace = durableStoreNamespace(config.namespace);
1488
+ const name = config.name;
1489
+ const acquired = acquireOutboxEngine({
1490
+ namespace,
1491
+ backoff: resolveBackoff(config.backoff),
1492
+ random: config.random,
1493
+ registerResource: (clear) => registerDurableResource(namespace, {
1494
+ kind: "outbox",
1495
+ clear
1496
+ })
1497
+ }, {
1498
+ object: name,
1499
+ transport: config.transport,
1500
+ onSyncStateChange: config.onSyncStateChange,
1501
+ onConflict: config.onConflict
1502
+ });
1503
+ let engine = acquired.engine;
1504
+ const record = acquired.record;
1505
+ handlesByNamespace.set(namespace, engine);
1506
+ return {
1507
+ async enqueue(input) {
1508
+ if (!engine) return void 0;
1509
+ return engine.enqueue({
1510
+ kind: input.op === "create" || input.op === void 0 ? "insert" : input.op,
1511
+ object: name,
1512
+ rowId: input.rowId ?? "",
1513
+ data: input.payload ?? {},
1514
+ baseUpdatedAt: input.baseUpdatedAt,
1515
+ transport: name
1516
+ });
1517
+ },
1518
+ snapshot: async () => engine ? engine.snapshot() : [],
1519
+ retry: async (itemId) => {
1520
+ await engine?.retry(itemId);
1521
+ },
1522
+ async dispose() {
1523
+ const current = engine;
1524
+ if (!current) return;
1525
+ engine = void 0;
1526
+ await releaseBinding(namespace, current, name, record);
1210
1527
  }
1211
1528
  };
1212
1529
  }
@@ -1331,6 +1648,17 @@ function openSnapshotStore(namespace) {
1331
1648
  //#endregion
1332
1649
  //#region src/persistence.ts
1333
1650
  var DEFAULT_PERSIST_DEBOUNCE_MS = 250;
1651
+ function registerForWipe(engine, namespace) {
1652
+ if (engine.unregister) return;
1653
+ engine.unregister = registerDurableResource(namespace, {
1654
+ kind: "persisted-collection",
1655
+ clear: async () => {
1656
+ engine.wipeEpoch += 1;
1657
+ engine.unregister = void 0;
1658
+ await (await engine.ready)?.clear();
1659
+ }
1660
+ });
1661
+ }
1334
1662
  var enginesByNamespace = /* @__PURE__ */ new Map();
1335
1663
  var warnedNoIndexedDb = false;
1336
1664
  function warnNoIndexedDbOnce() {
@@ -1342,13 +1670,15 @@ function acquireSnapshotEngine(namespace) {
1342
1670
  const existing = enginesByNamespace.get(namespace);
1343
1671
  if (existing) {
1344
1672
  existing.refCount += 1;
1673
+ registerForWipe(existing, namespace);
1345
1674
  return existing;
1346
1675
  }
1347
1676
  const engine = {
1348
1677
  store: void 0,
1349
1678
  refCount: 1,
1350
1679
  unregister: void 0,
1351
- ready: Promise.resolve(void 0)
1680
+ ready: Promise.resolve(void 0),
1681
+ wipeEpoch: 0
1352
1682
  };
1353
1683
  engine.ready = (async () => {
1354
1684
  if (!await probeIndexedDb()) {
@@ -1358,10 +1688,6 @@ function acquireSnapshotEngine(namespace) {
1358
1688
  try {
1359
1689
  const store = await openSnapshotStore(namespace);
1360
1690
  engine.store = store;
1361
- engine.unregister = registerDurableResource(namespace, {
1362
- kind: "persisted-collection",
1363
- clear: () => store.clear()
1364
- });
1365
1691
  return store;
1366
1692
  } catch {
1367
1693
  warnNoIndexedDbOnce();
@@ -1369,6 +1695,7 @@ function acquireSnapshotEngine(namespace) {
1369
1695
  }
1370
1696
  })();
1371
1697
  enginesByNamespace.set(namespace, engine);
1698
+ registerForWipe(engine, namespace);
1372
1699
  return engine;
1373
1700
  }
1374
1701
  async function releaseSnapshotEngine(namespace) {
@@ -1458,6 +1785,74 @@ function persistCollection(config) {
1458
1785
  }
1459
1786
  };
1460
1787
  }
1788
+ function dataSurfaceSnapshotKey(identity) {
1789
+ if (!identity || typeof identity.surfaceId !== "string" || identity.surfaceId.length === 0 || typeof identity.kind !== "string") throw new TypeError("persistDataSurface requires an identity with a surfaceId and kind");
1790
+ return `data-surface:${JSON.stringify([
1791
+ identity.kind,
1792
+ identity.surfaceId,
1793
+ identity.subject?.type ?? null,
1794
+ identity.subject?.id ?? null
1795
+ ])}`;
1796
+ }
1797
+ function persistDataSurface(config) {
1798
+ const namespace = durableStoreNamespace(config.namespace);
1799
+ const key = dataSurfaceSnapshotKey(config.identity);
1800
+ const debounceMs = config.debounceMs ?? 250;
1801
+ let engine = acquireSnapshotEngine(namespace);
1802
+ const bornEpoch = engine.wipeEpoch;
1803
+ const wiped = (current) => current.wipeEpoch !== bornEpoch;
1804
+ let pending;
1805
+ let timer;
1806
+ let writing = Promise.resolve();
1807
+ const writePending = () => {
1808
+ if (timer) {
1809
+ clearTimeout(timer);
1810
+ timer = void 0;
1811
+ }
1812
+ const rows = pending;
1813
+ pending = void 0;
1814
+ const current = engine;
1815
+ if (!rows || !current) return writing;
1816
+ writing = writing.then(async () => {
1817
+ const store = await current.ready;
1818
+ if (!store || wiped(current)) return;
1819
+ try {
1820
+ await store.save(key, rows);
1821
+ } catch {}
1822
+ });
1823
+ return writing;
1824
+ };
1825
+ return {
1826
+ async load() {
1827
+ const current = engine;
1828
+ if (!current) return void 0;
1829
+ const store = await current.ready;
1830
+ if (!store || wiped(current)) return void 0;
1831
+ const rows = await store.load(key);
1832
+ if (!rows || rows.length === 0) return void 0;
1833
+ return rows;
1834
+ },
1835
+ save(rows) {
1836
+ if (!engine) return;
1837
+ if (!Array.isArray(rows)) throw new TypeError("persistDataSurface.save requires an array of rows");
1838
+ if (wiped(engine)) return;
1839
+ pending = rows.map((row) => ({ ...row }));
1840
+ if (timer) clearTimeout(timer);
1841
+ timer = setTimeout(() => {
1842
+ timer = void 0;
1843
+ writePending();
1844
+ }, Math.max(0, debounceMs));
1845
+ timer.unref?.();
1846
+ },
1847
+ flush: () => writePending(),
1848
+ async dispose() {
1849
+ if (!engine) return;
1850
+ await writePending();
1851
+ engine = void 0;
1852
+ await releaseSnapshotEngine(namespace);
1853
+ }
1854
+ };
1855
+ }
1461
1856
  //#endregion
1462
1857
  //#region src/remote-query.ts
1463
1858
  function canonicalize(value) {
@@ -3151,6 +3546,6 @@ function createSmrtCollection(definition, options) {
3151
3546
  return handle;
3152
3547
  }
3153
3548
  //#endregion
3154
- export { MAX_SMRT_WEB_DATA_SURFACE_ACTION_RESULT_BYTES as A, MAX_SMRT_WEB_DATA_QUERY_STRING_LENGTH as B, offlineOutbox as C, MAX_SMRT_WEB_DATA_SURFACE_ACTION_CONTAINER_ITEMS as D, wipeDurableStore as E, MAX_SMRT_WEB_DATA_QUERY_FACET_VALUES as F, executeSmrtWebDataQuery as H, MAX_SMRT_WEB_DATA_QUERY_OFFSET as I, MAX_SMRT_WEB_DATA_QUERY_PAGE_LIMIT as L, normalizeSmrtWebDataSurfaceActionResult as M, MAX_SMRT_WEB_DATA_QUERY_CONTAINER_ITEMS as N, MAX_SMRT_WEB_DATA_SURFACE_ACTION_IDENTIFIER_LENGTH as O, MAX_SMRT_WEB_DATA_QUERY_FACETS as P, MAX_SMRT_WEB_DATA_QUERY_RESULT_BYTES as R, getOutboxHandle as S, registerDurableResource as T, normalizeSmrtWebDataQueryResult as U, MAX_SMRT_WEB_DATA_QUERY_WARNINGS as V, runWrapMutation as W, createSmrtWebEventSubscriber as _, createSmrtWebClient as a, DEFAULT_PERSIST_DEBOUNCE_MS as b, newLocalId as c, unwrapListResult as d, validateSmrtWebClient as f, createUpdateState as g, registerWebMcpTools as h, createSmrtCollection as i, executeSmrtWebDataSurfaceAction as j, MAX_SMRT_WEB_DATA_SURFACE_ACTION_JSON_DEPTH as k, throwIfSmrtWebError as l, registerWebMcpBespokeTool as m, buildListQuery as n, getEngineCollection as o, registerViewIntent as p, createDefinitionFetchers as r, invalidateSmrtWebCollections as s, SmrtWebRequestError as t, unwrapItemResult as u, liveInvalidation as v, durableStoreNamespace as w, persistCollection as x, createSmrtWebQuery as y, MAX_SMRT_WEB_DATA_QUERY_ROWS as z };
3549
+ export { MAX_SMRT_WEB_DATA_SURFACE_ACTION_IDENTIFIER_LENGTH as A, MAX_SMRT_WEB_DATA_QUERY_OFFSET as B, getOutboxHandle as C, registerDurableResource as D, durableStoreNamespace as E, executeSmrtWebDataSurfaceAction as F, MAX_SMRT_WEB_DATA_QUERY_WARNINGS as G, MAX_SMRT_WEB_DATA_QUERY_RESULT_BYTES as H, normalizeSmrtWebDataSurfaceActionResult as I, runWrapMutation as J, executeSmrtWebDataQuery as K, MAX_SMRT_WEB_DATA_QUERY_CONTAINER_ITEMS as L, MAX_SMRT_WEB_DATA_SURFACE_ACTION_RESULT_BYTES as M, classifySmrtWebDataSurfaceActionResult as N, wipeDurableStore as O, dataSurfaceActionCommandTransport as P, MAX_SMRT_WEB_DATA_QUERY_FACETS as R, persistDataSurface as S, offlineOutbox as T, MAX_SMRT_WEB_DATA_QUERY_ROWS as U, MAX_SMRT_WEB_DATA_QUERY_PAGE_LIMIT as V, MAX_SMRT_WEB_DATA_QUERY_STRING_LENGTH as W, createSmrtWebEventSubscriber as _, createSmrtWebClient as a, DEFAULT_PERSIST_DEBOUNCE_MS as b, newLocalId as c, unwrapListResult as d, validateSmrtWebClient as f, createUpdateState as g, registerWebMcpTools as h, createSmrtCollection as i, MAX_SMRT_WEB_DATA_SURFACE_ACTION_JSON_DEPTH as j, MAX_SMRT_WEB_DATA_SURFACE_ACTION_CONTAINER_ITEMS as k, throwIfSmrtWebError as l, registerWebMcpBespokeTool as m, buildListQuery as n, getEngineCollection as o, registerViewIntent as p, normalizeSmrtWebDataQueryResult as q, createDefinitionFetchers as r, invalidateSmrtWebCollections as s, SmrtWebRequestError as t, unwrapItemResult as u, liveInvalidation as v, offlineCommandQueue as w, persistCollection as x, createSmrtWebQuery as y, MAX_SMRT_WEB_DATA_QUERY_FACET_VALUES as z };
3155
3550
 
3156
- //# sourceMappingURL=src-0IQGhPIM.js.map
3551
+ //# sourceMappingURL=src-B2uQWayW.js.map