@mdbase-dev/connect 0.1.0-beta.121 → 0.1.0-beta.124
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -6
- package/dist/application-session.js +51 -27
- package/dist/authority-features.d.ts +29 -0
- package/dist/authority-features.js +84 -0
- package/dist/browser/integrity.json +1 -1
- package/dist/browser/mdbase-connect.min.js +4 -4
- package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
- package/dist/change-events.d.ts +6 -0
- package/dist/change-events.js +95 -0
- package/dist/collection-client.d.ts +13 -3
- package/dist/collection-client.js +98 -140
- package/dist/connection.d.ts +12 -2
- package/dist/connection.js +57 -117
- package/dist/description-cache.d.ts +14 -0
- package/dist/description-cache.js +46 -0
- package/dist/file-crypto.js +10 -6
- package/dist/file-descriptor.d.ts +14 -0
- package/dist/file-descriptor.js +29 -0
- package/dist/file-metadata.d.ts +21 -0
- package/dist/file-metadata.js +112 -0
- package/dist/file-sha256.js +3 -3
- package/dist/files.d.ts +9 -19
- package/dist/files.js +49 -75
- package/dist/index.d.ts +5 -2
- package/dist/index.js +2 -0
- package/dist/local-file-transport.js +18 -16
- package/dist/operation-types.d.ts +155 -14
- package/dist/query-pagination-internals.d.ts +9 -0
- package/dist/query-pagination-internals.js +52 -0
- package/dist/query-pagination.d.ts +7 -3
- package/dist/query-pagination.js +31 -27
- package/dist/query-predicates.d.ts +11 -0
- package/dist/query-predicates.js +15 -0
- package/dist/query-wire.d.ts +61 -0
- package/dist/query-wire.js +110 -0
- package/dist/read-many.d.ts +7 -0
- package/dist/read-many.js +175 -0
- package/dist/record-session.d.ts +3 -2
- package/dist/record-session.js +11 -2
- package/dist/records.d.ts +15 -2
- package/dist/records.js +109 -7
- package/dist/saved-view-pagination.js +20 -20
- package/dist/startup-timing.d.ts +2 -0
- package/dist/startup-timing.js +20 -0
- package/dist/structured-readiness.d.ts +3 -1
- package/dist/structured-readiness.js +21 -1
- package/dist/watch-policy.d.ts +22 -1
- package/dist/watch-policy.js +104 -0
- package/package.json +2 -2
package/dist/connection.js
CHANGED
|
@@ -2,9 +2,13 @@ import { MdbasePeopleClient, requestPeople } from "./people-client.js";
|
|
|
2
2
|
import { capabilityOperationsForContractVersion } from "@mdbase-dev/connect-protocol";
|
|
3
3
|
import { retainedCapabilities, semanticContractVersion, validateAuthorizationSelection } from "./application-contract.js";
|
|
4
4
|
import { MdbaseCollectionClient } from "./collection-client.js";
|
|
5
|
+
import { AuthorityFeatures, authorityCapabilities } from "./authority-features.js";
|
|
6
|
+
import { filePage } from "./file-metadata.js";
|
|
7
|
+
import { wireCollectionDescription } from "./query-wire.js";
|
|
8
|
+
import { CollectionWatchSubscription } from "./watch-policy.js";
|
|
5
9
|
import { ConnectionNotifications } from "./connection-notifications.js";
|
|
6
10
|
import { ConnectionTransport } from "./connection-transport.js";
|
|
7
|
-
import { MdbaseConnectError, connectProblem, operationProblem } from "./errors.js";
|
|
11
|
+
import { MdbaseConnectError, connectError, connectProblem, operationProblem } from "./errors.js";
|
|
8
12
|
import { MdbaseFileClient } from "./files.js";
|
|
9
13
|
import { MdbaseRecords } from "./records.js";
|
|
10
14
|
import { DEFAULT_OPERATIONS } from "./internal-types.js";
|
|
@@ -18,10 +22,12 @@ export class MdbaseConnection {
|
|
|
18
22
|
collectionClient;
|
|
19
23
|
notifications;
|
|
20
24
|
transport;
|
|
25
|
+
authorityFeatures;
|
|
21
26
|
files;
|
|
22
27
|
/** Editable record sessions shared by every view of this connection. */
|
|
23
28
|
records;
|
|
24
29
|
people;
|
|
30
|
+
readMany;
|
|
25
31
|
connectionListeners = new Set();
|
|
26
32
|
constructor(internals, collectionId) {
|
|
27
33
|
this.internals = internals;
|
|
@@ -35,8 +41,34 @@ export class MdbaseConnection {
|
|
|
35
41
|
collectionId,
|
|
36
42
|
internals,
|
|
37
43
|
timeouts: internals.timeouts,
|
|
38
|
-
onChange: () =>
|
|
44
|
+
onChange: () => {
|
|
45
|
+
this.authorityFeatures.refreshLifetime();
|
|
46
|
+
this.emitConnection();
|
|
47
|
+
}
|
|
39
48
|
});
|
|
49
|
+
this.authorityFeatures = new AuthorityFeatures({
|
|
50
|
+
lifetime: () => {
|
|
51
|
+
const token = this.transport.currentToken();
|
|
52
|
+
return JSON.stringify([token?.savedAt, token?.grantId, token?.keyHandle,
|
|
53
|
+
token?.authority?.operationsUrl, token?.authority?.filesUrl, token?.authority?.replicaId,
|
|
54
|
+
token?.authority?.proofPublicKey, token?.clientId, token?.applicationOrigin,
|
|
55
|
+
token?.encryption, token?.scope, token?.operations, token?.fileCapability, this.transport.route]);
|
|
56
|
+
},
|
|
57
|
+
operations: () => this.operations,
|
|
58
|
+
fileCapability: () => this.fileCapability,
|
|
59
|
+
// Catalog TTL/in-flight loads are not evidence for a new authority route.
|
|
60
|
+
// B1 owns this authenticated baseline's sharing, deadline and lifetime.
|
|
61
|
+
describe: async (options) => {
|
|
62
|
+
const value = wireCollectionDescription(await this.transport.performOperation("describe", {}, options));
|
|
63
|
+
if (value.collectionId.toLowerCase() !== this.collectionId.toLowerCase())
|
|
64
|
+
throw connectError("invalid_operation_response", "The authority described a different collection.");
|
|
65
|
+
return connectSuccess(value);
|
|
66
|
+
},
|
|
67
|
+
filesPage: async (folder, signal) => {
|
|
68
|
+
const page = await filePage((method, path, input, signal) => this.transport.files.control(method, path, input, signal), new URLSearchParams({ protocol_version: "1", limit: "1", ...(folder ? { folder } : {}) }), signal);
|
|
69
|
+
return authorityCapabilities(page.authority_capabilities);
|
|
70
|
+
}
|
|
71
|
+
}, internals.timeouts.requestMs);
|
|
40
72
|
this.people = new MdbasePeopleClient({
|
|
41
73
|
request: (resource, options) => requestPeople(resource, options ?? {}, {
|
|
42
74
|
serverUrl: internals.serverUrl, collectionId, requestMs: internals.timeouts.requestMs,
|
|
@@ -50,11 +82,12 @@ export class MdbaseConnection {
|
|
|
50
82
|
downloadChunk: (session, chunkIndex, signal) => this.transport.files.downloadChunk(session, chunkIndex, signal)
|
|
51
83
|
}, {
|
|
52
84
|
downloadPart: (session, partIndex, expectedLength, signal) => this.transport.files.downloadHostedPart(session, partIndex, expectedLength, signal)
|
|
53
|
-
}, internals.timeouts);
|
|
85
|
+
}, internals.timeouts, (id, options) => this.supportsAuthorityFeature(id, options));
|
|
54
86
|
this.records = new MdbaseRecords(this, (path) => this.transport.pendingUpdate(path));
|
|
55
87
|
this.collectionClient = new MdbaseCollectionClient(new CollectionRequestCoordinator({
|
|
56
88
|
operation: (operation, input, requestOptions) => this.transport.performOperation(operation, input, requestOptions)
|
|
57
|
-
}, internals.timeouts.requestMs), internals.timeouts.requestMs);
|
|
89
|
+
}, internals.timeouts.requestMs), internals.timeouts.requestMs, (id, options) => this.supportsAuthorityFeature(id, options));
|
|
90
|
+
this.readMany = this.collectionClient.readMany.bind(this.collectionClient);
|
|
58
91
|
this.notifications = new ConnectionNotifications({
|
|
59
92
|
serverUrl: internals.serverUrl,
|
|
60
93
|
storage: internals.storage,
|
|
@@ -83,6 +116,11 @@ export class MdbaseConnection {
|
|
|
83
116
|
get route() {
|
|
84
117
|
return this.transport.route;
|
|
85
118
|
}
|
|
119
|
+
/** Implementation support for this connection lifetime; not an authorization check. */
|
|
120
|
+
supportsAuthorityFeature(id, options) {
|
|
121
|
+
return this.authorityFeatures.supports(id, options);
|
|
122
|
+
}
|
|
123
|
+
get authorityCapabilities() { return this.authorityFeatures.capabilities; }
|
|
86
124
|
register(options) {
|
|
87
125
|
return captureConnectOutcome(() => this.internals.register(options), REGISTRATION_PROBLEM_CODES);
|
|
88
126
|
}
|
|
@@ -166,6 +204,7 @@ export class MdbaseConnection {
|
|
|
166
204
|
return () => this.connectionListeners.delete(listener);
|
|
167
205
|
}
|
|
168
206
|
notifyStorageChanged() {
|
|
207
|
+
this.authorityFeatures.invalidate();
|
|
169
208
|
this.transport.notifyStorageChanged();
|
|
170
209
|
}
|
|
171
210
|
checkDirectAccess(options) {
|
|
@@ -254,8 +293,13 @@ export class MdbaseConnection {
|
|
|
254
293
|
this.internals.removeToken(this.collectionId, token?.keyHandle, "not_authorized", true);
|
|
255
294
|
this.transport.notifyStorageChanged();
|
|
256
295
|
}
|
|
257
|
-
|
|
258
|
-
|
|
296
|
+
get schemaGeneration() { return this.collectionClient.schemaGeneration; }
|
|
297
|
+
async describe(options) {
|
|
298
|
+
const outcome = await this.collectionClient.describe(options);
|
|
299
|
+
if (outcome.ok && outcome.value.collectionId.toLowerCase() !== this.collectionId.toLowerCase()) {
|
|
300
|
+
return connectFailure(connectProblem("invalid_operation_response", "The authority described a different collection."));
|
|
301
|
+
}
|
|
302
|
+
return outcome;
|
|
259
303
|
}
|
|
260
304
|
changes(input = {}, options) {
|
|
261
305
|
return this.collectionClient.changes(input, options);
|
|
@@ -267,10 +311,14 @@ export class MdbaseConnection {
|
|
|
267
311
|
return this.collectionClient.query(input, options);
|
|
268
312
|
}
|
|
269
313
|
queryPages(input = {}, options = {}) {
|
|
270
|
-
return
|
|
314
|
+
return input.output === "metadata"
|
|
315
|
+
? this.collectionClient.queryPages(input, options)
|
|
316
|
+
: this.collectionClient.queryPages(input, options);
|
|
271
317
|
}
|
|
272
318
|
queryAll(input = {}, options = {}) {
|
|
273
|
-
return
|
|
319
|
+
return input.output === "metadata"
|
|
320
|
+
? this.collectionClient.queryAll(input, options)
|
|
321
|
+
: this.collectionClient.queryAll(input, options);
|
|
274
322
|
}
|
|
275
323
|
listViews(options) {
|
|
276
324
|
return this.collectionClient.listViews(options);
|
|
@@ -492,7 +540,7 @@ export class MdbaseConnection {
|
|
|
492
540
|
if (initial.value.reset) {
|
|
493
541
|
throw new MdbaseConnectError(connectProblem("change_cursor_reset", "The collection change cursor expired. Refresh collection state before subscribing again."));
|
|
494
542
|
}
|
|
495
|
-
return new CollectionWatchSubscription(this.collectionClient, initial.value.cursor, input, input.cursor === undefined ? [] : initial.value.events, this.internals.timeouts.watchStartMs);
|
|
543
|
+
return new CollectionWatchSubscription(options => this.collectionClient.watch(options), initial.value.cursor, input, input.cursor === undefined ? [] : initial.value.events, this.internals.timeouts.watchStartMs);
|
|
496
544
|
}), COLLECTION_CHANGES_PROBLEM_CODES);
|
|
497
545
|
}
|
|
498
546
|
emitConnection() {
|
|
@@ -501,114 +549,6 @@ export class MdbaseConnection {
|
|
|
501
549
|
listener(connection);
|
|
502
550
|
}
|
|
503
551
|
}
|
|
504
|
-
class CollectionWatchSubscription {
|
|
505
|
-
client;
|
|
506
|
-
input;
|
|
507
|
-
watchStartTimeoutMs;
|
|
508
|
-
changes = new Set();
|
|
509
|
-
statuses = new Set();
|
|
510
|
-
problems = new Set();
|
|
511
|
-
controller = new AbortController();
|
|
512
|
-
removeLifetimeAbort;
|
|
513
|
-
currentStatus;
|
|
514
|
-
currentProblem = null;
|
|
515
|
-
pendingChanges;
|
|
516
|
-
constructor(client, cursor, input, pendingChanges, watchStartTimeoutMs) {
|
|
517
|
-
this.client = client;
|
|
518
|
-
this.input = input;
|
|
519
|
-
this.watchStartTimeoutMs = watchStartTimeoutMs;
|
|
520
|
-
this.pendingChanges = [...pendingChanges];
|
|
521
|
-
this.currentStatus = { state: "connected", cursor, recovered: false };
|
|
522
|
-
const lifetimeSignal = input.lifetimeSignal;
|
|
523
|
-
if (lifetimeSignal?.aborted)
|
|
524
|
-
this.close();
|
|
525
|
-
else if (lifetimeSignal) {
|
|
526
|
-
const close = () => this.close();
|
|
527
|
-
lifetimeSignal.addEventListener("abort", close, { once: true });
|
|
528
|
-
this.removeLifetimeAbort = () => lifetimeSignal.removeEventListener("abort", close);
|
|
529
|
-
}
|
|
530
|
-
if (!this.controller.signal.aborted)
|
|
531
|
-
void this.run(cursor);
|
|
532
|
-
}
|
|
533
|
-
get status() {
|
|
534
|
-
return this.currentStatus;
|
|
535
|
-
}
|
|
536
|
-
get problem() {
|
|
537
|
-
return this.currentProblem;
|
|
538
|
-
}
|
|
539
|
-
subscribe(listener, onStatus, onProblem) {
|
|
540
|
-
this.changes.add(listener);
|
|
541
|
-
if (onStatus) {
|
|
542
|
-
this.statuses.add(onStatus);
|
|
543
|
-
onStatus(this.currentStatus);
|
|
544
|
-
}
|
|
545
|
-
if (onProblem) {
|
|
546
|
-
this.problems.add(onProblem);
|
|
547
|
-
if (this.currentProblem)
|
|
548
|
-
onProblem(this.currentProblem);
|
|
549
|
-
}
|
|
550
|
-
for (const change of this.pendingChanges)
|
|
551
|
-
listener(change);
|
|
552
|
-
this.pendingChanges = [];
|
|
553
|
-
return () => {
|
|
554
|
-
this.changes.delete(listener);
|
|
555
|
-
if (onStatus)
|
|
556
|
-
this.statuses.delete(onStatus);
|
|
557
|
-
if (onProblem)
|
|
558
|
-
this.problems.delete(onProblem);
|
|
559
|
-
};
|
|
560
|
-
}
|
|
561
|
-
close() {
|
|
562
|
-
if (this.controller.signal.aborted)
|
|
563
|
-
return;
|
|
564
|
-
this.controller.abort();
|
|
565
|
-
this.removeLifetimeAbort?.();
|
|
566
|
-
this.removeLifetimeAbort = undefined;
|
|
567
|
-
const cursor = "cursor" in this.currentStatus ? this.currentStatus.cursor : undefined;
|
|
568
|
-
this.publishStatus({ state: "closed", ...(cursor === undefined ? {} : { cursor }) });
|
|
569
|
-
}
|
|
570
|
-
async run(cursor) {
|
|
571
|
-
let firstStatus = true;
|
|
572
|
-
const iterator = this.client.watch({
|
|
573
|
-
cursor,
|
|
574
|
-
pollIntervalMs: this.input.pollIntervalMs,
|
|
575
|
-
retry: this.input.retry,
|
|
576
|
-
signal: this.controller.signal,
|
|
577
|
-
timeoutMs: this.watchStartTimeoutMs,
|
|
578
|
-
onStatus: (status) => {
|
|
579
|
-
if (firstStatus && status.state === "connecting") {
|
|
580
|
-
firstStatus = false;
|
|
581
|
-
return;
|
|
582
|
-
}
|
|
583
|
-
firstStatus = false;
|
|
584
|
-
this.publishStatus(status);
|
|
585
|
-
}
|
|
586
|
-
});
|
|
587
|
-
try {
|
|
588
|
-
for await (const outcome of iterator) {
|
|
589
|
-
if (this.controller.signal.aborted)
|
|
590
|
-
return;
|
|
591
|
-
if (!outcome.ok) {
|
|
592
|
-
this.currentProblem = outcome.problem;
|
|
593
|
-
for (const listener of this.problems)
|
|
594
|
-
listener(outcome.problem);
|
|
595
|
-
return;
|
|
596
|
-
}
|
|
597
|
-
for (const listener of this.changes)
|
|
598
|
-
listener(outcome.value);
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
finally {
|
|
602
|
-
if (!this.controller.signal.aborted)
|
|
603
|
-
this.close();
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
publishStatus(status) {
|
|
607
|
-
this.currentStatus = status;
|
|
608
|
-
for (const listener of this.statuses)
|
|
609
|
-
listener(status);
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
552
|
function syncRequestOptions(options, defaultTimeoutMs) {
|
|
613
553
|
return {
|
|
614
554
|
...options,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CollectionDescription, DescribeOptions, MdbaseCollectionTransport } from "./operation-types.js";
|
|
2
|
+
import { type CollectionDescriptionProblemCode, type ConnectOutcome } from "./outcomes.js";
|
|
3
|
+
/** One collection's description lifecycle; no background observer or persisted schema state. */
|
|
4
|
+
export declare class CollectionDescriptionCache {
|
|
5
|
+
private readonly transport;
|
|
6
|
+
private readonly requestTimeoutMs;
|
|
7
|
+
private generation;
|
|
8
|
+
private description?;
|
|
9
|
+
private describing?;
|
|
10
|
+
constructor(transport: MdbaseCollectionTransport, requestTimeoutMs: number | null);
|
|
11
|
+
get schemaGeneration(): number;
|
|
12
|
+
invalidate(): void;
|
|
13
|
+
describe(options?: DescribeOptions): Promise<ConnectOutcome<CollectionDescription, CollectionDescriptionProblemCode>>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { COLLECTION_DESCRIPTION_PROBLEM_CODES, captureConnectOutcome, connectSuccess } from "./outcomes.js";
|
|
2
|
+
import { withRequestBudget } from "./request-budget.js";
|
|
3
|
+
import { wireCollectionDescription } from "./query-wire.js";
|
|
4
|
+
/** One collection's description lifecycle; no background observer or persisted schema state. */
|
|
5
|
+
export class CollectionDescriptionCache {
|
|
6
|
+
transport;
|
|
7
|
+
requestTimeoutMs;
|
|
8
|
+
generation = 0;
|
|
9
|
+
description;
|
|
10
|
+
describing;
|
|
11
|
+
constructor(transport, requestTimeoutMs) {
|
|
12
|
+
this.transport = transport;
|
|
13
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
14
|
+
}
|
|
15
|
+
get schemaGeneration() { return this.generation; }
|
|
16
|
+
invalidate() {
|
|
17
|
+
this.generation += 1;
|
|
18
|
+
this.description = undefined;
|
|
19
|
+
this.describing = undefined;
|
|
20
|
+
}
|
|
21
|
+
describe(options = {}) {
|
|
22
|
+
return captureConnectOutcome(() => withRequestBudget(options, this.requestTimeoutMs, async () => {
|
|
23
|
+
if (!options.fresh && this.description && Date.now() < this.description.expiresAt) {
|
|
24
|
+
return connectSuccess(this.description.value);
|
|
25
|
+
}
|
|
26
|
+
if (!this.describing) {
|
|
27
|
+
this.description = undefined;
|
|
28
|
+
const generation = this.generation;
|
|
29
|
+
// The shared load has its own budget. Cancelling one waiter cannot cancel another.
|
|
30
|
+
const request = captureConnectOutcome(() => withRequestBudget({}, this.requestTimeoutMs, (budget) => this.transport.operation("describe", {}, { signal: budget.signal, timeoutMs: this.requestTimeoutMs })), COLLECTION_DESCRIPTION_PROBLEM_CODES).then((outcome) => {
|
|
31
|
+
if (!outcome.ok)
|
|
32
|
+
return outcome;
|
|
33
|
+
const value = wireCollectionDescription(outcome.value);
|
|
34
|
+
if (this.generation === generation)
|
|
35
|
+
this.description = { value, expiresAt: Date.now() + 60_000 };
|
|
36
|
+
return connectSuccess(value, outcome.diagnostics);
|
|
37
|
+
}).finally(() => {
|
|
38
|
+
if (this.describing === request)
|
|
39
|
+
this.describing = undefined;
|
|
40
|
+
});
|
|
41
|
+
this.describing = request;
|
|
42
|
+
}
|
|
43
|
+
return this.describing;
|
|
44
|
+
}), COLLECTION_DESCRIPTION_PROBLEM_CODES).then((outcome) => outcome.ok ? outcome.value : outcome);
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/file-crypto.js
CHANGED
|
@@ -42,9 +42,9 @@ export class GrantFileTransferCipher {
|
|
|
42
42
|
const payload = await crypto.subtle.encrypt({
|
|
43
43
|
name: "AES-GCM",
|
|
44
44
|
iv: chunkNonce(header.chunk_index),
|
|
45
|
-
additionalData:
|
|
45
|
+
additionalData: webCryptoBytes(authenticatedData),
|
|
46
46
|
tagLength: 128
|
|
47
|
-
}, this.key,
|
|
47
|
+
}, this.key, webCryptoBytes(plaintext));
|
|
48
48
|
return encodeFileFrame({ kind, header, payload: new Uint8Array(payload) });
|
|
49
49
|
}
|
|
50
50
|
async decryptChunk(encoded) {
|
|
@@ -55,9 +55,9 @@ export class GrantFileTransferCipher {
|
|
|
55
55
|
const plaintext = await crypto.subtle.decrypt({
|
|
56
56
|
name: "AES-GCM",
|
|
57
57
|
iv: chunkNonce(frame.header.chunk_index),
|
|
58
|
-
additionalData:
|
|
58
|
+
additionalData: webCryptoBytes(authenticatedData),
|
|
59
59
|
tagLength: 128
|
|
60
|
-
}, this.key,
|
|
60
|
+
}, this.key, webCryptoBytes(frame.payload));
|
|
61
61
|
return new Uint8Array(plaintext);
|
|
62
62
|
}
|
|
63
63
|
catch {
|
|
@@ -127,6 +127,10 @@ function chunkNonce(index) {
|
|
|
127
127
|
new DataView(nonce.buffer).setBigUint64(4, BigInt(index), false);
|
|
128
128
|
return nonce;
|
|
129
129
|
}
|
|
130
|
-
function
|
|
131
|
-
|
|
130
|
+
function webCryptoBytes(bytes) {
|
|
131
|
+
// WebCrypto snapshots BufferSource synchronously, including the view bounds.
|
|
132
|
+
// SharedArrayBuffer is not a valid BufferSource and still needs an owned copy.
|
|
133
|
+
return bytes.buffer instanceof ArrayBuffer
|
|
134
|
+
? new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
135
|
+
: Uint8Array.from(bytes);
|
|
132
136
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CollectionFileDescriptor as WireFile } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
export declare const FILE_ID: RegExp;
|
|
3
|
+
export interface CollectionFileDescriptor {
|
|
4
|
+
fileId: string;
|
|
5
|
+
path: string;
|
|
6
|
+
revision: string;
|
|
7
|
+
contentDigest: `sha256:${string}`;
|
|
8
|
+
size: number;
|
|
9
|
+
mediaType?: string;
|
|
10
|
+
mediaClass: import("@mdbase-dev/connect-protocol").FileMediaClass;
|
|
11
|
+
modifiedAt: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function validFileDescriptor(value: unknown): value is WireFile;
|
|
14
|
+
export declare function clientFileDescriptor(file: WireFile): CollectionFileDescriptor;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { connectError } from "./errors.js";
|
|
2
|
+
import { SHA256_DIGEST } from "./file-transfer-internals.js";
|
|
3
|
+
// Match files.v1's UUID format (including nil), not crypto transfer-version rules.
|
|
4
|
+
export const FILE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
5
|
+
export function validFileDescriptor(value) {
|
|
6
|
+
if (!value || typeof value !== "object")
|
|
7
|
+
return false;
|
|
8
|
+
const file = value;
|
|
9
|
+
return typeof file.file_id === "string" && FILE_ID.test(file.file_id)
|
|
10
|
+
&& typeof file.path === "string" && typeof file.revision === "string"
|
|
11
|
+
&& typeof file.content_digest === "string" && SHA256_DIGEST.test(file.content_digest)
|
|
12
|
+
&& Number.isSafeInteger(file.size) && file.size >= 0
|
|
13
|
+
&& ["image", "audio", "video", "pdf", "other"].includes(file.media_class)
|
|
14
|
+
&& typeof file.modified_at === "string" && (file.media_type === undefined || typeof file.media_type === "string");
|
|
15
|
+
}
|
|
16
|
+
export function clientFileDescriptor(file) {
|
|
17
|
+
if (!validFileDescriptor(file))
|
|
18
|
+
throw connectError("invalid_operation_response", "The authority returned an invalid file descriptor.");
|
|
19
|
+
return {
|
|
20
|
+
fileId: file.file_id,
|
|
21
|
+
path: file.path,
|
|
22
|
+
revision: file.revision,
|
|
23
|
+
contentDigest: file.content_digest,
|
|
24
|
+
size: file.size,
|
|
25
|
+
...(file.media_type ? { mediaType: file.media_type } : {}),
|
|
26
|
+
mediaClass: file.media_class,
|
|
27
|
+
modifiedAt: file.modified_at
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FileCapability, ListFilesPage } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
import type { ConnectRequestOptions } from "./operation-types.js";
|
|
3
|
+
import { type ConnectOutcome } from "./outcomes.js";
|
|
4
|
+
import { type CollectionFileDescriptor } from "./file-descriptor.js";
|
|
5
|
+
export interface MdbaseFileListOptions extends ConnectRequestOptions {
|
|
6
|
+
folder?: string;
|
|
7
|
+
pageSize?: number;
|
|
8
|
+
/** Called while a cold or changed collection builds its verified binary index. */
|
|
9
|
+
onIndexing?: () => void;
|
|
10
|
+
}
|
|
11
|
+
export type MdbaseFileStatTarget = {
|
|
12
|
+
path: string;
|
|
13
|
+
fileId?: never;
|
|
14
|
+
} | {
|
|
15
|
+
path?: never;
|
|
16
|
+
fileId: string;
|
|
17
|
+
};
|
|
18
|
+
export type FileControlRequest = <Result>(method: "GET" | "POST" | "DELETE", path?: string, input?: unknown, signal?: AbortSignal) => Promise<Result>;
|
|
19
|
+
export declare function filePage(request: FileControlRequest, query: URLSearchParams, signal?: AbortSignal, onIndexing?: () => void): Promise<ListFilesPage>;
|
|
20
|
+
export declare function listFiles(capability: () => FileCapability | null, request: FileControlRequest, options: MdbaseFileListOptions, timeoutMs: number | null): AsyncGenerator<CollectionFileDescriptor>;
|
|
21
|
+
export declare function statFile(capability: () => FileCapability | null, request: FileControlRequest, supports: (id: string, options?: ConnectRequestOptions) => Promise<ConnectOutcome<boolean>>, list: (options: MdbaseFileListOptions) => AsyncIterable<CollectionFileDescriptor>, target: MdbaseFileStatTarget, options: ConnectRequestOptions, timeoutMs: number | null): Promise<ConnectOutcome<CollectionFileDescriptor | null>>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { abortableDelay } from "./async.js";
|
|
2
|
+
import { authorityCapabilities } from "./authority-features.js";
|
|
3
|
+
import { connectError, MdbaseConnectError } from "./errors.js";
|
|
4
|
+
import { normalizeFileError, throwIfAborted, validPageSize } from "./file-transfer-internals.js";
|
|
5
|
+
import { ALL_CONNECT_PROBLEM_CODES, captureConnectOutcome } from "./outcomes.js";
|
|
6
|
+
import { createRequestBudget, withRequestBudget } from "./request-budget.js";
|
|
7
|
+
import { clientFileDescriptor, FILE_ID } from "./file-descriptor.js";
|
|
8
|
+
export async function filePage(request, query, signal, onIndexing) {
|
|
9
|
+
while (true) {
|
|
10
|
+
throwIfAborted(signal);
|
|
11
|
+
try {
|
|
12
|
+
const page = await request("GET", `?${query}`, undefined, signal);
|
|
13
|
+
if (page?.protocol_version !== 1 || page.type !== "files_page" || !Array.isArray(page.files)
|
|
14
|
+
|| (page.next !== undefined && (typeof page.next !== "string" || !page.next))) {
|
|
15
|
+
throw connectError("invalid_operation_response", "The authority returned an invalid file page.");
|
|
16
|
+
}
|
|
17
|
+
authorityCapabilities(page.authority_capabilities);
|
|
18
|
+
return page;
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
const normalized = normalizeFileError(error);
|
|
22
|
+
if (normalized.code !== "file_index_warming")
|
|
23
|
+
throw normalized;
|
|
24
|
+
onIndexing?.();
|
|
25
|
+
await abortableDelay(500, signal);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function* listFiles(capability, request, options, timeoutMs) {
|
|
30
|
+
const budget = createRequestBudget(options, timeoutMs);
|
|
31
|
+
try {
|
|
32
|
+
requireList(capability());
|
|
33
|
+
let after;
|
|
34
|
+
do {
|
|
35
|
+
const query = new URLSearchParams({
|
|
36
|
+
protocol_version: "1",
|
|
37
|
+
...(options.folder ? { folder: options.folder } : {}),
|
|
38
|
+
...(after ? { after } : {}),
|
|
39
|
+
...(options.pageSize ? { limit: String(validPageSize(options.pageSize)) } : {})
|
|
40
|
+
});
|
|
41
|
+
const page = await filePage(request, query, budget.signal, options.onIndexing);
|
|
42
|
+
for (const file of page.files) {
|
|
43
|
+
throwIfAborted(budget.signal);
|
|
44
|
+
yield clientFileDescriptor(file);
|
|
45
|
+
}
|
|
46
|
+
after = page.next;
|
|
47
|
+
} while (after);
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
budget.dispose();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function statFile(capability, request, supports, list, target, options, timeoutMs) {
|
|
54
|
+
return captureConnectOutcome(() => withRequestBudget(options, timeoutMs, async (budget) => {
|
|
55
|
+
requireList(capability());
|
|
56
|
+
if (!target || typeof target !== "object" || Object.keys(target).length !== 1
|
|
57
|
+
|| !(Object.hasOwn(target, "path") && typeof target.path === "string"
|
|
58
|
+
|| Object.hasOwn(target, "fileId") && typeof target.fileId === "string" && FILE_ID.test(target.fileId))) {
|
|
59
|
+
throw connectError("invalid_request", "File stat requires exactly one path or UUID fileId.");
|
|
60
|
+
}
|
|
61
|
+
// Collection-relative wire syntax only. Eligibility, exclusion rules and
|
|
62
|
+
// filesystem access remain authority-owned, including during legacy listing.
|
|
63
|
+
if (target.path !== undefined && (target.path.length > 1024 || target.path.includes("\\")
|
|
64
|
+
|| target.path.split("/").some(part => !part || part.startsWith(".")))) {
|
|
65
|
+
throw connectError("invalid_request", "File stat requires a collection-relative file path.");
|
|
66
|
+
}
|
|
67
|
+
const supported = await supports("files-stat-v1", { signal: budget.signal, timeoutMs: null });
|
|
68
|
+
if (!supported.ok)
|
|
69
|
+
throw new MdbaseConnectError(supported.problem);
|
|
70
|
+
throwIfAborted(budget.signal);
|
|
71
|
+
if (supported.value) {
|
|
72
|
+
const result = await request("POST", "stat", {
|
|
73
|
+
protocol_version: 1, type: "stat_file",
|
|
74
|
+
...(target.path !== undefined ? { path: target.path } : { file_id: target.fileId })
|
|
75
|
+
}, budget.signal);
|
|
76
|
+
if (result?.protocol_version !== 1 || result.type !== "file_stat" || result.file === undefined) {
|
|
77
|
+
throw connectError("invalid_operation_response", "The authority returned an invalid file stat.");
|
|
78
|
+
}
|
|
79
|
+
if (result.file === null)
|
|
80
|
+
return null;
|
|
81
|
+
const file = clientFileDescriptor(result.file);
|
|
82
|
+
if (target.path !== undefined ? portableKey(file.path) !== portableKey(target.path)
|
|
83
|
+
: file.fileId.toLowerCase() !== target.fileId.toLowerCase()) {
|
|
84
|
+
throw connectError("invalid_operation_response", "The authority returned a stat for a different file.");
|
|
85
|
+
}
|
|
86
|
+
return file;
|
|
87
|
+
}
|
|
88
|
+
// Consumers: Writer/Reader/editor/TaskNotes against late-updated authorities.
|
|
89
|
+
// Remove once the minimum files-stat authority, all consumer pins, and N-1
|
|
90
|
+
// rollback/connection-cache windows close. Listing is not point-cost or atomic.
|
|
91
|
+
const folder = target.path?.includes("/") ? target.path.slice(0, target.path.lastIndexOf("/")) : undefined;
|
|
92
|
+
for await (const file of list({ folder, signal: budget.signal, timeoutMs: null })) {
|
|
93
|
+
throwIfAborted(budget.signal);
|
|
94
|
+
if (target.path !== undefined
|
|
95
|
+
? portableKey(file.path) === portableKey(target.path)
|
|
96
|
+
: file.fileId.toLowerCase() === target.fileId.toLowerCase())
|
|
97
|
+
return file;
|
|
98
|
+
}
|
|
99
|
+
throwIfAborted(budget.signal);
|
|
100
|
+
return null;
|
|
101
|
+
}), ALL_CONNECT_PROBLEM_CODES);
|
|
102
|
+
}
|
|
103
|
+
function requireList(capability) {
|
|
104
|
+
if (!capability?.actions.includes("list"))
|
|
105
|
+
throw connectError("not_authorized", "This connection is not authorized to list files.");
|
|
106
|
+
}
|
|
107
|
+
// File protocol portable identity comparison, not collection eligibility validation.
|
|
108
|
+
function portableKey(path) {
|
|
109
|
+
// Match Connect's NFC + per-Unicode-scalar lowercase + NFC key, not JS's
|
|
110
|
+
// context-sensitive whole-string lowercase (e.g. Greek final sigma).
|
|
111
|
+
return Array.from(path.normalize("NFC"), scalar => scalar.toLowerCase()).join("").normalize("NFC");
|
|
112
|
+
}
|
package/dist/file-sha256.js
CHANGED
|
@@ -53,7 +53,7 @@ export class IncrementalSha256 {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
while (offset + 64 <= input.byteLength) {
|
|
56
|
-
this.compress(input
|
|
56
|
+
this.compress(input, offset);
|
|
57
57
|
offset += 64;
|
|
58
58
|
}
|
|
59
59
|
if (offset < input.byteLength) {
|
|
@@ -82,10 +82,10 @@ export class IncrementalSha256 {
|
|
|
82
82
|
.map((word) => word.toString(16).padStart(8, "0"))
|
|
83
83
|
.join("");
|
|
84
84
|
}
|
|
85
|
-
compress(block) {
|
|
85
|
+
compress(block, blockOffset = 0) {
|
|
86
86
|
const words = this.words;
|
|
87
87
|
for (let index = 0; index < 16; index += 1) {
|
|
88
|
-
const offset = index * 4;
|
|
88
|
+
const offset = blockOffset + index * 4;
|
|
89
89
|
words[index] = ((block[offset] << 24)
|
|
90
90
|
| (block[offset + 1] << 16)
|
|
91
91
|
| (block[offset + 2] << 8)
|
package/dist/files.d.ts
CHANGED
|
@@ -1,18 +1,13 @@
|
|
|
1
|
+
import { type CollectionFileDescriptor } from "./file-descriptor.js";
|
|
2
|
+
export type { CollectionFileDescriptor } from "./file-descriptor.js";
|
|
1
3
|
import type { FileCapability, FileTransferSession } from "@mdbase-dev/connect-protocol";
|
|
4
|
+
import { type FileControlRequest, type MdbaseFileListOptions, type MdbaseFileStatTarget } from "./file-metadata.js";
|
|
5
|
+
export type { MdbaseFileListOptions, MdbaseFileStatTarget } from "./file-metadata.js";
|
|
6
|
+
import { type ConnectOutcome } from "./outcomes.js";
|
|
2
7
|
import { type FileSource } from "./file-transfer-internals.js";
|
|
3
8
|
import type { ConnectRequestOptions } from "./operation-types.js";
|
|
4
9
|
import { type ResolvedConnectTimeouts } from "./request-budget.js";
|
|
5
10
|
export type MdbaseFileSource = FileSource;
|
|
6
|
-
export interface CollectionFileDescriptor {
|
|
7
|
-
fileId: string;
|
|
8
|
-
path: string;
|
|
9
|
-
revision: string;
|
|
10
|
-
contentDigest: `sha256:${string}`;
|
|
11
|
-
size: number;
|
|
12
|
-
mediaType?: string;
|
|
13
|
-
mediaClass: import("@mdbase-dev/connect-protocol").FileMediaClass;
|
|
14
|
-
modifiedAt: string;
|
|
15
|
-
}
|
|
16
11
|
export interface MdbaseFileDeleteReceipt {
|
|
17
12
|
protocolVersion: 1;
|
|
18
13
|
type: "file_deleted";
|
|
@@ -38,12 +33,6 @@ export interface MdbaseFileProgress {
|
|
|
38
33
|
transferredBytes: number;
|
|
39
34
|
totalBytes: number;
|
|
40
35
|
}
|
|
41
|
-
export interface MdbaseFileListOptions extends ConnectRequestOptions {
|
|
42
|
-
folder?: string;
|
|
43
|
-
pageSize?: number;
|
|
44
|
-
/** Called while a cold or changed collection builds its verified binary index. */
|
|
45
|
-
onIndexing?: () => void;
|
|
46
|
-
}
|
|
47
36
|
export interface MdbaseFileUploadOptions extends ConnectRequestOptions {
|
|
48
37
|
mediaType?: string;
|
|
49
38
|
ifRevision?: string;
|
|
@@ -76,7 +65,6 @@ export interface MdbaseFramedFileTransport {
|
|
|
76
65
|
export interface MdbaseHostedFileTransport {
|
|
77
66
|
downloadPart(session: FileTransferSession, partIndex: number, expectedLength: number, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>>;
|
|
78
67
|
}
|
|
79
|
-
type ControlRequest = <Result>(method: "GET" | "POST" | "DELETE", path?: string, input?: unknown, signal?: AbortSignal) => Promise<Result>;
|
|
80
68
|
/** Ergonomic facade over resumable, authority-specific file delivery. */
|
|
81
69
|
export declare class MdbaseFileClient {
|
|
82
70
|
private readonly capability;
|
|
@@ -84,8 +72,11 @@ export declare class MdbaseFileClient {
|
|
|
84
72
|
private readonly framed?;
|
|
85
73
|
private readonly hosted?;
|
|
86
74
|
private readonly timeouts;
|
|
87
|
-
|
|
75
|
+
private readonly supportsAuthorityFeature;
|
|
76
|
+
constructor(capability: () => FileCapability | null, request: FileControlRequest, framed?: MdbaseFramedFileTransport | undefined, hosted?: MdbaseHostedFileTransport | undefined, timeouts?: ResolvedConnectTimeouts, supportsAuthorityFeature?: (id: string, options?: ConnectRequestOptions) => Promise<ConnectOutcome<boolean>>);
|
|
88
77
|
list(options?: MdbaseFileListOptions): AsyncGenerator<CollectionFileDescriptor>;
|
|
78
|
+
/** Current metadata lookup; requires file list approval, not file byte access. */
|
|
79
|
+
stat(target: MdbaseFileStatTarget, options?: ConnectRequestOptions): Promise<ConnectOutcome<CollectionFileDescriptor | null>>;
|
|
89
80
|
upload(path: string, source: MdbaseFileSource, options?: MdbaseFileUploadOptions): Promise<CollectionFileDescriptor>;
|
|
90
81
|
private uploadWithinBudget;
|
|
91
82
|
/**
|
|
@@ -112,4 +103,3 @@ export declare class MdbaseFileClient {
|
|
|
112
103
|
private requireAction;
|
|
113
104
|
private abort;
|
|
114
105
|
}
|
|
115
|
-
export {};
|