@enbox/agent 0.5.11 → 0.5.13

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.
@@ -1173,7 +1173,19 @@ export class SyncEngineLevel implements SyncEngine {
1173
1173
  // Resolve the cursor from the link's pull checkpoint (preferred) or legacy storage.
1174
1174
  const cursorKey = this.buildCursorKey(did, dwnUrl, protocol);
1175
1175
  const link = this._activeLinks.get(cursorKey);
1176
- const cursor = link?.pull.contiguousAppliedToken ?? await this.getCursor(cursorKey);
1176
+ let cursor = link?.pull.contiguousAppliedToken ?? await this.getCursor(cursorKey);
1177
+
1178
+ // Guard against corrupted tokens with empty fields — these would fail
1179
+ // MessagesSubscribe JSON schema validation (minLength: 1). Discard and
1180
+ // start from the beginning rather than crash the subscription.
1181
+ if (cursor && (!cursor.streamId || !cursor.messageCid || !cursor.epoch || !cursor.position)) {
1182
+ console.warn(`SyncEngineLevel: Discarding stored cursor with empty field(s) for ${did} -> ${dwnUrl}`);
1183
+ cursor = undefined;
1184
+ if (link) {
1185
+ ReplicationLedger.resetCheckpoint(link.pull);
1186
+ await this.ledger.saveLink(link);
1187
+ }
1188
+ }
1177
1189
 
1178
1190
  // Build the MessagesSubscribe filters.
1179
1191
  // When the link has protocolPathPrefixes, include them in the filter so the
@@ -1426,7 +1438,11 @@ export class SyncEngineLevel implements SyncEngine {
1426
1438
  // a fresh cursor-stamped message after reconnection.
1427
1439
  const resubscribeFactory: ResubscribeFactory = async (resumeCursor?: ProgressToken) => {
1428
1440
  // On reconnect, use the latest durable checkpoint position if available.
1429
- const effectiveCursor = resumeCursor ?? link?.pull.contiguousAppliedToken ?? cursor;
1441
+ // Discard tokens with empty fields to avoid schema validation failures.
1442
+ let effectiveCursor = resumeCursor ?? link?.pull.contiguousAppliedToken ?? cursor;
1443
+ if (effectiveCursor && (!effectiveCursor.streamId || !effectiveCursor.messageCid || !effectiveCursor.epoch || !effectiveCursor.position)) {
1444
+ effectiveCursor = undefined;
1445
+ }
1430
1446
  const resumeRequest = {
1431
1447
  ...subscribeRequest,
1432
1448
  messageParams: { ...subscribeRequest.messageParams, cursor: effectiveCursor },
@@ -1496,6 +1512,19 @@ export class SyncEngineLevel implements SyncEngine {
1496
1512
  }): Promise<void> {
1497
1513
  const { did, delegateDid, dwnUrl, protocol } = target;
1498
1514
 
1515
+ // Guard against corrupted push cursors — same validation as the pull side.
1516
+ let pushCursor = target.pushCursor;
1517
+ if (pushCursor && (!pushCursor.streamId || !pushCursor.messageCid || !pushCursor.epoch || !pushCursor.position)) {
1518
+ console.warn(`SyncEngineLevel: Discarding stored push cursor with empty field(s) for ${did} -> ${dwnUrl}`);
1519
+ pushCursor = undefined;
1520
+ const cursorKey = this.buildCursorKey(did, dwnUrl, protocol);
1521
+ const link = this._activeLinks.get(cursorKey);
1522
+ if (link) {
1523
+ ReplicationLedger.resetCheckpoint(link.push);
1524
+ await this.ledger.saveLink(link);
1525
+ }
1526
+ }
1527
+
1499
1528
  // Build filters scoped to the protocol (if any).
1500
1529
  const filters = protocol ? [{ protocol }] : [];
1501
1530
 
@@ -1584,7 +1613,7 @@ export class SyncEngineLevel implements SyncEngine {
1584
1613
  target : did,
1585
1614
  messageType : DwnInterface.MessagesSubscribe,
1586
1615
  granteeDid : delegateDid,
1587
- messageParams : { filters, permissionGrantId, cursor: target.pushCursor },
1616
+ messageParams : { filters, permissionGrantId, cursor: pushCursor },
1588
1617
  subscriptionHandler : subscriptionHandler as any,
1589
1618
  });
1590
1619
 
@@ -1655,10 +1684,13 @@ export class SyncEngineLevel implements SyncEngine {
1655
1684
  await this.ledger.saveLink(link);
1656
1685
  }
1657
1686
 
1658
- // Re-queue failed entries so they are retried on the next debounce
1659
- // cycle (or picked up by the SMT integrity check).
1687
+ // Re-queue only TRANSIENT failures for retry. Permanent failures (400/401/403)
1688
+ // are dropped they will never succeed regardless of retry.
1660
1689
  if (result.failed.length > 0) {
1661
- console.error(`SyncEngineLevel: Push-on-write failed for ${did} -> ${dwnUrl}: ${result.failed.length} of ${cids.length} messages failed`);
1690
+ console.error(
1691
+ `SyncEngineLevel: Push-on-write failed for ${did} -> ${dwnUrl}: ` +
1692
+ `${result.failed.length} transient failures of ${cids.length} messages`
1693
+ );
1662
1694
  const failedSet = new Set(result.failed);
1663
1695
  const failedEntries = pushEntries.filter(e => failedSet.has(e.cid));
1664
1696
  let requeued = this._pendingPushCids.get(targetKey);
@@ -1672,9 +1704,12 @@ export class SyncEngineLevel implements SyncEngine {
1672
1704
  if (!this._pushDebounceTimer) {
1673
1705
  this._pushDebounceTimer = setTimeout((): void => {
1674
1706
  void this.flushPendingPushes();
1675
- }, PUSH_DEBOUNCE_MS * 4); // Back off: 1 second instead of 250ms.
1707
+ }, PUSH_DEBOUNCE_MS * 4);
1676
1708
  }
1677
1709
  }
1710
+ // Permanent failures are logged by pushMessages but NOT re-queued.
1711
+ // They will be rediscovered by the next SMT integrity check if the
1712
+ // local/remote state has changed, but won't spin in a retry loop.
1678
1713
  } catch (error: any) {
1679
1714
  // Truly unexpected error (not per-message failure). Re-queue entire
1680
1715
  // batch so entries aren't silently dropped from the debounce queue.
@@ -1717,10 +1752,10 @@ export class SyncEngineLevel implements SyncEngine {
1717
1752
  try {
1718
1753
  const parsed = JSON.parse(raw);
1719
1754
  if (parsed && typeof parsed === 'object' &&
1720
- typeof parsed.streamId === 'string' &&
1721
- typeof parsed.epoch === 'string' &&
1722
- typeof parsed.position === 'string' &&
1723
- typeof parsed.messageCid === 'string') {
1755
+ typeof parsed.streamId === 'string' && parsed.streamId.length > 0 &&
1756
+ typeof parsed.epoch === 'string' && parsed.epoch.length > 0 &&
1757
+ typeof parsed.position === 'string' && parsed.position.length > 0 &&
1758
+ typeof parsed.messageCid === 'string' && parsed.messageCid.length > 0) {
1724
1759
  return parsed as ProgressToken;
1725
1760
  }
1726
1761
  } catch {
@@ -37,6 +37,20 @@ export function syncMessageReplyIsSuccessful(reply: UnionMessageReply): boolean
37
37
  );
38
38
  }
39
39
 
40
+ /**
41
+ * Determines whether a failed push reply represents a permanent failure that
42
+ * should NOT be retried. Permanent failures include protocol violations (400),
43
+ * authorization errors (401/403), and schema validation errors that will never
44
+ * succeed regardless of retry.
45
+ *
46
+ * Transient failures (5xx, network errors) are worth retrying.
47
+ */
48
+ export function isPermanentPushFailure(reply: UnionMessageReply): boolean {
49
+ return reply.status.code === 400 ||
50
+ reply.status.code === 401 ||
51
+ reply.status.code === 403;
52
+ }
53
+
40
54
  /**
41
55
  * Helper to get the CID of a message for logging purposes.
42
56
  */
@@ -313,6 +327,7 @@ export async function pushMessages({ did, dwnUrl, delegateDid, protocol, message
313
327
  }): Promise<PushResult> {
314
328
  const succeeded: string[] = [];
315
329
  const failed: string[] = [];
330
+ const permanentlyFailed: string[] = [];
316
331
 
317
332
  // Step 1: Fetch all local messages (streams are pull-based, not yet consumed).
318
333
  const fetched: SyncMessageEntry[] = [];
@@ -342,17 +357,25 @@ export async function pushMessages({ did, dwnUrl, delegateDid, protocol, message
342
357
 
343
358
  if (syncMessageReplyIsSuccessful(reply)) {
344
359
  succeeded.push(cid);
360
+ } else if (isPermanentPushFailure(reply)) {
361
+ // Permanent failures (400/401/403) will never succeed — do NOT retry.
362
+ // These include protocol violations (RecordLimitExceeded), auth errors,
363
+ // and schema validation failures.
364
+ console.warn(`SyncEngineLevel: push permanently failed for ${cid}: ${reply.status.code} ${reply.status.detail}`);
365
+ permanentlyFailed.push(cid);
345
366
  } else {
367
+ // Transient failures (5xx, etc.) — worth retrying.
346
368
  console.error(`SyncEngineLevel: push failed for ${cid}: ${reply.status.code} ${reply.status.detail}`);
347
369
  failed.push(cid);
348
370
  }
349
371
  } catch (error: any) {
372
+ // Network errors — transient, worth retrying.
350
373
  console.error(`SyncEngineLevel: push error for ${cid}: ${error.message ?? error}`);
351
374
  failed.push(cid);
352
375
  }
353
376
  }
354
377
 
355
- return { succeeded, failed };
378
+ return { succeeded, failed, permanentlyFailed };
356
379
  }
357
380
 
358
381
  /**
package/src/types/sync.ts CHANGED
@@ -194,8 +194,10 @@ export type ReplicationLinkState = {
194
194
  export type PushResult = {
195
195
  /** messageCids that were accepted (202/204/409 — idempotent success). */
196
196
  succeeded: string[];
197
- /** messageCids that failed (retryable or hard error). */
197
+ /** messageCids that failed with a transient error (5xx, network) — worth retrying. */
198
198
  failed: string[];
199
+ /** messageCids that failed permanently (400/401/403) — will never succeed, do NOT retry. */
200
+ permanentlyFailed: string[];
199
201
  };
200
202
 
201
203
  /**