@pylonsync/sync 0.3.187 → 0.3.188
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/package.json +1 -1
- package/src/index.ts +93 -14
package/package.json
CHANGED
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
|
|
957
|
-
// before the tab was closed come back as pending here
|
|
958
|
-
//
|
|
959
|
-
//
|
|
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
|
-
//
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
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
|
-
|
|
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:
|
|
2229
|
+
row_id: id,
|
|
2151
2230
|
kind: "insert",
|
|
2152
|
-
data,
|
|
2231
|
+
data: dataWithId,
|
|
2153
2232
|
});
|
|
2154
2233
|
await this.push();
|
|
2155
|
-
return
|
|
2234
|
+
return id;
|
|
2156
2235
|
}
|
|
2157
2236
|
|
|
2158
2237
|
/** Update a row with optimistic local update. */
|