@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
package/README.md CHANGED
@@ -85,7 +85,7 @@ if (!watch.ok) {
85
85
  renderProblem(watch.problem);
86
86
  } else {
87
87
  const unsubscribeWatch = watch.value.subscribe(
88
- (change) => console.log(change.type, change.payload.path),
88
+ (change) => console.log(change.kind, change.kind === "record.updated" ? change.path : change.raw),
89
89
  renderWatchStatus,
90
90
  renderProblem
91
91
  );
@@ -158,9 +158,22 @@ pending mutation, never sent again, even after a reload: `open()` finds the
158
158
  record's interrupted update (by a digest, so its path is never stored) and
159
159
  resumes it as `recovery`. `follow()` refreshes open sessions from the
160
160
  watch, follows renames, marks deletions (local text is kept), and re-reads
161
- everything after a change gap. Releasing the last view never cancels a pending
162
- save. `snapshot.state` is one of `saved`, `unsaved`, `saving`, `conflict`,
163
- `recovery`, `error` or `deleted`; `snapshot.problem` says why a write failed.
161
+ everything after a change gap. It admits at most four session refreshes at once
162
+ and coalesces repeated events: events during a refresh cause one follow-up read.
163
+ Transient read failures retry three times after 100, 200 and 400 ms; cancellation
164
+ and authorization/input failures do not retry. Reconciliation waits for exact
165
+ write recovery, rather than dropping the missed change. Stopping the last
166
+ follower drops queued refreshes and retries; admitted reads still settle.
167
+ Releasing the last view never cancels a pending save. `snapshot.state` is one of
168
+ `saved`, `unsaved`, `saving`, `conflict`, `recovery`, `error` or `deleted`;
169
+ `snapshot.problem` says why a write, recovery or refresh failed. A failed
170
+ refresh keeps the last accepted record and local edits, but a clean session
171
+ reports `error`, not `saved`, until an authoritative read or acknowledged write
172
+ succeeds (even if the revision is unchanged). No new states or method signatures
173
+ are required; consumers upgrading from earlier SDKs should render `error` for
174
+ read failures as well as write failures. Explicit `session.refresh()` still
175
+ performs one queued read and returns its outcome; only `follow()` schedules
176
+ coalescing and retries.
164
177
  The session is an external store, so `externalStore(session)` works with
165
178
  `useSyncExternalStore`. Applications whose records live behind their own
166
179
  repository construct `MdbaseRecordSession` with an `MdbaseRecordSessionAdapter`
@@ -198,6 +211,69 @@ versioned capabilities in their manifest; they never maintain a parallel array
198
211
  of protocol operations. Use `getSnapshot()` and `subscribe()` directly or
199
212
  through your framework's external-store integration.
200
213
 
214
+ ### Authority implementation features
215
+
216
+ `await connection.supportsAuthorityFeature(id, options)` returns a
217
+ `ConnectOutcome<boolean>`, not permission. Discovery uses the current authority's
218
+ approved `describe`, or a one-item file listing within the approved scope for
219
+ file-only connections. Concurrent calls share discovery; each caller retains its
220
+ own cancellation/deadline. Missing or unknown advertisements mean unsupported.
221
+ Discovery failures remain failures, never evidence for a legacy fallback.
222
+
223
+ `connection.authorityCapabilities` is an immutable, connection-lifetime cache;
224
+ before discovery it is empty. Normalized `describe()` responses also expose
225
+ immutable `authorityCapabilities`. Reauthorization, reconnect, authority
226
+ replacement and direct/relay route changes discard cached support. Nothing is
227
+ persisted, and relay/control-plane capabilities are not authority evidence.
228
+
229
+ ```ts
230
+ const support = await connection.supportsAuthorityFeature("query-metadata-v1");
231
+ if (!support.ok) renderProblem(support.problem);
232
+ else if (support.value) {
233
+ const result = await connection.query({
234
+ output: "metadata", select: ["source", "file.path"]
235
+ });
236
+ if (result.ok) {
237
+ // Required path, types, revision and values; no frontmatter/file/body/document.
238
+ for (const row of result.value.results) useSummary(row.path, row.revision, row.values);
239
+ }
240
+ } else {
241
+ // Late-updated authorities: keep the ordinary query, not a metadata cast.
242
+ const result = await connection.query({ select: ["source", "file.path"] });
243
+ if (result.ok) useLegacyRows(result.value.results);
244
+ }
245
+
246
+ const stat = await connection.files.stat({ path: "Assets/book.pdf" });
247
+ // Or: connection.files.stat({ fileId: descriptor.fileId }, { signal });
248
+ if (!stat.ok) renderProblem(stat.problem);
249
+ else if (stat.value) useFile(stat.value); // null means missing or invisible.
250
+ ```
251
+
252
+ `query`, `queryPages` and `queryAll` discriminate metadata rows in their return
253
+ and progress types. Explicit metadata calls require `query-metadata-v1` and fail
254
+ with `unsupported_operation` **before dispatch** when absent. They reject
255
+ `includeBody:true`. Ordinary queries and their inputs remain unchanged; their
256
+ optional `revision` is the authority's exact-source token, not a semantic-cache
257
+ freshness guarantee. Never attach a query token to a later point-read body.
258
+ Advanced `MdbaseCollectionClient` transports may supply the constructor's feature
259
+ helper; without one, metadata is unsupported.
260
+
261
+ `files.stat` requires existing file **list** approval and returns
262
+ `ConnectOutcome<CollectionFileDescriptor|null>`. When `files-stat-v1` is absent,
263
+ it uses the existing paginated listing: the known folder for a path, the approved
264
+ scope for a file ID, comparing portable file identity. This legacy path is not
265
+ point-cost or an atomic snapshot; listing-reset (`file_list_changed`), denial,
266
+ cancellation and timeout outcomes remain visible. A stat does not reserve bytes:
267
+ downloads still need revision/digest validation.
268
+
269
+ **Migration:** existing query/list callers need no changes. Writer, Reader,
270
+ TaskNotes and editor can replace point-discovery folder scans with `files.stat`;
271
+ metadata consumers must branch on successful discovery and keep their ordinary
272
+ query path for old authorities. Remove those fallbacks only after the minimum
273
+ supported authority implements the feature, all consumer pins adopt it, and
274
+ N-1/rollback/connection-cache windows close. Feature support never broadens the
275
+ exact local grant. Authorities advertise only after their own qualification.
276
+
201
277
  ### Legacy v1 and capability-group v2 declarations
202
278
 
203
279
  The same SDK accepts both declaration versions without translating legacy exact
@@ -267,6 +343,11 @@ in the background. Route details remain diagnostics; feature UI should read
267
343
  needs authorization/setup, is temporarily unavailable, or is unsupported by the
268
344
  selected authority.
269
345
 
346
+ A route or selection refresh supersedes its previous setup verification.
347
+ Cancellation of that obsolete generation must not fail startup or disconnect
348
+ its replacement; genuine exceptions and current assessment failures still
349
+ surface normally.
350
+
270
351
  `MdbaseBrowserSelection` keeps the stable collection ID in
271
352
  `?collection=<id>`, preserves unrelated path/query/hash and router state, and
272
353
  reports browser back/forward
@@ -616,8 +697,63 @@ The SDK returns typed outcomes, carries successful mdbase diagnostics alongside
616
697
  the value, carries revision tokens in typed record results, and accepts
617
698
  `ifRevision` on mutations. `describe()` exposes
618
699
  JSON Schemas, portable type definitions, canonical collection settings, and
619
- first-class data contracts. `watch()` resumes from a local collection cursor;
700
+ first-class data contracts. `watch()` resumes from an authority collection cursor;
620
701
  the Connect server does not store the change feed.
702
+
703
+ ### Typed changes and description caching
704
+
705
+ `changes()` and `watch()` return the same discriminated `CollectionChange` union.
706
+ Switch on `change.kind`: `record.created`, `record.updated`, `record.deleted`,
707
+ `record.renamed`, `schema.changed`, `config.changed`, `contract.changed`,
708
+ `view.changed`, `file.changed`, `file.put`, `file.removed`, `gap`, or `unknown`.
709
+ Record variants expose `path` (or `from`/`to`) and optional `revision`,
710
+ `previousRevision`, `types`, `previousTypes`, `changedFields`, `bodyChanged`,
711
+ `before`/`after` frontmatter. Metadata is never inferred when an older authority
712
+ omits it. In particular, hosted deletion `types` describes the deleted record,
713
+ whereas local deletion `types` is the empty after-state; use `previousTypes`
714
+ when supplied. `changedFields` preserves authority identifiers (local JSON
715
+ Pointers, hosted top-level keys). A change does not replace an authoritative read.
716
+
717
+ ```ts
718
+ const watched = await connection.watch();
719
+ if (watched.ok) watched.value.subscribe((change) => {
720
+ if (change.kind === "record.updated") {
721
+ console.log(change.path, change.changedFields, change.bodyChanged);
722
+ } else if (change.kind === "schema.changed") {
723
+ console.log("Schema generation", connection.schemaGeneration);
724
+ }
725
+ });
726
+ ```
727
+
728
+ Original `type` and `payload` remain available, and `raw` retains the exact wire
729
+ event. Unrecognized IDs and malformed known payloads become explicit `unknown`
730
+ events; inspect `reason` and `raw` rather than guessing a known variant.
731
+ `mdbase.collection.invalidated` becomes `gap`. A reset page's `events` contains a
732
+ synthetic `reset` variant whose `raw` is the original page, not a wire event.
733
+ The page's `reset` flag and watch's terminal `change_cursor_reset` failure and
734
+ `reset_required` status remain unchanged: refresh state and subscribe again.
735
+ Custom transports/test emitters can use `normalizeCollectionChange(wireEvent)`.
736
+
737
+ `describe()` now caches successful descriptions for at most **60 seconds** per
738
+ connection/client. `describe({ fresh: true })` bypasses the settled cache.
739
+ Concurrent calls share one request; failures are not cached and a failed fresh
740
+ load does not fall back to an old description. Each waiter's cancellation and
741
+ timeout are independent; the shared load uses the connection's request budget.
742
+ Schema/config/contract/view events, unknown events, and feed gaps/reset pages
743
+ observed through this client's `changes()`/`watch()` invalidate the cache before
744
+ publication and advance `schemaGeneration`. Accepted type/setup/view mutations
745
+ also invalidate it. A load spanning an invalidation cannot refill the cache.
746
+ Generation is a local invalidation counter, not an authority revision or a
747
+ cross-connection schema identity. Without an active watch, the 60-second bound
748
+ still applies; use `fresh: true` at correctness-critical refresh boundaries.
749
+
750
+ **Migration:** replace raw-ID regexes and snake-case payload parsing with `kind`
751
+ and typed fields, retaining full refresh/read handling for `unknown`, `gap`, and
752
+ reset. Existing raw consumers can continue using `type`/`payload`. Hand-built
753
+ `CollectionChange` values must go through `normalizeCollectionChange` (or supply
754
+ the typed variant and raw event). Callers requiring every `describe()` to reach
755
+ the authority must now pass `{ fresh: true }`.
756
+
621
757
  `queryAll()` treats `timeoutMs` as one deadline for the complete paginated
622
758
  query. The caller-driven `queryPages()` iterator instead accepts
623
759
  `pageTimeoutMs`, an independent budget for each page, plus a lifetime `signal`.
@@ -626,7 +762,26 @@ Large hosted pages may omit `meta.totalCount` and return
626
762
  so page latency is not coupled to a collection-wide count. `meta.hasMore` and the
627
763
  opaque cursor remain authoritative for paging. `queryAll()` reports the exact
628
764
  number of returned records after it has intentionally consumed the complete
629
- sequence.
765
+ sequence (unless an explicit `maxResults` cap ends it early).
766
+
767
+ `queryPages()`, `queryAll()` and `executeViewPages()` accept `maxResults` as an
768
+ opt-in total cap; `limit` remains a page-size fallback. `pageSize` sets the initial
769
+ cursor size unless `firstPageSize` overrides it; cursor continuations use that
770
+ pinned size. Abort releases even a paused iterator's cursor without needing
771
+ `return()`. `readMany(paths, { includeBody, frontmatterMode, types })` provides
772
+ ordered found/missing/error entries over bounded independent batches. When
773
+ `read-many-documents-v1` is advertised it uses revision-bearing document reads;
774
+ otherwise it retains typed path queries without sending extended inputs. The
775
+ signature and query-shaped result stay unchanged (`includeBody` defaults false,
776
+ `frontmatterMode` defaults effective). A `types` filter is evaluated by an
777
+ authority query before hydration; selection and reads are not one snapshot.
778
+ Qualified reads require existing `read` approval, plus `query` for type selection.
779
+ Legacy revisions remain optional; never infer them from mtime/size or combine a
780
+ query token with later content. Keep `read()` for exact Markdown/full documents.
781
+ `linksTo(field, path)` builds a guarded server-side `asFile()` predicate (use
782
+ `{multiple: true}` for a link list), not a client resolver. See
783
+ [query helper contracts and consumer migration notes](../../docs/sdk-query-helpers.md).
784
+
630
785
  `preflightRename()` and `preflightDelete()` run the canonical collection
631
786
  operation without changing records or advancing the change cursor, so an app
632
787
  can show authoritative reference impact before asking for confirmation.
@@ -1,10 +1,11 @@
1
1
  import { APPLICATION_SETUP_OPERATIONS } from "@mdbase-dev/connect-protocol";
2
2
  import { declaredOperations, validateAuthorizationSelection } from "./application-contract.js";
3
- import { applicationReadinessContext, collectionSetupInput, publishedStructuredReadiness, verifyStructuredReadiness, verificationKey, verificationValue } from "./structured-readiness.js";
3
+ import { accessRequirementSatisfied, applicationReadinessContext, collectionSetupInput, publishedStructuredReadiness, reviewableTypePackAdoptions, verifyStructuredReadiness, verificationKey, verificationValue } from "./structured-readiness.js";
4
4
  import { MdbaseConnectError, connectProblem } from "./errors.js";
5
5
  import { connectFailure, connectSuccess } from "./outcomes.js";
6
6
  import { createRequestBudget, resolveConnectTimeouts, withRequestBudget } from "./request-budget.js";
7
7
  import { defaultCallbackUrl } from "./runtime-utils.js";
8
+ import { timeStartupRead } from "./startup-timing.js";
8
9
  import { declarationIdFromFamilyIdentity } from "./connect-authorization-helpers.js";
9
10
  import { MdbaseSession } from "./session.js";
10
11
  export class MdbaseMemoryVerificationStore {
@@ -129,7 +130,7 @@ export class MdbaseApplicationSession {
129
130
  }
130
131
  }
131
132
  async performStartWithinBudget(options, generation) {
132
- const registration = await this.connect.register(options);
133
+ const registration = await timeStartupRead("registration", () => this.connect.register(options));
133
134
  if (!registration.ok)
134
135
  return this.startFailure(registration.problem, generation);
135
136
  const manifest = await this.connect.manifest(options);
@@ -451,9 +452,46 @@ export class MdbaseApplicationSession {
451
452
  if (!connection || connection.collectionId !== context.collectionId)
452
453
  return;
453
454
  const initialInput = this.collectionSetupInput();
454
- let outcome = options
455
- ? await connection.assessCollectionSetup(initialInput, options)
456
- : await connection.assessCollectionSetup(initialInput);
455
+ const contracts = manifest.requirements?.contracts ?? [];
456
+ const controller = new AbortController();
457
+ this.readinessController?.abort();
458
+ this.readinessController = controller;
459
+ let readinessFailure = null;
460
+ // Both are authorized reads. Verify live contracts alongside setup, but do
461
+ // not publish ready (or a contract failure) before the setup review decision.
462
+ const verification = context.readiness && contracts.length
463
+ ? verifyStructuredReadiness({
464
+ ...context, readiness: context.readiness, contracts, connection, controller, options,
465
+ requestMs: this.timeouts.requestMs,
466
+ isCurrent: () => generation === this.verificationGeneration && this.connection() === connection,
467
+ publish: result => { readinessFailure = { ...result, ...context }; }
468
+ })
469
+ : Promise.resolve(true);
470
+ const abortAssessment = () => controller.abort(options?.signal?.reason);
471
+ if (options?.signal?.aborted)
472
+ abortAssessment();
473
+ else
474
+ options?.signal?.addEventListener("abort", abortAssessment, { once: true });
475
+ let outcome;
476
+ let contractsVerified;
477
+ try {
478
+ [outcome, contractsVerified] = await Promise.all([
479
+ timeStartupRead("setup-assessment", () => connection.assessCollectionSetup(initialInput, { ...options, signal: controller.signal })), verification
480
+ ]);
481
+ }
482
+ catch (error) {
483
+ // Only expected cancellation of an obsolete verification is not a startup fault.
484
+ if (generation !== this.verificationGeneration && controller.signal.aborted
485
+ && (error === controller.signal.reason || (error instanceof MdbaseConnectError && error.code === "operation_cancelled")))
486
+ return;
487
+ throw error;
488
+ }
489
+ finally {
490
+ options?.signal?.removeEventListener("abort", abortAssessment);
491
+ controller.abort();
492
+ if (this.readinessController === controller)
493
+ this.readinessController = undefined;
494
+ }
457
495
  if (generation !== this.verificationGeneration)
458
496
  return;
459
497
  if (!outcome.ok) {
@@ -494,11 +532,16 @@ export class MdbaseApplicationSession {
494
532
  this.verificationStore.write(verificationKey(manifest, context.collectionId), verificationValue(manifest));
495
533
  if (context.readiness)
496
534
  context.readiness.setup = { state: "current", evidence: [{ source: "authority", fact: "Collection setup assessment is current." }] };
497
- await this.publishReady(context, generation, options);
535
+ if (!contractsVerified) {
536
+ if (readinessFailure)
537
+ this.publish(readinessFailure);
538
+ return;
539
+ }
540
+ await this.publishReady(context, generation, options, true);
498
541
  }
499
- async publishReady(context, generation, options) {
542
+ async publishReady(context, generation, options, contractsVerified = false) {
500
543
  const contracts = this.requireManifest().requirements?.contracts ?? [];
501
- if (context.readiness && contracts.length) {
544
+ if (!contractsVerified && context.readiness && contracts.length) {
502
545
  const connection = this.connection();
503
546
  if (!connection || connection.collectionId !== context.collectionId)
504
547
  return;
@@ -684,25 +727,6 @@ function collectionSetupUpdate(assessment, provisions) {
684
727
  reason
685
728
  };
686
729
  }
687
- function reviewableTypePackAdoptions(assessment) {
688
- const adoptions = {};
689
- for (const pack of assessment.typePacks) {
690
- const resources = Object.fromEntries(pack.resources
691
- .filter((resource) => resource.action === "conflict"
692
- && resource.mode === "managed"
693
- && resource.currentDigest !== undefined
694
- && resource.installedDigest === undefined)
695
- .map((resource) => [resource.target, resource.currentDigest]));
696
- if (Object.keys(resources).length > 0)
697
- adoptions[pack.desired.id] = resources;
698
- }
699
- return adoptions;
700
- }
701
- function accessRequirementSatisfied(manifest, connection) {
702
- return manifest.requirements?.access === "full_collection"
703
- && connection.scope.access === "full_collection"
704
- && connection.scope.contracts.length === 0;
705
- }
706
730
  function lifecycleProblemMessage(code) {
707
731
  if (code === "session_destroyed")
708
732
  return "This application session has been destroyed.";
@@ -0,0 +1,29 @@
1
+ import type { FileCapability } from "@mdbase-dev/connect-protocol";
2
+ import type { CollectionDescription, ConnectRequestOptions } from "./operation-types.js";
3
+ import { type ConnectOutcome } from "./outcomes.js";
4
+ /** Optional response member: absence is legacy; malformed presence is a failure. */
5
+ export declare function authorityCapabilities(value: unknown): readonly string[];
6
+ interface Discovery {
7
+ /** Changes with authorization/authority replacement and direct/relay route. Never persisted. */
8
+ lifetime(): string;
9
+ operations(): readonly string[];
10
+ fileCapability(): FileCapability | null;
11
+ describe(options: ConnectRequestOptions): Promise<ConnectOutcome<CollectionDescription>>;
12
+ filesPage(folder: string | undefined, signal: AbortSignal): Promise<readonly string[]>;
13
+ }
14
+ /** One authority discovery cache per connection, never a global or persisted registry. */
15
+ export declare class AuthorityFeatures {
16
+ private readonly discovery;
17
+ private readonly timeoutMs;
18
+ private lifetime;
19
+ private generation;
20
+ private cached;
21
+ private readonly pending;
22
+ constructor(discovery: Discovery, timeoutMs: number | null);
23
+ get capabilities(): readonly string[];
24
+ supports(id: string, options?: ConnectRequestOptions): Promise<ConnectOutcome<boolean>>;
25
+ invalidate(): void;
26
+ refreshLifetime(): void;
27
+ private discover;
28
+ }
29
+ export {};
@@ -0,0 +1,84 @@
1
+ import { connectError, MdbaseConnectError } from "./errors.js";
2
+ import { ALL_CONNECT_PROBLEM_CODES, captureConnectOutcome } from "./outcomes.js";
3
+ import { withRequestBudget } from "./request-budget.js";
4
+ import { SharedOperationMap } from "./shared-operation.js";
5
+ const AUTHORITY_FEATURES = new Set([
6
+ "files-stat-v1", "query-record-revisions-v1", "read-many-documents-v1",
7
+ "query-metadata-v1", "contract-query-v1", "link-resolution-options-v1"
8
+ ]);
9
+ const EMPTY_CAPABILITIES = Object.freeze([]);
10
+ /** Optional response member: absence is legacy; malformed presence is a failure. */
11
+ export function authorityCapabilities(value) {
12
+ if (value === undefined)
13
+ return EMPTY_CAPABILITIES;
14
+ if (!Array.isArray(value) || value.some(id => typeof id !== "string")) {
15
+ throw connectError("invalid_operation_response", "The authority returned invalid feature capabilities.");
16
+ }
17
+ return Object.freeze([...new Set(value.filter(id => AUTHORITY_FEATURES.has(id)))]);
18
+ }
19
+ /** One authority discovery cache per connection, never a global or persisted registry. */
20
+ export class AuthorityFeatures {
21
+ discovery;
22
+ timeoutMs;
23
+ lifetime;
24
+ generation = 0;
25
+ cached;
26
+ pending = new SharedOperationMap();
27
+ constructor(discovery, timeoutMs) {
28
+ this.discovery = discovery;
29
+ this.timeoutMs = timeoutMs;
30
+ }
31
+ get capabilities() {
32
+ this.refreshLifetime();
33
+ return this.cached ?? EMPTY_CAPABILITIES;
34
+ }
35
+ supports(id, options) {
36
+ return captureConnectOutcome(() => withRequestBudget(options, this.timeoutMs, async (budget) => {
37
+ if (!AUTHORITY_FEATURES.has(id))
38
+ return false;
39
+ while (true) {
40
+ this.refreshLifetime();
41
+ const generation = this.generation;
42
+ const capabilities = this.cached ?? await this.pending.run(String(generation), { signal: budget.signal, timeoutMs: null }, null, signal => this.discover(signal));
43
+ this.refreshLifetime();
44
+ // A successful discovery can itself select a direct route. Rediscover on
45
+ // the current route, rather than borrowing evidence from a retired one.
46
+ if (generation !== this.generation)
47
+ continue;
48
+ if (!budget.signal.aborted)
49
+ this.cached = capabilities;
50
+ return capabilities.includes(id);
51
+ }
52
+ }), ALL_CONNECT_PROBLEM_CODES);
53
+ }
54
+ invalidate() {
55
+ this.cached = undefined;
56
+ this.lifetime = undefined;
57
+ }
58
+ refreshLifetime() {
59
+ const lifetime = this.discovery.lifetime();
60
+ if (lifetime === this.lifetime)
61
+ return;
62
+ this.lifetime = lifetime;
63
+ this.generation += 1;
64
+ this.cached = undefined;
65
+ }
66
+ async discover(signal) {
67
+ if (this.discovery.operations().includes("describe")) {
68
+ const outcome = await this.discovery.describe({ signal, timeoutMs: null, coordination: { coalesce: false } });
69
+ if (!outcome.ok)
70
+ throw new MdbaseConnectError(outcome.problem);
71
+ return authorityCapabilities(outcome.value.authorityCapabilities);
72
+ }
73
+ const capability = this.discovery.fileCapability();
74
+ if (capability?.actions.includes("list")) {
75
+ const folder = capability.scope.kind === "selected_folders" ? capability.scope.folders[0] : undefined;
76
+ const capabilities = await this.discovery.filesPage(folder, signal);
77
+ // File-only discovery is evidence for file features, never native queries.
78
+ return Object.freeze(capabilities.filter(id => id === "files-stat-v1"));
79
+ }
80
+ // Consumer: legacy grants without describe/file-list permission. Remove when
81
+ // the minimum authority/consumer pins and N-1 rollback/cache windows close.
82
+ return EMPTY_CAPABILITIES;
83
+ }
84
+ }
@@ -1,4 +1,4 @@
1
1
  {
2
2
  "file": "mdbase-connect.min.js",
3
- "integrity": "sha384-Nv0Du948fR/kSC/QXiny8mY1SZ2akUFBT7QmsPb3irgnUGNN0LgsWDubjxozn5cU"
3
+ "integrity": "sha384-23aqw9c9S7YPXCd5zLVLjUFjKy3QggldsE+mDLmLzzRkxaOnQxOkLxoQ2j1oujFh"
4
4
  }