@mdbase-dev/connect 0.1.0-beta.121 → 0.1.0-beta.123

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 (39) hide show
  1. package/README.md +86 -6
  2. package/dist/application-session.js +45 -27
  3. package/dist/browser/integrity.json +1 -1
  4. package/dist/browser/mdbase-connect.min.js +4 -4
  5. package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
  6. package/dist/change-events.d.ts +6 -0
  7. package/dist/change-events.js +102 -0
  8. package/dist/collection-client.d.ts +7 -2
  9. package/dist/collection-client.js +35 -54
  10. package/dist/connection.d.ts +5 -2
  11. package/dist/connection.js +4 -3
  12. package/dist/description-cache.d.ts +14 -0
  13. package/dist/description-cache.js +73 -0
  14. package/dist/file-crypto.js +10 -6
  15. package/dist/file-descriptor.d.ts +12 -0
  16. package/dist/file-descriptor.js +12 -0
  17. package/dist/file-sha256.js +3 -3
  18. package/dist/files.d.ts +2 -11
  19. package/dist/files.js +36 -30
  20. package/dist/index.d.ts +4 -1
  21. package/dist/index.js +2 -0
  22. package/dist/operation-types.d.ts +127 -6
  23. package/dist/query-pagination-internals.d.ts +9 -0
  24. package/dist/query-pagination-internals.js +52 -0
  25. package/dist/query-pagination.js +28 -26
  26. package/dist/query-predicates.d.ts +11 -0
  27. package/dist/query-predicates.js +15 -0
  28. package/dist/read-many.d.ts +6 -0
  29. package/dist/read-many.js +70 -0
  30. package/dist/record-session.d.ts +3 -2
  31. package/dist/record-session.js +11 -2
  32. package/dist/records.d.ts +15 -2
  33. package/dist/records.js +109 -7
  34. package/dist/saved-view-pagination.js +20 -20
  35. package/dist/startup-timing.d.ts +2 -0
  36. package/dist/startup-timing.js +20 -0
  37. package/dist/structured-readiness.d.ts +3 -1
  38. package/dist/structured-readiness.js +21 -1
  39. 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`
@@ -616,8 +629,63 @@ The SDK returns typed outcomes, carries successful mdbase diagnostics alongside
616
629
  the value, carries revision tokens in typed record results, and accepts
617
630
  `ifRevision` on mutations. `describe()` exposes
618
631
  JSON Schemas, portable type definitions, canonical collection settings, and
619
- first-class data contracts. `watch()` resumes from a local collection cursor;
632
+ first-class data contracts. `watch()` resumes from an authority collection cursor;
620
633
  the Connect server does not store the change feed.
634
+
635
+ ### Typed changes and description caching
636
+
637
+ `changes()` and `watch()` return the same discriminated `CollectionChange` union.
638
+ Switch on `change.kind`: `record.created`, `record.updated`, `record.deleted`,
639
+ `record.renamed`, `schema.changed`, `config.changed`, `contract.changed`,
640
+ `view.changed`, `file.changed`, `file.put`, `file.removed`, `gap`, or `unknown`.
641
+ Record variants expose `path` (or `from`/`to`) and optional `revision`,
642
+ `previousRevision`, `types`, `previousTypes`, `changedFields`, `bodyChanged`,
643
+ `before`/`after` frontmatter. Metadata is never inferred when an older authority
644
+ omits it. In particular, hosted deletion `types` describes the deleted record,
645
+ whereas local deletion `types` is the empty after-state; use `previousTypes`
646
+ when supplied. `changedFields` preserves authority identifiers (local JSON
647
+ Pointers, hosted top-level keys). A change does not replace an authoritative read.
648
+
649
+ ```ts
650
+ const watched = await connection.watch();
651
+ if (watched.ok) watched.value.subscribe((change) => {
652
+ if (change.kind === "record.updated") {
653
+ console.log(change.path, change.changedFields, change.bodyChanged);
654
+ } else if (change.kind === "schema.changed") {
655
+ console.log("Schema generation", connection.schemaGeneration);
656
+ }
657
+ });
658
+ ```
659
+
660
+ Original `type` and `payload` remain available, and `raw` retains the exact wire
661
+ event. Unrecognized IDs and malformed known payloads become explicit `unknown`
662
+ events; inspect `reason` and `raw` rather than guessing a known variant.
663
+ `mdbase.collection.invalidated` becomes `gap`. A reset page's `events` contains a
664
+ synthetic `reset` variant whose `raw` is the original page, not a wire event.
665
+ The page's `reset` flag and watch's terminal `change_cursor_reset` failure and
666
+ `reset_required` status remain unchanged: refresh state and subscribe again.
667
+ Custom transports/test emitters can use `normalizeCollectionChange(wireEvent)`.
668
+
669
+ `describe()` now caches successful descriptions for at most **60 seconds** per
670
+ connection/client. `describe({ fresh: true })` bypasses the settled cache.
671
+ Concurrent calls share one request; failures are not cached and a failed fresh
672
+ load does not fall back to an old description. Each waiter's cancellation and
673
+ timeout are independent; the shared load uses the connection's request budget.
674
+ Schema/config/contract/view events, unknown events, and feed gaps/reset pages
675
+ observed through this client's `changes()`/`watch()` invalidate the cache before
676
+ publication and advance `schemaGeneration`. Accepted type/setup/view mutations
677
+ also invalidate it. A load spanning an invalidation cannot refill the cache.
678
+ Generation is a local invalidation counter, not an authority revision or a
679
+ cross-connection schema identity. Without an active watch, the 60-second bound
680
+ still applies; use `fresh: true` at correctness-critical refresh boundaries.
681
+
682
+ **Migration:** replace raw-ID regexes and snake-case payload parsing with `kind`
683
+ and typed fields, retaining full refresh/read handling for `unknown`, `gap`, and
684
+ reset. Existing raw consumers can continue using `type`/`payload`. Hand-built
685
+ `CollectionChange` values must go through `normalizeCollectionChange` (or supply
686
+ the typed variant and raw event). Callers requiring every `describe()` to reach
687
+ the authority must now pass `{ fresh: true }`.
688
+
621
689
  `queryAll()` treats `timeoutMs` as one deadline for the complete paginated
622
690
  query. The caller-driven `queryPages()` iterator instead accepts
623
691
  `pageTimeoutMs`, an independent budget for each page, plus a lifetime `signal`.
@@ -626,7 +694,19 @@ Large hosted pages may omit `meta.totalCount` and return
626
694
  so page latency is not coupled to a collection-wide count. `meta.hasMore` and the
627
695
  opaque cursor remain authoritative for paging. `queryAll()` reports the exact
628
696
  number of returned records after it has intentionally consumed the complete
629
- sequence.
697
+ sequence (unless an explicit `maxResults` cap ends it early).
698
+
699
+ `queryPages()`, `queryAll()` and `executeViewPages()` accept `maxResults` as an
700
+ opt-in total cap; `limit` remains a page-size fallback. `pageSize` sets the initial
701
+ cursor size unless `firstPageSize` overrides it; cursor continuations use that
702
+ pinned size. Abort releases even a paused iterator's cursor without needing
703
+ `return()`. `readMany(paths, { includeBody, frontmatterMode, types })` provides
704
+ ordered found/missing/error entries over bounded independent path-query batches.
705
+ It has **no authoritative revisions today**; keep `read()` for editing revisions.
706
+ `linksTo(field, path)` builds a guarded server-side `asFile()` predicate (use
707
+ `{multiple: true}` for a link list), not a client resolver. See
708
+ [query helper contracts and consumer migration notes](../../docs/sdk-query-helpers.md).
709
+
630
710
  `preflightRename()` and `preflightDelete()` run the canonical collection
631
711
  operation without changing records or advancing the change cursor, so an app
632
712
  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,40 @@ 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 assessed;
476
+ let contractsVerified;
477
+ try {
478
+ [assessed, contractsVerified] = await Promise.all([
479
+ timeStartupRead("setup-assessment", () => connection.assessCollectionSetup(initialInput, { ...options, signal: controller.signal })), verification
480
+ ]);
481
+ }
482
+ finally {
483
+ options?.signal?.removeEventListener("abort", abortAssessment);
484
+ controller.abort();
485
+ if (this.readinessController === controller)
486
+ this.readinessController = undefined;
487
+ }
488
+ let outcome = assessed;
457
489
  if (generation !== this.verificationGeneration)
458
490
  return;
459
491
  if (!outcome.ok) {
@@ -494,11 +526,16 @@ export class MdbaseApplicationSession {
494
526
  this.verificationStore.write(verificationKey(manifest, context.collectionId), verificationValue(manifest));
495
527
  if (context.readiness)
496
528
  context.readiness.setup = { state: "current", evidence: [{ source: "authority", fact: "Collection setup assessment is current." }] };
497
- await this.publishReady(context, generation, options);
529
+ if (!contractsVerified) {
530
+ if (readinessFailure)
531
+ this.publish(readinessFailure);
532
+ return;
533
+ }
534
+ await this.publishReady(context, generation, options, true);
498
535
  }
499
- async publishReady(context, generation, options) {
536
+ async publishReady(context, generation, options, contractsVerified = false) {
500
537
  const contracts = this.requireManifest().requirements?.contracts ?? [];
501
- if (context.readiness && contracts.length) {
538
+ if (!contractsVerified && context.readiness && contracts.length) {
502
539
  const connection = this.connection();
503
540
  if (!connection || connection.collectionId !== context.collectionId)
504
541
  return;
@@ -684,25 +721,6 @@ function collectionSetupUpdate(assessment, provisions) {
684
721
  reason
685
722
  };
686
723
  }
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
724
  function lifecycleProblemMessage(code) {
707
725
  if (code === "session_destroyed")
708
726
  return "This application session has been destroyed.";
@@ -1,4 +1,4 @@
1
1
  {
2
2
  "file": "mdbase-connect.min.js",
3
- "integrity": "sha384-Nv0Du948fR/kSC/QXiny8mY1SZ2akUFBT7QmsPb3irgnUGNN0LgsWDubjxozn5cU"
3
+ "integrity": "sha384-oeF346B54GbocYns1w4tBu6zmyDtayo5iL/ujiP4qtf4pk1NsES2mMQj/0lIYmBb"
4
4
  }