@pylonsync/sync 0.3.187 → 0.3.189

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 +93 -14
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.187",
6
+ "version": "0.3.189",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -51,9 +51,32 @@ export interface ResolvedSession {
51
51
  roles: string[];
52
52
  }
53
53
 
54
+ /**
55
+ * Per-op result entry for `/api/sync/push`. Returned in the `results`
56
+ * array so the client can map each mutation back to its op_id and
57
+ * know exactly which applied / deduped / failed. Server emits these
58
+ * in arrival order (one per input change). Codex P1: the previous
59
+ * `{applied, deduped, errors}` count-based shape lost per-op
60
+ * mapping — clients had to guess by ordering and got it wrong on
61
+ * partial failures, stranding optimistic ghosts.
62
+ */
63
+ export interface PushOpResult {
64
+ /** op_id from the request, if the client supplied one. */
65
+ op_id?: string | null;
66
+ status: "applied" | "deduped" | "error";
67
+ /** Assigned seq when `status === "applied"`. */
68
+ seq?: number;
69
+ /** Server's error message when `status === "error"`. */
70
+ error?: string;
71
+ }
72
+
54
73
  export interface PushResponse {
55
74
  applied: number;
75
+ deduped: number;
56
76
  errors: string[];
77
+ /** Per-op results in arrival order. Prefer this over the count
78
+ * fields for status mapping. */
79
+ results?: PushOpResult[];
57
80
  cursor: SyncCursor;
58
81
  }
59
82
 
@@ -953,15 +976,27 @@ export class SyncEngine {
953
976
  if (persistence) await persistChange(persistence, change);
954
977
  };
955
978
 
956
- // Hydrate the mutation queue from disk. Any offline writes queued
957
- // before the tab was closed come back as pending here and will be
958
- // pushed on the next `push()` tick. Without this, `MutationQueue`
959
- // stayed memory-only and offline mutations were silently lost.
979
+ // Hydrate the mutation queue from disk. Any offline writes
980
+ // queued before the tab was closed come back as pending here.
981
+ // Codex P1: previously these only got pushed on the next
982
+ // `push()` tick (polling mode) or when a NEW local mutation
983
+ // triggered push. In WebSocket-only mode there's no polling,
984
+ // and if the user reloads without making a fresh mutation,
985
+ // pull+reconcile (which run shortly after this) can sweep
986
+ // the optimistic ghosts before push() ever fires. Fire push
987
+ // explicitly here so hydrated offline mutations reach the
988
+ // server before reconcile inspects local state.
960
989
  try {
961
990
  const { IndexedDBMutationPersistence } = await import("./persistence");
962
991
  const mqPersistence = new IndexedDBMutationPersistence(persistence);
963
992
  this.mutations.attachPersistence(mqPersistence);
964
993
  await this.mutations.hydrate();
994
+ // Fire-and-forget — the actual mutation HTTP calls happen
995
+ // async, and we don't want to block engine startup on them.
996
+ // pull()/reconcile() below run in parallel; push()'s
997
+ // mutations carry op_ids so racing the broadcasts won't
998
+ // double-apply.
999
+ void this.push();
965
1000
  } catch {
966
1001
  // Queue persistence optional — memory-only still works.
967
1002
  }
@@ -2127,12 +2162,43 @@ export class SyncEngine {
2127
2162
  client_id: this.clientId,
2128
2163
  });
2129
2164
 
2130
- // Mark mutations based on response.
2131
- for (let i = 0; i < pending.length; i++) {
2132
- if (i < resp.applied) {
2133
- this.mutations.markApplied(pending[i].id);
2134
- } else if (resp.errors[i - resp.applied]) {
2135
- this.mutations.markFailed(pending[i].id, resp.errors[i - resp.applied]);
2165
+ // Prefer the per-op `results` array (added 0.3.188). Match each
2166
+ // by op_id when present, else fall back to positional matching.
2167
+ // Codex P1: previous count-based mapping ("first N applied,
2168
+ // next M failed") got partial failures wrong — when op 2 of 3
2169
+ // failed, op 3 was incorrectly marked failed and a successful
2170
+ // retry-after-lost-response came back deduped but stayed
2171
+ // pending forever.
2172
+ if (Array.isArray(resp.results)) {
2173
+ const byOpId = new Map<string, PushOpResult>();
2174
+ for (const r of resp.results) {
2175
+ if (r.op_id) byOpId.set(r.op_id, r);
2176
+ }
2177
+ for (let i = 0; i < pending.length; i++) {
2178
+ const m = pending[i];
2179
+ const r =
2180
+ (m.change.op_id ? byOpId.get(m.change.op_id) : undefined) ??
2181
+ resp.results[i];
2182
+ if (!r) continue;
2183
+ if (r.status === "applied" || r.status === "deduped") {
2184
+ this.mutations.markApplied(m.id);
2185
+ } else if (r.status === "error") {
2186
+ this.mutations.markFailed(m.id, r.error ?? "unknown");
2187
+ }
2188
+ }
2189
+ } else {
2190
+ // Legacy server response (pre-0.3.188): count-based mapping.
2191
+ // Buggy on partial failures but the best we can do without
2192
+ // the per-op envelope.
2193
+ for (let i = 0; i < pending.length; i++) {
2194
+ if (i < resp.applied) {
2195
+ this.mutations.markApplied(pending[i].id);
2196
+ } else if (resp.errors[i - resp.applied]) {
2197
+ this.mutations.markFailed(
2198
+ pending[i].id,
2199
+ resp.errors[i - resp.applied],
2200
+ );
2201
+ }
2136
2202
  }
2137
2203
  }
2138
2204
 
@@ -2144,15 +2210,28 @@ export class SyncEngine {
2144
2210
 
2145
2211
  /** Insert a row with optimistic local update. */
2146
2212
  async insert(entity: string, data: Row): Promise<string> {
2147
- const tempId = this.store.optimisticInsert(entity, data);
2213
+ // Codex P1: previously this minted a `_pending_<random>` local id
2214
+ // via `optimisticInsert(entity, data)` but sent `data` to the
2215
+ // server WITHOUT that id. The server generated its own canonical
2216
+ // id, broadcast under that id, and the local replica ended up
2217
+ // with two rows: the `_pending_` ghost (never cleared, because
2218
+ // server's broadcast doesn't reference it) and the canonical row.
2219
+ // Fix: mint a real Pylon-shaped id client-side, force the data
2220
+ // payload to carry it, optimistic-insert under that exact id,
2221
+ // and queue the mutation with the same id. Server honors the
2222
+ // provided id, broadcasts under it, the optimistic ghost is the
2223
+ // canonical row.
2224
+ const id = generateId();
2225
+ const dataWithId = { ...data, id };
2226
+ this.store.optimisticInsertWithId(entity, id, dataWithId);
2148
2227
  this.mutations.add({
2149
2228
  entity,
2150
- row_id: tempId,
2229
+ row_id: id,
2151
2230
  kind: "insert",
2152
- data,
2231
+ data: dataWithId,
2153
2232
  });
2154
2233
  await this.push();
2155
- return tempId;
2234
+ return id;
2156
2235
  }
2157
2236
 
2158
2237
  /** Update a row with optimistic local update. */