@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.
Files changed (49) hide show
  1. package/README.md +161 -6
  2. package/dist/application-session.js +51 -27
  3. package/dist/authority-features.d.ts +29 -0
  4. package/dist/authority-features.js +84 -0
  5. package/dist/browser/integrity.json +1 -1
  6. package/dist/browser/mdbase-connect.min.js +4 -4
  7. package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
  8. package/dist/change-events.d.ts +6 -0
  9. package/dist/change-events.js +95 -0
  10. package/dist/collection-client.d.ts +13 -3
  11. package/dist/collection-client.js +98 -140
  12. package/dist/connection.d.ts +12 -2
  13. package/dist/connection.js +57 -117
  14. package/dist/description-cache.d.ts +14 -0
  15. package/dist/description-cache.js +46 -0
  16. package/dist/file-crypto.js +10 -6
  17. package/dist/file-descriptor.d.ts +14 -0
  18. package/dist/file-descriptor.js +29 -0
  19. package/dist/file-metadata.d.ts +21 -0
  20. package/dist/file-metadata.js +112 -0
  21. package/dist/file-sha256.js +3 -3
  22. package/dist/files.d.ts +9 -19
  23. package/dist/files.js +49 -75
  24. package/dist/index.d.ts +5 -2
  25. package/dist/index.js +2 -0
  26. package/dist/local-file-transport.js +18 -16
  27. package/dist/operation-types.d.ts +155 -14
  28. package/dist/query-pagination-internals.d.ts +9 -0
  29. package/dist/query-pagination-internals.js +52 -0
  30. package/dist/query-pagination.d.ts +7 -3
  31. package/dist/query-pagination.js +31 -27
  32. package/dist/query-predicates.d.ts +11 -0
  33. package/dist/query-predicates.js +15 -0
  34. package/dist/query-wire.d.ts +61 -0
  35. package/dist/query-wire.js +110 -0
  36. package/dist/read-many.d.ts +7 -0
  37. package/dist/read-many.js +175 -0
  38. package/dist/record-session.d.ts +3 -2
  39. package/dist/record-session.js +11 -2
  40. package/dist/records.d.ts +15 -2
  41. package/dist/records.js +109 -7
  42. package/dist/saved-view-pagination.js +20 -20
  43. package/dist/startup-timing.d.ts +2 -0
  44. package/dist/startup-timing.js +20 -0
  45. package/dist/structured-readiness.d.ts +3 -1
  46. package/dist/structured-readiness.js +21 -1
  47. package/dist/watch-policy.d.ts +22 -1
  48. package/dist/watch-policy.js +104 -0
  49. package/package.json +2 -2
@@ -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: () => this.emitConnection()
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
- describe(options) {
258
- return this.collectionClient.describe(options);
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 this.collectionClient.queryPages(input, options);
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 this.collectionClient.queryAll(input, options);
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
+ }
@@ -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: arrayBuffer(authenticatedData),
45
+ additionalData: webCryptoBytes(authenticatedData),
46
46
  tagLength: 128
47
- }, this.key, arrayBuffer(plaintext));
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: arrayBuffer(authenticatedData),
58
+ additionalData: webCryptoBytes(authenticatedData),
59
59
  tagLength: 128
60
- }, this.key, arrayBuffer(frame.payload));
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 arrayBuffer(bytes) {
131
- return Uint8Array.from(bytes).buffer;
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
+ }
@@ -53,7 +53,7 @@ export class IncrementalSha256 {
53
53
  }
54
54
  }
55
55
  while (offset + 64 <= input.byteLength) {
56
- this.compress(input.subarray(offset, offset + 64));
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
- constructor(capability: () => FileCapability | null, request: ControlRequest, framed?: MdbaseFramedFileTransport | undefined, hosted?: MdbaseHostedFileTransport | undefined, timeouts?: ResolvedConnectTimeouts);
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 {};