@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.
- package/README.md +161 -6
- package/dist/application-session.js +51 -27
- package/dist/authority-features.d.ts +29 -0
- package/dist/authority-features.js +84 -0
- package/dist/browser/integrity.json +1 -1
- package/dist/browser/mdbase-connect.min.js +4 -4
- package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
- package/dist/change-events.d.ts +6 -0
- package/dist/change-events.js +95 -0
- package/dist/collection-client.d.ts +13 -3
- package/dist/collection-client.js +98 -140
- package/dist/connection.d.ts +12 -2
- package/dist/connection.js +57 -117
- package/dist/description-cache.d.ts +14 -0
- package/dist/description-cache.js +46 -0
- package/dist/file-crypto.js +10 -6
- package/dist/file-descriptor.d.ts +14 -0
- package/dist/file-descriptor.js +29 -0
- package/dist/file-metadata.d.ts +21 -0
- package/dist/file-metadata.js +112 -0
- package/dist/file-sha256.js +3 -3
- package/dist/files.d.ts +9 -19
- package/dist/files.js +49 -75
- package/dist/index.d.ts +5 -2
- package/dist/index.js +2 -0
- package/dist/local-file-transport.js +18 -16
- package/dist/operation-types.d.ts +155 -14
- package/dist/query-pagination-internals.d.ts +9 -0
- package/dist/query-pagination-internals.js +52 -0
- package/dist/query-pagination.d.ts +7 -3
- package/dist/query-pagination.js +31 -27
- package/dist/query-predicates.d.ts +11 -0
- package/dist/query-predicates.js +15 -0
- package/dist/query-wire.d.ts +61 -0
- package/dist/query-wire.js +110 -0
- package/dist/read-many.d.ts +7 -0
- package/dist/read-many.js +175 -0
- package/dist/record-session.d.ts +3 -2
- package/dist/record-session.js +11 -2
- package/dist/records.d.ts +15 -2
- package/dist/records.js +109 -7
- package/dist/saved-view-pagination.js +20 -20
- package/dist/startup-timing.d.ts +2 -0
- package/dist/startup-timing.js +20 -0
- package/dist/structured-readiness.d.ts +3 -1
- package/dist/structured-readiness.js +21 -1
- package/dist/watch-policy.d.ts +22 -1
- package/dist/watch-policy.js +104 -0
- package/package.json +2 -2
package/dist/query-pagination.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
29
|
-
|
|
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
|
-
|
|
83
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/record-session.d.ts
CHANGED
|
@@ -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`:
|
|
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
|
|
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;
|
package/dist/record-session.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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;
|