@pylonsync/sync 0.3.222 → 0.3.224

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +63 -10
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.222",
6
+ "version": "0.3.224",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -1976,21 +1976,23 @@ export class SyncEngine {
1976
1976
  typeof r.error === "string"
1977
1977
  ? r.error
1978
1978
  : r.error?.message ?? "unknown";
1979
- this.mutations.markFailed(m.id, msg);
1979
+ this.failPushedMutation(m, msg);
1980
1980
  }
1981
1981
  }
1982
1982
  } else {
1983
1983
  // Legacy server response (pre-0.3.188): count-based mapping.
1984
1984
  // Buggy on partial failures but the best we can do without
1985
- // the per-op envelope.
1985
+ // the per-op envelope. Guard `resp.errors` — older test
1986
+ // mocks omit the field entirely; pre-0.3.224 the `[]` index
1987
+ // threw and was swallowed by the bare catch below, which
1988
+ // silently dropped the success path for legacy responses.
1989
+ const applied = typeof resp.applied === "number" ? resp.applied : 0;
1990
+ const errors = Array.isArray(resp.errors) ? resp.errors : [];
1986
1991
  for (let i = 0; i < pending.length; i++) {
1987
- if (i < resp.applied) {
1992
+ if (i < applied) {
1988
1993
  this.mutations.markApplied(pending[i].id);
1989
- } else if (resp.errors[i - resp.applied]) {
1990
- this.mutations.markFailed(
1991
- pending[i].id,
1992
- resp.errors[i - resp.applied],
1993
- );
1994
+ } else if (errors[i - applied]) {
1995
+ this.failPushedMutation(pending[i], errors[i - applied]);
1994
1996
  }
1995
1997
  }
1996
1998
  }
@@ -2046,9 +2048,60 @@ export class SyncEngine {
2046
2048
  void this.push();
2047
2049
  }, 250);
2048
2050
  }
2049
- } catch {
2050
- // Will retry on next tick. op_id makes retries idempotent on the server.
2051
+ } catch (err) {
2052
+ // Transport-level failure (network down, CORS, 5xx without a
2053
+ // typed body, parse error). Pre-0.3.224 swallowed silently:
2054
+ // the mutation stayed `pending` forever and the optimistic
2055
+ // ghost survived even though the server never accepted the
2056
+ // write. That's the "I sent it, it's there, then it's gone"
2057
+ // pattern users see after a reload.
2058
+ //
2059
+ // Now: fail every pending mutation in this batch, roll back
2060
+ // any optimistic ghost, surface via mutations-failed so the
2061
+ // UI can prompt + retry. op_id keeps a retry idempotent on
2062
+ // the server if the failure was a transient transport error
2063
+ // — the next push() will re-include the user's intent.
2064
+ const msg = err instanceof Error ? err.message : String(err);
2065
+ const failedOps: { opId: string; error: string }[] = [];
2066
+ for (const m of pending) {
2067
+ this.failPushedMutation(m, msg);
2068
+ const opId = m.change.op_id;
2069
+ if (typeof opId === "string") {
2070
+ failedOps.push({ opId, error: msg });
2071
+ }
2072
+ }
2073
+ if (failedOps.length > 0) {
2074
+ this.broadcastToTabs({ type: "mutations-failed", ops: failedOps });
2075
+ }
2076
+ this.mutations.clear();
2077
+ // eslint-disable-next-line no-console
2078
+ console.warn("[sync] /api/sync/push failed:", msg);
2079
+ }
2080
+ }
2081
+
2082
+ /**
2083
+ * Mark a pending mutation as failed AND undo its optimistic ghost
2084
+ * in the local replica. Without the rollback step, a server-
2085
+ * rejected insert leaves a ghost row that survives indefinitely
2086
+ * (reconcile skips rows with pending/failed mutations to avoid
2087
+ * sweeping the user's in-flight edit). The exact failure mode is
2088
+ * "send a message, the server says no, refresh — the ghost is
2089
+ * still there until you find the failed-state UI."
2090
+ *
2091
+ * Updates can't be rolled back without a pre-update snapshot
2092
+ * (not captured today); the user-visible ghost-update sticks
2093
+ * until the next reconcile observes the canonical row. Deletes
2094
+ * leave a tombstone that should also be cleared, but the current
2095
+ * `LocalStore` API doesn't expose the un-tombstone path — flagged
2096
+ * for follow-up. Inserts are the dominant case (chat send is an
2097
+ * insert; collaborative-edit is an update with a separate CRDT
2098
+ * channel) so insert-only rollback is the right shape to ship now.
2099
+ */
2100
+ private failPushedMutation(m: PendingMutation, error: string): void {
2101
+ if (m.change.kind === "insert") {
2102
+ this.store.rollbackOptimisticInsert(m.change.entity, m.change.row_id);
2051
2103
  }
2104
+ this.mutations.markFailed(m.id, error);
2052
2105
  }
2053
2106
 
2054
2107
  /** Insert a row with optimistic local update.