@gaganref/confect-server 10.3.1-fork.30 → 10.3.1-fork.31

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.
@@ -834,6 +834,21 @@ const fillMergeSource = <Doc, E>(
834
834
  BudgetLimited: () => Effect.succeed(source),
835
835
  });
836
836
 
837
+ const consumeMergeHead = <Doc, E>(
838
+ source: MergeSource<Doc, E>,
839
+ ): MergeSource<Doc, E> =>
840
+ source._tag === "Buffered"
841
+ ? Option.match(Chunk.head(source.tail), {
842
+ onNone: () => MergeSource.NeedsPull({ pull: source.pull }),
843
+ onSome: (head) =>
844
+ MergeSource.Buffered({
845
+ pull: source.pull,
846
+ head,
847
+ tail: Chunk.drop(source.tail, 1),
848
+ }),
849
+ })
850
+ : source;
851
+
837
852
  /**
838
853
  * One step of the k-way merge as a pure unfold: fill every source, emit the
839
854
  * earliest head (ties go to the earliest source, keeping the merge stable), and
@@ -841,7 +856,10 @@ const fillMergeSource = <Doc, E>(
841
856
  * exhausted or an input stopped before its next key was known.
842
857
  */
843
858
  const mergeStep =
844
- <Doc, E>(keyOrder: Order.Order<QueryStreamKeyValues.QueryStreamKeyValues>) =>
859
+ <Doc, E>(
860
+ keyOrder: Order.Order<QueryStreamKeyValues.QueryStreamKeyValues>,
861
+ dedupe: boolean,
862
+ ) =>
845
863
  (
846
864
  sources: ReadonlyArray<MergeSource<Doc, E>>,
847
865
  ): Effect.Effect<
@@ -883,36 +901,73 @@ const mergeStep =
883
901
  ),
884
902
  );
885
903
 
886
- return Option.getOrUndefined(
887
- Option.map(earliest, ({ index, element }) =>
888
- Tuple.make(
889
- element,
890
- Array.map(filled, (source, sourceIndex) =>
891
- sourceIndex === index && source._tag === "Buffered"
892
- ? Option.match(Chunk.head(source.tail), {
893
- onNone: () => MergeSource.NeedsPull({ pull: source.pull }),
894
- onSome: (head) =>
895
- MergeSource.Buffered({
896
- pull: source.pull,
897
- head,
898
- tail: Chunk.drop(source.tail, 1),
899
- }),
900
- })
901
- : source,
902
- ),
904
+ if (Option.isNone(earliest)) return undefined;
905
+ if (!dedupe) {
906
+ const { index, element } = earliest.value;
907
+ return Tuple.make(
908
+ element,
909
+ Array.map(filled, (source, sourceIndex) =>
910
+ sourceIndex === index ? consumeMergeHead(source) : source,
903
911
  ),
904
- ),
905
- );
912
+ );
913
+ }
914
+ const keyValues = earliest.value.element.keyValues;
915
+ const matches = (source: MergeSource<Doc, E>) =>
916
+ source._tag === "Buffered" &&
917
+ keyOrder(source.head.keyValues, keyValues) === 0;
918
+ let representative = earliest.value;
919
+ let remaining = filled;
920
+ // Resolve the entire equal-key group before publishing its boundary.
921
+ // A budget stop leaves it for the next page, including any visible copy.
922
+ for (;;) {
923
+ representative = Array.reduce(
924
+ remaining,
925
+ representative,
926
+ (best, source, index) =>
927
+ source._tag === "Buffered" &&
928
+ matches(source) &&
929
+ Option.isSome(source.head.doc) &&
930
+ (Option.isNone(best.element.doc) || index < best.index)
931
+ ? new MergeCandidate({ index, element: source.head })
932
+ : best,
933
+ );
934
+ remaining = yield* Effect.forEach(
935
+ Array.map(remaining, (source) =>
936
+ matches(source) ? consumeMergeHead(source) : source,
937
+ ),
938
+ fillMergeSource,
939
+ { concurrency: (yield* readBudget.isUnlimited) ? "unbounded" : 1 },
940
+ );
941
+ if (
942
+ remaining.some((source) => MergeSource.$is("BudgetLimited")(source))
943
+ )
944
+ return undefined;
945
+ if (!remaining.some(matches))
946
+ return Tuple.make(representative.element, remaining);
947
+ }
906
948
  });
907
949
 
950
+ /**
951
+ * Optional merge behavior. With `dedupe`, equal complete stored keys, including
952
+ * ID tiebreakers, emit once. The first visible copy in source order wins; a
953
+ * filtered copy cannot suppress a visible one. Different keys remain distinct.
954
+ *
955
+ * @experimental
956
+ */
957
+ export interface MergeOptions {
958
+ readonly dedupe?: boolean;
959
+ }
960
+
908
961
  /**
909
962
  * Combine streams sharing a key layout into one stream in key order. Use this
910
963
  * to query several index ranges at once, such as notes by multiple roles.
911
964
  *
912
965
  * The merge emits the smallest next key when ascending and the largest when
913
- * descending. Unlike `Stream.merge`, it does not interleave by arrival time.
914
- * Overlapping inputs are not deduplicated: a document matched twice appears
915
- * twice. Supply at least one input; use `empty` for an empty dynamic list.
966
+ * descending. Unlike `Stream.merge`, it does not interleave by arrival time. By
967
+ * default, a document matched twice appears twice. Pass `{ dedupe: true }` to
968
+ * coalesce equal complete keys, including ID tiebreakers. Resolving a group can
969
+ * prefetch the next key from each input; all such reads count against the
970
+ * shared budget. Supply at least one input; use `empty` for an empty list.
916
971
  *
917
972
  * Inputs need compatible document types and identical key layouts and
918
973
  * directions. TypeScript rejects known mismatches; runtime mismatches throw
@@ -934,6 +989,7 @@ export const merge = <
934
989
  QueryStream<Doc, KeyLabels, NoInfer<OrderDirection>, E, R>
935
990
  >,
936
991
  ],
992
+ options?: MergeOptions,
937
993
  ): QueryStream<Doc, KeyLabels, OrderDirection, E, R> => {
938
994
  const head = Array.headNonEmpty(streams);
939
995
  const incompatible = Array.findFirst(
@@ -950,7 +1006,7 @@ export const merge = <
950
1006
  actualKeyLayout: incompatible.value.keyLayout,
951
1007
  });
952
1008
  }
953
- return mergeUnchecked(streams);
1009
+ return mergeUnchecked(streams, options);
954
1010
  };
955
1011
 
956
1012
  /**
@@ -969,6 +1025,7 @@ const mergeUnchecked = <
969
1025
  QueryStream<Doc, KeyLabels, OrderDirection, E, R>,
970
1026
  ...ReadonlyArray<QueryStream<Doc, KeyLabels, OrderDirection, E, R>>,
971
1027
  ],
1028
+ options?: MergeOptions,
972
1029
  ): QueryStream<Doc, KeyLabels, OrderDirection, E, R> => {
973
1030
  const head = Array.headNonEmpty(streams);
974
1031
  const annotated: Stream.Stream<
@@ -981,7 +1038,10 @@ const mergeUnchecked = <
981
1038
  (pulls) =>
982
1039
  Stream.unfold(
983
1040
  Array.map(pulls, (pull) => MergeSource.NeedsPull({ pull })),
984
- mergeStep<Doc, E>(QueryStreamKeyValues.Order(head.orderDirection)),
1041
+ mergeStep<Doc, E>(
1042
+ QueryStreamKeyValues.Order(head.orderDirection),
1043
+ options?.dedupe === true,
1044
+ ),
985
1045
  ),
986
1046
  ),
987
1047
  );
@@ -995,17 +1055,25 @@ const mergeUnchecked = <
995
1055
  // key space, so each branch converts them to its own index-key
996
1056
  // space itself.
997
1057
  (parsedBounds) =>
998
- mergeUnchecked([
999
- narrowByParsedBounds(head, parsedBounds),
1000
- ...Array.map(Array.tailNonEmpty(streams), (stream) =>
1001
- narrowByParsedBounds(stream, parsedBounds),
1002
- ),
1003
- ]),
1058
+ mergeUnchecked(
1059
+ [
1060
+ narrowByParsedBounds(head, parsedBounds),
1061
+ ...Array.map(Array.tailNonEmpty(streams), (stream) =>
1062
+ narrowByParsedBounds(stream, parsedBounds),
1063
+ ),
1064
+ ],
1065
+ options,
1066
+ ),
1004
1067
  () =>
1005
- mergeUnchecked([
1006
- reverse(head),
1007
- ...Array.map(Array.tailNonEmpty(streams), (stream) => reverse(stream)),
1008
- ]),
1068
+ mergeUnchecked(
1069
+ [
1070
+ reverse(head),
1071
+ ...Array.map(Array.tailNonEmpty(streams), (stream) =>
1072
+ reverse(stream),
1073
+ ),
1074
+ ],
1075
+ options,
1076
+ ),
1009
1077
  );
1010
1078
  };
1011
1079
 
@@ -2373,6 +2441,27 @@ export const unique = Effect.fn("QueryStream.unique")(
2373
2441
  // Pagination
2374
2442
  // -----------------------------------------------------------------------------
2375
2443
 
2444
+ /**
2445
+ * Caller-owned envelope for every pagination cursor, including end sentinels.
2446
+ * `open` validates a received cursor and returns `None` on a mismatch; paginate
2447
+ * then dies with `ConvexError({ paginationError: "InvalidCursor" })` before
2448
+ * decoding or reading. `seal` wraps every outgoing continuation and split.
2449
+ * `seal` must return the same string for equal inputs so cursor equality stays
2450
+ * stable.
2451
+ *
2452
+ * Use this to bind cursors to query semantics or sign them. Both callbacks may
2453
+ * use Effect services. Confect owns no keys, fingerprints, or encryption
2454
+ * policy.
2455
+ *
2456
+ * @experimental
2457
+ */
2458
+ export interface CursorEnvelope<R = never> {
2459
+ readonly open: (
2460
+ cursor: string,
2461
+ ) => Effect.Effect<Option.Option<string>, never, R>;
2462
+ readonly seal: (cursor: string) => Effect.Effect<string, never, R>;
2463
+ }
2464
+
2376
2465
  /**
2377
2466
  * Convex pagination options interpreted over a query stream's stored keys. Pass
2378
2467
  * a paginated query handler's `paginationOpts` directly to `paginate`.
@@ -2389,6 +2478,9 @@ export const unique = Effect.fn("QueryStream.unique")(
2389
2478
  * - `maximumBytesRead`: optional budget charging estimated document size on every
2390
2479
  * read, not Convex's exact billed bytes. The final document can take the
2391
2480
  * total over the limit because its size is known only after reading it.
2481
+ * - `cursorEnvelope`: optional caller-owned validation and sealing. Both input
2482
+ * cursors are opened before decoding, including an end sentinel. Every output
2483
+ * cursor is sealed. A refused envelope ends in `InvalidCursor`.
2392
2484
  *
2393
2485
  * At either read limit, including zero, one extra row may be read to
2394
2486
  * distinguish completion from an overrun. It is counted but never emitted. An
@@ -2399,7 +2491,9 @@ export const unique = Effect.fn("QueryStream.unique")(
2399
2491
  *
2400
2492
  * @experimental
2401
2493
  */
2402
- export type PaginateOptions = ConvexPaginationOptions;
2494
+ export type PaginateOptions<R = never> = ConvexPaginationOptions & {
2495
+ readonly cursorEnvelope?: CursorEnvelope<R>;
2496
+ };
2403
2497
 
2404
2498
  /**
2405
2499
  * One page in Convex's pagination result shape. Return the whole result to
@@ -2453,9 +2547,9 @@ export type PaginationResult<Doc> = ConvexPaginationResult<Doc>;
2453
2547
  * query semantics change, even if an old cursor is still accepted.
2454
2548
  *
2455
2549
  * Cursors expose boundary field names and key values, including document IDs.
2456
- * They are neither opaque nor signed: clients can read or craft them. Do not
2457
- * publicly paginate over sensitive indexed fields unless pinned with `eq`.
2458
- * Never construct cursors from documents or decode them into `narrow` bounds.
2550
+ * Without a `cursorEnvelope`, clients can read or craft them. Do not publicly
2551
+ * paginate over sensitive indexed fields unless pinned with `eq`. Never
2552
+ * construct cursors from documents or decode them into `narrow` bounds.
2459
2553
  *
2460
2554
  * Return the whole result to reactive clients. React's
2461
2555
  * `useStreamPaginatedQuery` handles pinning, splitting, and invalid-cursor
@@ -2465,8 +2559,8 @@ export type PaginationResult<Doc> = ConvexPaginationResult<Doc>;
2465
2559
  * @experimental
2466
2560
  */
2467
2561
  export const paginate: {
2468
- (
2469
- options: PaginateOptions,
2562
+ <R2 = never>(
2563
+ options: PaginateOptions<R2>,
2470
2564
  ): <Doc, KeyLabels extends QueryStreamKeyLabels.QueryStreamKeyLabels, E, R>(
2471
2565
  self: QueryStream<
2472
2566
  Doc,
@@ -2475,8 +2569,18 @@ export const paginate: {
2475
2569
  E,
2476
2570
  R
2477
2571
  >,
2478
- ) => Effect.Effect<PaginationResult<Doc>, E | ReadBudgetExceededError, R>;
2479
- <Doc, KeyLabels extends QueryStreamKeyLabels.QueryStreamKeyLabels, E, R>(
2572
+ ) => Effect.Effect<
2573
+ PaginationResult<Doc>,
2574
+ E | ReadBudgetExceededError,
2575
+ R | R2
2576
+ >;
2577
+ <
2578
+ Doc,
2579
+ KeyLabels extends QueryStreamKeyLabels.QueryStreamKeyLabels,
2580
+ E,
2581
+ R,
2582
+ R2 = never,
2583
+ >(
2480
2584
  self: QueryStream<
2481
2585
  Doc,
2482
2586
  KeyLabels,
@@ -2484,8 +2588,8 @@ export const paginate: {
2484
2588
  E,
2485
2589
  R
2486
2590
  >,
2487
- options: PaginateOptions,
2488
- ): Effect.Effect<PaginationResult<Doc>, E | ReadBudgetExceededError, R>;
2591
+ options: PaginateOptions<R2>,
2592
+ ): Effect.Effect<PaginationResult<Doc>, E | ReadBudgetExceededError, R | R2>;
2489
2593
  } = dual(
2490
2594
  2,
2491
2595
  Effect.fn("QueryStream.paginate")(
@@ -2496,10 +2600,37 @@ export const paginate: {
2496
2600
  R,
2497
2601
  OrderDirection extends
2498
2602
  QueryStreamOrderDirection.QueryStreamOrderDirection,
2603
+ R2 = never,
2499
2604
  >(
2500
2605
  self: QueryStream<Doc, KeyLabels, OrderDirection, E, R>,
2501
- options: PaginateOptions,
2606
+ options: PaginateOptions<R2>,
2502
2607
  ) {
2608
+ const seal = (cursor: string): Effect.Effect<string, never, R2> =>
2609
+ options.cursorEnvelope === undefined
2610
+ ? Effect.succeed(cursor)
2611
+ : options.cursorEnvelope.seal(cursor);
2612
+ const open = (cursor: string): Effect.Effect<string, never, R2> =>
2613
+ options.cursorEnvelope === undefined
2614
+ ? Effect.succeed(cursor)
2615
+ : options.cursorEnvelope.open(cursor).pipe(
2616
+ Effect.flatMap(
2617
+ Option.match({
2618
+ onNone: () =>
2619
+ Effect.die(
2620
+ new ConvexError({ paginationError: "InvalidCursor" }),
2621
+ ),
2622
+ onSome: Effect.succeed,
2623
+ }),
2624
+ ),
2625
+ );
2626
+ const opened = yield* Effect.all({
2627
+ start:
2628
+ options.cursor === null ? Effect.succeed(null) : open(options.cursor),
2629
+ end:
2630
+ options.endCursor == null
2631
+ ? Effect.succeed(null)
2632
+ : open(options.endCursor),
2633
+ });
2503
2634
  const cursorSchema = QueryStreamCursor.fromKeyLayout(self.keyLayout);
2504
2635
  const encodeCursor = Schema.encodeEffect(cursorSchema);
2505
2636
  const decodeCursor = Schema.decodeEffect(cursorSchema);
@@ -2509,7 +2640,7 @@ export const paginate: {
2509
2640
  identity,
2510
2641
  );
2511
2642
  const decoded = yield* Effect.all({
2512
- start: Option.match(Option.fromNullOr(options.cursor), {
2643
+ start: Option.match(Option.fromNullOr(opened.start), {
2513
2644
  onNone: () => Effect.succeed(QueryStreamPagination.Start.Beginning()),
2514
2645
  onSome: (cursor) =>
2515
2646
  Effect.map(decodeCursor(cursor), (key) =>
@@ -2519,7 +2650,7 @@ export const paginate: {
2519
2650
  }),
2520
2651
  ),
2521
2652
  }),
2522
- range: Option.match(Option.fromNullishOr(options.endCursor), {
2653
+ range: Option.match(Option.fromNullishOr(opened.end), {
2523
2654
  onNone: () => Effect.succeed(QueryStreamPagination.Range.Unpinned()),
2524
2655
  onSome: (cursor) =>
2525
2656
  cursor === QueryStreamCursor.END_CURSOR
@@ -2550,7 +2681,7 @@ export const paginate: {
2550
2681
  return {
2551
2682
  page: [],
2552
2683
  isDone: false,
2553
- continueCursor: request.cursor,
2684
+ continueCursor: yield* seal(request.cursor),
2554
2685
  } satisfies PaginationResult<Doc>;
2555
2686
  }
2556
2687
  const start = Option.map(request.after, (key) => ({
@@ -2603,7 +2734,10 @@ export const paginate: {
2603
2734
  return yield* new ReadBudgetExceededError(counts);
2604
2735
  }
2605
2736
  const encode = (key: QueryStreamKey.Complete) =>
2606
- encodeCursor(key).pipe(Effect.catchTag("SchemaError", Effect.die));
2737
+ encodeCursor(key).pipe(
2738
+ Effect.catchTag("SchemaError", Effect.die),
2739
+ Effect.flatMap(seal),
2740
+ );
2607
2741
  const encodeSplit = (
2608
2742
  result: Extract<
2609
2743
  QueryStreamPagination.Outcome<Doc>,
@@ -2613,7 +2747,7 @@ export const paginate: {
2613
2747
  Effect.gen(function* () {
2614
2748
  const continueCursor = yield* Match.value(result.continuation).pipe(
2615
2749
  Match.tagsExhaustive({
2616
- End: () => Effect.succeed(QueryStreamCursor.END_CURSOR),
2750
+ End: () => seal(QueryStreamCursor.END_CURSOR),
2617
2751
  Key: ({ key }) => encode(key),
2618
2752
  }),
2619
2753
  );
@@ -2628,11 +2762,15 @@ export const paginate: {
2628
2762
  return yield* Match.value(outcome.success).pipe(
2629
2763
  Match.tagsExhaustive({
2630
2764
  Done: ({ page }) =>
2631
- Effect.succeed({
2632
- page: Array.fromIterable(page),
2633
- isDone: true,
2634
- continueCursor: QueryStreamCursor.END_CURSOR,
2635
- } satisfies PaginationResult<Doc>),
2765
+ Effect.map(
2766
+ seal(QueryStreamCursor.END_CURSOR),
2767
+ (continueCursor) =>
2768
+ ({
2769
+ page: Array.fromIterable(page),
2770
+ isDone: true,
2771
+ continueCursor,
2772
+ }) satisfies PaginationResult<Doc>,
2773
+ ),
2636
2774
  Continue: ({ page, key }) =>
2637
2775
  Effect.map(
2638
2776
  encode(key),