@mdbase-dev/connect 0.1.0-beta.126 → 0.1.0-beta.148

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/dist/application-session.d.ts +1 -1
  2. package/dist/application-session.js +1 -1
  3. package/dist/async.d.ts +6 -0
  4. package/dist/async.js +39 -0
  5. package/dist/authorization-refresh.d.ts +2 -0
  6. package/dist/authorization-refresh.js +13 -1
  7. package/dist/browser/integrity.json +1 -1
  8. package/dist/browser/mdbase-connect.min.js +4 -4
  9. package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
  10. package/dist/capabilities.d.ts +1 -1
  11. package/dist/collection-client.d.ts +2 -0
  12. package/dist/collection-client.js +8 -4
  13. package/dist/connect-options.d.ts +1 -1
  14. package/dist/connect.d.ts +1 -1
  15. package/dist/connect.js +1 -1
  16. package/dist/connection.d.ts +2 -1
  17. package/dist/connection.js +3 -1
  18. package/dist/control-timers.d.ts +43 -0
  19. package/dist/control-timers.js +290 -0
  20. package/dist/errors.d.ts +2 -28
  21. package/dist/errors.js +2 -52
  22. package/dist/file-transfer-internals.d.ts +2 -1
  23. package/dist/file-transfer-internals.js +10 -3
  24. package/dist/files.d.ts +4 -0
  25. package/dist/files.js +17 -18
  26. package/dist/index.d.ts +2 -1
  27. package/dist/index.js +1 -0
  28. package/dist/internal-types.d.ts +2 -2
  29. package/dist/internal-types.js +1 -1
  30. package/dist/mdbase-connect.d.ts +1 -1
  31. package/dist/observe.d.ts +81 -0
  32. package/dist/observe.js +390 -0
  33. package/dist/operation-types.d.ts +3 -1
  34. package/dist/outcomes.d.ts +13 -13
  35. package/dist/outcomes.js +17 -0
  36. package/dist/query-pagination-internals.d.ts +1 -1
  37. package/dist/query-pagination-internals.js +4 -2
  38. package/dist/query-pagination.js +1 -1
  39. package/dist/query-wire.d.ts +16 -10
  40. package/dist/query-wire.js +44 -13
  41. package/dist/read-many.js +4 -2
  42. package/dist/runtime-utils.d.ts +1 -1
  43. package/dist/saved-view-pagination.js +1 -1
  44. package/dist/session.d.ts +1 -1
  45. package/dist/session.js +1 -1
  46. package/dist/structured-readiness.d.ts +1 -1
  47. package/package.json +6 -2
  48. package/dist/application-contract.d.ts +0 -22
  49. package/dist/application-contract.js +0 -75
package/dist/files.d.ts CHANGED
@@ -39,6 +39,10 @@ export interface MdbaseFileUploadOptions extends ConnectRequestOptions {
39
39
  concurrency?: number;
40
40
  /** Stable retry key. Reuse it after an ambiguous failure to resume or replay commit. */
41
41
  transferId?: string;
42
+ /** Attempts per upload part/commit, including the first; integer 1..10. Omitted preserves existing defaults (3 object/commit, 10 framed). */
43
+ maxAttempts?: number;
44
+ /** Leave an incomplete transfer for caller-owned recovery instead of automatically aborting it. Prefer an explicit transferId for tracking. */
45
+ noAutoAbort?: boolean;
42
46
  onProgress?: (progress: MdbaseFileProgress) => void;
43
47
  }
44
48
  export type MdbaseFileStreamUploadOptions = Omit<MdbaseFileUploadOptions, "concurrency">;
package/dist/files.js CHANGED
@@ -5,7 +5,7 @@ import { connectSuccess } from "./outcomes.js";
5
5
  import { MdbaseConnectError, connectError } from "./errors.js";
6
6
  import { IncrementalSha256 } from "./file-sha256.js";
7
7
  import { BinaryPartReader } from "./file-stream-source.js";
8
- import { MAX_BUFFERED_DOWNLOAD_BYTES, MAX_OBJECT_ATTEMPTS, SHA256_DIGEST, UUID, browserObjectHeaders, chunkLength, hashBlob, mapConcurrent, normalizeFileError, requirePreparedPart, requireTransferSession, requireTransferStatus, retryChunk, sourceBlob, throwIfAborted, uploadBodyBytes, uploadBodyLength, validConcurrency } from "./file-transfer-internals.js";
8
+ import { MAX_BUFFERED_DOWNLOAD_BYTES, MAX_OBJECT_ATTEMPTS, SHA256_DIGEST, UUID, browserObjectHeaders, chunkLength, hashBlob, mapConcurrent, normalizeFileError, requirePreparedPart, requireTransferSession, requireTransferStatus, retryChunk, validUploadAttempts, sourceBlob, throwIfAborted, uploadBodyBytes, uploadBodyLength, validConcurrency } from "./file-transfer-internals.js";
9
9
  import { resolveConnectTimeouts, withCooperativeRequestBudget } from "./request-budget.js";
10
10
  /** Ergonomic facade over resumable, authority-specific file delivery. */
11
11
  export class MdbaseFileClient {
@@ -67,6 +67,7 @@ export class MdbaseFileClient {
67
67
  if (!SHA256_DIGEST.test(source.contentDigest)) {
68
68
  throw connectError("invalid_request", "Streamed files require a lowercase SHA-256 content digest.");
69
69
  }
70
+ validUploadAttempts(options.maxAttempts, options.noAutoAbort);
70
71
  const reader = new BinaryPartReader(source.stream, options.signal);
71
72
  const hash = new IncrementalSha256();
72
73
  return this.uploadKnownSource(path, source.size, source.contentDigest, options.mediaType ?? source.mediaType, options, 1, async (_partIndex, _offset, length) => {
@@ -81,6 +82,7 @@ export class MdbaseFileClient {
81
82
  }, () => reader.close(), (partSize) => reader.setMaxSourceChunkBytes(partSize));
82
83
  }
83
84
  async uploadKnownSource(path, size, contentDigest, mediaType, options, concurrency, readPart, finishSource, closeSource, prepareSource) {
85
+ const maxAttempts = validUploadAttempts(options.maxAttempts, options.noAutoAbort);
84
86
  const transferId = options.transferId ?? crypto.randomUUID();
85
87
  if (!UUID.test(transferId)) {
86
88
  throw connectError("invalid_request", "File transfer ID must be a UUID.");
@@ -135,7 +137,7 @@ export class MdbaseFileClient {
135
137
  }
136
138
  prepareSource?.(partSize);
137
139
  if (!framed && status?.received.length === partCount) {
138
- const replay = await this.tryReplayUploadCommit(transferId, session.strategy.kind === "object_multipart" ? status.uploaded_parts : undefined, options.signal);
140
+ const replay = await this.tryReplayUploadCommit(transferId, session.strategy.kind === "object_multipart" ? status.uploaded_parts : undefined, options.signal, maxAttempts);
139
141
  if (replay) {
140
142
  committed = true;
141
143
  return clientFileDescriptor(replay.file);
@@ -154,8 +156,8 @@ export class MdbaseFileClient {
154
156
  if (received.has(partIndex))
155
157
  return uploadedParts.get(partIndex) ?? null;
156
158
  const uploaded = framed
157
- ? await retryChunk(async () => this.framed.uploadChunk(session, partIndex, await uploadBodyBytes(part), options.signal), options.signal).then(() => null)
158
- : await this.uploadPart(transferId, partIndex, offset, part, length, session.strategy.kind === "object_multipart", options.signal, bootstrap ? { session, part: bootstrap } : undefined);
159
+ ? await retryChunk(async () => this.framed.uploadChunk(session, partIndex, await uploadBodyBytes(part), options.signal), options.signal, maxAttempts).then(() => null)
160
+ : await this.uploadPart(transferId, partIndex, offset, part, length, session.strategy.kind === "object_multipart", options.signal, bootstrap ? { session, part: bootstrap } : undefined, maxAttempts);
159
161
  transferredBytes += length;
160
162
  options.onProgress?.({
161
163
  phase: "uploading",
@@ -167,7 +169,7 @@ export class MdbaseFileClient {
167
169
  await finishSource?.();
168
170
  const receipt = await this.commitUpload(transferId, session.strategy.kind === "object_multipart"
169
171
  ? parts.filter((part) => part !== null)
170
- : undefined, options.signal);
172
+ : undefined, options.signal, maxAttempts);
171
173
  if (receipt.protocol_version !== 1
172
174
  || receipt.type !== "file_upload_committed"
173
175
  || receipt.transfer_id !== transferId
@@ -184,7 +186,7 @@ export class MdbaseFileClient {
184
186
  }
185
187
  finally {
186
188
  await closeSource?.().catch(() => undefined);
187
- if (!committed && options.transferId === undefined)
189
+ if (!committed && options.transferId === undefined && !options.noAutoAbort)
188
190
  await this.abort(transferId);
189
191
  }
190
192
  }
@@ -473,8 +475,8 @@ export class MdbaseFileClient {
473
475
  revision: receipt.revision
474
476
  };
475
477
  }
476
- async uploadPart(transferId, partIndex, offset, body, contentLength, requireEtag, signal, bootstrap) {
477
- for (let attempt = 1; attempt <= MAX_OBJECT_ATTEMPTS; attempt += 1) {
478
+ async uploadPart(transferId, partIndex, offset, body, contentLength, requireEtag, signal, bootstrap, maxAttempts = MAX_OBJECT_ATTEMPTS) {
479
+ for (let attempt = 1;; attempt += 1) {
478
480
  throwIfAborted(signal);
479
481
  try {
480
482
  const reuseBootstrap = attempt === 1 && bootstrap
@@ -524,15 +526,14 @@ export class MdbaseFileClient {
524
526
  catch (error) {
525
527
  if (signal?.aborted)
526
528
  throw error;
527
- if (attempt === MAX_OBJECT_ATTEMPTS)
529
+ if (attempt === maxAttempts)
528
530
  throw error;
529
531
  }
530
532
  }
531
- throw new Error("Unreachable upload retry state.");
532
533
  }
533
- async tryReplayUploadCommit(transferId, parts, signal) {
534
+ async tryReplayUploadCommit(transferId, parts, signal, maxAttempts) {
534
535
  try {
535
- return await this.commitUpload(transferId, parts, signal);
536
+ return await this.commitUpload(transferId, parts, signal, maxAttempts);
536
537
  }
537
538
  catch (error) {
538
539
  if (error instanceof MdbaseConnectError && error.code === "file_upload_incomplete") {
@@ -541,9 +542,9 @@ export class MdbaseFileClient {
541
542
  throw error;
542
543
  }
543
544
  }
544
- async commitUpload(transferId, parts, signal) {
545
- let lastError;
546
- for (let attempt = 1; attempt <= MAX_OBJECT_ATTEMPTS; attempt += 1) {
545
+ async commitUpload(transferId, parts, signal, maxAttempts = MAX_OBJECT_ATTEMPTS) {
546
+ // maxAttempts is validated before upload IO; the final failure always throws.
547
+ for (let attempt = 1;; attempt += 1) {
547
548
  throwIfAborted(signal);
548
549
  try {
549
550
  return await this.request("POST", `uploads/${encodeURIComponent(transferId)}/commit`, {
@@ -554,13 +555,11 @@ export class MdbaseFileClient {
554
555
  }, signal);
555
556
  }
556
557
  catch (error) {
557
- lastError = error;
558
- if (signal?.aborted || error instanceof MdbaseConnectError || attempt === MAX_OBJECT_ATTEMPTS) {
558
+ if (signal?.aborted || error instanceof MdbaseConnectError || attempt === maxAttempts) {
559
559
  throw error;
560
560
  }
561
561
  }
562
562
  }
563
- throw lastError;
564
563
  }
565
564
  requireAction(action) {
566
565
  if (!this.capability()?.actions.includes(action)) {
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { MdbaseQueryObserver, type ObserveOptions, type ObserveSnapshot, type ObserveDelta, type ObserveOverlay } from "./observe.js";
1
2
  export { normalizeCollectionChange } from "./change-events.js";
2
3
  export { MdbaseApplicationSession, MdbaseMemoryVerificationStore } from "./application-session.js";
3
4
  export type { MdbaseApplicationSessionOptions, MdbaseApplicationVerificationStore, MdbaseDefinitionUpdate, MdbaseCollectionSetupUpdate, MdbaseApplicationSessionSnapshot } from "./application-session.js";
@@ -32,5 +33,5 @@ export { MdbaseBrowserSelection, MdbaseMemorySelection } from "./selection.js";
32
33
  export type { MdbaseSelectionHistory, MdbaseApplicationSelection, MdbaseBrowserSelectionOptions } from "./selection.js";
33
34
  export type { MdbaseUnavailableReason } from "./session.js";
34
35
  export { MDBASE_RECORD_CREATED_CONTRACT, MDBASE_TIMER_FIRED_CONTRACT, operationsForApplicationCapabilities } from "@mdbase-dev/connect-protocol";
35
- export type { MdbaseApplicationManifest, MdbaseApplicationManifest as MdbaseAppManifest, MdbaseApplicationRequirements, MdbaseApplicationCapabilityId as ApplicationCapabilityId, MdbaseApplicationCapabilityRequirements as ApplicationCapabilityRequirements } from "./application-contract.js";
36
+ export type { MdbaseApplicationManifest, MdbaseApplicationManifest as MdbaseAppManifest, MdbaseApplicationRequirements, MdbaseApplicationCapabilityId as ApplicationCapabilityId, MdbaseApplicationCapabilityRequirements as ApplicationCapabilityRequirements } from "@mdbase-dev/connect-protocol";
36
37
  export type { ApplicationProvisions, ApplicationRequirements, LegacyApplicationRequirements, LegacyApplicationCapabilityId, LegacyApplicationCapabilityRequirements, LegacyMdbaseAppManifest, ApplicationNotifications, ApplicationAuthorizationBinding, ApplicationAuthorizationProof, FileAction, FileCapability, CollectionOperation as MdbaseOperation, CollectionTypeDocument, ConnectOperationOutcome, ConnectProblem, ConnectProblemCategory, ConnectProblemCode, ConnectProblemDetailsByCode, ConnectRecoveryAction, ContractRequirement, GrantScope, JsonObject, NotificationCriterion, MdbaseDiagnostic, MdbaseOperationEnvelope, KnownConnectProblem, TypePackManifest, TypePackManifestResource, TypePackProvision, TypePackSourceResource, UnknownConnectProblem } from "@mdbase-dev/connect-protocol";
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { MdbaseQueryObserver } from "./observe.js";
1
2
  export { normalizeCollectionChange } from "./change-events.js";
2
3
  export { MdbaseApplicationSession, MdbaseMemoryVerificationStore } from "./application-session.js";
3
4
  export { MdbaseConnect } from "./mdbase-connect.js";
@@ -1,5 +1,5 @@
1
1
  import type { ApplicationNotifications, ApplicationProvisions, CollectionOperation, EncryptedRelayOperationRequest, GrantEncryption, FileCapability, GrantScope, MdbaseOperationRequest, MutationOperationIdentifier } from "@mdbase-dev/connect-protocol";
2
- import type { MdbaseApplicationRequirements as ApplicationRequirements } from "./application-contract.js";
2
+ import type { MdbaseApplicationRequirements as ApplicationRequirements } from "@mdbase-dev/connect-protocol";
3
3
  import { encryptRelayRequest } from "./crypto.js";
4
4
  export interface Application {
5
5
  id: string;
@@ -99,4 +99,4 @@ export interface OperationAttempt {
99
99
  pendingMutationRecord?: PendingMutation;
100
100
  resumingMutation?: boolean;
101
101
  }
102
- export { DEFAULT_OPERATIONS } from "./application-contract.js";
102
+ export { DEFAULT_OPERATIONS } from "@mdbase-dev/connect-protocol";
@@ -1 +1 @@
1
- export { DEFAULT_OPERATIONS } from "./application-contract.js";
1
+ export { DEFAULT_OPERATIONS } from "@mdbase-dev/connect-protocol";
@@ -14,7 +14,7 @@ export declare class MdbaseConnect<Frontmatter extends JsonObject = JsonObject>
14
14
  authorize(options?: MdbaseAuthorizeOptions): Promise<ConnectOutcome<MdbaseAuthorizationOutcome<Frontmatter>, AuthorizationProblemCode>>;
15
15
  /** The ordinary application lifecycle entry point. */
16
16
  application(options: MdbaseApplicationSessionOptions): MdbaseApplicationSession<Frontmatter>;
17
- manifest(options?: ConnectRequestOptions): Promise<ConnectOutcome<import("./application-contract.js").MdbaseApplicationManifest, RegistrationProblemCode>>;
17
+ manifest(options?: ConnectRequestOptions): Promise<ConnectOutcome<import("@mdbase-dev/connect-protocol").MdbaseApplicationManifest, RegistrationProblemCode>>;
18
18
  environment(): MdbaseConnectEnvironment;
19
19
  completeAuthorization(callbackUrl?: string | URL, options?: ConnectRequestOptions): Promise<ConnectOutcome<MdbaseAuthorizationResult<Frontmatter>, AuthorizationProblemCode>>;
20
20
  connections(): MdbaseConnectionInfo[];
@@ -0,0 +1,81 @@
1
+ import type { ConnectProblem, JsonObject } from "@mdbase-dev/connect-protocol";
2
+ import { type ConnectOutcome } from "./outcomes.js";
3
+ import type { CollectionChange, CollectionChangesPage, ChangesInput, ConnectRequestOptions, QueryInput, QueryRecord, QueryPage, QueryPagesOptions, QueryMetadataInput, QueryMetadataRecord, QueryMetadataResult, QueryAllOptions, QueryResult, ReadManyOptions, ReadManyResult, WatchOptions, WatchStatus } from "./operation-types.js";
4
+ interface ObserveSource<F extends JsonObject> {
5
+ changes(input?: ChangesInput, options?: ConnectRequestOptions): Promise<ConnectOutcome<CollectionChangesPage>>;
6
+ watch(options?: WatchOptions): AsyncIterable<ConnectOutcome<CollectionChange>>;
7
+ readMany(paths: readonly string[], options?: ReadManyOptions): Promise<ConnectOutcome<ReadManyResult<F>>>;
8
+ queryPages(input: QueryInput, options?: QueryPagesOptions<F>): AsyncIterable<ConnectOutcome<QueryPage<F>>>;
9
+ queryAll(input: QueryMetadataInput, options?: QueryAllOptions<JsonObject, QueryMetadataRecord>): Promise<ConnectOutcome<QueryMetadataResult>>;
10
+ queryAll(input: QueryInput, options?: QueryAllOptions<F>): Promise<ConnectOutcome<QueryResult<F>>>;
11
+ }
12
+ export interface ObserveOptions {
13
+ mode?: "watch" | "manual";
14
+ /** Opt into targeted membership checks for predicates whose dependencies are path-local. */
15
+ invalidation?: "paths" | "collection";
16
+ signal?: AbortSignal;
17
+ firstPageSize?: number;
18
+ pageSize?: number;
19
+ coalesceMs?: number;
20
+ /** Backlog above this bound causes a full reconciliation, not dropped paths. */
21
+ maxPendingPaths?: number;
22
+ watch?: Omit<WatchOptions, "cursor" | "signal" | "onStatus">;
23
+ }
24
+ export interface ObserveSnapshot<F extends JsonObject = JsonObject> {
25
+ readonly records: readonly QueryRecord<F>[];
26
+ readonly state: "loading" | "ready" | "error" | "closed";
27
+ readonly generation: number;
28
+ readonly total?: number;
29
+ readonly problem: ConnectProblem | null;
30
+ readonly watchStatus: WatchStatus | null;
31
+ }
32
+ export interface ObserveDelta<F extends JsonObject = JsonObject> {
33
+ readonly reason: "page" | "reload" | "changes" | "local" | "status";
34
+ readonly upserts: readonly QueryRecord<F>[];
35
+ readonly removed: readonly string[];
36
+ }
37
+ export interface ObserveOverlay {
38
+ /** Call only after the write succeeds; rereads retire this overlay, not newer ones. */
39
+ commit(): void;
40
+ rollback(): void;
41
+ }
42
+ /** Collection-owned query synchronization. No drafts, domain indexes or persistence. */
43
+ export declare class MdbaseQueryObserver<F extends JsonObject = JsonObject> {
44
+ private readonly client;
45
+ private readonly query;
46
+ private readonly options;
47
+ private readonly supports?;
48
+ private records;
49
+ private overlays;
50
+ private pending;
51
+ private listeners;
52
+ private changes;
53
+ private lifetime;
54
+ private request;
55
+ private timer?;
56
+ private draining;
57
+ private removeAbort?;
58
+ private snapshot;
59
+ private settleReady;
60
+ readonly ready: Promise<ConnectOutcome<void>>;
61
+ constructor(client: ObserveSource<F>, query: QueryInput, options?: ObserveOptions, supports?: ((id: string, options?: ConnectRequestOptions) => Promise<ConnectOutcome<boolean>>) | undefined);
62
+ getSnapshot: () => ObserveSnapshot<F>;
63
+ subscribe: (listener: (snapshot: ObserveSnapshot<F>, delta: ObserveDelta<F>) => void) => (() => void);
64
+ subscribeChanges(listener: (change: CollectionChange) => void): () => void;
65
+ /** Upgrade this observer to body-bearing rows, retaining one synchronization owner. */
66
+ hydrate(): Promise<ConnectOutcome<void>>;
67
+ refresh(): Promise<ConnectOutcome<void>>;
68
+ private load;
69
+ /** Overlay caller-supplied query rows; no authority write is performed. */
70
+ optimistic(upserts?: readonly QueryRecord<F>[], removed?: readonly string[]): ObserveOverlay;
71
+ close(): void;
72
+ private fullReloadRequired;
73
+ private follow;
74
+ private schedule;
75
+ private drain;
76
+ private reread;
77
+ private retire;
78
+ private fail;
79
+ private publish;
80
+ }
81
+ export {};
@@ -0,0 +1,390 @@
1
+ import { MdbaseConnectError, connectProblem, connectError } from "./errors.js";
2
+ import { connectFailure, connectSuccess } from "./outcomes.js";
3
+ /** Collection-owned query synchronization. No drafts, domain indexes or persistence. */
4
+ export class MdbaseQueryObserver {
5
+ client;
6
+ query;
7
+ options;
8
+ supports;
9
+ records = new Map();
10
+ overlays = new Map();
11
+ pending = new Set();
12
+ listeners = new Set();
13
+ changes = new Set();
14
+ lifetime = new AbortController();
15
+ request = new AbortController();
16
+ timer;
17
+ draining = false;
18
+ removeAbort;
19
+ snapshot = Object.freeze({ records: Object.freeze([]), state: "loading", generation: 0, problem: null, watchStatus: null });
20
+ settleReady;
21
+ ready = new Promise(resolve => { this.settleReady = resolve; });
22
+ constructor(client, query, options = {}, supports) {
23
+ this.client = client;
24
+ this.query = query;
25
+ this.options = options;
26
+ this.supports = supports;
27
+ for (const [name, value] of [["pageSize", options.pageSize], ["firstPageSize", options.firstPageSize], ["maxPendingPaths", options.maxPendingPaths]]) {
28
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 1))
29
+ throw new TypeError(`${name} must be a positive integer.`);
30
+ }
31
+ if (options.coalesceMs !== undefined && (!Number.isFinite(options.coalesceMs) || options.coalesceMs < 0))
32
+ throw new TypeError("coalesceMs must be nonnegative.");
33
+ if (query.output !== undefined || query.cursor || query.snapshot || query.groupBy || query.summaries || query.summaryFunctions)
34
+ throw new TypeError("observe requires a record query, not a continuation or aggregate.");
35
+ // Copy caller-owned criteria; changing a query means opening a new observer.
36
+ this.query = structuredClone(query);
37
+ if (options.signal?.aborted)
38
+ this.close();
39
+ else if (options.signal) {
40
+ const close = () => this.close();
41
+ options.signal.addEventListener("abort", close, { once: true });
42
+ this.removeAbort = () => options.signal?.removeEventListener("abort", close);
43
+ }
44
+ void this.refresh();
45
+ }
46
+ getSnapshot = () => this.snapshot;
47
+ subscribe = (listener) => {
48
+ this.listeners.add(listener);
49
+ return () => { this.listeners.delete(listener); };
50
+ };
51
+ subscribeChanges(listener) {
52
+ this.changes.add(listener);
53
+ return () => { this.changes.delete(listener); };
54
+ }
55
+ /** Upgrade this observer to body-bearing rows, retaining one synchronization owner. */
56
+ hydrate() { this.query.includeBody = true; return this.load(true); }
57
+ refresh() { return this.load(); }
58
+ async load(preserve = false) {
59
+ if (this.lifetime.signal.aborted)
60
+ return cancelled();
61
+ this.request.abort();
62
+ this.request = new AbortController();
63
+ const signal = this.request.signal;
64
+ const generation = this.snapshot.generation + 1;
65
+ this.pending.clear();
66
+ clearTimeout(this.timer);
67
+ this.timer = undefined;
68
+ this.publish("status", { state: "loading", generation, problem: null, watchStatus: null });
69
+ const current = () => !signal.aborted && !this.lifetime.signal.aborted;
70
+ try {
71
+ // Capture BEFORE querying. Watch catches up mutations during every page.
72
+ const baseline = this.options.mode === "manual" ? null : unwrap(await this.client.changes({}, { signal }));
73
+ const accepted = new Map([...this.overlays].filter(([, overlay]) => overlay.committed));
74
+ const loaded = new Map();
75
+ let stableScan = true;
76
+ const pageOptions = { signal, firstPageSize: this.options.firstPageSize, pageSize: this.options.pageSize };
77
+ // Full-row pages already carry file facts and qualified revisions.
78
+ const pages = this.client.queryPages(this.query, pageOptions)[Symbol.asyncIterator]();
79
+ try {
80
+ for (let next = pages.next(), step = await next; !step.done; step = await next) {
81
+ const page = step.value;
82
+ // One page ahead overlaps transport latency with immutable publication.
83
+ next = pages.next();
84
+ void next.catch(() => undefined); // Also observed by the loop unless cancelled.
85
+ if (!page.ok)
86
+ throw new MdbaseConnectError(page.problem);
87
+ const value = page.value;
88
+ if (value.page === 0)
89
+ stableScan = !value.meta?.hasMore || !!(value.cursor || value.snapshot);
90
+ if (!current())
91
+ return cancelled();
92
+ const rows = immutable(value.results);
93
+ for (const row of rows)
94
+ loaded.set(row.path, row);
95
+ const removed = value.page === 0 && !preserve ? this.snapshot.records
96
+ .filter(row => !loaded.has(row.path) && !this.overlays.get(row.path)?.record).map(row => row.path) : [];
97
+ if (value.page === 0 && !preserve)
98
+ this.records = loaded;
99
+ if (preserve)
100
+ for (const row of rows)
101
+ this.records.set(row.path, row);
102
+ this.publish("page", { total: value.meta?.totalCount }, { upserts: rows.filter(row => !this.overlays.has(row.path)), removed });
103
+ }
104
+ }
105
+ finally {
106
+ await pages.return?.();
107
+ }
108
+ if (!current())
109
+ return cancelled();
110
+ this.records = loaded;
111
+ for (const [path, overlay] of accepted)
112
+ if (this.overlays.get(path) === overlay)
113
+ this.overlays.delete(path);
114
+ this.settleReady(connectSuccess(undefined));
115
+ this.publish("reload", { state: "ready", total: loaded.size, problem: null });
116
+ if (baseline) {
117
+ void this.follow(baseline.cursor, signal, stableScan);
118
+ this.schedule();
119
+ }
120
+ return connectSuccess(undefined);
121
+ }
122
+ catch (error) {
123
+ if (!current())
124
+ return cancelled();
125
+ return this.fail(error);
126
+ }
127
+ }
128
+ /** Overlay caller-supplied query rows; no authority write is performed. */
129
+ optimistic(upserts = [], removed = []) {
130
+ if (this.lifetime.signal.aborted)
131
+ throw new Error("Observer is closed.");
132
+ const token = {};
133
+ const paths = [...new Set([...upserts.map(row => row.path), ...removed])];
134
+ for (const row of upserts)
135
+ this.overlays.set(row.path, { token, record: immutable(row), committed: false });
136
+ for (const path of removed)
137
+ this.overlays.set(path, { token, record: null, committed: false });
138
+ this.publish("local");
139
+ return {
140
+ commit: () => {
141
+ for (const path of paths) {
142
+ const overlay = this.overlays.get(path);
143
+ if (overlay?.token === token) {
144
+ overlay.committed = true;
145
+ // The echo may have finished before the write response arrived.
146
+ if (this.options.mode !== "manual" && !this.request.signal.aborted)
147
+ this.pending.add(path);
148
+ }
149
+ }
150
+ this.schedule();
151
+ },
152
+ rollback: () => this.retire(paths, token)
153
+ };
154
+ }
155
+ close() {
156
+ if (this.lifetime.signal.aborted)
157
+ return;
158
+ this.lifetime.abort();
159
+ this.request.abort();
160
+ clearTimeout(this.timer);
161
+ this.timer = undefined;
162
+ this.pending.clear();
163
+ this.records = new Map(this.snapshot.records.map(row => [row.path, row]));
164
+ this.overlays.clear();
165
+ this.removeAbort?.();
166
+ this.publish("status", { state: "closed" });
167
+ this.settleReady(cancelled());
168
+ this.listeners.clear();
169
+ this.changes.clear();
170
+ }
171
+ fullReloadRequired() {
172
+ return this.options.invalidation === "collection" || !!(this.query.contract || this.query.orderBy || this.query.offset !== undefined || this.query.select || this.query.projections
173
+ || (this.options.invalidation !== "paths" && (this.query.where || this.query.context)));
174
+ }
175
+ async follow(cursor, signal, stableScan) {
176
+ // Initial ready waiters run before watch; replacements never wait on old ready.
177
+ if (this.snapshot.generation === 1)
178
+ await this.ready;
179
+ if (signal.aborted)
180
+ return;
181
+ try {
182
+ for await (const outcome of this.client.watch({ ...this.options.watch, cursor, signal, onStatus: status => {
183
+ if (!signal.aborted)
184
+ this.publish("status", { watchStatus: immutable(status) });
185
+ } })) {
186
+ if (signal.aborted)
187
+ return;
188
+ if (!outcome.ok) {
189
+ if (outcome.problem.code === "change_cursor_reset") {
190
+ void this.refresh();
191
+ return;
192
+ }
193
+ this.fail(new MdbaseConnectError(outcome.problem));
194
+ return;
195
+ }
196
+ const change = immutable(outcome.value);
197
+ for (const listener of this.changes)
198
+ listener(change);
199
+ if (["reset", "gap", "unknown", "schema.changed", "config.changed", "contract.changed", "view.changed"].includes(change.kind)) {
200
+ void this.refresh();
201
+ return;
202
+ }
203
+ const paths = changedPaths(change);
204
+ // Legacy offset-only editor/Writer authorities cannot prove a scan did
205
+ // not skip an unchanged row after a structural shift. Remove when every
206
+ // supported authority supplies cursor/snapshot-pinned pages.
207
+ if (this.fullReloadRequired() || (!stableScan && (change.kind === "record.created" || change.kind === "record.deleted" || change.kind === "record.renamed"))) {
208
+ void this.refresh();
209
+ return;
210
+ }
211
+ if (!paths.length)
212
+ continue;
213
+ for (const path of paths)
214
+ this.pending.add(path);
215
+ this.schedule();
216
+ }
217
+ }
218
+ catch (error) {
219
+ if (!signal.aborted)
220
+ this.fail(error);
221
+ }
222
+ }
223
+ schedule() {
224
+ const signal = this.request.signal;
225
+ if (signal.aborted || this.options.mode === "manual" || !this.pending.size)
226
+ return;
227
+ if (this.pending.size > (this.options.maxPendingPaths ?? 1000)) {
228
+ void this.refresh();
229
+ return;
230
+ }
231
+ if (this.snapshot.state !== "ready")
232
+ return;
233
+ if (this.fullReloadRequired()) {
234
+ void this.refresh();
235
+ return;
236
+ }
237
+ // Fixed window shared by remote changes and accepted-write confirmations.
238
+ if (!this.timer && !this.draining)
239
+ this.timer = setTimeout(() => {
240
+ this.timer = undefined;
241
+ void this.drain(signal);
242
+ }, this.options.coalesceMs ?? 50);
243
+ }
244
+ async drain(signal) {
245
+ if (this.draining)
246
+ return;
247
+ this.draining = true;
248
+ try {
249
+ while (!signal.aborted && this.pending.size) {
250
+ const paths = [...this.pending];
251
+ this.pending.clear();
252
+ if (!(await this.reread(paths, signal)).ok)
253
+ return;
254
+ }
255
+ }
256
+ finally {
257
+ this.draining = false;
258
+ // A reset can replace the generation while this read is in flight.
259
+ this.schedule();
260
+ }
261
+ }
262
+ async reread(paths, signal) {
263
+ const overlays = new Map([...this.overlays].filter(([path, overlay]) => paths.includes(path) && overlay.committed));
264
+ try {
265
+ const where = `file.path in ${JSON.stringify(paths)}`;
266
+ const input = { ...this.query, where: this.query.where ? `(${this.query.where}) && (${where})` : where, includeBody: this.query.includeBody };
267
+ const support = this.supports ? unwrap(await this.supports("query-metadata-v1", { signal })) : false;
268
+ // Editor/Writer/TaskNotes on pre-wave-B authorities use ordinary discovery.
269
+ // Remove when minimum supported authorities all advertise query-metadata-v1.
270
+ const membership = support
271
+ ? unwrap(await this.client.queryAll({ ...input, select: fileFields.map(field => `file.${field}`), includeBody: false, output: "metadata" }, { signal })).results
272
+ : unwrap(await this.client.queryAll(input, { signal })).results;
273
+ const matches = new Map(membership.map(row => [row.path, row]));
274
+ const mode = this.query.frontmatterMode ?? "effective";
275
+ // Confirmed rows remain revision-qualified after their overlay retires.
276
+ const cached = new Map(paths.flatMap(path => {
277
+ const record = overlays.get(path)?.record ?? this.records.get(path);
278
+ return support && record?.revision && record.revision === matches.get(path)?.revision
279
+ && (!this.query.includeBody || record.body !== undefined)
280
+ && (mode === "effective" || record.frontmatter !== undefined) && (mode === "persisted" || record.effectiveFrontmatter !== undefined)
281
+ ? [[path, record]] : [];
282
+ }));
283
+ // Ordinary discovery already contains full rows; only metadata discovery
284
+ // needs documents. readMany owns its own native/legacy capability gate.
285
+ const result = support ? unwrap(await this.client.readMany(paths.filter(path => matches.has(path) && !cached.has(path)), {
286
+ signal, includeBody: this.query.includeBody, frontmatterMode: this.query.frontmatterMode
287
+ })) : { results: membership.map(record => ({ status: "found", path: record.path, record })), errors: [] };
288
+ if (signal.aborted || this.lifetime.signal.aborted)
289
+ return cancelled();
290
+ if (result.errors.length)
291
+ throw new MdbaseConnectError(result.errors[0].failure.problem);
292
+ const found = new Map([...cached, ...result.results.flatMap(entry => entry.status === "found" ? [[entry.path, entry.record]] : [])]
293
+ .map(([path, record]) => [path, immutable(withQueryFile(record, matches.get(path)))]));
294
+ for (const path of paths)
295
+ if (!matches.has(path))
296
+ this.records.delete(path);
297
+ for (const entry of result.results)
298
+ if (entry.status === "missing")
299
+ this.records.delete(entry.path);
300
+ for (const [path, record] of found)
301
+ this.records.set(path, record);
302
+ for (const [path, overlay] of overlays)
303
+ if (overlay?.committed && this.overlays.get(path) === overlay)
304
+ this.overlays.delete(path);
305
+ this.publish("changes", { state: "ready", problem: null });
306
+ return connectSuccess(undefined);
307
+ }
308
+ catch (error) {
309
+ if (signal.aborted || this.lifetime.signal.aborted)
310
+ return cancelled();
311
+ return this.fail(error);
312
+ }
313
+ }
314
+ retire(paths, token) {
315
+ for (const path of paths)
316
+ if (this.overlays.get(path)?.token === token)
317
+ this.overlays.delete(path);
318
+ if (!this.lifetime.signal.aborted)
319
+ this.publish("local");
320
+ }
321
+ fail(error) {
322
+ const problem = error instanceof MdbaseConnectError ? error.problem : connectProblem("operation_failed", error instanceof Error ? error.message : "Query observation failed.");
323
+ // A stopped generation cannot silently recover a stale path via unrelated work.
324
+ this.request.abort();
325
+ clearTimeout(this.timer);
326
+ this.timer = undefined;
327
+ this.pending.clear();
328
+ this.publish("status", { state: "error", problem: immutable(problem), watchStatus: this.options.mode === "manual" ? null : Object.freeze({ state: "closed" }) });
329
+ const outcome = connectFailure(problem);
330
+ this.settleReady(outcome);
331
+ return outcome;
332
+ }
333
+ publish(reason, patch = {}, changed) {
334
+ const visible = this.overlays.size ? new Map(this.records) : this.records;
335
+ for (const [path, overlay] of this.overlays) {
336
+ if (overlay.record)
337
+ visible.set(path, overlay.record);
338
+ else
339
+ visible.delete(path);
340
+ }
341
+ const previous = this.snapshot.records;
342
+ const records = reason === "status" ? previous : Object.freeze([...visible.values()]);
343
+ const before = new Map(changed || records === previous ? [] : previous.map(row => [row.path, row]));
344
+ const delta = Object.freeze({ reason,
345
+ upserts: Object.freeze(changed?.upserts ?? (records === previous ? [] : records.filter(row => before.get(row.path) !== row))),
346
+ removed: Object.freeze(changed?.removed ?? (records === previous ? [] : previous.filter(row => !visible.has(row.path)).map(row => row.path))) });
347
+ this.snapshot = Object.freeze({ ...this.snapshot, ...patch, records,
348
+ ...((patch.state ?? this.snapshot.state) === "ready" ? { total: records.length } : {}) });
349
+ for (const listener of this.listeners)
350
+ listener(this.snapshot, delta);
351
+ }
352
+ }
353
+ // Refresh authority file facts even when an unchanged revision reuses its row.
354
+ // Do not reimplement Markdown semantics or infer timestamps from document bytes.
355
+ const fileFields = ["path", "name", "folder", "size", "mtime", "ctime", "tags", "links", "embeds"];
356
+ function withQueryFile(record, membership) {
357
+ const file = "file" in membership ? membership.file : membership.values;
358
+ if (!("file" in membership))
359
+ for (const field of ["tags", "links", "embeds"]) {
360
+ const value = file[field];
361
+ if (!Array.isArray(value) || (field === "tags" && value.some(tag => typeof tag !== "string")))
362
+ throw connectError("invalid_operation_response", "Invalid authority-derived file metadata.");
363
+ }
364
+ return { ...record, file: "file" in membership ? { ...file, ...record.file } : { ...record.file, ...file } };
365
+ }
366
+ function unwrap(outcome) {
367
+ if (!outcome.ok)
368
+ throw new MdbaseConnectError(outcome.problem);
369
+ return outcome.value;
370
+ }
371
+ function cancelled() { return connectFailure(connectProblem("operation_cancelled", "Query observation was cancelled.")); }
372
+ function changedPaths(change) {
373
+ if (change.kind === "record.renamed")
374
+ return [change.from, change.to];
375
+ if (change.kind === "record.created" || change.kind === "record.updated" || change.kind === "record.deleted")
376
+ return [change.path];
377
+ return [];
378
+ }
379
+ function immutable(value) {
380
+ // Copy/freeze JSON containers once; strings already have immutable ownership.
381
+ if (!value || typeof value !== "object")
382
+ return value;
383
+ if (Array.isArray(value))
384
+ return Object.freeze(value.map(immutable));
385
+ const copy = { ...value };
386
+ for (const key in copy)
387
+ if (Object.hasOwn(copy, key) && copy[key] && typeof copy[key] === "object")
388
+ copy[key] = immutable(copy[key]);
389
+ return Object.freeze(copy);
390
+ }