@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.
- package/dist/application-session.d.ts +1 -1
- package/dist/application-session.js +1 -1
- package/dist/async.d.ts +6 -0
- package/dist/async.js +39 -0
- package/dist/authorization-refresh.d.ts +2 -0
- package/dist/authorization-refresh.js +13 -1
- 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/capabilities.d.ts +1 -1
- package/dist/collection-client.d.ts +2 -0
- package/dist/collection-client.js +8 -4
- package/dist/connect-options.d.ts +1 -1
- package/dist/connect.d.ts +1 -1
- package/dist/connect.js +1 -1
- package/dist/connection.d.ts +2 -1
- package/dist/connection.js +3 -1
- package/dist/control-timers.d.ts +43 -0
- package/dist/control-timers.js +290 -0
- package/dist/errors.d.ts +2 -28
- package/dist/errors.js +2 -52
- package/dist/file-transfer-internals.d.ts +2 -1
- package/dist/file-transfer-internals.js +10 -3
- package/dist/files.d.ts +4 -0
- package/dist/files.js +17 -18
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/internal-types.d.ts +2 -2
- package/dist/internal-types.js +1 -1
- package/dist/mdbase-connect.d.ts +1 -1
- package/dist/observe.d.ts +81 -0
- package/dist/observe.js +390 -0
- package/dist/operation-types.d.ts +3 -1
- package/dist/outcomes.d.ts +13 -13
- package/dist/outcomes.js +17 -0
- package/dist/query-pagination-internals.d.ts +1 -1
- package/dist/query-pagination-internals.js +4 -2
- package/dist/query-pagination.js +1 -1
- package/dist/query-wire.d.ts +16 -10
- package/dist/query-wire.js +44 -13
- package/dist/read-many.js +4 -2
- package/dist/runtime-utils.d.ts +1 -1
- package/dist/saved-view-pagination.js +1 -1
- package/dist/session.d.ts +1 -1
- package/dist/session.js +1 -1
- package/dist/structured-readiness.d.ts +1 -1
- package/package.json +6 -2
- package/dist/application-contract.d.ts +0 -22
- 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
|
|
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 ===
|
|
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
|
-
|
|
546
|
-
for (let 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
|
-
|
|
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 "
|
|
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";
|
package/dist/internal-types.d.ts
CHANGED
|
@@ -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 "
|
|
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 "
|
|
102
|
+
export { DEFAULT_OPERATIONS } from "@mdbase-dev/connect-protocol";
|
package/dist/internal-types.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { DEFAULT_OPERATIONS } from "
|
|
1
|
+
export { DEFAULT_OPERATIONS } from "@mdbase-dev/connect-protocol";
|
package/dist/mdbase-connect.d.ts
CHANGED
|
@@ -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("
|
|
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 {};
|
package/dist/observe.js
ADDED
|
@@ -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
|
+
}
|