@drakkar.software/starfish-client 3.0.0-alpha.35 → 3.0.0-alpha.37

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/client.d.ts CHANGED
@@ -110,6 +110,12 @@ export declare class StarfishClient {
110
110
  private readonly cacheFallbackStatuses?;
111
111
  private readonly onRevalidated?;
112
112
  private readonly revalidating;
113
+ /**
114
+ * In-memory mirror of the latest document timestamp written to each cache
115
+ * key via {@link writeCache}. Updated synchronously so {@link revalidateLoop}
116
+ * can guard against stale overwrites without an extra async cache read.
117
+ */
118
+ private readonly latestCacheTimestamp;
113
119
  /**
114
120
  * Installed client-side plugins. Currently stored as inert data; no
115
121
  * hooks fire yet. Extensions can inspect this list if needed.
package/dist/index.js CHANGED
@@ -110,6 +110,12 @@ var StarfishClient = class {
110
110
  cacheFallbackStatuses;
111
111
  onRevalidated;
112
112
  revalidating = /* @__PURE__ */ new Set();
113
+ /**
114
+ * In-memory mirror of the latest document timestamp written to each cache
115
+ * key via {@link writeCache}. Updated synchronously so {@link revalidateLoop}
116
+ * can guard against stale overwrites without an extra async cache read.
117
+ */
118
+ latestCacheTimestamp = /* @__PURE__ */ new Map();
113
119
  /**
114
120
  * Installed client-side plugins. Currently stored as inert data; no
115
121
  * hooks fire yet. Extensions can inspect this list if needed.
@@ -320,6 +326,9 @@ var StarfishClient = class {
320
326
  */
321
327
  writeCache(cacheKey, result) {
322
328
  if (!this.cache) return;
329
+ if (result.timestamp > (this.latestCacheTimestamp.get(cacheKey) ?? -1)) {
330
+ this.latestCacheTimestamp.set(cacheKey, result.timestamp);
331
+ }
323
332
  const snapshot = {
324
333
  data: result.data,
325
334
  hash: result.hash,
@@ -381,8 +390,11 @@ var StarfishClient = class {
381
390
  const res = await this.revalidateFetch(pathAndQuery);
382
391
  if (res.ok) {
383
392
  const result = await res.json();
384
- this.writeCache(cacheKey, result);
385
- this.onRevalidated?.(pathAndQuery, result);
393
+ const latestTs = this.latestCacheTimestamp.get(cacheKey) ?? -1;
394
+ if (result.timestamp >= latestTs) {
395
+ this.writeCache(cacheKey, result);
396
+ this.onRevalidated?.(pathAndQuery, result);
397
+ }
386
398
  return;
387
399
  }
388
400
  if (!fallbackSet?.has(res.status)) {
@@ -794,20 +806,32 @@ var SyncManager = class {
794
806
  * action to absorb a background revalidation result (delivered via
795
807
  * {@link StarfishClientOptions.onRevalidated}) into the store.
796
808
  *
797
- * Unlike {@link pull}, `ingest` never does a deep-merge with the previous
798
- * checkpoint the revalidated result is always a full fresh snapshot. It
799
- * sets `lastFromCache = false` (a revalidation is a live response) so the
800
- * binding can clear its `stale` flag.
809
+ * Like {@link pull}, `ingest` conflict-merges the snapshot against the
810
+ * established baseline via `this.onConflict` when a checkpoint exists so a
811
+ * union-merge store does not lose array items when a revalidation result
812
+ * (e.g. a stale cache-fallback on 429/5xx) is a shorter snapshot. The first
813
+ * ingest (no checkpoint yet) takes the snapshot wholesale. Sets
814
+ * `lastFromCache = false` (a revalidation is a live response) so the binding
815
+ * can clear its `stale` flag.
816
+ *
817
+ * **Staleness guard**: if a `push()` advanced `lastCheckpoint` between the
818
+ * time the revalidation request was sent and the time it resolves, the
819
+ * result is from an older document version. Ingesting it would clobber the
820
+ * user's just-saved edit and reset `lastHash` to a stale server hash
821
+ * (causing a spurious 409 on the next push). We silently drop the result in
822
+ * that case — the store's post-push state is already correct.
801
823
  */
802
824
  async ingest(result) {
803
825
  if (this.aborted) return;
826
+ if (result.timestamp < this.lastCheckpoint) return;
827
+ let incoming;
804
828
  if (this.encryptor) {
805
- const decrypted = await this.encryptor.decrypt(result.data);
829
+ incoming = await this.encryptor.decrypt(result.data);
806
830
  if (this.aborted) return;
807
- this.localData = decrypted;
808
831
  } else {
809
- this.localData = result.data;
832
+ incoming = result.data;
810
833
  }
834
+ this.localData = this.lastCheckpoint > 0 ? this.onConflict(this.localData, incoming) : incoming;
811
835
  this.lastHash = result.hash;
812
836
  this.lastCheckpoint = result.timestamp;
813
837
  this.lastFromCache = false;
@@ -820,17 +844,15 @@ var SyncManager = class {
820
844
  const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
821
845
  if (this.aborted) throw new AbortError();
822
846
  this.lastFromCache = pullWasFromCache(result);
847
+ let incoming;
823
848
  if (this.encryptor) {
824
- const decrypted = await this.encryptor.decrypt(result.data);
849
+ incoming = await this.encryptor.decrypt(result.data);
825
850
  if (this.aborted) throw new AbortError();
826
- this.localData = decrypted;
827
- result.data = decrypted;
828
- } else if (this.lastCheckpoint > 0) {
829
- this.localData = deepMerge(this.localData, result.data);
830
- result.data = this.localData;
831
851
  } else {
832
- this.localData = result.data;
852
+ incoming = result.data;
833
853
  }
854
+ this.localData = this.lastCheckpoint > 0 ? this.onConflict(this.localData, incoming) : incoming;
855
+ result.data = this.localData;
834
856
  this.lastHash = result.hash;
835
857
  this.lastCheckpoint = result.timestamp;
836
858
  this.logger?.pullSuccess(this.loggerName, Math.round(performance.now() - start));