@mdbase-dev/connect 0.1.0-beta.121 → 0.1.0-beta.123

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 (39) hide show
  1. package/README.md +86 -6
  2. package/dist/application-session.js +45 -27
  3. package/dist/browser/integrity.json +1 -1
  4. package/dist/browser/mdbase-connect.min.js +4 -4
  5. package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
  6. package/dist/change-events.d.ts +6 -0
  7. package/dist/change-events.js +102 -0
  8. package/dist/collection-client.d.ts +7 -2
  9. package/dist/collection-client.js +35 -54
  10. package/dist/connection.d.ts +5 -2
  11. package/dist/connection.js +4 -3
  12. package/dist/description-cache.d.ts +14 -0
  13. package/dist/description-cache.js +73 -0
  14. package/dist/file-crypto.js +10 -6
  15. package/dist/file-descriptor.d.ts +12 -0
  16. package/dist/file-descriptor.js +12 -0
  17. package/dist/file-sha256.js +3 -3
  18. package/dist/files.d.ts +2 -11
  19. package/dist/files.js +36 -30
  20. package/dist/index.d.ts +4 -1
  21. package/dist/index.js +2 -0
  22. package/dist/operation-types.d.ts +127 -6
  23. package/dist/query-pagination-internals.d.ts +9 -0
  24. package/dist/query-pagination-internals.js +52 -0
  25. package/dist/query-pagination.js +28 -26
  26. package/dist/query-predicates.d.ts +11 -0
  27. package/dist/query-predicates.js +15 -0
  28. package/dist/read-many.d.ts +6 -0
  29. package/dist/read-many.js +70 -0
  30. package/dist/record-session.d.ts +3 -2
  31. package/dist/record-session.js +11 -2
  32. package/dist/records.d.ts +15 -2
  33. package/dist/records.js +109 -7
  34. package/dist/saved-view-pagination.js +20 -20
  35. package/dist/startup-timing.d.ts +2 -0
  36. package/dist/startup-timing.js +20 -0
  37. package/dist/structured-readiness.d.ts +3 -1
  38. package/dist/structured-readiness.js +21 -1
  39. package/package.json +2 -2
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { normalizeCollectionChange } from "./change-events.js";
1
2
  export { MdbaseApplicationSession, MdbaseMemoryVerificationStore } from "./application-session.js";
2
3
  export type { MdbaseApplicationSessionOptions, MdbaseApplicationVerificationStore, MdbaseDefinitionUpdate, MdbaseCollectionSetupUpdate, MdbaseApplicationSessionSnapshot } from "./application-session.js";
3
4
  export type { MdbaseConnectOptions, MdbaseConnectTimeouts, MdbaseFrontmatter } from "./connect-options.js";
@@ -22,7 +23,9 @@ export type { MdbaseFileSource, CollectionFileDescriptor, MdbaseFileDeleteReceip
22
23
  export { parseMdbasePushPayload, parseMdbaseNativeNotificationData, showMdbasePushNotification } from "./notifications.js";
23
24
  export type { MdbaseNotificationRegistrationOptions, MdbaseNotificationRegistration, MdbaseNativeNotificationRegistrationOptions, MdbaseNativeNotificationRegistration, MdbaseNativeNotificationData, MdbasePushPayload } from "./notifications.js";
24
25
  export type { ConnectSuccess, ConnectFailure, ConnectOutcome, CommonOperationProblemCode, RegistrationProblemCode, AuthorizationProblemCode, NotificationProblemCode, DirectAccessProblemCode, SessionProblemCode, CollectionSetupProblemCode, CollectionDescriptionProblemCode, CollectionReadProblemCode, CollectionQueryProblemCode, CollectionMutationProblemCode, CollectionChangesProblemCode, CollectionTypeProblemCode } from "./outcomes.js";
25
- export type { MdbaseDesiredTimer, MdbaseTimer, MdbaseTimerList, MdbaseTimerReconciliation, ReadInput, DataContractSelector, CollectionFileMetadata, DataContractViewIdentity, QueryRecord, RecordDocument, QueryInput, QueryProjection, QuerySelectionExpression, QueryOrder, QuerySummary, QueryResult, QueryPagesOptions, QueryAllOptions, QueryPage, ConnectRequestOptions, MutationEstimate, MutationProgressState, MutationProgress, MutationProgressOptions, RequestCoordinationOptions, RenameProgressOptions, DeleteProgressOptions, PendingMutationSummary, PendingMutation, CreateInput, UpdateInput, DeleteInput, DeleteResult, DeletePreflightResult, RenameInput, RenameResult, RenamePreflightResult, ReadTypeInput, CreateTypeInput, UpdateTypeInput, ReadViewSourceInput, CreateViewSourceInput, UpdateViewSourceInput, DeleteViewSourceInput, DeleteViewSourceResult, ExecuteViewInput, SavedViewPresentation, SavedViewSource, SavedViewProperty, SavedNamedView, SavedViewDocument, SavedViewList, SavedViewSourceDocument, SavedViewExecution, SavedViewPage, SavedViewPagesOptions, ContractSetupChoice, TypePackResourceDiff, TypePackReceipt, TypePackAssessment, TypePackApplyResult, ConfigurationContributionValue, ConfigurationRequirement, ConfigurationProvision, ApplicationCollectionSetupRequirements, ApplicationCollectionSetupProvisions, ConfigurationSetupConflict, ConfigurationSetupAssessment, CollectionSetupAssessment, CollectionSetupReceipt, CollectionSetupApplyResult, AssessTypePackInput, ApplyTypePackInput, AssessCollectionSetupInput, ApplyCollectionSetupInput, ChangesInput, WatchOptions, WatchRetryOptions, WatchStatus, WatchInput, MdbaseWatchSubscription, CollectionChange, CollectionChangesPage, CollectionDescription, CollectionContractDescriptor, CollectionContractImplementationDescriptor, CollectionTypeDescriptor } from "./operation-types.js";
26
+ export type { MdbaseDesiredTimer, MdbaseTimer, MdbaseTimerList, MdbaseTimerReconciliation, ReadInput, ReadManyOptions, ReadManyRecord, ReadManyEntry, ReadManyBatchError, ReadManyResult, DataContractSelector, CollectionFileMetadata, DataContractViewIdentity, QueryRecord, RecordDocument, QueryInput, QueryProjection, QuerySelectionExpression, QueryOrder, QuerySummary, QueryResult, QueryPagesOptions, QueryAllOptions, QueryPage, ConnectRequestOptions, MutationEstimate, MutationProgressState, MutationProgress, MutationProgressOptions, RequestCoordinationOptions, RenameProgressOptions, DeleteProgressOptions, PendingMutationSummary, PendingMutation, CreateInput, UpdateInput, DeleteInput, DeleteResult, DeletePreflightResult, RenameInput, RenameResult, RenamePreflightResult, ReadTypeInput, CreateTypeInput, UpdateTypeInput, ReadViewSourceInput, CreateViewSourceInput, UpdateViewSourceInput, DeleteViewSourceInput, DeleteViewSourceResult, ExecuteViewInput, SavedViewPresentation, SavedViewSource, SavedViewProperty, SavedNamedView, SavedViewDocument, SavedViewList, SavedViewSourceDocument, SavedViewExecution, SavedViewPage, SavedViewPagesOptions, ContractSetupChoice, TypePackResourceDiff, TypePackReceipt, TypePackAssessment, TypePackApplyResult, ConfigurationContributionValue, ConfigurationRequirement, ConfigurationProvision, ApplicationCollectionSetupRequirements, ApplicationCollectionSetupProvisions, ConfigurationSetupConflict, ConfigurationSetupAssessment, CollectionSetupAssessment, CollectionSetupReceipt, CollectionSetupApplyResult, AssessTypePackInput, ApplyTypePackInput, AssessCollectionSetupInput, ApplyCollectionSetupInput, ChangesInput, WatchOptions, WatchRetryOptions, WatchStatus, WatchInput, MdbaseWatchSubscription, CollectionChange, RecordChangeMetadata, DescribeOptions, CollectionChangesPage, CollectionDescription, CollectionContractDescriptor, CollectionContractImplementationDescriptor, CollectionTypeDescriptor } from "./operation-types.js";
27
+ export { linksTo } from "./query-predicates.js";
28
+ export type { LinksToOptions } from "./query-predicates.js";
26
29
  export { externalStore } from "./external-store.js";
27
30
  export type { MdbaseExternalStore } from "./external-store.js";
28
31
  export { MdbaseBrowserSelection, MdbaseMemorySelection } from "./selection.js";
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { normalizeCollectionChange } from "./change-events.js";
1
2
  export { MdbaseApplicationSession, MdbaseMemoryVerificationStore } from "./application-session.js";
2
3
  export { MdbaseConnect } from "./mdbase-connect.js";
3
4
  export { MdbaseConnection } from "./connection.js";
@@ -8,6 +9,7 @@ export { MdbaseRecords } from "./records.js";
8
9
  export { MdbaseRecordSession } from "./record-session.js";
9
10
  export { MdbasePeopleClient, PERSON_CONTRACT, sameIdentity } from "./people-client.js";
10
11
  export { parseMdbasePushPayload, parseMdbaseNativeNotificationData, showMdbasePushNotification } from "./notifications.js";
12
+ export { linksTo } from "./query-predicates.js";
11
13
  export { externalStore } from "./external-store.js";
12
14
  export { MdbaseBrowserSelection, MdbaseMemorySelection } from "./selection.js";
13
15
  export { MDBASE_RECORD_CREATED_CONTRACT, MDBASE_TIMER_FIRED_CONTRACT, operationsForApplicationCapabilities } from "@mdbase-dev/connect-protocol";
@@ -1,4 +1,4 @@
1
- import type { CollectionOperation, ConnectProblem, ContractRequirement, JsonObject, MutationOperationIdentifier, TypePackProvision } from "@mdbase-dev/connect-protocol";
1
+ import type { CollectionChange as WireCollectionChange, CollectionChangesPage as WireCollectionChangesPage, CollectionOperation, ConnectProblem, ContractRequirement, JsonObject, MutationOperationIdentifier, TypePackProvision } from "@mdbase-dev/connect-protocol";
2
2
  export interface MdbaseDesiredTimer {
3
3
  /** Stable within the timer namespace. */
4
4
  id: string;
@@ -64,6 +64,44 @@ export interface QueryRecord<Frontmatter extends JsonObject = JsonObject> {
64
64
  values?: JsonObject;
65
65
  contract?: DataContractViewIdentity;
66
66
  }
67
+ /** Query-shaped batched record today; a future authority may supply a revision. */
68
+ export interface ReadManyRecord<Frontmatter extends JsonObject = JsonObject> extends QueryRecord<Frontmatter> {
69
+ /** Absent on current query authorities. Never synthesized from timestamps or content. */
70
+ revision?: string;
71
+ }
72
+ export interface ReadManyOptions extends ConnectRequestOptions {
73
+ includeBody?: boolean;
74
+ frontmatterMode?: QueryInput["frontmatterMode"];
75
+ /** Restrict matching records to these raw collection types, not a contract view. */
76
+ types?: string[];
77
+ /** Unique paths per independent query, default 100; range 1..1,000. */
78
+ batchSize?: number;
79
+ /** Independent batches in flight, default 4; range 1..4. Cursor pages stay serial. */
80
+ concurrency?: number;
81
+ }
82
+ export type ReadManyEntry<Frontmatter extends JsonObject = JsonObject> = {
83
+ status: "found";
84
+ path: string;
85
+ record: ReadManyRecord<Frontmatter>;
86
+ } | {
87
+ status: "missing";
88
+ path: string;
89
+ } | {
90
+ status: "error";
91
+ path: string;
92
+ batch: number;
93
+ };
94
+ export interface ReadManyBatchError {
95
+ batch: number;
96
+ paths: string[];
97
+ failure: import("./outcomes.js").ConnectFailure<import("./outcomes.js").CollectionQueryProblemCode>;
98
+ }
99
+ export interface ReadManyResult<Frontmatter extends JsonObject = JsonObject> {
100
+ /** One entry per input path, in input order, including duplicates. */
101
+ results: ReadManyEntry<Frontmatter>[];
102
+ /** A failed batch is not proof of missing records; its entries are errors. */
103
+ errors: ReadManyBatchError[];
104
+ }
67
105
  export interface RecordDocument<Frontmatter extends JsonObject = JsonObject> {
68
106
  path: string;
69
107
  revision: string;
@@ -189,10 +227,12 @@ export interface QueryResult<Record extends JsonObject = JsonObject> {
189
227
  };
190
228
  }
191
229
  export interface QueryPagesOptions<Record extends JsonObject = JsonObject> {
192
- /** Initial page size. Cursor authorities pin this size for the query lifetime. */
230
+ /** Initial page size. Cursor continuations use this pinned size. */
193
231
  firstPageSize?: number;
194
- /** Also sets the initial size unless firstPageSize is explicit; authorities may cap it. */
232
+ /** Initial cursor size (default 200), unless firstPageSize is set. Offset continuations use this size (default 1,000). Authorities may cap it. */
195
233
  pageSize?: number;
234
+ /** Total rows to deliver, not a page size. Zero makes no data requests. */
235
+ maxResults?: number;
196
236
  signal?: AbortSignal;
197
237
  /** Independent budget for each page requested by this caller-driven iterator. */
198
238
  pageTimeoutMs?: number | null;
@@ -200,10 +240,12 @@ export interface QueryPagesOptions<Record extends JsonObject = JsonObject> {
200
240
  onProgress?: (page: QueryPage<Record>) => void;
201
241
  }
202
242
  export interface QueryAllOptions<Record extends JsonObject = JsonObject> extends ConnectRequestOptions {
203
- /** Initial page size. Cursor authorities pin this size for the query lifetime. */
243
+ /** Initial page size. Cursor continuations use this pinned size. */
204
244
  firstPageSize?: number;
205
- /** Also sets the initial size unless firstPageSize is explicit; authorities may cap it. */
245
+ /** Initial cursor size (default 200), unless firstPageSize is set. Offset continuations use this size (default 1,000). Authorities may cap it. */
206
246
  pageSize?: number;
247
+ /** Total rows to return, independent of page size. */
248
+ maxResults?: number;
207
249
  onProgress?: (page: QueryPage<Record>) => void;
208
250
  }
209
251
  export interface QueryPage<Record extends JsonObject = JsonObject> {
@@ -404,8 +446,12 @@ export interface ExecuteViewInput {
404
446
  render?: boolean;
405
447
  }
406
448
  export interface SavedViewPagesOptions<Frontmatter extends JsonObject = JsonObject> {
449
+ /** Initial page size, pinned for cursor continuations. */
407
450
  firstPageSize?: number;
451
+ /** Initial cursor size (default 200), unless firstPageSize is set. Offset continuations use this size (default 1,000). Authorities may cap it. */
408
452
  pageSize?: number;
453
+ /** Total rows to deliver, not a page size. Zero makes no data requests. */
454
+ maxResults?: number;
409
455
  signal?: AbortSignal;
410
456
  /** Independent budget for each page requested by this caller-driven iterator. */
411
457
  pageTimeoutMs?: number | null;
@@ -685,12 +731,87 @@ export interface MdbaseWatchSubscription {
685
731
  subscribe(listener: (change: CollectionChange) => void, onStatus?: (status: WatchStatus) => void, onProblem?: (problem: ConnectProblem) => void): () => void;
686
732
  close(): void;
687
733
  }
688
- export interface CollectionChange {
734
+ export interface DescribeOptions extends ConnectRequestOptions {
735
+ /** Bypass the settled description cache (concurrent requests still share a load). */
736
+ fresh?: boolean;
737
+ }
738
+ interface ChangeBase {
689
739
  cursor: number;
740
+ /** Original authority event ID. Prefer kind for application logic. */
690
741
  type: string;
691
742
  occurredAt: string;
692
743
  payload: JsonObject;
744
+ /** Unmodified wire event, including authority-specific metadata. */
745
+ raw: WireCollectionChange;
746
+ }
747
+ export interface RecordChangeMetadata {
748
+ revision?: string;
749
+ previousRevision?: string;
750
+ types?: string[];
751
+ previousTypes?: string[];
752
+ /** Authority field identifiers: local JSON Pointers, hosted top-level keys. Preserved, not guessed. */
753
+ changedFields?: string[];
754
+ bodyChanged?: boolean;
755
+ /** Hosted authorities may supply frontmatter; local authorities need not. */
756
+ before?: JsonObject;
757
+ after?: JsonObject;
758
+ }
759
+ interface ResourceChangeBase extends ChangeBase {
760
+ path?: string;
761
+ name?: string;
762
+ revision?: string;
763
+ previousRevision?: string;
693
764
  }
765
+ export type CollectionChange = (ChangeBase & RecordChangeMetadata & {
766
+ kind: "record.created";
767
+ path: string;
768
+ }) | (ChangeBase & RecordChangeMetadata & {
769
+ kind: "record.updated";
770
+ path: string;
771
+ }) | (ChangeBase & RecordChangeMetadata & {
772
+ kind: "record.deleted";
773
+ path: string;
774
+ }) | (ChangeBase & RecordChangeMetadata & {
775
+ kind: "record.renamed";
776
+ from: string;
777
+ to: string;
778
+ }) | (ResourceChangeBase & {
779
+ kind: "schema.changed";
780
+ }) | (ResourceChangeBase & {
781
+ kind: "config.changed";
782
+ }) | (ResourceChangeBase & {
783
+ kind: "contract.changed";
784
+ }) | (ResourceChangeBase & {
785
+ kind: "view.changed";
786
+ }) | (ChangeBase & {
787
+ kind: "file.changed";
788
+ path: string;
789
+ revision?: string;
790
+ previousRevision?: string;
791
+ }) | (ChangeBase & {
792
+ kind: "file.put";
793
+ file: import("./file-descriptor.js").CollectionFileDescriptor;
794
+ }) | (ChangeBase & {
795
+ kind: "file.removed";
796
+ fileId: string;
797
+ previousPath?: string;
798
+ revision?: string;
799
+ }) | (ChangeBase & {
800
+ kind: "gap";
801
+ reason?: string;
802
+ }) | (ChangeBase & {
803
+ kind: "unknown";
804
+ reason: "unrecognized_type" | "invalid_payload";
805
+ })
806
+ /** Synthesized from a reset page, not a new authority event ID. Watch still terminates with change_cursor_reset. */
807
+ | {
808
+ kind: "reset";
809
+ cursor: number;
810
+ type: "reset";
811
+ occurredAt: null;
812
+ payload: JsonObject;
813
+ raw: WireCollectionChangesPage;
814
+ };
694
815
  export interface CollectionChangesPage {
695
816
  events: CollectionChange[];
696
817
  cursor: number;
@@ -0,0 +1,9 @@
1
+ /** Owns the latest single-use cursor, including while an iterator is paused at yield. */
2
+ export declare function queryCursorLease(release: (cursor: string) => Promise<void>, signal?: AbortSignal, initialCursor?: string): {
3
+ startRequest(): void;
4
+ finishRequest(nextCursor?: string): void;
5
+ dispose(): void;
6
+ };
7
+ export declare function resultCap(value?: number): number;
8
+ export declare function nonNegativeInteger(value: unknown, fallback: number): number;
9
+ export declare function positiveInteger(value: unknown, fallback: number): number;
@@ -0,0 +1,52 @@
1
+ /** Owns the latest single-use cursor, including while an iterator is paused at yield. */
2
+ export function queryCursorLease(release, signal, initialCursor) {
3
+ let cursor = initialCursor;
4
+ let inFlight = false;
5
+ const close = () => {
6
+ if (!cursor)
7
+ return;
8
+ const owned = cursor;
9
+ cursor = undefined;
10
+ // Collection cleanup has an independent bounded budget, never the data signal.
11
+ void release(owned).catch(() => undefined);
12
+ };
13
+ const abort = () => {
14
+ // A consumed token can rotate while the request settles. Release the returned
15
+ // token instead; a paused iterator has no request and closes immediately.
16
+ if (!inFlight)
17
+ close();
18
+ };
19
+ signal?.addEventListener("abort", abort, { once: true });
20
+ return {
21
+ startRequest() { inFlight = true; },
22
+ finishRequest(nextCursor) {
23
+ if (nextCursor)
24
+ cursor = nextCursor;
25
+ inFlight = false;
26
+ if (signal?.aborted)
27
+ close();
28
+ },
29
+ dispose() {
30
+ signal?.removeEventListener("abort", abort);
31
+ close();
32
+ }
33
+ };
34
+ }
35
+ export function resultCap(value) {
36
+ if (value === undefined)
37
+ return Infinity;
38
+ if (!Number.isSafeInteger(value) || value < 0) {
39
+ throw new TypeError("maxResults must be a non-negative safe integer.");
40
+ }
41
+ return value;
42
+ }
43
+ export function nonNegativeInteger(value, fallback) {
44
+ return typeof value === "number" && Number.isFinite(value)
45
+ ? Math.max(0, Math.floor(value))
46
+ : fallback;
47
+ }
48
+ export function positiveInteger(value, fallback) {
49
+ return typeof value === "number" && Number.isFinite(value) && value >= 1
50
+ ? Math.floor(value)
51
+ : fallback;
52
+ }
@@ -1,5 +1,6 @@
1
1
  import { connectProblem } from "./errors.js";
2
2
  import { connectFailure, connectSuccess } from "./outcomes.js";
3
+ import { nonNegativeInteger, positiveInteger, queryCursorLease, resultCap } from "./query-pagination-internals.js";
3
4
  export async function* coordinatedQueryPages(query, releaseQueryCursor, input = {}, options = {}) {
4
5
  const { offset: requestedOffset, limit: requestedLimit, cursor: requestedCursor, pagination: requestedPagination, snapshot: requestedSnapshot, ...criteria } = input;
5
6
  let offset = nonNegativeInteger(requestedOffset, 0);
@@ -7,12 +8,14 @@ export async function* coordinatedQueryPages(query, releaseQueryCursor, input =
7
8
  const pageSize = positiveInteger(options.pageSize ?? requestedLimit, 1_000);
8
9
  let cursor = requestedCursor;
9
10
  let cursorMode = requestedCursor !== undefined;
10
- let cursorToRelease = requestedCursor;
11
+ const maxResults = resultCap(options.maxResults);
12
+ const lease = queryCursorLease(releaseQueryCursor, options.signal, requestedCursor);
11
13
  let snapshot = requestedSnapshot;
12
14
  let loaded = 0;
13
15
  let pageNumber = 0;
14
16
  try {
15
- while (!options.signal?.aborted) {
17
+ while (!options.signal?.aborted && loaded < maxResults) {
18
+ lease.startRequest();
16
19
  const pageCursor = cursor;
17
20
  const pageRequestOptions = {
18
21
  signal: options.signal,
@@ -25,8 +28,11 @@ export async function* coordinatedQueryPages(query, releaseQueryCursor, input =
25
28
  && requestedSnapshot === undefined;
26
29
  let queried = await query({
27
30
  ...criteria,
28
- // Continuations use the page size pinned when the cursor was opened.
29
- ...(!cursorMode ? { limit: pageNumber === 0 ? firstPageSize : pageSize } : {}),
31
+ // Initial size is portable across authorities; continuations use the
32
+ // pinned size. Do not infer variable-size support from operation errors.
33
+ ...(!cursorMode
34
+ ? { limit: Math.min(pageNumber === 0 ? firstPageSize : pageSize, maxResults - loaded) }
35
+ : {}),
30
36
  ...(cursorMode
31
37
  ? (pageCursor ? { cursor: pageCursor } : { pagination: "cursor" })
32
38
  : {
@@ -43,17 +49,23 @@ export async function* coordinatedQueryPages(query, releaseQueryCursor, input =
43
49
  // Cursor pagination is a read-only capability probe for authorities
44
50
  // predating generation cursors. Retry the first page without that
45
51
  // optional field only when the authority rejected the operation
46
- // schema; explicit cursor requests remain strict.
52
+ // schema; explicit cursor requests remain strict. This existing fallback
53
+ // serves pre-cursor desktop authorities; remove when those are unsupported.
47
54
  if (automaticCursorProbe
55
+ && !options.signal?.aborted
48
56
  && !queried.ok
49
57
  && queried.problem.code === "operation_invalid") {
50
58
  queried = await query({
51
59
  ...criteria,
52
- limit: firstPageSize,
60
+ limit: Math.min(firstPageSize, maxResults - loaded),
53
61
  offset
54
62
  }, pageRequestOptions);
55
63
  }
64
+ lease.finishRequest(queried.ok ? queried.value.meta?.cursor : undefined);
65
+ if (options.signal?.aborted)
66
+ return;
56
67
  if (!queried.ok) {
68
+ lease.dispose();
57
69
  yield queried;
58
70
  return;
59
71
  }
@@ -62,27 +74,29 @@ export async function* coordinatedQueryPages(query, releaseQueryCursor, input =
62
74
  if (returnedCursor) {
63
75
  cursorMode = true;
64
76
  cursor = returnedCursor;
65
- cursorToRelease = returnedCursor;
66
77
  }
67
78
  else if (cursorMode) {
68
79
  cursor = undefined;
69
- cursorToRelease = pageCursor;
70
80
  }
71
81
  if (cursorMode && result.meta?.hasMore && !returnedCursor) {
82
+ lease.dispose();
72
83
  yield connectFailure(connectProblem("invalid_operation_response", "The collection authority omitted the cursor required for the next query page."));
73
84
  return;
74
85
  }
75
86
  const returnedSnapshot = result.meta?.snapshot;
76
87
  if (!cursorMode && snapshot && returnedSnapshot && snapshot !== returnedSnapshot) {
88
+ lease.dispose();
77
89
  yield connectFailure(connectProblem("query_snapshot_changed", "The collection query snapshot changed while paging. Refresh the query before continuing."));
78
90
  return;
79
91
  }
80
92
  if (!cursorMode && !snapshot && returnedSnapshot)
81
93
  snapshot = returnedSnapshot;
82
- loaded += result.results.length;
83
- const complete = !result.meta?.hasMore || result.results.length === 0;
94
+ const remaining = maxResults - loaded;
95
+ const results = result.results.length > remaining ? result.results.slice(0, remaining) : result.results;
96
+ loaded += results.length;
97
+ const complete = loaded >= maxResults || !result.meta?.hasMore || result.results.length === 0;
84
98
  const page = {
85
- results: result.results,
99
+ results,
86
100
  ...(result.meta ? { meta: result.meta } : {}),
87
101
  page: pageNumber,
88
102
  offset,
@@ -91,6 +105,8 @@ export async function* coordinatedQueryPages(query, releaseQueryCursor, input =
91
105
  ...(returnedCursor ? { cursor: returnedCursor } : {}),
92
106
  ...(!cursorMode && snapshot ? { snapshot } : {})
93
107
  };
108
+ if (complete)
109
+ lease.dispose();
94
110
  options.onProgress?.(page);
95
111
  yield connectSuccess(page, queried.diagnostics);
96
112
  if (complete)
@@ -100,20 +116,6 @@ export async function* coordinatedQueryPages(query, releaseQueryCursor, input =
100
116
  }
101
117
  }
102
118
  finally {
103
- if (cursorMode && cursorToRelease) {
104
- // Cleanup has its own bounded request. It must not delay delivery of
105
- // completed pages or consume the caller's data deadline.
106
- void releaseQueryCursor(cursorToRelease).catch(() => undefined);
107
- }
119
+ lease.dispose();
108
120
  }
109
121
  }
110
- function nonNegativeInteger(value, fallback) {
111
- return typeof value === "number" && Number.isFinite(value)
112
- ? Math.max(0, Math.floor(value))
113
- : fallback;
114
- }
115
- function positiveInteger(value, fallback) {
116
- return typeof value === "number" && Number.isFinite(value) && value > 0
117
- ? Math.floor(value)
118
- : fallback;
119
- }
@@ -0,0 +1,11 @@
1
+ export interface LinksToOptions {
2
+ /** The field is a link list instead of a scalar link. Uses CEL exists(). */
3
+ multiple?: boolean;
4
+ }
5
+ /**
6
+ * A CEL predicate comparing an authority-resolved link with an exact collection
7
+ * path. `field` is a literal top-level effective-frontmatter key, not an expression.
8
+ * Missing/null fields and unresolved links do not match. Resolution (including
9
+ * configured IDs, relative paths and duplicate names) belongs to the authority.
10
+ */
11
+ export declare function linksTo(field: string, path: string, options?: LinksToOptions): string;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A CEL predicate comparing an authority-resolved link with an exact collection
3
+ * path. `field` is a literal top-level effective-frontmatter key, not an expression.
4
+ * Missing/null fields and unresolved links do not match. Resolution (including
5
+ * configured IDs, relative paths and duplicate names) belongs to the authority.
6
+ */
7
+ export function linksTo(field, path, options = {}) {
8
+ const key = JSON.stringify(field);
9
+ const value = `record[${key}]`;
10
+ const target = JSON.stringify(path);
11
+ const matches = (link) => `${link} != null && ${link}.asFile() != null && ${link}.asFile().file.path == ${target}`;
12
+ return `${key} in record && ${options.multiple
13
+ ? `${value} != null && ${value}.exists(link, ${matches("link")})`
14
+ : matches(value)}`;
15
+ }
@@ -0,0 +1,6 @@
1
+ import type { JsonObject } from "@mdbase-dev/connect-protocol";
2
+ import { type CollectionQueryProblemCode, type ConnectOutcome } from "./outcomes.js";
3
+ import type { QueryAllOptions, QueryInput, QueryResult, ReadManyOptions, ReadManyResult } from "./operation-types.js";
4
+ type QueryAll<Frontmatter extends JsonObject> = (input: QueryInput, options: QueryAllOptions<Frontmatter>) => Promise<ConnectOutcome<QueryResult<Frontmatter>, CollectionQueryProblemCode>>;
5
+ export declare function queryReadMany<Frontmatter extends JsonObject>(queryAll: QueryAll<Frontmatter>, paths: readonly string[], options: ReadManyOptions, defaultTimeoutMs: number | null): Promise<ConnectOutcome<ReadManyResult<Frontmatter>, CollectionQueryProblemCode>>;
6
+ export {};
@@ -0,0 +1,70 @@
1
+ import { MdbaseConnectError } from "./errors.js";
2
+ import { connectFailure, connectSuccess } from "./outcomes.js";
3
+ import { createRequestBudget, requestAbortReason } from "./request-budget.js";
4
+ export async function queryReadMany(queryAll, paths, options, defaultTimeoutMs) {
5
+ const batchSize = boundedInteger(options.batchSize ?? 100, 1_000, "batchSize");
6
+ const concurrency = boundedInteger(options.concurrency ?? 4, 4, "concurrency");
7
+ if (options.coordination?.latestWins) {
8
+ throw new TypeError("readMany batches cannot use latestWins coordination.");
9
+ }
10
+ const inputs = [...paths];
11
+ const unique = [...new Set(inputs)];
12
+ const entries = new Map();
13
+ const errors = [];
14
+ const budget = createRequestBudget(options, defaultTimeoutMs);
15
+ let nextBatch = 0;
16
+ const batchCount = Math.ceil(unique.length / batchSize);
17
+ const batchDiagnostics = [];
18
+ const worker = async () => {
19
+ while (!budget.signal.aborted && nextBatch < batchCount) {
20
+ const batch = nextBatch++;
21
+ const selected = unique.slice(batch * batchSize, (batch + 1) * batchSize);
22
+ const outcome = await queryAll({
23
+ where: `file.path in ${JSON.stringify(selected)}`,
24
+ ...(options.types ? { types: options.types } : {}),
25
+ ...(options.includeBody === undefined ? {} : { includeBody: options.includeBody }),
26
+ ...(options.frontmatterMode ? { frontmatterMode: options.frontmatterMode } : {})
27
+ }, {
28
+ pageSize: selected.length,
29
+ signal: budget.signal,
30
+ timeoutMs: null,
31
+ coordination: options.coordination
32
+ });
33
+ if (!outcome.ok) {
34
+ errors.push({ batch, paths: selected, failure: outcome });
35
+ for (const path of selected)
36
+ entries.set(path, { status: "error", path, batch });
37
+ continue;
38
+ }
39
+ batchDiagnostics[batch] = outcome.diagnostics;
40
+ const records = new Map(outcome.value.results.map(record => [record.path, record]));
41
+ for (const path of selected) {
42
+ const record = records.get(path);
43
+ entries.set(path, record ? { status: "found", path, record } : { status: "missing", path });
44
+ }
45
+ }
46
+ };
47
+ try {
48
+ await Promise.all(Array.from({ length: Math.min(concurrency, batchCount) }, worker));
49
+ if (budget.signal.aborted)
50
+ throw requestAbortReason(budget.signal);
51
+ return connectSuccess({
52
+ results: inputs.map(path => entries.get(path)),
53
+ errors: errors.sort((a, b) => a.batch - b.batch)
54
+ }, batchDiagnostics.flat());
55
+ }
56
+ catch (error) {
57
+ if (error instanceof MdbaseConnectError)
58
+ return connectFailure(error.problem);
59
+ throw error;
60
+ }
61
+ finally {
62
+ budget.dispose();
63
+ }
64
+ }
65
+ function boundedInteger(value, max, name) {
66
+ if (!Number.isSafeInteger(value) || value < 1 || value > max) {
67
+ throw new TypeError(`${name} must be an integer between 1 and ${max}.`);
68
+ }
69
+ return value;
70
+ }
@@ -7,7 +7,7 @@ import { type ConnectOutcome } from "./outcomes.js";
7
7
  * - `saving`: a write or exact recovery is in flight.
8
8
  * - `conflict`: a newer record changed what was edited locally; see `remote`.
9
9
  * - `recovery`: a write's outcome is unknown; no new write is sent until it is recovered.
10
- * - `error`: the last write failed; local changes are kept.
10
+ * - `error`: a write or refresh failed; local changes and the last accepted record are kept.
11
11
  * - `deleted`: the record no longer exists; local changes are kept.
12
12
  */
13
13
  export type MdbaseRecordSessionState = "saved" | "unsaved" | "saving" | "conflict" | "recovery" | "error" | "deleted";
@@ -51,7 +51,7 @@ export interface MdbaseRecordSessionSnapshot<R> {
51
51
  readonly remote: R | null;
52
52
  /** Local changes not yet acknowledged, including a write in flight or awaiting recovery. */
53
53
  readonly dirty: boolean;
54
- /** Why the last write or recovery failed. */
54
+ /** Why a write, recovery or refresh failed. */
55
55
  readonly problem: ConnectProblem | null;
56
56
  /** The interrupted write's request ID, while `state` is `recovery`. */
57
57
  readonly pendingRequestId?: string;
@@ -76,6 +76,7 @@ export declare class MdbaseRecordSession<R> {
76
76
  private patch;
77
77
  private remote;
78
78
  private problem;
79
+ private refreshProblem;
79
80
  private deleted;
80
81
  private busy;
81
82
  private pending;
@@ -15,6 +15,8 @@ export class MdbaseRecordSession {
15
15
  patch = {};
16
16
  remote = null;
17
17
  problem = null;
18
+ // Editing/discarding a draft cannot establish freshness after a failed read.
19
+ refreshProblem = null;
18
20
  deleted = false;
19
21
  busy = false;
20
22
  pending;
@@ -129,8 +131,13 @@ export class MdbaseRecordSession {
129
131
  if (!outcome.ok) {
130
132
  if (outcome.problem.code === "file_not_found")
131
133
  this.markDeleted();
134
+ else {
135
+ this.refreshProblem = outcome.problem;
136
+ this.emit();
137
+ }
132
138
  return outcome;
133
139
  }
140
+ this.refreshProblem = null;
134
141
  this.seen.add(this.adapter.revision(outcome.value));
135
142
  this.classify(outcome.value);
136
143
  this.emit();
@@ -156,6 +163,7 @@ export class MdbaseRecordSession {
156
163
  this.record = record;
157
164
  this.remote = null;
158
165
  this.problem = null;
166
+ this.refreshProblem = null;
159
167
  this.emit();
160
168
  }
161
169
  markDeleted() {
@@ -347,6 +355,7 @@ export class MdbaseRecordSession {
347
355
  delete this.patch[key];
348
356
  }
349
357
  this.problem = null;
358
+ this.refreshProblem = null;
350
359
  this.pending = undefined;
351
360
  this.settle();
352
361
  if (this.dirtyLocally())
@@ -419,7 +428,7 @@ export class MdbaseRecordSession {
419
428
  : this.remote ? "conflict"
420
429
  : this.busy ? "saving"
421
430
  : this.pending ? "recovery"
422
- : this.problem ? "error"
431
+ : this.problem || this.refreshProblem ? "error"
423
432
  : dirty ? "unsaved" : "saved";
424
433
  return {
425
434
  state,
@@ -428,7 +437,7 @@ export class MdbaseRecordSession {
428
437
  record: this.record,
429
438
  remote: this.remote,
430
439
  dirty,
431
- problem: this.problem,
440
+ problem: this.problem ?? this.refreshProblem,
432
441
  ...(this.pending ? { pendingRequestId: this.pending.requestId } : {})
433
442
  };
434
443
  }
package/dist/records.d.ts CHANGED
@@ -9,7 +9,8 @@ export interface MdbaseRecordLease<Frontmatter extends JsonObject = JsonObject>
9
9
  readonly session: MdbaseRecordSession<RecordDocument<Frontmatter>>;
10
10
  /**
11
11
  * Stop using the session from this view. Idempotent. Local changes are
12
- * still saved; the session is dropped once no view holds it and it is saved.
12
+ * still saved; the session is dropped once no view holds it and it has
13
+ * nothing left to save.
13
14
  */
14
15
  release(): void;
15
16
  }
@@ -30,6 +31,10 @@ export declare class MdbaseRecords<Frontmatter extends JsonObject = JsonObject>
30
31
  private readonly pendingUpdate;
31
32
  private readonly entries;
32
33
  private readonly opening;
34
+ private readonly refreshes;
35
+ private readonly refreshQueue;
36
+ private activeRefreshes;
37
+ private followers;
33
38
  constructor(connection: RecordConnection<Frontmatter>,
34
39
  /** Finds this record's interrupted update, which survives reloads in durable storage. */
35
40
  pendingUpdate?: (path: string) => Promise<string | null>);
@@ -42,9 +47,17 @@ export declare class MdbaseRecords<Frontmatter extends JsonObject = JsonObject>
42
47
  /**
43
48
  * Keep open sessions current from a collection watch: changed records are
44
49
  * refreshed, renamed records are followed and deleted records are marked.
45
- * Returns a function that stops following.
50
+ * Refreshes are bounded and coalesced per session, with three backed-off
51
+ * retries for transient/not-sent reads. Failures remain in the snapshot.
52
+ * Stopping the last follower drops queued work; admitted reads still settle.
46
53
  */
47
54
  follow(watch: Pick<MdbaseWatchSubscription, "subscribe">): () => void;
55
+ /** One invalidation owner per entry, including while it waits behind a write. */
56
+ private refresh;
57
+ /** Exact recovery must settle before refresh() can read the authoritative record. */
58
+ private enqueueRefresh;
59
+ private pumpRefreshes;
60
+ private runRefresh;
48
61
  private load;
49
62
  /** Writes and reads follow the entry's current path, so a followed rename needs no new session. */
50
63
  private adapter;