@abraca/resend 2.16.0 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -18,6 +18,7 @@ declare class AbracadabraResendServer {
18
18
  private _connection;
19
19
  private childCache;
20
20
  private evictionTimer;
21
+ private heartbeatTimer;
21
22
  private _userId;
22
23
  private _signFn;
23
24
  private _reconnecting;
@@ -123,11 +124,15 @@ declare class OutboxWatcher {
123
124
  private readonly bootstrap;
124
125
  private readonly defaultFrom;
125
126
  private readonly inFlight;
126
- private readonly handled;
127
127
  private observer;
128
128
  private treeMap;
129
129
  private rootDoc;
130
130
  private txHandler;
131
+ private subdocsHandler;
132
+ private updateHandler;
133
+ private reconnectedHandler;
134
+ private reconnectProvider;
135
+ private readonly subdocHandlers;
131
136
  constructor(opts: OutboxWatcherOptions);
132
137
  start(): void;
133
138
  stop(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/resend",
3
- "version": "2.16.0",
3
+ "version": "2.17.0",
4
4
  "description": "Resend bridge for Abracadabra — Inbox + Outbox documents wired to email send/receive",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,8 +33,8 @@
33
33
  "resend": "^4.0.0",
34
34
  "yjs": "^13.6.8",
35
35
  "zod": "^4.3.6",
36
- "@abraca/convert": "2.16.0",
37
- "@abraca/dabra": "2.16.0"
36
+ "@abraca/convert": "2.17.0",
37
+ "@abraca/dabra": "2.17.0"
38
38
  },
39
39
  "scripts": {
40
40
  "test": "node --no-warnings --conditions=source --experimental-transform-types --test 'tests/*.test.ts'"
@@ -54,12 +54,41 @@ export class OutboxWatcher {
54
54
  private readonly sender: ResendSender;
55
55
  private readonly bootstrap: BootstrapResult;
56
56
  private readonly defaultFrom: string;
57
+ // Transient mid-dispatch guard ONLY: holds ids whose send is in flight RIGHT
58
+ // NOW, so a burst of observe events doesn't fire a second concurrent send
59
+ // before `meta.resendId` lands. Cleared the instant a dispatch settles — it
60
+ // is NEVER a durable "already sent" marker. The ONLY durable sent-state is
61
+ // `meta.resendId` on the document (set on success); a failed send is durably
62
+ // recorded by the move to the Failed column + `meta.error`. There is no
63
+ // in-memory "handled" set: a card re-queued into Ready (resendId cleared) is
64
+ // re-sent, and the bridge's view never diverges from the persisted tree.
57
65
  private readonly inFlight = new Set<string>();
58
- private readonly handled = new Set<string>();
59
66
  private observer: ((events: Y.YEvent<any>[]) => void) | null = null;
60
67
  private treeMap: Y.Map<any> | null = null;
61
68
  private rootDoc: Y.Doc | null = null;
62
69
  private txHandler: ((tx: Y.Transaction) => void) | null = null;
70
+ private subdocsHandler:
71
+ | ((changes: {
72
+ added: Set<Y.Doc>;
73
+ removed: Set<Y.Doc>;
74
+ loaded: Set<Y.Doc>;
75
+ }) => void)
76
+ | null = null;
77
+ private updateHandler:
78
+ | ((update: Uint8Array, origin: unknown) => void)
79
+ | null = null;
80
+ private reconnectedHandler: (() => void) | null = null;
81
+ // The provider we attached `reconnected` to, kept so stop() can detach.
82
+ private reconnectProvider: { off(ev: string, cb: () => void): void } | null =
83
+ null;
84
+ // Per-subdoc afterTransaction handlers. Tracked so we (a) detach them on
85
+ // stop() and (b) never stack a second listener when the same subdoc reloads
86
+ // (the old code re-`on`'d every subdoc on every `subdocs` event — an
87
+ // unbounded listener leak that grew for the life of the daemon).
88
+ private readonly subdocHandlers = new Map<
89
+ Y.Doc,
90
+ (tx: Y.Transaction) => void
91
+ >();
63
92
 
64
93
  constructor(opts: OutboxWatcherOptions) {
65
94
  this.server = opts.server;
@@ -114,31 +143,65 @@ export class OutboxWatcher {
114
143
 
115
144
  // Belt-and-suspenders: subdocs. abracadabra-rs may route some space-tree
116
145
  // edits through subdoc payloads that don't fire transactions on the root
117
- // doc directly. Watch every subdoc's transactions too.
118
- const onSubdocs = (changes: { added: Set<Y.Doc>; removed: Set<Y.Doc>; loaded: Set<Y.Doc> }) => {
146
+ // doc directly. Watch every subdoc's transactions too — but dedup, so a
147
+ // subdoc that loads/unloads/reloads doesn't accumulate listeners.
148
+ const onSubdocs = (changes: {
149
+ added: Set<Y.Doc>;
150
+ removed: Set<Y.Doc>;
151
+ loaded: Set<Y.Doc>;
152
+ }) => {
119
153
  console.error(
120
- `[abracadabra-resend] subdocs: added=${changes.added.size} loaded=${changes.loaded.size}`,
154
+ `[abracadabra-resend] subdocs: added=${changes.added.size} loaded=${changes.loaded.size} removed=${changes.removed.size}`,
121
155
  );
156
+ for (const sub of changes.removed) {
157
+ const handler = this.subdocHandlers.get(sub);
158
+ if (handler) {
159
+ sub.off("afterTransaction", handler);
160
+ this.subdocHandlers.delete(sub);
161
+ }
162
+ }
122
163
  for (const sub of [...changes.added, ...changes.loaded]) {
123
- sub.on("afterTransaction", (tx: Y.Transaction) => {
164
+ if (this.subdocHandlers.has(sub)) continue;
165
+ const handler = (tx: Y.Transaction) => {
124
166
  console.error(
125
167
  `[abracadabra-resend] subdoc afterTransaction (guid=${sub.guid}, local=${tx.local})`,
126
168
  );
127
169
  void this.scan("subdoc");
128
- });
170
+ };
171
+ sub.on("afterTransaction", handler);
172
+ this.subdocHandlers.set(sub, handler);
129
173
  }
130
174
  };
131
175
  rootDoc.on("subdocs", onSubdocs);
176
+ this.subdocsHandler = onSubdocs;
132
177
 
133
178
  // Raw doc update — fires for every binary Yjs update applied to the doc,
134
179
  // from any origin. If THIS doesn't fire when a remote writer changes
135
180
  // state, the bridge's WS isn't delivering updates.
136
- rootDoc.on("update", (_update: Uint8Array, origin: unknown) => {
181
+ const onUpdate = (_update: Uint8Array, origin: unknown) => {
137
182
  console.error(
138
183
  `[abracadabra-resend] root update applied (origin=${String(origin)})`,
139
184
  );
140
185
  void this.scan("update");
141
- });
186
+ };
187
+ rootDoc.on("update", onUpdate);
188
+ this.updateHandler = onUpdate;
189
+
190
+ // Re-scan whenever the provider's socket reconnects. The doc (and these
191
+ // observers) survive a reconnect untouched, and the post-reconnect sync
192
+ // re-fires them for anything that changed while we were offline — but an
193
+ // explicit, idempotent catch-up scan guarantees we don't sit on a Ready
194
+ // doc that landed during the outage.
195
+ const provider = this.server.rootProvider;
196
+ if (provider && typeof (provider as any).on === "function") {
197
+ const onReconnected = () => {
198
+ console.error("[abracadabra-resend] provider reconnected — re-scanning");
199
+ void this.scan("reconnected");
200
+ };
201
+ (provider as any).on("reconnected", onReconnected);
202
+ this.reconnectedHandler = onReconnected;
203
+ this.reconnectProvider = provider as any;
204
+ }
142
205
 
143
206
  console.error(
144
207
  `[abracadabra-resend] Outbox watcher attached (ready column ${this.bootstrap.columns.Ready})`,
@@ -149,10 +212,27 @@ export class OutboxWatcher {
149
212
  if (this.rootDoc && this.txHandler) {
150
213
  this.rootDoc.off("afterTransaction", this.txHandler);
151
214
  }
215
+ if (this.rootDoc && this.subdocsHandler) {
216
+ this.rootDoc.off("subdocs", this.subdocsHandler);
217
+ }
218
+ if (this.rootDoc && this.updateHandler) {
219
+ this.rootDoc.off("update", this.updateHandler);
220
+ }
221
+ for (const [sub, handler] of this.subdocHandlers) {
222
+ sub.off("afterTransaction", handler);
223
+ }
224
+ this.subdocHandlers.clear();
152
225
  if (this.treeMap && this.observer) {
153
226
  this.treeMap.unobserveDeep(this.observer);
154
227
  }
228
+ if (this.reconnectProvider && this.reconnectedHandler) {
229
+ this.reconnectProvider.off("reconnected", this.reconnectedHandler);
230
+ }
155
231
  this.txHandler = null;
232
+ this.subdocsHandler = null;
233
+ this.updateHandler = null;
234
+ this.reconnectedHandler = null;
235
+ this.reconnectProvider = null;
156
236
  this.rootDoc = null;
157
237
  this.observer = null;
158
238
  this.treeMap = null;
@@ -189,9 +269,12 @@ export class OutboxWatcher {
189
269
  }
190
270
  if (e.parentId !== readyColId) return;
191
271
  inReadyCount++;
192
- if (this.inFlight.has(id) || this.handled.has(id)) return;
272
+ // Transient: a send for this id is already in flight in THIS process.
273
+ if (this.inFlight.has(id)) return;
274
+ // Durable: already sent (resendId persisted on the doc). Never re-send;
275
+ // just reconcile its column to Sent. This is the single source of truth
276
+ // for "done" — survives restarts, lives in document metadata.
193
277
  if (typeof e.meta.resendId === "string" && e.meta.resendId.length > 0) {
194
- this.handled.add(id);
195
278
  this.moveTo(id, this.bootstrap.columns.Sent);
196
279
  return;
197
280
  }
@@ -219,14 +302,15 @@ export class OutboxWatcher {
219
302
  const { id: resendId } = await this.sender.send(payload, {
220
303
  "X-Abra-Doc-Id": id,
221
304
  });
222
- this.handled.add(id);
305
+ // Durable sent-marker: persisted on the doc, the only record of "sent".
223
306
  this.patchMeta(id, { resendId, sentAt: Date.now(), error: null });
224
307
  this.moveTo(id, this.bootstrap.columns.Sent);
225
308
  console.error(
226
309
  `[abracadabra-resend] sent "${payload.subject}" (resend=${resendId}, doc=${id})`,
227
310
  );
228
311
  } catch (err: any) {
229
- this.handled.add(id);
312
+ // Durable failure record: meta.error + the move to the Failed column
313
+ // (below) — left for human triage. No in-memory marker.
230
314
  const message =
231
315
  err instanceof RenderError
232
316
  ? err.message
package/src/server.ts CHANGED
@@ -48,6 +48,7 @@ export class AbracadabraResendServer {
48
48
  private _connection: SpaceConnection | null = null;
49
49
  private childCache = new Map<string, CachedProvider>();
50
50
  private evictionTimer: ReturnType<typeof setInterval> | null = null;
51
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
51
52
  private _userId: string | null = null;
52
53
  private _signFn: ((challenge: string) => Promise<string>) | null = null;
53
54
  private _reconnecting: Promise<void> | null = null;
@@ -152,6 +153,16 @@ export class AbracadabraResendServer {
152
153
  console.error("[abracadabra-resend] Space doc synced");
153
154
 
154
155
  this.evictionTimer = setInterval(() => this.evictIdle(), 60_000);
156
+
157
+ // Connection heartbeat. The bridge is a long-running daemon that can sit
158
+ // idle for hours; its JWT will expire and the socket can drop with no
159
+ // dispatch to trigger a heal. `ensureConnected` is a no-op while healthy
160
+ // (just refreshes the token if it's near expiry), and revives the socket
161
+ // on the SAME doc — keeping the outbox watcher's observers alive — if it
162
+ // died. This is what makes "once connected, STAY connected" true when idle.
163
+ this.heartbeatTimer = setInterval(() => {
164
+ void this.ensureConnected();
165
+ }, 30_000);
155
166
  }
156
167
 
157
168
  private async _connectToSpace(docId: string): Promise<SpaceConnection> {
@@ -206,35 +217,36 @@ export class AbracadabraResendServer {
206
217
  const conn = this._connection;
207
218
  if (!conn) return;
208
219
  if (this._wsConnected(conn.provider)) return;
220
+
221
+ // Give the SDK's own auto-reconnect a bounded window before forcing
222
+ // one ourselves.
209
223
  try {
210
224
  await waitForSync(conn.provider, 6000);
211
225
  } catch {
212
- /* fall through to rebuild */
226
+ /* fall through to forced reconnect */
213
227
  }
214
228
  if (this._wsConnected(conn.provider)) return;
215
229
 
230
+ // Still dead → force a reconnect on the SAME Y.Doc. Critically we do
231
+ // NOT destroy + recreate the provider: the outbox watcher's
232
+ // observeDeep / afterTransaction listeners are attached to THIS doc,
233
+ // and swapping the doc out from under them is exactly what made the
234
+ // bridge go silent ("works until it doesn't") after a drop. The doc
235
+ // survives, observers keep firing, and step 1's token refresh lets
236
+ // the recycled socket re-authenticate cleanly. Cached child providers
237
+ // multiplex this same socket and re-sync on reopen, so we keep them.
216
238
  console.error(
217
- "[abracadabra-resend] Active connection dead — rebuilding…",
239
+ "[abracadabra-resend] Socket dead — forcing reconnect on the same doc…",
218
240
  );
219
- const docId = conn.docId;
241
+ conn.provider.reconnect();
220
242
  try {
221
- conn.provider.destroy();
222
- } catch {
223
- /* already gone */
224
- }
225
- for (const [, cached] of this.childCache) {
226
- try {
227
- cached.provider.destroy();
228
- } catch {
229
- /* already gone */
230
- }
231
- }
232
- this.childCache.clear();
233
- try {
234
- await this._connectToSpace(docId);
235
- console.error("[abracadabra-resend] Space provider rebuilt + synced");
243
+ await waitForSync(conn.provider, 10000);
244
+ console.error("[abracadabra-resend] Reconnected + re-synced");
236
245
  } catch (e) {
237
- console.error("[abracadabra-resend] Connection rebuild failed:", e);
246
+ console.error(
247
+ "[abracadabra-resend] Reconnect did not re-sync in time:",
248
+ e,
249
+ );
238
250
  }
239
251
  } finally {
240
252
  this._reconnecting = null;
@@ -301,6 +313,10 @@ export class AbracadabraResendServer {
301
313
  clearInterval(this.evictionTimer);
302
314
  this.evictionTimer = null;
303
315
  }
316
+ if (this.heartbeatTimer) {
317
+ clearInterval(this.heartbeatTimer);
318
+ this.heartbeatTimer = null;
319
+ }
304
320
  for (const [, cached] of this.childCache) {
305
321
  try {
306
322
  cached.provider.destroy();