@gaganref/confect-server 10.3.1-fork.5 → 10.3.1-fork.50

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 (147) hide show
  1. package/dist/ActionCall.d.ts +30 -0
  2. package/dist/ActionCall.d.ts.map +1 -0
  3. package/dist/ActionCall.js +41 -0
  4. package/dist/ActionCall.js.map +1 -0
  5. package/dist/ActionRunner.d.ts +1 -1
  6. package/dist/ActionRunner.d.ts.map +1 -1
  7. package/dist/ActionRunner.js +3 -3
  8. package/dist/ActionRunner.js.map +1 -1
  9. package/dist/AiGatewayDecisionClient.js +1 -1
  10. package/dist/AiGatewayLanguageClient.js +1 -1
  11. package/dist/Auth.d.ts +13 -1
  12. package/dist/Auth.d.ts.map +1 -1
  13. package/dist/Auth.js +13 -3
  14. package/dist/Auth.js.map +1 -1
  15. package/dist/Callback.js +4 -4
  16. package/dist/ConvexConfigProvider.js +2 -2
  17. package/dist/ConvexFunctionDefinitions.d.ts.map +1 -1
  18. package/dist/ConvexFunctionDefinitions.js +4 -4
  19. package/dist/ConvexFunctionDefinitions.js.map +1 -1
  20. package/dist/ConvexLogger.js +1 -1
  21. package/dist/CronJobs.js +6 -6
  22. package/dist/DatabaseSchema.js +2 -2
  23. package/dist/DatabaseWriter.d.ts +36 -2
  24. package/dist/DatabaseWriter.d.ts.map +1 -1
  25. package/dist/DatabaseWriter.js +55 -8
  26. package/dist/DatabaseWriter.js.map +1 -1
  27. package/dist/ExecutionMetadata.js +1 -1
  28. package/dist/FunctionImpl.js +2 -2
  29. package/dist/FunctionRegistryItem.d.ts +1 -0
  30. package/dist/FunctionRegistryItem.d.ts.map +1 -1
  31. package/dist/FunctionRegistryItem.js +2 -1
  32. package/dist/FunctionRegistryItem.js.map +1 -1
  33. package/dist/GroupImpl.d.ts +21 -2
  34. package/dist/GroupImpl.d.ts.map +1 -1
  35. package/dist/GroupImpl.js +21 -22
  36. package/dist/GroupImpl.js.map +1 -1
  37. package/dist/GroupRegistry.d.ts +1 -0
  38. package/dist/GroupRegistry.d.ts.map +1 -1
  39. package/dist/GroupRegistry.js +10 -3
  40. package/dist/GroupRegistry.js.map +1 -1
  41. package/dist/Handler.d.ts +8 -4
  42. package/dist/Handler.d.ts.map +1 -1
  43. package/dist/Handler.js.map +1 -1
  44. package/dist/HttpRouter.d.ts +2 -0
  45. package/dist/HttpRouter.d.ts.map +1 -1
  46. package/dist/HttpRouter.js +17 -8
  47. package/dist/HttpRouter.js.map +1 -1
  48. package/dist/MiddlewareImpl.js +1 -1
  49. package/dist/MutationRunner.d.ts +1 -1
  50. package/dist/MutationRunner.d.ts.map +1 -1
  51. package/dist/MutationRunner.js +3 -3
  52. package/dist/MutationRunner.js.map +1 -1
  53. package/dist/MutationTransactionContext.d.ts.map +1 -1
  54. package/dist/MutationTransactionContext.js +4 -3
  55. package/dist/MutationTransactionContext.js.map +1 -1
  56. package/dist/OfficialComponent.d.ts +60 -0
  57. package/dist/OfficialComponent.d.ts.map +1 -0
  58. package/dist/OfficialComponent.js +71 -0
  59. package/dist/OfficialComponent.js.map +1 -0
  60. package/dist/QueryInitializer.js +2 -2
  61. package/dist/QueryRunner.js +2 -2
  62. package/dist/QueryStream.d.ts +120 -12
  63. package/dist/QueryStream.d.ts.map +1 -1
  64. package/dist/QueryStream.js +143 -41
  65. package/dist/QueryStream.js.map +1 -1
  66. package/dist/QueryStreamCursor.js +1 -1
  67. package/dist/QueryStreamIndexPrefix.js +1 -1
  68. package/dist/QueryStreamIndexRange.js +2 -2
  69. package/dist/QueryStreamKey.js +1 -1
  70. package/dist/QueryStreamKeyBounds.js +2 -2
  71. package/dist/QueryStreamKeyLayout.js +2 -2
  72. package/dist/QueryStreamKeyValues.js +2 -2
  73. package/dist/QueryStreamPagination.js +1 -1
  74. package/dist/QueryStreamReadBudget.d.ts +3 -2
  75. package/dist/QueryStreamReadBudget.d.ts.map +1 -1
  76. package/dist/QueryStreamReadBudget.js +8 -5
  77. package/dist/QueryStreamReadBudget.js.map +1 -1
  78. package/dist/QueryTransactionContext.js +2 -2
  79. package/dist/RegisteredConvexFunction.d.ts +10 -5
  80. package/dist/RegisteredConvexFunction.d.ts.map +1 -1
  81. package/dist/RegisteredConvexFunction.js +45 -29
  82. package/dist/RegisteredConvexFunction.js.map +1 -1
  83. package/dist/RegisteredFunction.d.ts +39 -22
  84. package/dist/RegisteredFunction.d.ts.map +1 -1
  85. package/dist/RegisteredFunction.js +155 -42
  86. package/dist/RegisteredFunction.js.map +1 -1
  87. package/dist/RegisteredNodeFunction.d.ts +1 -1
  88. package/dist/RegisteredNodeFunction.d.ts.map +1 -1
  89. package/dist/RegisteredNodeFunction.js +10 -6
  90. package/dist/RegisteredNodeFunction.js.map +1 -1
  91. package/dist/RegistryItems.d.ts +7 -0
  92. package/dist/RegistryItems.d.ts.map +1 -1
  93. package/dist/RegistryItems.js +8 -3
  94. package/dist/RegistryItems.js.map +1 -1
  95. package/dist/RequestMetadata.js +1 -1
  96. package/dist/Scheduler.d.ts.map +1 -1
  97. package/dist/Scheduler.js +11 -10
  98. package/dist/Scheduler.js.map +1 -1
  99. package/dist/Storage.js +1 -1
  100. package/dist/StorageActionWriter.js +1 -1
  101. package/dist/StorageReader.js +1 -1
  102. package/dist/StorageWriter.js +1 -1
  103. package/dist/TransactionMetadata.js +1 -1
  104. package/dist/VectorSearch.js +1 -1
  105. package/dist/index.d.ts +2 -0
  106. package/dist/index.d.ts.map +1 -1
  107. package/dist/index.js +20 -18
  108. package/dist/internal/AiGatewayDecisionClient.js +1 -1
  109. package/dist/internal/AiGatewayLanguageClient.js +1 -1
  110. package/dist/internal/AiGatewayServiceToken.js +1 -1
  111. package/dist/internal/DocumentPatch.d.ts +16 -0
  112. package/dist/internal/DocumentPatch.d.ts.map +1 -0
  113. package/dist/internal/DocumentPatch.js +36 -0
  114. package/dist/internal/DocumentPatch.js.map +1 -0
  115. package/dist/internal/observability.d.ts +5 -0
  116. package/dist/internal/observability.d.ts.map +1 -0
  117. package/dist/internal/observability.js +21 -0
  118. package/dist/internal/observability.js.map +1 -0
  119. package/dist/internal/reservedArgument.d.ts +8 -0
  120. package/dist/internal/reservedArgument.d.ts.map +1 -0
  121. package/dist/internal/reservedArgument.js +30 -0
  122. package/dist/internal/reservedArgument.js.map +1 -0
  123. package/package.json +5 -5
  124. package/src/ActionCall.ts +83 -0
  125. package/src/ActionRunner.ts +3 -2
  126. package/src/Auth.ts +23 -1
  127. package/src/ConvexFunctionDefinitions.ts +2 -1
  128. package/src/DatabaseWriter.ts +183 -25
  129. package/src/FunctionRegistryItem.ts +4 -0
  130. package/src/GroupImpl.ts +61 -8
  131. package/src/GroupRegistry.ts +7 -3
  132. package/src/Handler.ts +21 -5
  133. package/src/HttpRouter.ts +54 -4
  134. package/src/MutationRunner.ts +3 -2
  135. package/src/MutationTransactionContext.ts +2 -1
  136. package/src/OfficialComponent.ts +167 -0
  137. package/src/QueryStream.ts +391 -61
  138. package/src/QueryStreamReadBudget.ts +19 -6
  139. package/src/RegisteredConvexFunction.ts +35 -2
  140. package/src/RegisteredFunction.ts +251 -45
  141. package/src/RegisteredNodeFunction.ts +11 -1
  142. package/src/RegistryItems.ts +31 -0
  143. package/src/Scheduler.ts +13 -16
  144. package/src/index.ts +2 -0
  145. package/src/internal/DocumentPatch.ts +87 -0
  146. package/src/internal/observability.ts +27 -0
  147. package/src/internal/reservedArgument.ts +56 -0
@@ -70,7 +70,16 @@ import * as QueryStreamPagination from "./QueryStreamPagination.js";
70
70
  import * as QueryStreamReadBudget from "./QueryStreamReadBudget.js";
71
71
 
72
72
  /**
73
- * A page exhausted its read budget without a safe logical continuation.
73
+ * Default physical row budget for `paginate` and `collect`. Supply an explicit
74
+ * `maximumRowsRead` to override it. Plain Effect Stream consumers use their
75
+ * enclosing budget and remain unlimited when none is provided.
76
+ *
77
+ * @experimental
78
+ */
79
+ export const DEFAULT_MAXIMUM_ROWS_READ = 2_000;
80
+
81
+ /**
82
+ * A query stream exhausted its read budget before its consumer could finish.
74
83
  *
75
84
  * @experimental
76
85
  */
@@ -79,7 +88,7 @@ export class ReadBudgetExceededError extends Schema.TaggedError<ReadBudgetExceed
79
88
  { rowsRead: Schema.Natural, bytesRead: Schema.Natural },
80
89
  ) {
81
90
  override get message() {
82
- return "The read budget was exhausted before a safe page boundary; increase the budget or simplify the query";
91
+ return "The read budget was exhausted before the query stream completed; increase the budget or simplify the query";
83
92
  }
84
93
  }
85
94
 
@@ -441,6 +450,89 @@ export const empty =
441
450
  return make(orderDirection);
442
451
  };
443
452
 
453
+ /**
454
+ * A deferred stream's resolved direction or layout differs from its
455
+ * declaration. The check runs on consumption, before the resolved source is
456
+ * pulled.
457
+ *
458
+ * @experimental
459
+ */
460
+ export class DeferredStreamMismatchError extends Data.TaggedError(
461
+ "DeferredStreamMismatchError",
462
+ )<{
463
+ readonly expectedOrderDirection: QueryStreamOrderDirection.QueryStreamOrderDirection;
464
+ readonly actualOrderDirection: QueryStreamOrderDirection.QueryStreamOrderDirection;
465
+ readonly expectedKeyLayout: QueryStreamKeyLayout.QueryStreamKeyLayout;
466
+ readonly actualKeyLayout: QueryStreamKeyLayout.QueryStreamKeyLayout;
467
+ }> {}
468
+
469
+ /**
470
+ * Defer an effect that checks access or constructs a query stream until it is
471
+ * pulled. Supply its layout and direction without reading documents. This lets
472
+ * `flatMap` build and narrow an inner stream before running its checks.
473
+ *
474
+ * The effect runs afresh on each consumption, seek, or reversal. Failures and
475
+ * service requirements are preserved. Its stream must match the declared layout
476
+ * and direction, or consumption dies with `DeferredStreamMismatchError`.
477
+ *
478
+ * @experimental
479
+ */
480
+ export const defer = <
481
+ Doc,
482
+ KeyLabels extends QueryStreamKeyLabels.QueryStreamKeyLabels,
483
+ OrderDirection extends QueryStreamOrderDirection.QueryStreamOrderDirection,
484
+ E = never,
485
+ R = never,
486
+ E2 = never,
487
+ R2 = never,
488
+ >(
489
+ options: {
490
+ readonly keyLayout: QueryStreamKeyLayout.QueryStreamKeyLayout<KeyLabels>;
491
+ readonly orderDirection: OrderDirection;
492
+ },
493
+ make: Effect.Effect<
494
+ QueryStream<Doc, KeyLabels, NoInfer<OrderDirection>, E, R>,
495
+ E2,
496
+ R2
497
+ >,
498
+ ): QueryStream<Doc, KeyLabels, OrderDirection, E | E2, R | R2> => {
499
+ const checked = Effect.map(make, (stream) => {
500
+ if (
501
+ stream.orderDirection !== options.orderDirection ||
502
+ !QueryStreamKeyLayout.Equivalence(stream.keyLayout, options.keyLayout)
503
+ ) {
504
+ throw new DeferredStreamMismatchError({
505
+ expectedOrderDirection: options.orderDirection,
506
+ actualOrderDirection: stream.orderDirection,
507
+ expectedKeyLayout: options.keyLayout,
508
+ actualKeyLayout: stream.keyLayout,
509
+ });
510
+ }
511
+ return stream;
512
+ });
513
+ return new QueryStream(
514
+ options.orderDirection,
515
+ options.keyLayout,
516
+ Stream.unwrap(Effect.map(checked, (stream) => stream.annotated)),
517
+ undefined,
518
+ (bounds) =>
519
+ defer(
520
+ options,
521
+ Effect.map(checked, (stream) => narrowByParsedBounds(stream, bounds)),
522
+ ),
523
+ () =>
524
+ defer(
525
+ {
526
+ ...options,
527
+ orderDirection: QueryStreamOrderDirection.flip(
528
+ options.orderDirection,
529
+ ),
530
+ },
531
+ Effect.map(checked, reverse),
532
+ ),
533
+ );
534
+ };
535
+
444
536
  // -----------------------------------------------------------------------------
445
537
  // Constructors
446
538
  // -----------------------------------------------------------------------------
@@ -742,6 +834,21 @@ const fillMergeSource = <Doc, E>(
742
834
  BudgetLimited: () => Effect.succeed(source),
743
835
  });
744
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
+
745
852
  /**
746
853
  * One step of the k-way merge as a pure unfold: fill every source, emit the
747
854
  * earliest head (ties go to the earliest source, keeping the merge stable), and
@@ -749,7 +856,10 @@ const fillMergeSource = <Doc, E>(
749
856
  * exhausted or an input stopped before its next key was known.
750
857
  */
751
858
  const mergeStep =
752
- <Doc, E>(keyOrder: Order.Order<QueryStreamKeyValues.QueryStreamKeyValues>) =>
859
+ <Doc, E>(
860
+ keyOrder: Order.Order<QueryStreamKeyValues.QueryStreamKeyValues>,
861
+ dedupe: boolean,
862
+ ) =>
753
863
  (
754
864
  sources: ReadonlyArray<MergeSource<Doc, E>>,
755
865
  ): Effect.Effect<
@@ -791,36 +901,73 @@ const mergeStep =
791
901
  ),
792
902
  );
793
903
 
794
- return Option.getOrUndefined(
795
- Option.map(earliest, ({ index, element }) =>
796
- Tuple.make(
797
- element,
798
- Array.map(filled, (source, sourceIndex) =>
799
- sourceIndex === index && source._tag === "Buffered"
800
- ? Option.match(Chunk.head(source.tail), {
801
- onNone: () => MergeSource.NeedsPull({ pull: source.pull }),
802
- onSome: (head) =>
803
- MergeSource.Buffered({
804
- pull: source.pull,
805
- head,
806
- tail: Chunk.drop(source.tail, 1),
807
- }),
808
- })
809
- : source,
810
- ),
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,
811
911
  ),
812
- ),
813
- );
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
+ }
814
948
  });
815
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
+
816
961
  /**
817
962
  * Combine streams sharing a key layout into one stream in key order. Use this
818
963
  * to query several index ranges at once, such as notes by multiple roles.
819
964
  *
820
965
  * The merge emits the smallest next key when ascending and the largest when
821
- * descending. Unlike `Stream.merge`, it does not interleave by arrival time.
822
- * Overlapping inputs are not deduplicated: a document matched twice appears
823
- * 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.
824
971
  *
825
972
  * Inputs need compatible document types and identical key layouts and
826
973
  * directions. TypeScript rejects known mismatches; runtime mismatches throw
@@ -842,6 +989,7 @@ export const merge = <
842
989
  QueryStream<Doc, KeyLabels, NoInfer<OrderDirection>, E, R>
843
990
  >,
844
991
  ],
992
+ options?: MergeOptions,
845
993
  ): QueryStream<Doc, KeyLabels, OrderDirection, E, R> => {
846
994
  const head = Array.headNonEmpty(streams);
847
995
  const incompatible = Array.findFirst(
@@ -858,7 +1006,7 @@ export const merge = <
858
1006
  actualKeyLayout: incompatible.value.keyLayout,
859
1007
  });
860
1008
  }
861
- return mergeUnchecked(streams);
1009
+ return mergeUnchecked(streams, options);
862
1010
  };
863
1011
 
864
1012
  /**
@@ -877,6 +1025,7 @@ const mergeUnchecked = <
877
1025
  QueryStream<Doc, KeyLabels, OrderDirection, E, R>,
878
1026
  ...ReadonlyArray<QueryStream<Doc, KeyLabels, OrderDirection, E, R>>,
879
1027
  ],
1028
+ options?: MergeOptions,
880
1029
  ): QueryStream<Doc, KeyLabels, OrderDirection, E, R> => {
881
1030
  const head = Array.headNonEmpty(streams);
882
1031
  const annotated: Stream.Stream<
@@ -889,7 +1038,10 @@ const mergeUnchecked = <
889
1038
  (pulls) =>
890
1039
  Stream.unfold(
891
1040
  Array.map(pulls, (pull) => MergeSource.NeedsPull({ pull })),
892
- mergeStep<Doc, E>(QueryStreamKeyValues.Order(head.orderDirection)),
1041
+ mergeStep<Doc, E>(
1042
+ QueryStreamKeyValues.Order(head.orderDirection),
1043
+ options?.dedupe === true,
1044
+ ),
893
1045
  ),
894
1046
  ),
895
1047
  );
@@ -903,17 +1055,25 @@ const mergeUnchecked = <
903
1055
  // key space, so each branch converts them to its own index-key
904
1056
  // space itself.
905
1057
  (parsedBounds) =>
906
- mergeUnchecked([
907
- narrowByParsedBounds(head, parsedBounds),
908
- ...Array.map(Array.tailNonEmpty(streams), (stream) =>
909
- narrowByParsedBounds(stream, parsedBounds),
910
- ),
911
- ]),
1058
+ mergeUnchecked(
1059
+ [
1060
+ narrowByParsedBounds(head, parsedBounds),
1061
+ ...Array.map(Array.tailNonEmpty(streams), (stream) =>
1062
+ narrowByParsedBounds(stream, parsedBounds),
1063
+ ),
1064
+ ],
1065
+ options,
1066
+ ),
912
1067
  () =>
913
- mergeUnchecked([
914
- reverse(head),
915
- ...Array.map(Array.tailNonEmpty(streams), (stream) => reverse(stream)),
916
- ]),
1068
+ mergeUnchecked(
1069
+ [
1070
+ reverse(head),
1071
+ ...Array.map(Array.tailNonEmpty(streams), (stream) =>
1072
+ reverse(stream),
1073
+ ),
1074
+ ],
1075
+ options,
1076
+ ),
917
1077
  );
918
1078
  };
919
1079
 
@@ -2140,6 +2300,98 @@ const narrowInMemory = <
2140
2300
  // Sinks
2141
2301
  // -----------------------------------------------------------------------------
2142
2302
 
2303
+ /**
2304
+ * Read limits for `collect`. These count underlying index reads, including
2305
+ * filtered rows and join inputs, rather than just emitted values. At either
2306
+ * limit, one extra row may be read to distinguish completion from an overrun;
2307
+ * it counts toward reported reads but is never emitted. This also applies at
2308
+ * zero, so an empty stream completes under a zero budget.
2309
+ *
2310
+ * @experimental
2311
+ */
2312
+ export interface CollectOptions {
2313
+ readonly maximumRowsRead?: number;
2314
+ readonly maximumBytesRead?: number;
2315
+ }
2316
+
2317
+ /**
2318
+ * Collect the whole query stream or fail with `ReadBudgetExceededError` when a
2319
+ * read budget stops it. No partial array reaches the caller. Arbitrary I/O in
2320
+ * callbacks is not counted. Use `paginate` for data that can grow without
2321
+ * bound.
2322
+ *
2323
+ * With explicit options, each run gets a fresh budget. Without options, use the
2324
+ * enclosing budget if present, or the default row budget.
2325
+ *
2326
+ * @experimental
2327
+ */
2328
+ export const collect: {
2329
+ (
2330
+ options?: CollectOptions,
2331
+ ): <Doc, KeyLabels extends QueryStreamKeyLabels.QueryStreamKeyLabels, E, R>(
2332
+ self: QueryStream<
2333
+ Doc,
2334
+ KeyLabels,
2335
+ QueryStreamOrderDirection.QueryStreamOrderDirection,
2336
+ E,
2337
+ R
2338
+ >,
2339
+ ) => Effect.Effect<Array<Doc>, E | ReadBudgetExceededError, R>;
2340
+ <Doc, KeyLabels extends QueryStreamKeyLabels.QueryStreamKeyLabels, E, R>(
2341
+ self: QueryStream<
2342
+ Doc,
2343
+ KeyLabels,
2344
+ QueryStreamOrderDirection.QueryStreamOrderDirection,
2345
+ E,
2346
+ R
2347
+ >,
2348
+ options?: CollectOptions,
2349
+ ): Effect.Effect<Array<Doc>, E | ReadBudgetExceededError, R>;
2350
+ } = dual(
2351
+ (args) => isQueryStream(args[0]),
2352
+ Effect.fn("QueryStream.collect")(function* <
2353
+ Doc,
2354
+ KeyLabels extends QueryStreamKeyLabels.QueryStreamKeyLabels,
2355
+ E,
2356
+ R,
2357
+ >(
2358
+ self: QueryStream<
2359
+ Doc,
2360
+ KeyLabels,
2361
+ QueryStreamOrderDirection.QueryStreamOrderDirection,
2362
+ E,
2363
+ R
2364
+ >,
2365
+ options?: CollectOptions,
2366
+ ) {
2367
+ const makeBudget = Schema.decodeEffect(QueryStreamReadBudget.Limits)({
2368
+ maximumRowsRead: DEFAULT_MAXIMUM_ROWS_READ,
2369
+ ...options,
2370
+ }).pipe(
2371
+ Effect.catchTag("SchemaError", (cause) =>
2372
+ Effect.die(new QueryStreamReadBudget.InvalidReadLimitError({ cause })),
2373
+ ),
2374
+ Effect.flatMap(QueryStreamReadBudget.make),
2375
+ );
2376
+ const budget = yield* options === undefined
2377
+ ? Effect.serviceOption(QueryStreamReadBudget.QueryStreamReadBudget).pipe(
2378
+ Effect.flatMap(
2379
+ Option.match({ onNone: () => makeBudget, onSome: Effect.succeed }),
2380
+ ),
2381
+ )
2382
+ : makeBudget;
2383
+ const rows = yield* Stream.runCollect(self.toStream()).pipe(
2384
+ Effect.provideService(
2385
+ QueryStreamReadBudget.QueryStreamReadBudget,
2386
+ budget,
2387
+ ),
2388
+ );
2389
+ if (yield* budget.isStopped)
2390
+ return yield* new ReadBudgetExceededError(yield* budget.getReadCounts);
2391
+ return rows;
2392
+ }),
2393
+ );
2394
+
2143
2395
  /**
2144
2396
  * `unique` found at least two emitted values. Narrow the query if at most one
2145
2397
  * value was expected, or use another consumer to handle multiple results.
@@ -2189,6 +2441,27 @@ export const unique = Effect.fn("QueryStream.unique")(
2189
2441
  // Pagination
2190
2442
  // -----------------------------------------------------------------------------
2191
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
+
2192
2465
  /**
2193
2466
  * Convex pagination options interpreted over a query stream's stored keys. Pass
2194
2467
  * a paginated query handler's `paginationOpts` directly to `paginate`.
@@ -2200,17 +2473,27 @@ export const unique = Effect.fn("QueryStream.unique")(
2200
2473
  * - `endCursor`: optional inclusive boundary returned by a previous page. Pins
2201
2474
  * the page to a key range regardless of how many values it holds.
2202
2475
  * - `maximumRowsRead`: optional budget for physical document reads from
2203
- * underlying query-stream indexes, including filtered documents.
2476
+ * underlying query-stream indexes, including filtered documents. Defaults to
2477
+ * 2,000; an explicit limit overrides the default.
2204
2478
  * - `maximumBytesRead`: optional budget charging estimated document size on every
2205
2479
  * read, not Convex's exact billed bytes. The final document can take the
2206
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`.
2484
+ *
2485
+ * At either read limit, including zero, one extra row may be read to
2486
+ * distinguish completion from an overrun. It is counted but never emitted. An
2487
+ * empty stream completes under a zero budget.
2207
2488
  *
2208
2489
  * With `numItems: 0`, an existing cursor is returned unchanged with an empty
2209
2490
  * page and `isDone: false`; a null cursor dies with `EmptyInitialPageError`.
2210
2491
  *
2211
2492
  * @experimental
2212
2493
  */
2213
- export type PaginateOptions = ConvexPaginationOptions;
2494
+ export type PaginateOptions<R = never> = ConvexPaginationOptions & {
2495
+ readonly cursorEnvelope?: CursorEnvelope<R>;
2496
+ };
2214
2497
 
2215
2498
  /**
2216
2499
  * One page in Convex's pagination result shape. Return the whole result to
@@ -2264,9 +2547,9 @@ export type PaginationResult<Doc> = ConvexPaginationResult<Doc>;
2264
2547
  * query semantics change, even if an old cursor is still accepted.
2265
2548
  *
2266
2549
  * Cursors expose boundary field names and key values, including document IDs.
2267
- * They are neither opaque nor signed: clients can read or craft them. Do not
2268
- * publicly paginate over sensitive indexed fields unless pinned with `eq`.
2269
- * 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.
2270
2553
  *
2271
2554
  * Return the whole result to reactive clients. React's
2272
2555
  * `useStreamPaginatedQuery` handles pinning, splitting, and invalid-cursor
@@ -2276,8 +2559,8 @@ export type PaginationResult<Doc> = ConvexPaginationResult<Doc>;
2276
2559
  * @experimental
2277
2560
  */
2278
2561
  export const paginate: {
2279
- (
2280
- options: PaginateOptions,
2562
+ <R2 = never>(
2563
+ options: PaginateOptions<R2>,
2281
2564
  ): <Doc, KeyLabels extends QueryStreamKeyLabels.QueryStreamKeyLabels, E, R>(
2282
2565
  self: QueryStream<
2283
2566
  Doc,
@@ -2286,8 +2569,18 @@ export const paginate: {
2286
2569
  E,
2287
2570
  R
2288
2571
  >,
2289
- ) => Effect.Effect<PaginationResult<Doc>, E | ReadBudgetExceededError, R>;
2290
- <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
+ >(
2291
2584
  self: QueryStream<
2292
2585
  Doc,
2293
2586
  KeyLabels,
@@ -2295,8 +2588,8 @@ export const paginate: {
2295
2588
  E,
2296
2589
  R
2297
2590
  >,
2298
- options: PaginateOptions,
2299
- ): Effect.Effect<PaginationResult<Doc>, E | ReadBudgetExceededError, R>;
2591
+ options: PaginateOptions<R2>,
2592
+ ): Effect.Effect<PaginationResult<Doc>, E | ReadBudgetExceededError, R | R2>;
2300
2593
  } = dual(
2301
2594
  2,
2302
2595
  Effect.fn("QueryStream.paginate")(
@@ -2307,10 +2600,37 @@ export const paginate: {
2307
2600
  R,
2308
2601
  OrderDirection extends
2309
2602
  QueryStreamOrderDirection.QueryStreamOrderDirection,
2603
+ R2 = never,
2310
2604
  >(
2311
2605
  self: QueryStream<Doc, KeyLabels, OrderDirection, E, R>,
2312
- options: PaginateOptions,
2606
+ options: PaginateOptions<R2>,
2313
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
+ });
2314
2634
  const cursorSchema = QueryStreamCursor.fromKeyLayout(self.keyLayout);
2315
2635
  const encodeCursor = Schema.encodeEffect(cursorSchema);
2316
2636
  const decodeCursor = Schema.decodeEffect(cursorSchema);
@@ -2320,7 +2640,7 @@ export const paginate: {
2320
2640
  identity,
2321
2641
  );
2322
2642
  const decoded = yield* Effect.all({
2323
- start: Option.match(Option.fromNullOr(options.cursor), {
2643
+ start: Option.match(Option.fromNullOr(opened.start), {
2324
2644
  onNone: () => Effect.succeed(QueryStreamPagination.Start.Beginning()),
2325
2645
  onSome: (cursor) =>
2326
2646
  Effect.map(decodeCursor(cursor), (key) =>
@@ -2330,7 +2650,7 @@ export const paginate: {
2330
2650
  }),
2331
2651
  ),
2332
2652
  }),
2333
- range: Option.match(Option.fromNullishOr(options.endCursor), {
2653
+ range: Option.match(Option.fromNullishOr(opened.end), {
2334
2654
  onNone: () => Effect.succeed(QueryStreamPagination.Range.Unpinned()),
2335
2655
  onSome: (cursor) =>
2336
2656
  cursor === QueryStreamCursor.END_CURSOR
@@ -2361,7 +2681,7 @@ export const paginate: {
2361
2681
  return {
2362
2682
  page: [],
2363
2683
  isDone: false,
2364
- continueCursor: request.cursor,
2684
+ continueCursor: yield* seal(request.cursor),
2365
2685
  } satisfies PaginationResult<Doc>;
2366
2686
  }
2367
2687
  const start = Option.map(request.after, (key) => ({
@@ -2396,7 +2716,7 @@ export const paginate: {
2396
2716
  () => QueryStreamPagination.initial<Doc>(),
2397
2717
  (state) => state._tag === "Reading",
2398
2718
  (state, { doc, keyValues }: Element<Doc>) =>
2399
- Effect.map(readBudget.isExhausted, (hitLimit) =>
2719
+ Effect.map(readBudget.isStopped, (hitLimit) =>
2400
2720
  QueryStreamPagination.record(
2401
2721
  request,
2402
2722
  state,
@@ -2414,7 +2734,10 @@ export const paginate: {
2414
2734
  return yield* new ReadBudgetExceededError(counts);
2415
2735
  }
2416
2736
  const encode = (key: QueryStreamKey.Complete) =>
2417
- encodeCursor(key).pipe(Effect.catchTag("SchemaError", Effect.die));
2737
+ encodeCursor(key).pipe(
2738
+ Effect.catchTag("SchemaError", Effect.die),
2739
+ Effect.flatMap(seal),
2740
+ );
2418
2741
  const encodeSplit = (
2419
2742
  result: Extract<
2420
2743
  QueryStreamPagination.Outcome<Doc>,
@@ -2424,7 +2747,7 @@ export const paginate: {
2424
2747
  Effect.gen(function* () {
2425
2748
  const continueCursor = yield* Match.value(result.continuation).pipe(
2426
2749
  Match.tagsExhaustive({
2427
- End: () => Effect.succeed(QueryStreamCursor.END_CURSOR),
2750
+ End: () => seal(QueryStreamCursor.END_CURSOR),
2428
2751
  Key: ({ key }) => encode(key),
2429
2752
  }),
2430
2753
  );
@@ -2439,11 +2762,15 @@ export const paginate: {
2439
2762
  return yield* Match.value(outcome.success).pipe(
2440
2763
  Match.tagsExhaustive({
2441
2764
  Done: ({ page }) =>
2442
- Effect.succeed({
2443
- page: Array.fromIterable(page),
2444
- isDone: true,
2445
- continueCursor: QueryStreamCursor.END_CURSOR,
2446
- } 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
+ ),
2447
2774
  Continue: ({ page, key }) =>
2448
2775
  Effect.map(
2449
2776
  encode(key),
@@ -2463,7 +2790,10 @@ export const paginate: {
2463
2790
  Effect.provideServiceEffect(
2464
2791
  effect,
2465
2792
  QueryStreamReadBudget.QueryStreamReadBudget,
2466
- Schema.decodeEffect(QueryStreamReadBudget.Limits)(options).pipe(
2793
+ Schema.decodeEffect(QueryStreamReadBudget.Limits)({
2794
+ maximumRowsRead: DEFAULT_MAXIMUM_ROWS_READ,
2795
+ ...options,
2796
+ }).pipe(
2467
2797
  Effect.catchTag("SchemaError", (cause) =>
2468
2798
  Effect.die(
2469
2799
  new QueryStreamReadBudget.InvalidReadLimitError({ cause }),
@@ -22,8 +22,9 @@ export interface QueryStreamReadBudget {
22
22
  readonly getReadCounts: Effect.Effect<ReadCounts>;
23
23
  /**
24
24
  * Returns a stream that records reads against this budget and stops
25
- * requesting further batches once exhausted. Apply directly to the document
26
- * source before buffering, filtering, or combining its output.
25
+ * requesting further batches once exhausted, except for one limit-boundary
26
+ * lookahead to detect completion. Apply directly to the document source
27
+ * before buffering, filtering, or combining its output.
27
28
  */
28
29
  readonly accountFor: (
29
30
  encodedDocuments: Stream.Stream<unknown>,
@@ -133,10 +134,22 @@ export const make = Effect.fnUntraced(function* (
133
134
  SynchronizedRef.modifyEffect(stateRef, (state) =>
134
135
  Effect.gen(function* () {
135
136
  if (isExhausted(state, limits)) {
136
- return Tuple.make(
137
- Option.none(),
138
- State.Stopped(readCounts(state)),
139
- );
137
+ const canProbeEnd =
138
+ State.$is("Active")(state) &&
139
+ !Option.exists(
140
+ limits.maximumRowsRead,
141
+ (limit) => state.rowsRead > limit,
142
+ ) &&
143
+ !Option.exists(
144
+ limits.maximumBytesRead,
145
+ (limit) => state.bytesRead > limit,
146
+ );
147
+ // Iterators expose completion only through a pull. Count a
148
+ // lookahead row, but never publish it beyond either limit.
149
+ const counts = canProbeEnd
150
+ ? record(state, yield* pull)
151
+ : readCounts(state);
152
+ return Tuple.make(Option.none(), State.Stopped(counts));
140
153
  }
141
154
  const documents = yield* pull;
142
155
  return Tuple.make(