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

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.
@@ -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
+ }
@@ -1,6 +1,7 @@
1
- import type { JsonObject } from "@mdbase-dev/connect-protocol";
1
+ import { type JsonObject, type ReadManyDocumentsResult } from "@mdbase-dev/connect-protocol";
2
2
  import { type CollectionQueryProblemCode, type ConnectOutcome } from "./outcomes.js";
3
- import type { QueryAllOptions, QueryInput, QueryResult, ReadManyOptions, ReadManyResult } from "./operation-types.js";
3
+ import type { ConnectRequestOptions, QueryAllOptions, QueryInput, QueryResult, ReadManyOptions, ReadManyResult } from "./operation-types.js";
4
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>>;
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>>;
6
7
  export {};
package/dist/read-many.js CHANGED
@@ -1,70 +1,175 @@
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");
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");
6
7
  const concurrency = boundedInteger(options.concurrency ?? 4, 4, "concurrency");
7
- if (options.coordination?.latestWins) {
8
+ if (options.coordination?.latestWins)
8
9
  throw new TypeError("readMany batches cannot use latestWins coordination.");
9
- }
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.");
10
14
  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 });
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 });
35
28
  for (const path of selected)
36
29
  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());
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
+ });
55
122
  }
56
123
  catch (error) {
57
124
  if (error instanceof MdbaseConnectError)
58
- return connectFailure(error.problem);
125
+ return captureConnectOutcome(async () => { throw error; }, COLLECTION_QUERY_PROBLEM_CODES);
59
126
  throw error;
60
127
  }
61
- finally {
62
- budget.dispose();
63
- }
64
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."); }
65
171
  function boundedInteger(value, max, name) {
66
- if (!Number.isSafeInteger(value) || value < 1 || value > max) {
172
+ if (!Number.isSafeInteger(value) || value < 1 || value > max)
67
173
  throw new TypeError(`${name} must be an integer between 1 and ${max}.`);
68
- }
69
174
  return value;
70
175
  }
@@ -1,4 +1,5 @@
1
- import type { WatchOptions } from "./operation-types.js";
1
+ import type { CollectionChange, MdbaseWatchSubscription, WatchInput, WatchOptions, WatchStatus } from "./operation-types.js";
2
+ import type { ConnectOutcome } from "./outcomes.js";
2
3
  export interface ResolvedWatchRetryOptions {
3
4
  initialDelayMs: number;
4
5
  maxDelayMs: number;
@@ -6,3 +7,23 @@ export interface ResolvedWatchRetryOptions {
6
7
  maxAttempts?: number;
7
8
  }
8
9
  export declare function watchRetryPolicy(options: WatchOptions["retry"]): ResolvedWatchRetryOptions | undefined;
10
+ export declare class CollectionWatchSubscription implements MdbaseWatchSubscription {
11
+ private readonly watch;
12
+ private readonly input;
13
+ private readonly watchStartTimeoutMs;
14
+ private readonly changes;
15
+ private readonly statuses;
16
+ private readonly problems;
17
+ private readonly controller;
18
+ private removeLifetimeAbort?;
19
+ private currentStatus;
20
+ private currentProblem;
21
+ private pendingChanges;
22
+ constructor(watch: (options: WatchOptions) => AsyncIterable<ConnectOutcome<CollectionChange>>, cursor: number, input: WatchInput, pendingChanges: CollectionChange[], watchStartTimeoutMs: number | null);
23
+ get status(): WatchStatus;
24
+ get problem(): import("@mdbase-dev/connect-protocol").ConnectProblem | null;
25
+ subscribe(listener: (change: CollectionChange) => void, onStatus?: (status: WatchStatus) => void, onProblem?: (problem: import("@mdbase-dev/connect-protocol").ConnectProblem) => void): () => void;
26
+ close(): void;
27
+ private run;
28
+ private publishStatus;
29
+ }
@@ -10,3 +10,107 @@ export function watchRetryPolicy(options) {
10
10
  : { maxAttempts: Math.max(0, options.maxAttempts) })
11
11
  };
12
12
  }
13
+ export class CollectionWatchSubscription {
14
+ watch;
15
+ input;
16
+ watchStartTimeoutMs;
17
+ changes = new Set();
18
+ statuses = new Set();
19
+ problems = new Set();
20
+ controller = new AbortController();
21
+ removeLifetimeAbort;
22
+ currentStatus;
23
+ currentProblem = null;
24
+ pendingChanges;
25
+ constructor(watch, cursor, input, pendingChanges, watchStartTimeoutMs) {
26
+ this.watch = watch;
27
+ this.input = input;
28
+ this.watchStartTimeoutMs = watchStartTimeoutMs;
29
+ this.pendingChanges = [...pendingChanges];
30
+ this.currentStatus = { state: "connected", cursor, recovered: false };
31
+ const lifetimeSignal = input.lifetimeSignal;
32
+ if (lifetimeSignal?.aborted)
33
+ this.close();
34
+ else if (lifetimeSignal) {
35
+ const close = () => this.close();
36
+ lifetimeSignal.addEventListener("abort", close, { once: true });
37
+ this.removeLifetimeAbort = () => lifetimeSignal.removeEventListener("abort", close);
38
+ }
39
+ if (!this.controller.signal.aborted)
40
+ void this.run(cursor);
41
+ }
42
+ get status() { return this.currentStatus; }
43
+ get problem() { return this.currentProblem; }
44
+ subscribe(listener, onStatus, onProblem) {
45
+ this.changes.add(listener);
46
+ if (onStatus) {
47
+ this.statuses.add(onStatus);
48
+ onStatus(this.currentStatus);
49
+ }
50
+ if (onProblem) {
51
+ this.problems.add(onProblem);
52
+ if (this.currentProblem)
53
+ onProblem(this.currentProblem);
54
+ }
55
+ for (const change of this.pendingChanges)
56
+ listener(change);
57
+ this.pendingChanges = [];
58
+ return () => {
59
+ this.changes.delete(listener);
60
+ if (onStatus)
61
+ this.statuses.delete(onStatus);
62
+ if (onProblem)
63
+ this.problems.delete(onProblem);
64
+ };
65
+ }
66
+ close() {
67
+ if (this.controller.signal.aborted)
68
+ return;
69
+ this.controller.abort();
70
+ this.removeLifetimeAbort?.();
71
+ this.removeLifetimeAbort = undefined;
72
+ const cursor = "cursor" in this.currentStatus ? this.currentStatus.cursor : undefined;
73
+ this.publishStatus({ state: "closed", ...(cursor === undefined ? {} : { cursor }) });
74
+ }
75
+ async run(cursor) {
76
+ let firstStatus = true;
77
+ const iterator = this.watch({
78
+ cursor,
79
+ pollIntervalMs: this.input.pollIntervalMs,
80
+ retry: this.input.retry,
81
+ signal: this.controller.signal,
82
+ timeoutMs: this.watchStartTimeoutMs,
83
+ onStatus: (status) => {
84
+ if (firstStatus && status.state === "connecting") {
85
+ firstStatus = false;
86
+ return;
87
+ }
88
+ firstStatus = false;
89
+ this.publishStatus(status);
90
+ }
91
+ });
92
+ try {
93
+ for await (const outcome of iterator) {
94
+ if (this.controller.signal.aborted)
95
+ return;
96
+ if (!outcome.ok) {
97
+ this.currentProblem = outcome.problem;
98
+ for (const listener of this.problems)
99
+ listener(outcome.problem);
100
+ return;
101
+ }
102
+ for (const listener of this.changes)
103
+ listener(outcome.value);
104
+ }
105
+ }
106
+ finally {
107
+ if (!this.controller.signal.aborted)
108
+ this.close();
109
+ }
110
+ }
111
+ publishStatus(status) {
112
+ this.currentStatus = status;
113
+ for (const listener of this.statuses)
114
+ listener(status);
115
+ }
116
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mdbase-dev/connect",
3
- "version": "0.1.0-beta.123",
3
+ "version": "0.1.0-beta.125",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  "README.md"
34
34
  ],
35
35
  "dependencies": {
36
- "@mdbase-dev/connect-protocol": "0.1.0-beta.123"
36
+ "@mdbase-dev/connect-protocol": "0.1.0-beta.125"
37
37
  },
38
38
  "devDependencies": {
39
39
  "esbuild": "^0.28.1",
@@ -42,7 +42,7 @@
42
42
  },
43
43
  "scripts": {
44
44
  "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && node scripts/build-browser.mjs",
45
- "test": "vitest run",
45
+ "test": "node ../../scripts/ci/vitest.mjs",
46
46
  "test:browser-storage": "pnpm build && node scripts/browser-storage.test.mjs",
47
47
  "test:consumer-spikes": "tsc -p tsconfig.consumer-spikes.json",
48
48
  "test:public-api": "pnpm build && pnpm --filter @mdbase-dev/connect-protocol build && pnpm --filter @mdbase-dev/connect-testing build && tsc -p tsconfig.public-api.json && node scripts/check-public-api.mjs && node scripts/test-packed-api.mjs",