@mdbase-dev/connect 0.1.0-beta.100
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/LICENSE +21 -0
- package/README.md +696 -0
- package/dist/advanced.d.ts +9 -0
- package/dist/advanced.js +6 -0
- package/dist/application-contract.d.ts +22 -0
- package/dist/application-contract.js +75 -0
- package/dist/application-identity.d.ts +34 -0
- package/dist/application-identity.js +148 -0
- package/dist/application-session.d.ts +167 -0
- package/dist/application-session.js +722 -0
- package/dist/async.d.ts +1 -0
- package/dist/async.js +19 -0
- package/dist/authority-proof.d.ts +3 -0
- package/dist/authority-proof.js +24 -0
- package/dist/authority-sync-request.d.ts +3 -0
- package/dist/authority-sync-request.js +20 -0
- package/dist/authorization-refresh.d.ts +14 -0
- package/dist/authorization-refresh.js +41 -0
- package/dist/authorization-types.d.ts +7 -0
- package/dist/authorization-types.js +1 -0
- package/dist/authorization-url.d.ts +5 -0
- package/dist/authorization-url.js +34 -0
- package/dist/base64.d.ts +3 -0
- package/dist/base64.js +21 -0
- package/dist/browser/integrity.json +4 -0
- package/dist/browser/mdbase-connect.min.js +3 -0
- package/dist/browser/mdbase-connect.min.js.sha384 +1 -0
- package/dist/browser.d.ts +3 -0
- package/dist/browser.js +5 -0
- package/dist/capabilities.d.ts +24 -0
- package/dist/capabilities.js +91 -0
- package/dist/collection-client.d.ts +66 -0
- package/dist/collection-client.js +686 -0
- package/dist/connect-authorization-helpers.d.ts +4 -0
- package/dist/connect-authorization-helpers.js +25 -0
- package/dist/connect-options.d.ts +35 -0
- package/dist/connect-options.js +1 -0
- package/dist/connect.d.ts +66 -0
- package/dist/connect.js +711 -0
- package/dist/connection-file-transport.d.ts +34 -0
- package/dist/connection-file-transport.js +85 -0
- package/dist/connection-index.d.ts +3 -0
- package/dist/connection-index.js +20 -0
- package/dist/connection-notifications.d.ts +31 -0
- package/dist/connection-notifications.js +197 -0
- package/dist/connection-transport-internals.d.ts +24 -0
- package/dist/connection-transport-internals.js +1 -0
- package/dist/connection-transport.d.ts +64 -0
- package/dist/connection-transport.js +725 -0
- package/dist/connection-types.d.ts +38 -0
- package/dist/connection-types.js +1 -0
- package/dist/connection.d.ts +175 -0
- package/dist/connection.js +606 -0
- package/dist/crypto-entry.d.ts +7 -0
- package/dist/crypto-entry.js +4 -0
- package/dist/crypto.d.ts +63 -0
- package/dist/crypto.js +409 -0
- package/dist/direct-access.d.ts +3 -0
- package/dist/direct-access.js +29 -0
- package/dist/errors.d.ts +45 -0
- package/dist/errors.js +142 -0
- package/dist/external-store.d.ts +10 -0
- package/dist/external-store.js +10 -0
- package/dist/file-crypto.d.ts +25 -0
- package/dist/file-crypto.js +132 -0
- package/dist/file-sha256.d.ts +12 -0
- package/dist/file-sha256.js +143 -0
- package/dist/file-stream-source.d.ts +17 -0
- package/dist/file-stream-source.js +157 -0
- package/dist/file-transfer-internals.d.ts +23 -0
- package/dist/file-transfer-internals.js +228 -0
- package/dist/files.d.ts +115 -0
- package/dist/files.js +563 -0
- package/dist/fresh-encrypted-request.d.ts +4 -0
- package/dist/fresh-encrypted-request.js +28 -0
- package/dist/grant-key-leases.d.ts +24 -0
- package/dist/grant-key-leases.js +144 -0
- package/dist/hosted-file-request.d.ts +6 -0
- package/dist/hosted-file-request.js +63 -0
- package/dist/hosted-operation-response.d.ts +5 -0
- package/dist/hosted-operation-response.js +26 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +10 -0
- package/dist/internal-types.d.ts +100 -0
- package/dist/internal-types.js +1 -0
- package/dist/local-file-transport.d.ts +37 -0
- package/dist/local-file-transport.js +457 -0
- package/dist/mdbase-connect.d.ts +28 -0
- package/dist/mdbase-connect.js +53 -0
- package/dist/notifications.d.ts +48 -0
- package/dist/notifications.js +52 -0
- package/dist/operation-helpers.d.ts +27 -0
- package/dist/operation-helpers.js +144 -0
- package/dist/operation-types.d.ts +707 -0
- package/dist/operation-types.js +1 -0
- package/dist/outcomes.d.ts +51 -0
- package/dist/outcomes.js +194 -0
- package/dist/pending-mutation-store.d.ts +13 -0
- package/dist/pending-mutation-store.js +64 -0
- package/dist/pending-recovery-transports.d.ts +5 -0
- package/dist/pending-recovery-transports.js +52 -0
- package/dist/popup-authorization.d.ts +31 -0
- package/dist/popup-authorization.js +100 -0
- package/dist/query-pagination.d.ts +6 -0
- package/dist/query-pagination.js +119 -0
- package/dist/request-budget.d.ts +34 -0
- package/dist/request-budget.js +104 -0
- package/dist/request-coordinator.d.ts +29 -0
- package/dist/request-coordinator.js +213 -0
- package/dist/retired-grant-keys.d.ts +9 -0
- package/dist/retired-grant-keys.js +56 -0
- package/dist/runtime-utils.d.ts +52 -0
- package/dist/runtime-utils.js +368 -0
- package/dist/saved-view-pagination.d.ts +6 -0
- package/dist/saved-view-pagination.js +76 -0
- package/dist/selection.d.ts +48 -0
- package/dist/selection.js +112 -0
- package/dist/session.d.ts +114 -0
- package/dist/session.js +569 -0
- package/dist/shared-operation.d.ts +7 -0
- package/dist/shared-operation.js +41 -0
- package/dist/stored-token.d.ts +10 -0
- package/dist/stored-token.js +64 -0
- package/dist/structured-readiness.d.ts +77 -0
- package/dist/structured-readiness.js +126 -0
- package/dist/token-response.d.ts +12 -0
- package/dist/token-response.js +81 -0
- package/dist/transient-retry.d.ts +22 -0
- package/dist/transient-retry.js +59 -0
- package/dist/watch-policy.d.ts +8 -0
- package/dist/watch-policy.js +12 -0
- package/package.json +51 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { connectProblem } from "./errors.js";
|
|
2
|
+
import { connectFailure, connectSuccess } from "./outcomes.js";
|
|
3
|
+
export async function* coordinatedQueryPages(query, releaseQueryCursor, input = {}, options = {}) {
|
|
4
|
+
const { offset: requestedOffset, limit: requestedLimit, cursor: requestedCursor, pagination: requestedPagination, snapshot: requestedSnapshot, ...criteria } = input;
|
|
5
|
+
let offset = nonNegativeInteger(requestedOffset, 0);
|
|
6
|
+
const firstPageSize = positiveInteger(options.firstPageSize ?? options.pageSize ?? requestedLimit, 200);
|
|
7
|
+
const pageSize = positiveInteger(options.pageSize ?? requestedLimit, 1_000);
|
|
8
|
+
let cursor = requestedCursor;
|
|
9
|
+
let cursorMode = requestedCursor !== undefined;
|
|
10
|
+
let cursorToRelease = requestedCursor;
|
|
11
|
+
let snapshot = requestedSnapshot;
|
|
12
|
+
let loaded = 0;
|
|
13
|
+
let pageNumber = 0;
|
|
14
|
+
try {
|
|
15
|
+
while (!options.signal?.aborted) {
|
|
16
|
+
const pageCursor = cursor;
|
|
17
|
+
const pageRequestOptions = {
|
|
18
|
+
signal: options.signal,
|
|
19
|
+
timeoutMs: options.pageTimeoutMs,
|
|
20
|
+
coordination: { ...options.coordination, coalesce: false }
|
|
21
|
+
};
|
|
22
|
+
const automaticCursorProbe = pageNumber === 0
|
|
23
|
+
&& requestedCursor === undefined
|
|
24
|
+
&& requestedPagination === undefined
|
|
25
|
+
&& requestedSnapshot === undefined;
|
|
26
|
+
let queried = await query({
|
|
27
|
+
...criteria,
|
|
28
|
+
// Continuations use the page size pinned when the cursor was opened.
|
|
29
|
+
...(!cursorMode ? { limit: pageNumber === 0 ? firstPageSize : pageSize } : {}),
|
|
30
|
+
...(cursorMode
|
|
31
|
+
? (pageCursor ? { cursor: pageCursor } : { pagination: "cursor" })
|
|
32
|
+
: {
|
|
33
|
+
offset,
|
|
34
|
+
...(snapshot ? { snapshot } : {}),
|
|
35
|
+
...(!snapshot && requestedPagination === "cursor"
|
|
36
|
+
? { pagination: "cursor" }
|
|
37
|
+
: {})
|
|
38
|
+
}),
|
|
39
|
+
...(!cursorMode && !snapshot && automaticCursorProbe
|
|
40
|
+
? { pagination: "cursor" }
|
|
41
|
+
: {})
|
|
42
|
+
}, pageRequestOptions);
|
|
43
|
+
// Cursor pagination is a read-only capability probe for authorities
|
|
44
|
+
// predating generation cursors. Retry the first page without that
|
|
45
|
+
// optional field only when the authority rejected the operation
|
|
46
|
+
// schema; explicit cursor requests remain strict.
|
|
47
|
+
if (automaticCursorProbe
|
|
48
|
+
&& !queried.ok
|
|
49
|
+
&& queried.problem.code === "operation_invalid") {
|
|
50
|
+
queried = await query({
|
|
51
|
+
...criteria,
|
|
52
|
+
limit: firstPageSize,
|
|
53
|
+
offset
|
|
54
|
+
}, pageRequestOptions);
|
|
55
|
+
}
|
|
56
|
+
if (!queried.ok) {
|
|
57
|
+
yield queried;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const result = queried.value;
|
|
61
|
+
const returnedCursor = result.meta?.cursor;
|
|
62
|
+
if (returnedCursor) {
|
|
63
|
+
cursorMode = true;
|
|
64
|
+
cursor = returnedCursor;
|
|
65
|
+
cursorToRelease = returnedCursor;
|
|
66
|
+
}
|
|
67
|
+
else if (cursorMode) {
|
|
68
|
+
cursor = undefined;
|
|
69
|
+
cursorToRelease = pageCursor;
|
|
70
|
+
}
|
|
71
|
+
if (cursorMode && result.meta?.hasMore && !returnedCursor) {
|
|
72
|
+
yield connectFailure(connectProblem("invalid_operation_response", "The collection authority omitted the cursor required for the next query page."));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const returnedSnapshot = result.meta?.snapshot;
|
|
76
|
+
if (!cursorMode && snapshot && returnedSnapshot && snapshot !== returnedSnapshot) {
|
|
77
|
+
yield connectFailure(connectProblem("query_snapshot_changed", "The collection query snapshot changed while paging. Refresh the query before continuing."));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (!cursorMode && !snapshot && returnedSnapshot)
|
|
81
|
+
snapshot = returnedSnapshot;
|
|
82
|
+
loaded += result.results.length;
|
|
83
|
+
const complete = !result.meta?.hasMore || result.results.length === 0;
|
|
84
|
+
const page = {
|
|
85
|
+
results: result.results,
|
|
86
|
+
...(result.meta ? { meta: result.meta } : {}),
|
|
87
|
+
page: pageNumber,
|
|
88
|
+
offset,
|
|
89
|
+
loaded,
|
|
90
|
+
complete,
|
|
91
|
+
...(returnedCursor ? { cursor: returnedCursor } : {}),
|
|
92
|
+
...(!cursorMode && snapshot ? { snapshot } : {})
|
|
93
|
+
};
|
|
94
|
+
options.onProgress?.(page);
|
|
95
|
+
yield connectSuccess(page, queried.diagnostics);
|
|
96
|
+
if (complete)
|
|
97
|
+
return;
|
|
98
|
+
offset += result.results.length;
|
|
99
|
+
pageNumber += 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
if (cursorMode && cursorToRelease) {
|
|
104
|
+
// Cleanup has its own bounded request. It must not delay delivery of
|
|
105
|
+
// completed pages or consume the caller's data deadline.
|
|
106
|
+
void releaseQueryCursor(cursorToRelease).catch(() => undefined);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function nonNegativeInteger(value, fallback) {
|
|
111
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
112
|
+
? Math.max(0, Math.floor(value))
|
|
113
|
+
: fallback;
|
|
114
|
+
}
|
|
115
|
+
function positiveInteger(value, fallback) {
|
|
116
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
117
|
+
? Math.floor(value)
|
|
118
|
+
: fallback;
|
|
119
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ConnectRequestOptions } from "./operation-types.js";
|
|
2
|
+
import type { MdbaseConnectTimeouts } from "./connect-options.js";
|
|
3
|
+
export declare const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
|
|
4
|
+
export declare const DEFAULT_STARTUP_TIMEOUT_MS = 10000;
|
|
5
|
+
export declare const DEFAULT_FILE_INDEX_TIMEOUT_MS: number;
|
|
6
|
+
export declare const DEFAULT_UPLOAD_TIMEOUT_MS = 120000;
|
|
7
|
+
export declare const DEFAULT_SYNC_TIMEOUT_MS = 60000;
|
|
8
|
+
export interface ResolvedConnectTimeouts {
|
|
9
|
+
requestMs: number | null;
|
|
10
|
+
watchStartMs: number | null;
|
|
11
|
+
fileIndexMs: number | null;
|
|
12
|
+
uploadMs: number | null;
|
|
13
|
+
syncMs: number | null;
|
|
14
|
+
}
|
|
15
|
+
export declare function resolveConnectTimeouts(timeouts?: MdbaseConnectTimeouts): ResolvedConnectTimeouts;
|
|
16
|
+
export interface RequestBudget {
|
|
17
|
+
readonly signal: AbortSignal;
|
|
18
|
+
readonly deadline: number | null;
|
|
19
|
+
remainingMs(): number | null;
|
|
20
|
+
dispose(): void;
|
|
21
|
+
}
|
|
22
|
+
export type OperationRequestOptions = ConnectRequestOptions & {
|
|
23
|
+
/** Absolute budget shared with the connector; never supplied directly by callers. */
|
|
24
|
+
deadlineUnixMs?: number;
|
|
25
|
+
};
|
|
26
|
+
export declare function requestOptionsWithinBudget(options: ConnectRequestOptions, budget: RequestBudget): OperationRequestOptions;
|
|
27
|
+
export declare function createRequestBudget(options?: ConnectRequestOptions, defaultTimeoutMs?: number | null, now?: () => number): RequestBudget;
|
|
28
|
+
export declare function withRequestBudget<Result>(options: ConnectRequestOptions | undefined, defaultTimeoutMs: number | null, operation: (budget: RequestBudget) => Promise<Result>): Promise<Result>;
|
|
29
|
+
/**
|
|
30
|
+
* Budget an operation whose transport cooperates with AbortSignal and must run
|
|
31
|
+
* its own bounded cleanup/recovery before the public promise settles.
|
|
32
|
+
*/
|
|
33
|
+
export declare function withCooperativeRequestBudget<Result>(options: ConnectRequestOptions | undefined, defaultTimeoutMs: number | null, operation: (budget: RequestBudget) => Promise<Result>): Promise<Result>;
|
|
34
|
+
export declare function requestAbortReason(signal: AbortSignal): unknown;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { MdbaseConnectError, connectError } from "./errors.js";
|
|
2
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
3
|
+
export const DEFAULT_STARTUP_TIMEOUT_MS = 10_000;
|
|
4
|
+
export const DEFAULT_FILE_INDEX_TIMEOUT_MS = 10 * 60_000;
|
|
5
|
+
export const DEFAULT_UPLOAD_TIMEOUT_MS = 120_000;
|
|
6
|
+
export const DEFAULT_SYNC_TIMEOUT_MS = 60_000;
|
|
7
|
+
export function resolveConnectTimeouts(timeouts = {}) {
|
|
8
|
+
return {
|
|
9
|
+
requestMs: configuredTimeout(timeouts.requestMs, DEFAULT_REQUEST_TIMEOUT_MS, "requestMs"),
|
|
10
|
+
watchStartMs: configuredTimeout(timeouts.watchStartMs, DEFAULT_STARTUP_TIMEOUT_MS, "watchStartMs"),
|
|
11
|
+
fileIndexMs: configuredTimeout(timeouts.fileIndexMs, DEFAULT_FILE_INDEX_TIMEOUT_MS, "fileIndexMs"),
|
|
12
|
+
uploadMs: configuredTimeout(timeouts.uploadMs, DEFAULT_UPLOAD_TIMEOUT_MS, "uploadMs"),
|
|
13
|
+
syncMs: configuredTimeout(timeouts.syncMs, DEFAULT_SYNC_TIMEOUT_MS, "syncMs")
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function requestOptionsWithinBudget(options, budget) {
|
|
17
|
+
return {
|
|
18
|
+
...options,
|
|
19
|
+
signal: budget.signal,
|
|
20
|
+
...(budget.deadline === null ? {} : { deadlineUnixMs: budget.deadline })
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export function createRequestBudget(options = {}, defaultTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, now = Date.now) {
|
|
24
|
+
const configured = options.timeoutMs === undefined ? defaultTimeoutMs : options.timeoutMs;
|
|
25
|
+
const timeoutMs = configured === null ? null : validTimeout(configured);
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
const deadline = timeoutMs === null ? null : now() + timeoutMs;
|
|
28
|
+
const abortFromCaller = () => controller.abort(options.signal?.reason);
|
|
29
|
+
if (options.signal?.aborted)
|
|
30
|
+
abortFromCaller();
|
|
31
|
+
else
|
|
32
|
+
options.signal?.addEventListener("abort", abortFromCaller, { once: true });
|
|
33
|
+
const timer = timeoutMs === null
|
|
34
|
+
? undefined
|
|
35
|
+
: setTimeout(() => controller.abort(connectError("timeout", "The operation exceeded its request deadline.", { operationOutcome: "not_sent" })), timeoutMs);
|
|
36
|
+
let disposed = false;
|
|
37
|
+
return {
|
|
38
|
+
signal: controller.signal,
|
|
39
|
+
deadline,
|
|
40
|
+
remainingMs: () => deadline === null ? null : Math.max(0, deadline - now()),
|
|
41
|
+
dispose: () => {
|
|
42
|
+
if (disposed)
|
|
43
|
+
return;
|
|
44
|
+
disposed = true;
|
|
45
|
+
if (timer !== undefined)
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
options.signal?.removeEventListener("abort", abortFromCaller);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export async function withRequestBudget(options, defaultTimeoutMs, operation) {
|
|
52
|
+
const budget = createRequestBudget(options, defaultTimeoutMs);
|
|
53
|
+
let rejectOnAbort;
|
|
54
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
55
|
+
rejectOnAbort = reject;
|
|
56
|
+
});
|
|
57
|
+
const abort = () => rejectOnAbort?.(requestAbortReason(budget.signal));
|
|
58
|
+
budget.signal.addEventListener("abort", abort, { once: true });
|
|
59
|
+
try {
|
|
60
|
+
if (budget.signal.aborted)
|
|
61
|
+
throw requestAbortReason(budget.signal);
|
|
62
|
+
return await Promise.race([operation(budget), aborted]);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
budget.signal.removeEventListener("abort", abort);
|
|
66
|
+
budget.dispose();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Budget an operation whose transport cooperates with AbortSignal and must run
|
|
71
|
+
* its own bounded cleanup/recovery before the public promise settles.
|
|
72
|
+
*/
|
|
73
|
+
export async function withCooperativeRequestBudget(options, defaultTimeoutMs, operation) {
|
|
74
|
+
const budget = createRequestBudget(options, defaultTimeoutMs);
|
|
75
|
+
try {
|
|
76
|
+
if (budget.signal.aborted)
|
|
77
|
+
throw requestAbortReason(budget.signal);
|
|
78
|
+
return await operation(budget);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
budget.dispose();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export function requestAbortReason(signal) {
|
|
85
|
+
if (signal.reason instanceof MdbaseConnectError)
|
|
86
|
+
return signal.reason;
|
|
87
|
+
return connectError("operation_cancelled", "The operation was cancelled before it changed the collection.", { operationOutcome: "not_sent", cause: signal.reason });
|
|
88
|
+
}
|
|
89
|
+
function validTimeout(value) {
|
|
90
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
91
|
+
throw new TypeError("timeoutMs must be a positive safe integer or null.");
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
function configuredTimeout(value, fallback, name) {
|
|
96
|
+
if (value === undefined)
|
|
97
|
+
return fallback;
|
|
98
|
+
if (value === null)
|
|
99
|
+
return null;
|
|
100
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
101
|
+
throw new TypeError(`${name} must be a positive safe integer or null.`);
|
|
102
|
+
}
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type CollectionOperation } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
import type { ConnectRequestOptions, MdbaseCollectionTransport } from "./operation-types.js";
|
|
3
|
+
interface CoordinatorLimits {
|
|
4
|
+
foregroundCapacity?: number;
|
|
5
|
+
foregroundQueueCapacity?: number;
|
|
6
|
+
mutationQueueCapacity?: number;
|
|
7
|
+
}
|
|
8
|
+
/** One bounded request owner for a selected connection. */
|
|
9
|
+
export declare class CollectionRequestCoordinator implements MdbaseCollectionTransport {
|
|
10
|
+
private readonly transport;
|
|
11
|
+
private readonly requestTimeoutMs;
|
|
12
|
+
private readonly foregroundCapacity;
|
|
13
|
+
private readonly foregroundQueueCapacity;
|
|
14
|
+
private readonly mutationQueueCapacity;
|
|
15
|
+
private readonly foregroundQueue;
|
|
16
|
+
private readonly mutationQueue;
|
|
17
|
+
private readonly identicalReads;
|
|
18
|
+
private readonly latestFamilies;
|
|
19
|
+
private activeForeground;
|
|
20
|
+
private mutationActive;
|
|
21
|
+
constructor(transport: MdbaseCollectionTransport, requestTimeoutMs: number | null, limits?: CoordinatorLimits);
|
|
22
|
+
operation<Result>(operation: CollectionOperation, input: unknown, options?: ConnectRequestOptions): Promise<Result>;
|
|
23
|
+
private schedule;
|
|
24
|
+
private replaceLatest;
|
|
25
|
+
private releaseLatest;
|
|
26
|
+
private pump;
|
|
27
|
+
private start;
|
|
28
|
+
}
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { isMutatingOperation } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
import { connectError } from "./errors.js";
|
|
3
|
+
import { requestAbortReason, requestOptionsWithinBudget, withCooperativeRequestBudget } from "./request-budget.js";
|
|
4
|
+
const DEFAULT_FOREGROUND_CAPACITY = 4;
|
|
5
|
+
const DEFAULT_QUEUE_CAPACITY = 32;
|
|
6
|
+
/** One bounded request owner for a selected connection. */
|
|
7
|
+
export class CollectionRequestCoordinator {
|
|
8
|
+
transport;
|
|
9
|
+
requestTimeoutMs;
|
|
10
|
+
foregroundCapacity;
|
|
11
|
+
foregroundQueueCapacity;
|
|
12
|
+
mutationQueueCapacity;
|
|
13
|
+
foregroundQueue = [];
|
|
14
|
+
mutationQueue = [];
|
|
15
|
+
identicalReads = new Map();
|
|
16
|
+
latestFamilies = new Map();
|
|
17
|
+
activeForeground = 0;
|
|
18
|
+
mutationActive = false;
|
|
19
|
+
constructor(transport, requestTimeoutMs, limits = {}) {
|
|
20
|
+
this.transport = transport;
|
|
21
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
22
|
+
this.foregroundCapacity = positiveCapacity(limits.foregroundCapacity, DEFAULT_FOREGROUND_CAPACITY);
|
|
23
|
+
this.foregroundQueueCapacity = positiveCapacity(limits.foregroundQueueCapacity, DEFAULT_QUEUE_CAPACITY);
|
|
24
|
+
this.mutationQueueCapacity = positiveCapacity(limits.mutationQueueCapacity, DEFAULT_QUEUE_CAPACITY);
|
|
25
|
+
}
|
|
26
|
+
operation(operation, input, options = {}) {
|
|
27
|
+
const read = !isMutatingOperation(operation, input);
|
|
28
|
+
const coordination = validatedCoordination(options.coordination, read);
|
|
29
|
+
const coalesceKey = read
|
|
30
|
+
&& coordination.coalesce !== false
|
|
31
|
+
&& !coordination.latestWins
|
|
32
|
+
&& options.signal === undefined
|
|
33
|
+
? stableRequestKey(operation, input, options.timeoutMs)
|
|
34
|
+
: undefined;
|
|
35
|
+
if (coalesceKey) {
|
|
36
|
+
const existing = this.identicalReads.get(coalesceKey);
|
|
37
|
+
if (existing)
|
|
38
|
+
return existing;
|
|
39
|
+
}
|
|
40
|
+
const request = withCooperativeRequestBudget(options, this.requestTimeoutMs, (budget) => this.schedule(operation, input, options, coordination, read, budget));
|
|
41
|
+
if (coalesceKey) {
|
|
42
|
+
this.identicalReads.set(coalesceKey, request);
|
|
43
|
+
void request.finally(() => {
|
|
44
|
+
if (this.identicalReads.get(coalesceKey) === request) {
|
|
45
|
+
this.identicalReads.delete(coalesceKey);
|
|
46
|
+
}
|
|
47
|
+
}).catch(() => undefined);
|
|
48
|
+
}
|
|
49
|
+
return request;
|
|
50
|
+
}
|
|
51
|
+
schedule(operation, input, options, coordination, read, budget) {
|
|
52
|
+
const replacement = coordination.latestWins
|
|
53
|
+
? this.replaceLatest(coordination.family)
|
|
54
|
+
: undefined;
|
|
55
|
+
const combined = combineSignals(budget.signal, replacement?.signal);
|
|
56
|
+
const { coordination: _coordination, ...transportOptions } = options;
|
|
57
|
+
const withinBudget = requestOptionsWithinBudget({ ...transportOptions, timeoutMs: null }, budget);
|
|
58
|
+
const run = () => this.transport.operation(operation, input, {
|
|
59
|
+
...withinBudget,
|
|
60
|
+
signal: combined.signal
|
|
61
|
+
});
|
|
62
|
+
const queue = read ? this.foregroundQueue : this.mutationQueue;
|
|
63
|
+
const capacity = read ? this.foregroundQueueCapacity : this.mutationQueueCapacity;
|
|
64
|
+
if (queue.length >= capacity) {
|
|
65
|
+
combined.dispose();
|
|
66
|
+
this.releaseLatest(coordination.family, replacement);
|
|
67
|
+
return Promise.reject(connectError("connector_busy", "The selected connection request queue is full.", { operationOutcome: "not_sent", status: 503 }));
|
|
68
|
+
}
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const queued = {
|
|
71
|
+
budget,
|
|
72
|
+
run,
|
|
73
|
+
resolve: (result) => resolve(result),
|
|
74
|
+
reject,
|
|
75
|
+
started: false,
|
|
76
|
+
settled: false
|
|
77
|
+
};
|
|
78
|
+
const abortQueued = () => {
|
|
79
|
+
if (queued.started || queued.settled)
|
|
80
|
+
return;
|
|
81
|
+
queued.settled = true;
|
|
82
|
+
queue.splice(queue.indexOf(queued), 1);
|
|
83
|
+
queued.reject(requestAbortReason(combined.signal));
|
|
84
|
+
combined.dispose();
|
|
85
|
+
this.releaseLatest(coordination.family, replacement);
|
|
86
|
+
};
|
|
87
|
+
combined.signal.addEventListener("abort", abortQueued, { once: true });
|
|
88
|
+
const originalRun = queued.run;
|
|
89
|
+
queued.run = async () => {
|
|
90
|
+
combined.signal.removeEventListener("abort", abortQueued);
|
|
91
|
+
try {
|
|
92
|
+
if (combined.signal.aborted)
|
|
93
|
+
throw requestAbortReason(combined.signal);
|
|
94
|
+
return await originalRun();
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
combined.dispose();
|
|
98
|
+
this.releaseLatest(coordination.family, replacement);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
queue.push(queued);
|
|
102
|
+
this.pump();
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
replaceLatest(family) {
|
|
106
|
+
const replacement = new AbortController();
|
|
107
|
+
this.latestFamilies.get(family)?.abort(connectError("operation_cancelled", "A newer request replaced this read before it changed the collection.", { operationOutcome: "not_sent" }));
|
|
108
|
+
this.latestFamilies.set(family, replacement);
|
|
109
|
+
return replacement;
|
|
110
|
+
}
|
|
111
|
+
releaseLatest(family, controller) {
|
|
112
|
+
if (family && controller && this.latestFamilies.get(family) === controller) {
|
|
113
|
+
this.latestFamilies.delete(family);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
pump() {
|
|
117
|
+
while (this.activeForeground < this.foregroundCapacity) {
|
|
118
|
+
const request = nextLive(this.foregroundQueue);
|
|
119
|
+
if (!request)
|
|
120
|
+
break;
|
|
121
|
+
this.activeForeground += 1;
|
|
122
|
+
this.start(request, () => {
|
|
123
|
+
this.activeForeground -= 1;
|
|
124
|
+
this.pump();
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (!this.mutationActive) {
|
|
128
|
+
const request = nextLive(this.mutationQueue);
|
|
129
|
+
if (request) {
|
|
130
|
+
this.mutationActive = true;
|
|
131
|
+
this.start(request, () => {
|
|
132
|
+
this.mutationActive = false;
|
|
133
|
+
this.pump();
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
start(request, release) {
|
|
139
|
+
request.started = true;
|
|
140
|
+
void request.run().then((result) => {
|
|
141
|
+
if (!request.settled) {
|
|
142
|
+
request.settled = true;
|
|
143
|
+
request.resolve(result);
|
|
144
|
+
}
|
|
145
|
+
}, (error) => {
|
|
146
|
+
if (!request.settled) {
|
|
147
|
+
request.settled = true;
|
|
148
|
+
request.reject(error);
|
|
149
|
+
}
|
|
150
|
+
}).finally(release);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function nextLive(queue) {
|
|
154
|
+
while (queue.length > 0) {
|
|
155
|
+
const request = queue.shift();
|
|
156
|
+
if (!request.settled)
|
|
157
|
+
return request;
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
function validatedCoordination(coordination, read) {
|
|
162
|
+
const resolved = coordination ?? {};
|
|
163
|
+
if (resolved.latestWins && !read) {
|
|
164
|
+
throw new TypeError("latestWins coordination is available only for read operations.");
|
|
165
|
+
}
|
|
166
|
+
if (resolved.latestWins && !resolved.family?.trim()) {
|
|
167
|
+
throw new TypeError("latestWins coordination requires a non-empty family.");
|
|
168
|
+
}
|
|
169
|
+
return resolved;
|
|
170
|
+
}
|
|
171
|
+
function stableRequestKey(operation, input, timeoutMs) {
|
|
172
|
+
return `${operation}:${timeoutMs === undefined ? "default" : String(timeoutMs)}:${stableJson(input)}`;
|
|
173
|
+
}
|
|
174
|
+
function stableJson(value) {
|
|
175
|
+
if (value === null || typeof value !== "object") {
|
|
176
|
+
return JSON.stringify(value) ?? String(value);
|
|
177
|
+
}
|
|
178
|
+
if (Array.isArray(value))
|
|
179
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
180
|
+
return `{${Object.entries(value)
|
|
181
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
182
|
+
.map(([key, nested]) => `${JSON.stringify(key)}:${stableJson(nested)}`)
|
|
183
|
+
.join(",")}}`;
|
|
184
|
+
}
|
|
185
|
+
function combineSignals(primary, secondary) {
|
|
186
|
+
if (!secondary)
|
|
187
|
+
return { signal: primary, dispose: () => undefined };
|
|
188
|
+
const controller = new AbortController();
|
|
189
|
+
const abortPrimary = () => controller.abort(primary.reason);
|
|
190
|
+
const abortSecondary = () => controller.abort(secondary.reason);
|
|
191
|
+
if (primary.aborted)
|
|
192
|
+
abortPrimary();
|
|
193
|
+
else
|
|
194
|
+
primary.addEventListener("abort", abortPrimary, { once: true });
|
|
195
|
+
if (secondary.aborted)
|
|
196
|
+
abortSecondary();
|
|
197
|
+
else
|
|
198
|
+
secondary.addEventListener("abort", abortSecondary, { once: true });
|
|
199
|
+
return {
|
|
200
|
+
signal: controller.signal,
|
|
201
|
+
dispose: () => {
|
|
202
|
+
primary.removeEventListener("abort", abortPrimary);
|
|
203
|
+
secondary.removeEventListener("abort", abortSecondary);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
function positiveCapacity(value, fallback) {
|
|
208
|
+
const resolved = value ?? fallback;
|
|
209
|
+
if (!Number.isSafeInteger(resolved) || resolved <= 0) {
|
|
210
|
+
throw new TypeError("Request coordinator capacities must be positive safe integers.");
|
|
211
|
+
}
|
|
212
|
+
return resolved;
|
|
213
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { StoredToken } from "./internal-types.js";
|
|
2
|
+
import type { GrantKeyStore } from "./crypto.js";
|
|
3
|
+
export declare function storedTokenKeyHandles(token: StoredToken | null, additional?: string): Set<string>;
|
|
4
|
+
export declare function planRetiredGrantKeys(storage: Storage, pendingMutationKey: string, previous: StoredToken | null, current?: string, leased?: Iterable<string>, retainAll?: boolean): {
|
|
5
|
+
retained: string[];
|
|
6
|
+
disposable: string[];
|
|
7
|
+
};
|
|
8
|
+
export declare function removeRetiredGrantKeyMetadata(storage: Storage, tokenKey: string, handle: string): void;
|
|
9
|
+
export declare function deleteUnusedGrantKey(storage: Storage, keyStore: GrantKeyStore, pendingMutationKey: string, tokenKey: string, handle: string): Promise<void>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { pendingMutationKeyHandles, pendingMutationsUseKey } from "./pending-recovery-transports.js";
|
|
2
|
+
import { parseStored } from "./runtime-utils.js";
|
|
3
|
+
export function storedTokenKeyHandles(token, additional) {
|
|
4
|
+
const handles = new Set();
|
|
5
|
+
if (additional)
|
|
6
|
+
handles.add(additional);
|
|
7
|
+
if (token?.keyHandle)
|
|
8
|
+
handles.add(token.keyHandle);
|
|
9
|
+
if (Array.isArray(token?.retiredKeyHandles)) {
|
|
10
|
+
for (const handle of token.retiredKeyHandles) {
|
|
11
|
+
if (typeof handle === "string" && handle.length > 0)
|
|
12
|
+
handles.add(handle);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return handles;
|
|
16
|
+
}
|
|
17
|
+
export function planRetiredGrantKeys(storage, pendingMutationKey, previous, current, leased = [], retainAll = false) {
|
|
18
|
+
const retained = [];
|
|
19
|
+
const disposable = [];
|
|
20
|
+
const candidates = storedTokenKeyHandles(previous);
|
|
21
|
+
for (const handle of pendingMutationKeyHandles(storage, pendingMutationKey)) {
|
|
22
|
+
candidates.add(handle);
|
|
23
|
+
}
|
|
24
|
+
const leasedHandles = new Set(leased);
|
|
25
|
+
for (const handle of leasedHandles)
|
|
26
|
+
candidates.add(handle);
|
|
27
|
+
for (const handle of candidates) {
|
|
28
|
+
if (handle === current)
|
|
29
|
+
continue;
|
|
30
|
+
(retainAll || leasedHandles.has(handle) || pendingMutationsUseKey(storage, pendingMutationKey, handle)
|
|
31
|
+
? retained
|
|
32
|
+
: disposable).push(handle);
|
|
33
|
+
}
|
|
34
|
+
return { retained, disposable };
|
|
35
|
+
}
|
|
36
|
+
export function removeRetiredGrantKeyMetadata(storage, tokenKey, handle) {
|
|
37
|
+
const token = parseStored(storage.getItem(tokenKey));
|
|
38
|
+
if (!token || !Array.isArray(token.retiredKeyHandles))
|
|
39
|
+
return;
|
|
40
|
+
const retiredKeyHandles = token.retiredKeyHandles.filter((candidate) => candidate !== handle);
|
|
41
|
+
if (retiredKeyHandles.length === token.retiredKeyHandles.length)
|
|
42
|
+
return;
|
|
43
|
+
if (retiredKeyHandles.length > 0)
|
|
44
|
+
token.retiredKeyHandles = retiredKeyHandles;
|
|
45
|
+
else
|
|
46
|
+
delete token.retiredKeyHandles;
|
|
47
|
+
storage.setItem(tokenKey, JSON.stringify(token));
|
|
48
|
+
}
|
|
49
|
+
export async function deleteUnusedGrantKey(storage, keyStore, pendingMutationKey, tokenKey, handle) {
|
|
50
|
+
const current = parseStored(storage.getItem(tokenKey));
|
|
51
|
+
if (current?.keyHandle === handle
|
|
52
|
+
|| pendingMutationsUseKey(storage, pendingMutationKey, handle))
|
|
53
|
+
return;
|
|
54
|
+
await keyStore.delete(handle);
|
|
55
|
+
removeRetiredGrantKeyMetadata(storage, tokenKey, handle);
|
|
56
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { FileCapability, GrantEncryption, GrantScope } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
import type { MdbaseApplicationManifest as MdbaseAppManifest } from "./application-contract.js";
|
|
3
|
+
import { MdbaseConnectError } from "./errors.js";
|
|
4
|
+
import type { StoredToken } from "./internal-types.js";
|
|
5
|
+
import type { MdbaseDeviceAuthorization } from "./authorization-types.js";
|
|
6
|
+
/** Resolve bundled declarations and their stable storage identity without loading URLs. */
|
|
7
|
+
export declare function resolveManifestSource(input?: MdbaseAppManifest | string | URL, redirect?: string | URL): {
|
|
8
|
+
manifest: MdbaseAppManifest | string;
|
|
9
|
+
manifestSource: string;
|
|
10
|
+
redirectUri: string;
|
|
11
|
+
};
|
|
12
|
+
type NetworkProblemCode = "notification_registration_failed" | "notification_unregistration_failed" | "notifications_unavailable" | "temporarily_unavailable";
|
|
13
|
+
/** Normalize fetch rejection at the exact I/O boundary. */
|
|
14
|
+
export declare function connectFetch(input: RequestInfo | URL, init: RequestInit | undefined, code: NetworkProblemCode, message: string): Promise<Response>;
|
|
15
|
+
/** Decode JSON without allowing native syntax/body errors across the SDK boundary. */
|
|
16
|
+
export declare function decodeJsonResponse(response: Response, code: string, message: string): Promise<any>;
|
|
17
|
+
export declare function canonicalLoopbackUrl(value: string): string;
|
|
18
|
+
export declare function createPkce(): Promise<{
|
|
19
|
+
verifier: string;
|
|
20
|
+
challenge: string;
|
|
21
|
+
}>;
|
|
22
|
+
export declare function apiError(body: any, fallbackCode: string, fallbackMessage: string, status?: number): MdbaseConnectError;
|
|
23
|
+
export declare function oauthErrorCode(body: any): string | undefined;
|
|
24
|
+
export declare function validAuthorityTokenResponse(value: unknown, collectionId: unknown): boolean;
|
|
25
|
+
export declare function validStoredAuthority(authority: StoredToken["authority"], collectionId: string): boolean;
|
|
26
|
+
export declare function validFileCapability(value: unknown): value is FileCapability;
|
|
27
|
+
export declare function validStoredEncryption(encryption: StoredToken["encryption"], collectionId: string): encryption is GrantEncryption;
|
|
28
|
+
export declare function parseDeviceAuthorization(body: any): MdbaseDeviceAuthorization;
|
|
29
|
+
export declare function parseStored<T>(value: string | null): T | null;
|
|
30
|
+
/** Parse both canonical and N-1 grant shapes so callers can diagnose legacy grants. */
|
|
31
|
+
export declare function parseGrantScope(value: unknown): GrantScope | null;
|
|
32
|
+
/** Only this scope authorizes application data access. */
|
|
33
|
+
export declare function isCanonicalGrantScope(scope: GrantScope): boolean;
|
|
34
|
+
export declare function stripTrailingSlash(value: string): string;
|
|
35
|
+
export declare function defaultManifestSource(): string;
|
|
36
|
+
export declare function defaultRedirectUri(): string;
|
|
37
|
+
export declare function applicationStorageOrigin(manifest: MdbaseAppManifest | string, manifestSource: string, redirectUri: string, registeredPortable: boolean): string;
|
|
38
|
+
export declare function collectionIdFromTokenKey(key: string): string;
|
|
39
|
+
export declare function defaultCallbackUrl(): string;
|
|
40
|
+
export declare function defaultStorage(memoryOnly: boolean): Storage;
|
|
41
|
+
export declare class MemoryStorage implements Storage {
|
|
42
|
+
private readonly values;
|
|
43
|
+
get length(): number;
|
|
44
|
+
clear(): void;
|
|
45
|
+
getItem(key: string): string | null;
|
|
46
|
+
key(index: number): string | null;
|
|
47
|
+
removeItem(key: string): void;
|
|
48
|
+
setItem(key: string, value: string): void;
|
|
49
|
+
}
|
|
50
|
+
export declare function isOpaquePortableManifest(manifest: MdbaseAppManifest | string): boolean;
|
|
51
|
+
export declare function manifestStorageFingerprint(manifest: MdbaseAppManifest): string;
|
|
52
|
+
export {};
|