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

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
@@ -95,6 +95,25 @@ export interface BatchPullOptions {
95
95
  * omit the collection from `params` entirely (an unlisted collection reads one
96
96
  * auto-filled doc). Results come back under the same name in request order. */
97
97
  params?: Record<string, Record<string, string>[]>;
98
+ /**
99
+ * Per-collection append options, index-aligned to `params`. Makes the batch
100
+ * request **append/checkpoint-aware**: each entry returns the bounded tail of
101
+ * that collection's append-only log rather than the full document.
102
+ *
103
+ * Serialized as URL-encoded JSON alongside `params`. Server ignores it for
104
+ * collections that are not append-only (returns `{ error: "append_params_not_supported" }`
105
+ * for those entries). `full` is disallowed in batch (`full_not_allowed` per entry).
106
+ *
107
+ * Example — read the last 5 events for two rooms and the newest item for a third:
108
+ * ```ts
109
+ * await client.batchPull(["events"], {
110
+ * params: { events: [{ room: "a" }, { room: "b" }, { room: "c" }] },
111
+ * appendParams: { events: [{ last: 5 }, { last: 5 }, { last: 1 }] },
112
+ * })
113
+ * ```
114
+ * Each `data[appendField]` in the result is the filtered array for that entry.
115
+ */
116
+ appendParams?: Record<string, AppendPullOptions[]>;
98
117
  }
99
118
  /**
100
119
  * Low-level HTTP client for the Starfish sync protocol.
@@ -239,8 +258,7 @@ export declare class StarfishClient {
239
258
  *
240
259
  * For the common "many docs of one collection" case prefer {@link batchPullMany}.
241
260
  *
242
- * Note: not append/checkpoint-aware for incremental append-only reads use
243
- * `pull(path, { since })` (or `AppendLogCursor`) per collection.
261
+ * Pass `appendParams` per entry for append-only bounded-tail reads (see {@link batchPullManyAppend}).
244
262
  */
245
263
  batchPull(collections: string[], opts?: BatchPullOptions): Promise<BatchPullResult>;
246
264
  /**
@@ -251,6 +269,33 @@ export declare class StarfishClient {
251
269
  * issues no request and returns `[]`.
252
270
  */
253
271
  batchPullMany(collection: string, paramsList: Record<string, string>[]): Promise<BatchPullEntry[]>;
272
+ /**
273
+ * Convenience over {@link batchPull} for reading append-only bounded tails from
274
+ * MANY entries of ONE collection in a single round-trip.
275
+ *
276
+ * Each request in `requests` carries optional `params` (path params) and
277
+ * `options` (append bounds: `since`/`last`/`limit`/`appendField`). An empty
278
+ * `requests` issues no request and returns `[]`.
279
+ *
280
+ * Returns an array aligned to `requests` by index. Each element is either:
281
+ * - the filtered array `T[]` extracted from `entry.data[appendField]`, or
282
+ * - `{ error: string }` if the server returned a per-entry error.
283
+ *
284
+ * The `appendField` used for extraction defaults to `"items"` and can be
285
+ * overridden per request via `options.appendField`.
286
+ *
287
+ * The `appendField` option is client-side only (used for result extraction, not sent to the server).
288
+ * It must match the collection's server-configured append field and defaults to `"items"`.
289
+ *
290
+ * Note: `full: true` is not supported in batch and is rejected client-side
291
+ * before the request is sent.
292
+ */
293
+ batchPullManyAppend<T = unknown>(collection: string, requests: {
294
+ params?: Record<string, string>;
295
+ options: AppendPullOptions;
296
+ }[]): Promise<(T[] | {
297
+ error: string;
298
+ })[]>;
254
299
  /**
255
300
  * Push synced data to the server.
256
301
  * @param path - The push endpoint path (e.g. "/push/users/abc/settings")
package/dist/index.js CHANGED
@@ -446,8 +446,7 @@ var StarfishClient = class {
446
446
  *
447
447
  * For the common "many docs of one collection" case prefer {@link batchPullMany}.
448
448
  *
449
- * Note: not append/checkpoint-aware for incremental append-only reads use
450
- * `pull(path, { since })` (or `AppendLogCursor`) per collection.
449
+ * Pass `appendParams` per entry for append-only bounded-tail reads (see {@link batchPullManyAppend}).
451
450
  */
452
451
  async batchPull(collections, opts = {}) {
453
452
  const search = new URLSearchParams();
@@ -455,6 +454,27 @@ var StarfishClient = class {
455
454
  if (opts.params && Object.keys(opts.params).length > 0) {
456
455
  search.set("params", JSON.stringify(opts.params));
457
456
  }
457
+ if (opts.appendParams && Object.keys(opts.appendParams).length > 0) {
458
+ for (const [col, optsArr] of Object.entries(opts.appendParams)) {
459
+ for (const ap of optsArr) {
460
+ if (ap.full) {
461
+ throw new Error(
462
+ `batchPull: appendParams["${col}"] contains full:true \u2014 full is not supported in batch pull`
463
+ );
464
+ }
465
+ if (ap.since != null && (!Number.isInteger(ap.since) || ap.since < 0)) {
466
+ throw new Error(`batchPull: appendParams["${col}"].since must be a non-negative integer`);
467
+ }
468
+ if (ap.last != null && (!Number.isInteger(ap.last) || ap.last < 0)) {
469
+ throw new Error(`batchPull: appendParams["${col}"].last must be a non-negative integer`);
470
+ }
471
+ if (ap.limit != null && (!Number.isInteger(ap.limit) || ap.limit < 0)) {
472
+ throw new Error(`batchPull: appendParams["${col}"].limit must be a non-negative integer`);
473
+ }
474
+ }
475
+ }
476
+ search.set("appendParams", JSON.stringify(opts.appendParams));
477
+ }
458
478
  const pathAndQuery = `${this.applyNamespace("/batch/pull")}?${search.toString()}`;
459
479
  const url = `${this.baseUrl}${pathAndQuery}`;
460
480
  const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
@@ -479,6 +499,44 @@ var StarfishClient = class {
479
499
  const res = await this.batchPull([collection], { params: { [collection]: paramsList } });
480
500
  return res.collections[collection] ?? [];
481
501
  }
502
+ /**
503
+ * Convenience over {@link batchPull} for reading append-only bounded tails from
504
+ * MANY entries of ONE collection in a single round-trip.
505
+ *
506
+ * Each request in `requests` carries optional `params` (path params) and
507
+ * `options` (append bounds: `since`/`last`/`limit`/`appendField`). An empty
508
+ * `requests` issues no request and returns `[]`.
509
+ *
510
+ * Returns an array aligned to `requests` by index. Each element is either:
511
+ * - the filtered array `T[]` extracted from `entry.data[appendField]`, or
512
+ * - `{ error: string }` if the server returned a per-entry error.
513
+ *
514
+ * The `appendField` used for extraction defaults to `"items"` and can be
515
+ * overridden per request via `options.appendField`.
516
+ *
517
+ * The `appendField` option is client-side only (used for result extraction, not sent to the server).
518
+ * It must match the collection's server-configured append field and defaults to `"items"`.
519
+ *
520
+ * Note: `full: true` is not supported in batch and is rejected client-side
521
+ * before the request is sent.
522
+ */
523
+ async batchPullManyAppend(collection, requests) {
524
+ if (requests.length === 0) return [];
525
+ const paramsList = requests.map((r) => r.params ?? {});
526
+ const appendParamsList = requests.map(({ options: { appendField: _af, ...wireOpts } }) => wireOpts);
527
+ const res = await this.batchPull([collection], {
528
+ params: { [collection]: paramsList },
529
+ appendParams: { [collection]: appendParamsList }
530
+ });
531
+ const entries = res.collections[collection] ?? [];
532
+ return entries.map((entry, i) => {
533
+ if (entry.error) return { error: entry.error };
534
+ const appendField = requests[i]?.options.appendField ?? APPEND_DEFAULT_FIELD;
535
+ const data = entry.data;
536
+ const items = data?.[appendField];
537
+ return Array.isArray(items) ? items : [];
538
+ });
539
+ }
482
540
  /**
483
541
  * Push synced data to the server.
484
542
  * @param path - The push endpoint path (e.g. "/push/users/abc/settings")
@@ -723,6 +781,8 @@ var SyncManager = class {
723
781
  localData = {};
724
782
  aborted = false;
725
783
  lastFromCache = false;
784
+ /** True once {@link seedFromCache} has successfully seeded localData from the cache. */
785
+ seeded = false;
726
786
  constructor(options) {
727
787
  this.client = options.client;
728
788
  this.pullPath = options.pullPath;
@@ -744,6 +804,24 @@ var SyncManager = class {
744
804
  getData() {
745
805
  return { ...this.localData };
746
806
  }
807
+ /**
808
+ * Returns true when `pull()` / `ingest()` should merge against the current
809
+ * `localData` rather than replace it wholesale.
810
+ *
811
+ * Two situations establish a merge baseline:
812
+ * - A successful prior pull/ingest advanced `lastCheckpoint` beyond 0 (the
813
+ * normal steady-state case, unchanged since alpha.36).
814
+ * - A cache seed painted `localData` via {@link seedFromCache} AND the store
815
+ * uses a custom conflict resolver (i.e. NOT the default `deepMerge`). For a
816
+ * union/custom resolver the seeded snapshot is a real baseline that must not
817
+ * be clobbered by a short first live response (a cache-fallback on 429/5xx
818
+ * or a momentarily-short concurrent server snapshot). For the default
819
+ * `deepMerge` resolver we keep the pre-fix wholesale-replace behaviour so
820
+ * non-union stores are byte-identical to alpha.36.
821
+ */
822
+ hasMergeBaseline() {
823
+ return this.lastCheckpoint > 0 || this.seeded && this.onConflict !== deepMerge;
824
+ }
747
825
  /**
748
826
  * Merge a remote snapshot with local (optimistic) data using this manager's
749
827
  * conflict resolver — the same resolver the push-conflict path uses. A plain
@@ -778,7 +856,19 @@ var SyncManager = class {
778
856
  * WITHOUT touching the network, decrypting in memory for E2E collections.
779
857
  * Returns whether anything was seeded (false on a miss, an expired entry, or
780
858
  * a decrypt failure — e.g. keyring skew). Call once on store creation before
781
- * the initial live {@link pull}, which then supersedes the seeded snapshot.
859
+ * the initial live {@link pull}.
860
+ *
861
+ * `lastCheckpoint` is intentionally left at 0 so the first live pull sends a
862
+ * full (re)sync request to the server, not a delta. However, for stores with
863
+ * a custom conflict resolver (e.g. `createUnionMerge`) the seeded snapshot is
864
+ * treated as a merge baseline: {@link hasMergeBaseline} returns true, so the
865
+ * first pull/ingest merges against the seed rather than replacing it wholesale.
866
+ * This closes the bootstrap window where a short first-pull response (a cache-
867
+ * fallback on 429/5xx or a momentarily-short concurrent snapshot) would
868
+ * silently drop items the resolver was configured to preserve. For the default
869
+ * `deepMerge` resolver the first pull still takes the snapshot wholesale —
870
+ * behaviour is byte-identical to alpha.36.
871
+ *
782
872
  * Requires the client to have been built with a `cache`.
783
873
  */
784
874
  async seedFromCache() {
@@ -794,6 +884,7 @@ var SyncManager = class {
794
884
  if (this.aborted) return false;
795
885
  this.localData = data;
796
886
  this.lastHash = cached.hash;
887
+ this.seeded = true;
797
888
  this.lastFromCache = true;
798
889
  return true;
799
890
  }
@@ -807,12 +898,15 @@ var SyncManager = class {
807
898
  * {@link StarfishClientOptions.onRevalidated}) into the store.
808
899
  *
809
900
  * 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.
901
+ * established baseline via `this.onConflict` when a merge baseline exists
902
+ * ({@link hasMergeBaseline}) — so a union-merge store does not lose array
903
+ * items when a revalidation result (e.g. a stale cache-fallback on 429/5xx)
904
+ * is a shorter snapshot. The baseline is established by either a prior
905
+ * pull/ingest that advanced `lastCheckpoint`, or by a successful
906
+ * {@link seedFromCache} for a store with a custom resolver. The first ingest
907
+ * without such a baseline takes the snapshot wholesale (default `deepMerge`
908
+ * stores are byte-identical to alpha.36). Sets `lastFromCache = false` (a
909
+ * revalidation is a live response) so the binding can clear its `stale` flag.
816
910
  *
817
911
  * **Staleness guard**: if a `push()` advanced `lastCheckpoint` between the
818
912
  * time the revalidation request was sent and the time it resolves, the
@@ -831,7 +925,7 @@ var SyncManager = class {
831
925
  } else {
832
926
  incoming = result.data;
833
927
  }
834
- this.localData = this.lastCheckpoint > 0 ? this.onConflict(this.localData, incoming) : incoming;
928
+ this.localData = this.hasMergeBaseline() ? this.onConflict(this.localData, incoming) : incoming;
835
929
  this.lastHash = result.hash;
836
930
  this.lastCheckpoint = result.timestamp;
837
931
  this.lastFromCache = false;
@@ -851,7 +945,7 @@ var SyncManager = class {
851
945
  } else {
852
946
  incoming = result.data;
853
947
  }
854
- this.localData = this.lastCheckpoint > 0 ? this.onConflict(this.localData, incoming) : incoming;
948
+ this.localData = this.hasMergeBaseline() ? this.onConflict(this.localData, incoming) : incoming;
855
949
  result.data = this.localData;
856
950
  this.lastHash = result.hash;
857
951
  this.lastCheckpoint = result.timestamp;