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

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 (49) hide show
  1. package/README.md +161 -6
  2. package/dist/application-session.js +51 -27
  3. package/dist/authority-features.d.ts +29 -0
  4. package/dist/authority-features.js +84 -0
  5. package/dist/browser/integrity.json +1 -1
  6. package/dist/browser/mdbase-connect.min.js +4 -4
  7. package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
  8. package/dist/change-events.d.ts +6 -0
  9. package/dist/change-events.js +95 -0
  10. package/dist/collection-client.d.ts +13 -3
  11. package/dist/collection-client.js +98 -140
  12. package/dist/connection.d.ts +12 -2
  13. package/dist/connection.js +57 -117
  14. package/dist/description-cache.d.ts +14 -0
  15. package/dist/description-cache.js +46 -0
  16. package/dist/file-crypto.js +10 -6
  17. package/dist/file-descriptor.d.ts +14 -0
  18. package/dist/file-descriptor.js +29 -0
  19. package/dist/file-metadata.d.ts +21 -0
  20. package/dist/file-metadata.js +112 -0
  21. package/dist/file-sha256.js +3 -3
  22. package/dist/files.d.ts +9 -19
  23. package/dist/files.js +49 -75
  24. package/dist/index.d.ts +5 -2
  25. package/dist/index.js +2 -0
  26. package/dist/local-file-transport.js +18 -16
  27. package/dist/operation-types.d.ts +155 -14
  28. package/dist/query-pagination-internals.d.ts +9 -0
  29. package/dist/query-pagination-internals.js +52 -0
  30. package/dist/query-pagination.d.ts +7 -3
  31. package/dist/query-pagination.js +31 -27
  32. package/dist/query-predicates.d.ts +11 -0
  33. package/dist/query-predicates.js +15 -0
  34. package/dist/query-wire.d.ts +61 -0
  35. package/dist/query-wire.js +110 -0
  36. package/dist/read-many.d.ts +7 -0
  37. package/dist/read-many.js +175 -0
  38. package/dist/record-session.d.ts +3 -2
  39. package/dist/record-session.js +11 -2
  40. package/dist/records.d.ts +15 -2
  41. package/dist/records.js +109 -7
  42. package/dist/saved-view-pagination.js +20 -20
  43. package/dist/startup-timing.d.ts +2 -0
  44. package/dist/startup-timing.js +20 -0
  45. package/dist/structured-readiness.d.ts +3 -1
  46. package/dist/structured-readiness.js +21 -1
  47. package/dist/watch-policy.d.ts +22 -1
  48. package/dist/watch-policy.js +104 -0
  49. package/package.json +2 -2
@@ -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,
@@ -22,11 +25,15 @@ export async function* coordinatedQueryPages(query, releaseQueryCursor, input =
22
25
  const automaticCursorProbe = pageNumber === 0
23
26
  && requestedCursor === undefined
24
27
  && requestedPagination === undefined
25
- && requestedSnapshot === undefined;
28
+ && requestedSnapshot === undefined
29
+ && input.output !== "metadata";
26
30
  let queried = await query({
27
31
  ...criteria,
28
- // Continuations use the page size pinned when the cursor was opened.
29
- ...(!cursorMode ? { limit: pageNumber === 0 ? firstPageSize : pageSize } : {}),
32
+ // Initial size is portable across authorities; continuations use the
33
+ // pinned size. Do not infer variable-size support from operation errors.
34
+ ...(!cursorMode
35
+ ? { limit: Math.min(pageNumber === 0 ? firstPageSize : pageSize, maxResults - loaded) }
36
+ : {}),
30
37
  ...(cursorMode
31
38
  ? (pageCursor ? { cursor: pageCursor } : { pagination: "cursor" })
32
39
  : {
@@ -43,17 +50,23 @@ export async function* coordinatedQueryPages(query, releaseQueryCursor, input =
43
50
  // Cursor pagination is a read-only capability probe for authorities
44
51
  // predating generation cursors. Retry the first page without that
45
52
  // optional field only when the authority rejected the operation
46
- // schema; explicit cursor requests remain strict.
53
+ // schema; explicit cursor requests remain strict. This existing fallback
54
+ // serves pre-cursor desktop authorities; remove when those are unsupported.
47
55
  if (automaticCursorProbe
56
+ && !options.signal?.aborted
48
57
  && !queried.ok
49
58
  && queried.problem.code === "operation_invalid") {
50
59
  queried = await query({
51
60
  ...criteria,
52
- limit: firstPageSize,
61
+ limit: Math.min(firstPageSize, maxResults - loaded),
53
62
  offset
54
63
  }, pageRequestOptions);
55
64
  }
65
+ lease.finishRequest(queried.ok ? queried.value.meta?.cursor : undefined);
66
+ if (options.signal?.aborted)
67
+ return;
56
68
  if (!queried.ok) {
69
+ lease.dispose();
57
70
  yield queried;
58
71
  return;
59
72
  }
@@ -62,27 +75,30 @@ export async function* coordinatedQueryPages(query, releaseQueryCursor, input =
62
75
  if (returnedCursor) {
63
76
  cursorMode = true;
64
77
  cursor = returnedCursor;
65
- cursorToRelease = returnedCursor;
66
78
  }
67
79
  else if (cursorMode) {
68
80
  cursor = undefined;
69
- cursorToRelease = pageCursor;
70
81
  }
71
82
  if (cursorMode && result.meta?.hasMore && !returnedCursor) {
83
+ lease.dispose();
72
84
  yield connectFailure(connectProblem("invalid_operation_response", "The collection authority omitted the cursor required for the next query page."));
73
85
  return;
74
86
  }
75
87
  const returnedSnapshot = result.meta?.snapshot;
76
88
  if (!cursorMode && snapshot && returnedSnapshot && snapshot !== returnedSnapshot) {
89
+ lease.dispose();
77
90
  yield connectFailure(connectProblem("query_snapshot_changed", "The collection query snapshot changed while paging. Refresh the query before continuing."));
78
91
  return;
79
92
  }
80
93
  if (!cursorMode && !snapshot && returnedSnapshot)
81
94
  snapshot = returnedSnapshot;
82
- loaded += result.results.length;
83
- const complete = !result.meta?.hasMore || result.results.length === 0;
95
+ const remaining = maxResults - loaded;
96
+ const results = result.results.length > remaining ? result.results.slice(0, remaining) : result.results;
97
+ loaded += results.length;
98
+ const complete = loaded >= maxResults || !result.meta?.hasMore || result.results.length === 0;
84
99
  const page = {
85
- results: result.results,
100
+ ...(result.output ? { output: result.output } : {}),
101
+ results,
86
102
  ...(result.meta ? { meta: result.meta } : {}),
87
103
  page: pageNumber,
88
104
  offset,
@@ -91,6 +107,8 @@ export async function* coordinatedQueryPages(query, releaseQueryCursor, input =
91
107
  ...(returnedCursor ? { cursor: returnedCursor } : {}),
92
108
  ...(!cursorMode && snapshot ? { snapshot } : {})
93
109
  };
110
+ if (complete)
111
+ lease.dispose();
94
112
  options.onProgress?.(page);
95
113
  yield connectSuccess(page, queried.diagnostics);
96
114
  if (complete)
@@ -100,20 +118,6 @@ export async function* coordinatedQueryPages(query, releaseQueryCursor, input =
100
118
  }
101
119
  }
102
120
  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
- }
121
+ lease.dispose();
108
122
  }
109
123
  }
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,61 @@
1
+ import type { CollectionDescription as WireDescription, DataContractViewIdentity as WireIdentity, JsonObject, QueryRecord as WireQueryRecord } from "@mdbase-dev/connect-protocol";
2
+ import type { CollectionDescription, DataContractViewIdentity, QueryInput, QueryMetadataInput, QueryMetadataResult, QueryRecord, QueryResult } from "./operation-types.js";
3
+ export interface WireQueryResult<Frontmatter extends JsonObject> {
4
+ output?: "metadata";
5
+ results: Array<WireQueryRecord<Frontmatter> | import("@mdbase-dev/connect-protocol").QueryMetadataRecord>;
6
+ meta?: {
7
+ total_count?: number | null;
8
+ total_count_outcome?: {
9
+ status: "deferred";
10
+ budget: "eager_summary_rows";
11
+ limit: number;
12
+ };
13
+ has_more: boolean;
14
+ cursor?: string;
15
+ snapshot?: string;
16
+ };
17
+ }
18
+ export declare function wireQueryInput(input: QueryInput | QueryMetadataInput): {
19
+ output?: "metadata" | undefined;
20
+ types?: string[] | undefined;
21
+ timezone?: string | undefined;
22
+ context?: {
23
+ this: {
24
+ path: string;
25
+ };
26
+ } | undefined;
27
+ projections?: {
28
+ [k: string]: {
29
+ expr: string;
30
+ description?: string | undefined;
31
+ };
32
+ } | undefined;
33
+ where?: string | undefined;
34
+ select?: (string | {
35
+ name: string;
36
+ expr: string;
37
+ label?: string | undefined;
38
+ description?: string | undefined;
39
+ })[] | undefined;
40
+ order_by?: import("./operation-types.js").QueryOrder[] | undefined;
41
+ group_by?: import("./operation-types.js").QueryOrder[] | undefined;
42
+ summary_functions?: {
43
+ [k: string]: {
44
+ expr: string;
45
+ description?: string | undefined;
46
+ };
47
+ } | undefined;
48
+ summaries?: import("./operation-types.js").QuerySummary[] | undefined;
49
+ limit?: number | undefined;
50
+ offset?: number | undefined;
51
+ pagination?: "cursor" | undefined;
52
+ cursor?: string | undefined;
53
+ snapshot?: string | undefined;
54
+ include_body?: boolean | undefined;
55
+ frontmatter_mode?: "both" | "effective" | "persisted" | undefined;
56
+ contract?: import("./operation-types.js").DataContractSelector | undefined;
57
+ };
58
+ export declare function wireDataContractIdentity(value: WireIdentity): DataContractViewIdentity;
59
+ export declare function wireQueryRecord<Frontmatter extends JsonObject>(value: WireQueryRecord<Frontmatter>): QueryRecord<Frontmatter>;
60
+ export declare function wireQueryResult<Frontmatter extends JsonObject>(value: WireQueryResult<Frontmatter>, output: "metadata" | undefined): QueryResult<Frontmatter> | QueryMetadataResult;
61
+ export declare function wireCollectionDescription(value: WireDescription): CollectionDescription;
@@ -0,0 +1,110 @@
1
+ import { authorityCapabilities } from "./authority-features.js";
2
+ import { connectError } from "./errors.js";
3
+ export function wireQueryInput(input) {
4
+ return {
5
+ ...(input.output ? { output: input.output } : {}),
6
+ ...(input.types ? { types: input.types } : {}),
7
+ ...(input.timezone ? { timezone: input.timezone } : {}),
8
+ ...(input.context ? { context: input.context } : {}),
9
+ ...(input.projections ? {
10
+ projections: Object.fromEntries(Object.entries(input.projections).map(([name, projection]) => [
11
+ name, { expr: projection.expression, ...(projection.description ? { description: projection.description } : {}) }
12
+ ]))
13
+ } : {}),
14
+ ...(input.where ? { where: input.where } : {}),
15
+ ...(input.select ? {
16
+ select: input.select.map(selection => typeof selection === "string" ? selection : {
17
+ name: selection.name, expr: selection.expression,
18
+ ...(selection.label ? { label: selection.label } : {}),
19
+ ...(selection.description ? { description: selection.description } : {})
20
+ })
21
+ } : {}),
22
+ ...(input.orderBy ? { order_by: input.orderBy } : {}),
23
+ ...(input.groupBy ? { group_by: input.groupBy } : {}),
24
+ ...(input.summaryFunctions ? {
25
+ summary_functions: Object.fromEntries(Object.entries(input.summaryFunctions).map(([name, projection]) => [
26
+ name, { expr: projection.expression, ...(projection.description ? { description: projection.description } : {}) }
27
+ ]))
28
+ } : {}),
29
+ ...(input.summaries ? { summaries: input.summaries } : {}),
30
+ ...(input.limit === undefined ? {} : { limit: input.limit }),
31
+ ...(input.offset === undefined ? {} : { offset: input.offset }),
32
+ ...(input.pagination ? { pagination: input.pagination } : {}),
33
+ ...(input.cursor ? { cursor: input.cursor } : {}),
34
+ ...(input.snapshot ? { snapshot: input.snapshot } : {}),
35
+ ...(input.includeBody === undefined ? {} : { include_body: input.includeBody }),
36
+ ...(input.frontmatterMode ? { frontmatter_mode: input.frontmatterMode } : {}),
37
+ ...(input.contract ? { contract: input.contract } : {})
38
+ };
39
+ }
40
+ export function wireDataContractIdentity(value) {
41
+ return {
42
+ id: value.id, version: value.version, digest: value.digest,
43
+ type: value.type, implementationDigest: value.implementation_digest
44
+ };
45
+ }
46
+ export function wireQueryRecord(value) {
47
+ const { effective_frontmatter, contract, ...record } = value;
48
+ return {
49
+ ...record,
50
+ ...(effective_frontmatter === undefined ? {} : { effectiveFrontmatter: effective_frontmatter }),
51
+ ...(contract ? { contract: wireDataContractIdentity(contract) } : {})
52
+ };
53
+ }
54
+ function metadataRecord(value) {
55
+ if (!value || typeof value.path !== "string" || typeof value.revision !== "string" || !value.revision
56
+ || !Array.isArray(value.types) || value.types.some(type => typeof type !== "string")
57
+ || !value.values || typeof value.values !== "object" || Array.isArray(value.values)
58
+ || Object.keys(value).some(key => !["path", "types", "revision", "values", "contract"].includes(key))
59
+ || (value.contract !== undefined && (!value.contract
60
+ || ["id", "version", "digest", "type", "implementation_digest"].some(key => typeof value.contract[key] !== "string")))) {
61
+ throw connectError("invalid_operation_response", "The authority returned an invalid revision-bearing metadata row.");
62
+ }
63
+ return {
64
+ path: value.path, types: value.types, revision: value.revision, values: value.values,
65
+ ...(value.contract ? { contract: wireDataContractIdentity(value.contract) } : {})
66
+ };
67
+ }
68
+ export function wireQueryResult(value, output) {
69
+ if (!value || !Array.isArray(value.results) || value.output !== output) {
70
+ throw connectError("invalid_operation_response", "The authority returned an invalid query output mode.");
71
+ }
72
+ const meta = value.meta ? {
73
+ ...(typeof value.meta.total_count === "number" ? { totalCount: value.meta.total_count } : {}),
74
+ ...(value.meta.total_count_outcome === undefined ? {} : { totalCountOutcome: value.meta.total_count_outcome }),
75
+ hasMore: value.meta.has_more,
76
+ ...(value.meta.cursor ? { cursor: value.meta.cursor } : {}),
77
+ ...(value.meta.snapshot ? { snapshot: value.meta.snapshot } : {})
78
+ } : undefined;
79
+ if (output === "metadata")
80
+ return {
81
+ output, results: value.results.map(row => metadataRecord(row)),
82
+ ...(meta ? { meta } : {})
83
+ };
84
+ return { results: value.results.map(row => wireQueryRecord(row)), ...(meta ? { meta } : {}) };
85
+ }
86
+ export function wireCollectionDescription(value) {
87
+ if (!value || value.protocol_version !== 1 || typeof value.collection_id !== "string"
88
+ || typeof value.display_name !== "string" || typeof value.spec_version !== "string"
89
+ || !Array.isArray(value.operations) || !Array.isArray(value.types) || !Array.isArray(value.contracts)) {
90
+ throw connectError("invalid_operation_response", "The authority returned an invalid collection description.");
91
+ }
92
+ return {
93
+ authorityCapabilities: authorityCapabilities(value.authority_capabilities),
94
+ protocolVersion: value.protocol_version, collectionId: value.collection_id,
95
+ displayName: value.display_name, specVersion: value.spec_version,
96
+ operations: value.operations, changeCursor: value.change_cursor, types: value.types,
97
+ contracts: value.contracts.map(contract => ({
98
+ contractType: contract.contract_type, id: contract.id, version: contract.version,
99
+ digest: contract.digest, schema: contract.schema,
100
+ ...(contract.binding_schema ? { bindingSchema: contract.binding_schema } : {}),
101
+ implementations: contract.implementations.map(implementation => ({
102
+ typeName: implementation.type_name, typeVersion: implementation.type_version,
103
+ ...(implementation.type_path ? { typePath: implementation.type_path } : {}),
104
+ digest: implementation.digest, fields: implementation.fields,
105
+ ...(implementation.binding ? { binding: implementation.binding } : {})
106
+ }))
107
+ })),
108
+ ...(value.configuration ? { configuration: value.configuration } : {})
109
+ };
110
+ }
@@ -0,0 +1,7 @@
1
+ import { type JsonObject, type ReadManyDocumentsResult } from "@mdbase-dev/connect-protocol";
2
+ import { type CollectionQueryProblemCode, type ConnectOutcome } from "./outcomes.js";
3
+ import type { ConnectRequestOptions, 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
+ type ReadDocuments<Frontmatter extends JsonObject> = (paths: string[], includeBody: boolean, options: ConnectRequestOptions) => Promise<ConnectOutcome<ReadManyDocumentsResult<Frontmatter>, CollectionQueryProblemCode>>;
6
+ export declare function readMany<Frontmatter extends JsonObject>(queryAll: QueryAll<Frontmatter>, readDocuments: ReadDocuments<Frontmatter>, supports: ((id: string, options: ConnectRequestOptions) => Promise<ConnectOutcome<boolean>>) | undefined, paths: readonly string[], options: ReadManyOptions, defaultTimeoutMs: number | null): Promise<ConnectOutcome<ReadManyResult<Frontmatter>, CollectionQueryProblemCode>>;
7
+ export {};
@@ -0,0 +1,175 @@
1
+ import { MAX_READ_MANY_PATHS } from "@mdbase-dev/connect-protocol";
2
+ import { MdbaseConnectError, connectError, connectProblem } from "./errors.js";
3
+ import { COLLECTION_QUERY_PROBLEM_CODES, captureConnectOutcome, connectFailure, connectSuccess } from "./outcomes.js";
4
+ import { requestOptionsWithinBudget, withRequestBudget } from "./request-budget.js";
5
+ export async function readMany(queryAll, readDocuments, supports, paths, options, defaultTimeoutMs) {
6
+ const requestedBatchSize = boundedInteger(options.batchSize ?? 100, 1_000, "batchSize");
7
+ const concurrency = boundedInteger(options.concurrency ?? 4, 4, "concurrency");
8
+ if (options.coordination?.latestWins)
9
+ throw new TypeError("readMany batches cannot use latestWins coordination.");
10
+ if (options.includeBody !== undefined && typeof options.includeBody !== "boolean")
11
+ throw new TypeError("includeBody must be boolean.");
12
+ if (options.frontmatterMode !== undefined && !["effective", "persisted", "both"].includes(options.frontmatterMode))
13
+ throw new TypeError("Invalid frontmatterMode.");
14
+ const inputs = [...paths];
15
+ try {
16
+ return await withRequestBudget(options, defaultTimeoutMs, async (budget) => {
17
+ if (!inputs.length)
18
+ return connectSuccess({ results: [], errors: [] });
19
+ const requestOptions = requestOptionsWithinBudget({ ...options, timeoutMs: null, coordination: { ...options.coordination, coalesce: false } }, budget);
20
+ const unique = [...new Set(inputs)];
21
+ const entries = new Map();
22
+ const errors = [];
23
+ const batchDiagnostics = [];
24
+ let nextBatch = 0;
25
+ let nextPath = 0;
26
+ const failBatch = (batch, selected, failure) => {
27
+ errors.push({ batch, paths: selected, failure });
28
+ for (const path of selected)
29
+ entries.set(path, { status: "error", path, batch });
30
+ };
31
+ const worker = async () => {
32
+ while (!budget.signal.aborted && nextPath < unique.length) {
33
+ // Recheck cached lifetime evidence at each admission: queued work must
34
+ // not borrow a retired route's support after reconnect/reauthorization.
35
+ const support = await supports?.("read-many-documents-v1", requestOptions);
36
+ if (support && !support.ok) {
37
+ nextPath = unique.length;
38
+ throw new MdbaseConnectError(support.problem);
39
+ }
40
+ if (budget.signal.aborted || nextPath >= unique.length)
41
+ return;
42
+ let documents = support?.value === true;
43
+ // Writer, Reader, TaskNotes, editor and standalone legacy providers
44
+ // retain typed path queries until B1's minimum-authority, consumer-pin
45
+ // adoption, N-1/rollback and connection-cache windows close. No probing.
46
+ const batchSize = documents ? Math.min(requestedBatchSize, MAX_READ_MANY_PATHS) : requestedBatchSize;
47
+ const batch = nextBatch++;
48
+ const selected = unique.slice(nextPath, nextPath + batchSize);
49
+ nextPath += selected.length;
50
+ const criteria = { where: `file.path in ${JSON.stringify(selected)}`, ...(options.types ? { types: options.types } : {}) };
51
+ try {
52
+ let targets = selected;
53
+ let diagnostics = [];
54
+ if (documents && options.types !== undefined) {
55
+ // Only the authority evaluates type membership. This selection
56
+ // is discovery, not a snapshot pinned across the subsequent read.
57
+ const selection = await queryAll({ ...criteria, select: ["file.path"], includeBody: false }, { ...requestOptions, pageSize: selected.length });
58
+ if (!selection.ok) {
59
+ failBatch(batch, selected, selection);
60
+ continue;
61
+ }
62
+ const matches = new Set(selection.value.results.map(record => record.path));
63
+ targets = selected.filter(path => matches.has(path));
64
+ diagnostics = selection.diagnostics;
65
+ // Selection can change the route. Reuse the real legacy query
66
+ // only on fresh unsupported evidence, never on selection errors.
67
+ const current = await supports("read-many-documents-v1", requestOptions);
68
+ if (!current.ok)
69
+ throw new MdbaseConnectError(current.problem);
70
+ documents = current.value;
71
+ }
72
+ if (!documents) {
73
+ const outcome = await queryAll({
74
+ ...criteria,
75
+ ...(options.includeBody === undefined ? {} : { includeBody: options.includeBody }),
76
+ ...(options.frontmatterMode ? { frontmatterMode: options.frontmatterMode } : {})
77
+ }, { ...requestOptions, pageSize: selected.length });
78
+ if (!outcome.ok) {
79
+ failBatch(batch, selected, outcome);
80
+ continue;
81
+ }
82
+ batchDiagnostics[batch] = outcome.diagnostics;
83
+ const records = new Map(outcome.value.results.map(record => [record.path, record]));
84
+ for (const path of selected) {
85
+ const record = records.get(path);
86
+ entries.set(path, record ? { status: "found", path, record } : { status: "missing", path });
87
+ }
88
+ continue;
89
+ }
90
+ if (budget.signal.aborted)
91
+ return;
92
+ const outcome = targets.length
93
+ ? await readDocuments(targets, options.includeBody ?? false, requestOptions)
94
+ : connectSuccess({ items: [] });
95
+ if (!outcome.ok) {
96
+ failBatch(batch, selected, outcome);
97
+ continue;
98
+ }
99
+ // Parse the entire batch before installing any result: malformed or
100
+ // incoherently bound data never becomes successful partial/missing data.
101
+ const parsed = documentEntries(outcome.value, targets, batch, options);
102
+ for (const path of selected)
103
+ entries.set(path, parsed.entries.get(path) ?? { status: "missing", path });
104
+ batchDiagnostics[batch] = [...diagnostics, ...outcome.diagnostics];
105
+ if (parsed.errors.length)
106
+ errors.push({
107
+ batch, paths: parsed.errors.map(error => error.path),
108
+ failure: connectFailure(connectProblem("operation_invalid", "Some batch records could not be read.", { details: { diagnostics: parsed.errors } }))
109
+ });
110
+ }
111
+ catch (error) {
112
+ if (!(error instanceof MdbaseConnectError))
113
+ throw error;
114
+ const failure = await captureConnectOutcome(async () => { throw error; }, COLLECTION_QUERY_PROBLEM_CODES);
115
+ failBatch(batch, selected, failure);
116
+ }
117
+ }
118
+ };
119
+ await Promise.all(Array.from({ length: Math.min(concurrency, unique.length) }, worker));
120
+ return connectSuccess({ results: inputs.map(path => entries.get(path)), errors: errors.sort((a, b) => a.batch - b.batch) }, batchDiagnostics.flat());
121
+ });
122
+ }
123
+ catch (error) {
124
+ if (error instanceof MdbaseConnectError)
125
+ return captureConnectOutcome(async () => { throw error; }, COLLECTION_QUERY_PROBLEM_CODES);
126
+ throw error;
127
+ }
128
+ }
129
+ function documentEntries(value, paths, batch, options) {
130
+ if (!value || !Array.isArray(value.items) || value.items.length !== paths.length)
131
+ invalidResponse();
132
+ const entries = new Map();
133
+ const errors = [];
134
+ value.items.forEach((item, index) => {
135
+ if (!item || item.path !== paths[index])
136
+ invalidResponse();
137
+ switch (item.status) {
138
+ case "found":
139
+ entries.set(item.path, { status: "found", path: item.path, record: documentRecord(item.record, item.path, options) });
140
+ break;
141
+ case "missing":
142
+ entries.set(item.path, { status: "missing", path: item.path });
143
+ break;
144
+ case "error":
145
+ if (!item.error || typeof item.error.code !== "string" || typeof item.error.message !== "string")
146
+ invalidResponse();
147
+ entries.set(item.path, { status: "error", path: item.path, batch });
148
+ errors.push({ severity: "error", code: item.error.code, message: item.error.message, path: item.path });
149
+ break;
150
+ default: invalidResponse();
151
+ }
152
+ });
153
+ return { entries, errors };
154
+ }
155
+ function documentRecord(record, path, options) {
156
+ if (!record || record.path !== path || typeof record.revision !== "string" || !record.revision
157
+ || !Array.isArray(record.types) || record.types.some(type => typeof type !== "string")
158
+ || !object(record.frontmatter) || !object(record.effective_frontmatter) || !object(record.file)
159
+ || ((options.includeBody ?? false) && typeof record.body !== "string") || record.contract !== undefined)
160
+ invalidResponse();
161
+ const mode = options.frontmatterMode ?? "effective";
162
+ return {
163
+ path: record.path, revision: record.revision, types: record.types, file: record.file,
164
+ ...(mode !== "effective" ? { frontmatter: record.frontmatter } : {}),
165
+ ...(mode !== "persisted" ? { effectiveFrontmatter: record.effective_frontmatter } : {}),
166
+ ...(options.includeBody ? { body: record.body } : {})
167
+ };
168
+ }
169
+ function object(value) { return !!value && typeof value === "object" && !Array.isArray(value); }
170
+ function invalidResponse() { throw connectError("invalid_operation_response", "The authority returned an invalid or incoherently bound document batch."); }
171
+ function boundedInteger(value, max, name) {
172
+ if (!Number.isSafeInteger(value) || value < 1 || value > max)
173
+ throw new TypeError(`${name} must be an integer between 1 and ${max}.`);
174
+ return value;
175
+ }
@@ -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;