@mdbase-dev/connect 0.1.0-beta.110 → 0.1.0-beta.112

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.
@@ -1 +1 @@
1
- sha384-87GfQ8HHeR0B68yJLi01ycH5DHMGUf0jbXDsLhhpaYD8jpF/qvol4yQtP7hdPExh
1
+ sha384-uKL9FxAY8Ji2mQSA44JTIIarA2CnPGN3EFC4jxk1Kx+OfDsPbFr2J+iwlH/p77Qz
@@ -34,6 +34,8 @@ export declare class ConnectionTransport {
34
34
  disableDirectAccess(): void;
35
35
  /** Return pending writes without exposing authority transport credentials. */
36
36
  pendingMutations(): readonly PendingMutationSummary[];
37
+ /** The request ID of an interrupted update to one record, if any. */
38
+ pendingUpdate(path: string): Promise<string | null>;
37
39
  pendingMutation(requestId: string): PendingMutationSummary | null;
38
40
  recoverPendingMutation<Result>(requestId: string, options?: ConnectRequestOptions): Promise<Result>;
39
41
  performOperation<Result>(operation: CollectionOperation, input: unknown, options?: ConnectRequestOptions): Promise<Result>;
@@ -1,3 +1,4 @@
1
+ import { interruptedUpdate, updatedRecordDigest } from "./record-digest.js";
1
2
  import { OPERATION_TRANSPORT_PROTOCOL_VERSION, isConnectProblem, mutationOperationIdentifier } from "@mdbase-dev/connect-protocol";
2
3
  import { decryptRelayResponse, encryptRelayRequest, RelayCryptoError } from "./crypto.js";
3
4
  import { MdbaseConnectError, connectError, serverConnectError } from "./errors.js";
@@ -5,7 +6,7 @@ import { ConnectionFileTransport } from "./connection-file-transport.js";
5
6
  import { authorityProofHeaders } from "./authority-proof.js";
6
7
  import { performAuthorizationRefresh } from "./authorization-refresh.js";
7
8
  import { PendingMutationStore } from "./pending-mutation-store.js";
8
- import { directFallbackStatus, encryptedOperationError, fetchOperationRequest, isMutation, localNetworkPermission, loopbackRequest, operationFingerprint, operationTransportError, sameAuthorization, throwIfCancelled, unknownMutationOutcome, withOperationDeadline } from "./operation-helpers.js";
9
+ import { directFallbackResponse, encryptedOperationError, fetchOperationRequest, isMutation, localNetworkPermission, loopbackRequest, operationFingerprint, operationTransportError, sameAuthorization, throwIfCancelled, unknownMutationOutcome, withOperationDeadline } from "./operation-helpers.js";
9
10
  import { apiError, decodeJsonResponse, parseStored, } from "./runtime-utils.js";
10
11
  import { readStoredToken } from "./stored-token.js";
11
12
  import { requestOptionsWithinBudget, withCooperativeRequestBudget, withRequestBudget } from "./request-budget.js";
@@ -108,23 +109,16 @@ export class ConnectionTransport {
108
109
  }
109
110
  /** Return pending writes without exposing authority transport credentials. */
110
111
  pendingMutations() {
111
- return this.storedPendingMutations().map((pending) => ({
112
- requestId: pending.requestId,
113
- operation: this.pendingMutationStore.identifier(pending),
114
- fingerprint: pending.inputFingerprint,
115
- status: "outcome_unknown",
116
- createdAt: new Date(pending.createdAt).toISOString()
117
- }));
112
+ return this.storedPendingMutations().map((pending) => this.pendingMutationStore.summary(pending));
113
+ }
114
+ /** The request ID of an interrupted update to one record, if any. */
115
+ async pendingUpdate(path) {
116
+ const collectionId = this.currentToken()?.collectionId;
117
+ return collectionId ? interruptedUpdate(this.storedPendingMutations(), collectionId, path) : null;
118
118
  }
119
119
  pendingMutation(requestId) {
120
120
  const pending = this.storedPendingMutation(requestId);
121
- return pending ? {
122
- requestId: pending.requestId,
123
- operation: this.pendingMutationStore.identifier(pending),
124
- fingerprint: pending.inputFingerprint,
125
- status: "outcome_unknown",
126
- createdAt: new Date(pending.createdAt).toISOString()
127
- } : null;
121
+ return pending ? this.pendingMutationStore.summary(pending) : null;
128
122
  }
129
123
  async recoverPendingMutation(requestId, options) {
130
124
  const pending = this.storedPendingMutation(requestId);
@@ -372,10 +366,13 @@ export class ConnectionTransport {
372
366
  let requestId = pendingRequestId ?? crypto.randomUUID();
373
367
  let pending = null;
374
368
  let inputFingerprint;
369
+ let updatedRecord;
375
370
  if (pendingRequestId !== undefined) {
376
371
  pending = this.storedPendingMutation(pendingRequestId);
377
372
  inputFingerprint = pending?.inputFingerprint
378
373
  ?? await operationFingerprint(operation, input);
374
+ if (!pending)
375
+ updatedRecord = await updatedRecordDigest(token.collectionId, operation, input);
379
376
  if (pending) {
380
377
  if (pending.collectionId !== token.collectionId
381
378
  || pending.operation !== operation
@@ -420,6 +417,7 @@ export class ConnectionTransport {
420
417
  operation,
421
418
  mutation: mutation,
422
419
  inputFingerprint: inputFingerprint,
420
+ ...(updatedRecord ? { recordDigest: updatedRecord } : {}),
423
421
  requestId,
424
422
  envelope: encryptedRequest,
425
423
  createdAt: Date.now()
@@ -452,6 +450,7 @@ export class ConnectionTransport {
452
450
  operation,
453
451
  mutation: mutation,
454
452
  inputFingerprint: inputFingerprint,
453
+ ...(updatedRecord ? { recordDigest: updatedRecord } : {}),
455
454
  requestId,
456
455
  request,
457
456
  createdAt: Date.now()
@@ -467,7 +466,7 @@ export class ConnectionTransport {
467
466
  body: JSON.stringify(encryptedRequest),
468
467
  signal: options.signal
469
468
  }));
470
- if (!directFallbackStatus(response.status)
469
+ if (!await directFallbackResponse(response)
471
470
  || await isExplicitConnectorBusyResponse(response)) {
472
471
  if (response.ok) {
473
472
  this.markDirectAvailable();
@@ -603,9 +602,15 @@ export class ConnectionTransport {
603
602
  }
604
603
  async probeDirectAccess(signal) {
605
604
  this.setDirectStatus("checking");
606
- if (await probeLoopbackAccess(this.loopbackUrl, OPERATION_TRANSPORT_PROTOCOL_VERSION, signal)) {
607
- this.markDirectAvailable();
608
- return "available";
605
+ try {
606
+ if (await probeLoopbackAccess(this.loopbackUrl, OPERATION_TRANSPORT_PROTOCOL_VERSION, signal)) {
607
+ this.markDirectAvailable();
608
+ return "available";
609
+ }
610
+ }
611
+ catch (error) {
612
+ this.markDirectUnavailable();
613
+ throw error;
609
614
  }
610
615
  if ((await localNetworkPermission()) === "denied")
611
616
  return this.setDirectStatus("denied");
@@ -4,6 +4,7 @@ import { type ConnectionTransportInternals } from "./connection-transport.js";
4
4
  import type { DirectAccessStatus, MdbaseAuthorizationCapabilities, MdbaseConnectionInfo, MdbaseConnectionRoute, MdbaseSyncConnection } from "./connection-types.js";
5
5
  import type { GrantKeyStore } from "./crypto.js";
6
6
  import { MdbaseFileClient } from "./files.js";
7
+ import { MdbaseRecords } from "./records.js";
7
8
  import { type Application } from "./internal-types.js";
8
9
  import type { MdbaseNativeNotificationRegistration, MdbaseNativeNotificationRegistrationOptions, MdbaseNotificationRegistration, MdbaseNotificationRegistrationOptions } from "./notifications.js";
9
10
  import type { ChangesInput, CollectionChangesPage, CollectionDescription, CollectionSetupApplyResult, CollectionSetupAssessment, ApplyCollectionSetupInput, ApplyTypePackInput, AssessCollectionSetupInput, AssessTypePackInput, CreateInput, CreateTypeInput, CreateViewSourceInput, DeleteInput, DeletePreflightResult, DeleteProgressOptions, DeleteResult, DeleteViewSourceInput, DeleteViewSourceResult, ExecuteViewInput, MdbaseDesiredTimer, MdbaseTimer, MdbaseTimerList, MdbaseTimerReconciliation, ConnectRequestOptions, PendingMutation, QueryAllOptions, QueryInput, QueryPage, QueryPagesOptions, QueryResult, ReadInput, ReadTypeInput, ReadViewSourceInput, RenameInput, RenamePreflightResult, RenameProgressOptions, RenameResult, RecordDocument, SavedViewExecution, SavedViewPage, SavedViewPagesOptions, SavedViewList, SavedViewSourceDocument, TypePackApplyResult, TypePackAssessment, UpdateInput, UpdateTypeInput, UpdateViewSourceInput, MdbaseWatchSubscription, WatchInput } from "./operation-types.js";
@@ -74,6 +75,8 @@ export declare class MdbaseConnection<Frontmatter extends JsonObject = JsonObjec
74
75
  private readonly notifications;
75
76
  private readonly transport;
76
77
  readonly files: MdbaseFileClient;
78
+ /** Editable record sessions shared by every view of this connection. */
79
+ readonly records: MdbaseRecords<Frontmatter>;
77
80
  private readonly connectionListeners;
78
81
  constructor(internals: MdbaseConnectionInternals<Frontmatter>, collectionId: string);
79
82
  get displayName(): string;
@@ -5,6 +5,7 @@ import { ConnectionNotifications } from "./connection-notifications.js";
5
5
  import { ConnectionTransport } from "./connection-transport.js";
6
6
  import { MdbaseConnectError, connectProblem, operationProblem } from "./errors.js";
7
7
  import { MdbaseFileClient } from "./files.js";
8
+ import { MdbaseRecords } from "./records.js";
8
9
  import { DEFAULT_OPERATIONS } from "./internal-types.js";
9
10
  import { assertDeletePreview, assertRenamePreview, deleteEstimate, isCancellation, renameEstimate, throwIfCancelled, uniqueOperations } from "./operation-helpers.js";
10
11
  import { AUTHORIZATION_PROBLEM_CODES, COLLECTION_CHANGES_PROBLEM_CODES, COLLECTION_MUTATION_PROBLEM_CODES, DIRECT_ACCESS_PROBLEM_CODES, NOTIFICATION_PROBLEM_CODES, REGISTRATION_PROBLEM_CODES, captureConnectOutcome, connectFailure, connectSuccess } from "./outcomes.js";
@@ -17,6 +18,8 @@ export class MdbaseConnection {
17
18
  notifications;
18
19
  transport;
19
20
  files;
21
+ /** Editable record sessions shared by every view of this connection. */
22
+ records;
20
23
  connectionListeners = new Set();
21
24
  constructor(internals, collectionId) {
22
25
  this.internals = internals;
@@ -38,6 +41,7 @@ export class MdbaseConnection {
38
41
  }, {
39
42
  downloadPart: (session, partIndex, expectedLength, signal) => this.transport.files.downloadHostedPart(session, partIndex, expectedLength, signal)
40
43
  }, internals.timeouts);
44
+ this.records = new MdbaseRecords(this, (path) => this.transport.pendingUpdate(path));
41
45
  this.collectionClient = new MdbaseCollectionClient(new CollectionRequestCoordinator({
42
46
  operation: (operation, input, requestOptions) => this.transport.performOperation(operation, input, requestOptions)
43
47
  }, internals.timeouts.requestMs), internals.timeouts.requestMs);
package/dist/crypto.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { OPERATION_TRANSPORT_PROTOCOL_VERSION, GRANT_ENCRYPTION_PROTOCOL_VERSION, AUTHORITY_PROOF_DOMAIN, AUTHORITY_PROOF_HEADERS, AUTHORITY_PROOF_VERSION, RELAY_ENCRYPTION_SUITE, isConnectProblem } from "@mdbase-dev/connect-protocol";
2
+ import { base64UrlBytes, bytesToBase64Url } from "./base64.js";
2
3
  const DATABASE_NAME = "mdbase-connect-keys";
3
4
  const STORE_NAME = "grant-keys";
4
5
  const MAX_U64 = 18446744073709551615n;
@@ -206,7 +207,7 @@ export async function decryptRelayResponse(store, handle, binding, request, resp
206
207
  iv: nonce(request.counter),
207
208
  additionalData: new TextEncoder().encode(aad(metadata, "response")),
208
209
  tagLength: 128
209
- }, key, toArrayBuffer(base64UrlToBytes(response.ciphertext)));
210
+ }, key, base64UrlToBytes(response.ciphertext));
210
211
  const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(plaintext));
211
212
  if (value?.ok === true && "result" in value)
212
213
  return value;
@@ -364,12 +365,6 @@ function nonce(counter) {
364
365
  view.setBigUint64(4, value, false);
365
366
  return result;
366
367
  }
367
- function bytesToBase64Url(bytes) {
368
- let binary = "";
369
- for (const byte of bytes)
370
- binary += String.fromCharCode(byte);
371
- return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
372
- }
373
368
  async function sha256Base64Url(value) {
374
369
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
375
370
  return bytesToBase64Url(new Uint8Array(digest));
@@ -377,9 +372,7 @@ async function sha256Base64Url(value) {
377
372
  function base64UrlToBytes(value) {
378
373
  if (!/^[A-Za-z0-9_-]+$/.test(value))
379
374
  throw new RelayCryptoError("invalid_base64", "Encrypted relay data is malformed.");
380
- const padding = "=".repeat((4 - value.length % 4) % 4);
381
- const binary = atob(value.replaceAll("-", "+").replaceAll("_", "/") + padding);
382
- return Uint8Array.from(binary, (character) => character.charCodeAt(0));
375
+ return base64UrlBytes(value);
383
376
  }
384
377
  function p256PublicKey(value) {
385
378
  const bytes = base64UrlToBytes(value);
@@ -1,5 +1,6 @@
1
1
  import { loopbackRequest } from "./operation-helpers.js";
2
- import { decodeJsonResponse } from "./runtime-utils.js";
2
+ import { decodeJsonResponse, portableApplicationOrigin } from "./runtime-utils.js";
3
+ import { serverConnectError } from "./errors.js";
3
4
  export function tokenSupportsDirectAccess(token, mode) {
4
5
  if (!token || token.authority || !token.encryption || !token.grantId || !token.keyHandle) {
5
6
  return false;
@@ -8,22 +9,26 @@ export function tokenSupportsDirectAccess(token, mode) {
8
9
  return false;
9
10
  return typeof location === "undefined"
10
11
  || !token.applicationOrigin
11
- || token.applicationOrigin === location.origin;
12
+ || token.applicationOrigin === portableApplicationOrigin();
12
13
  }
13
14
  export async function probeLoopbackAccess(loopbackUrl, expectedOperationProtocol, signal) {
15
+ let response;
14
16
  try {
15
- const response = await fetch(`${loopbackUrl}/v1/ready`, loopbackRequest({
17
+ response = await fetch(`${loopbackUrl}/v1/ready`, loopbackRequest({
16
18
  method: "GET",
17
19
  cache: "no-store",
18
20
  signal
19
21
  }));
20
- const body = await decodeJsonResponse(response, "invalid_operation_response", "The connector returned an invalid readiness response.").catch(() => null);
21
- return response.ok
22
- && body?.service === "mdbase-connect"
23
- && body?.loopback_protocol_version === 1
24
- && body?.operation_transport_protocol_version === expectedOperationProtocol;
25
22
  }
26
23
  catch {
27
24
  return false;
28
25
  }
26
+ if (response.status === 403) {
27
+ throw serverConnectError("operation_failed", "The connector rejected direct access from this application. Relay access remains available.", { status: response.status });
28
+ }
29
+ const body = await decodeJsonResponse(response, "invalid_operation_response", "The connector returned an invalid readiness response.").catch(() => null);
30
+ return response.ok
31
+ && body?.service === "mdbase-connect"
32
+ && body?.loopback_protocol_version === 1
33
+ && body?.operation_transport_protocol_version === expectedOperationProtocol;
29
34
  }
package/dist/errors.d.ts CHANGED
@@ -39,7 +39,7 @@ export declare function serverConnectError(serverCode: string, message: string,
39
39
  status?: number;
40
40
  cause?: unknown;
41
41
  }): MdbaseConnectError;
42
- export declare function operationProblem<Result>(envelope: MdbaseOperationEnvelope<Result>): KnownConnectProblem<"collection_configuration_invalid" | "collection_invalid" | "collection_type_registry_invalid" | "collection_version_unsupported" | "operation_invalid">;
42
+ export declare function operationProblem<Result>(envelope: MdbaseOperationEnvelope<Result>): KnownConnectProblem<"collection_configuration_invalid" | "collection_invalid" | "collection_type_registry_invalid" | "collection_version_unsupported" | "concurrent_modification" | "file_not_found" | "invalid_path" | "operation_invalid">;
43
43
  /** True only when repeating an operation is safe without asking the user. */
44
44
  export declare function isRetryableConnectError(error: unknown): boolean;
45
45
  export {};
package/dist/errors.js CHANGED
@@ -68,6 +68,11 @@ export function serverConnectError(serverCode, message, options = {}) {
68
68
  export function operationProblem(envelope) {
69
69
  const code = diagnosticProblemCode(envelope.diagnostics);
70
70
  const message = diagnosticMessage(envelope.diagnostics);
71
+ // mdbase reports these record conditions as diagnostics; hosted authorities
72
+ // report them as problems. Surface one code for both.
73
+ if (code === "concurrent_modification" || code === "file_not_found" || code === "invalid_path") {
74
+ return connectProblem(code, message, { operationOutcome: "rejected" });
75
+ }
71
76
  if (code === "collection_version_unsupported") {
72
77
  const versions = diagnosticVersions(envelope.diagnostics);
73
78
  return connectProblem(code, message, {
@@ -110,6 +115,10 @@ function diagnosticProblemCode(diagnostics) {
110
115
  if (["collection_invalid", "collection_open_failed"]
111
116
  .some((code) => codes.has(code)))
112
117
  return "collection_invalid";
118
+ for (const code of ["concurrent_modification", "file_not_found", "invalid_path"]) {
119
+ if (codes.has(code))
120
+ return code;
121
+ }
113
122
  return "operation_invalid";
114
123
  }
115
124
  function diagnosticVersions(diagnostics) {
package/dist/index.d.ts CHANGED
@@ -11,6 +11,10 @@ export type { MdbaseCapabilityState, MdbaseCapabilityEvidence, MdbaseEffectiveCa
11
11
  export { MdbaseConnectError, isRetryableConnectError } from "./errors.js";
12
12
  export type { ConnectErrorContext } from "./errors.js";
13
13
  export { MdbaseFileClient } from "./files.js";
14
+ export { MdbaseRecords } from "./records.js";
15
+ export type { MdbaseRecordLease, MdbaseRecordOpenOptions } from "./records.js";
16
+ export { MdbaseRecordSession } from "./record-session.js";
17
+ export type { MdbaseRecordResolution, MdbaseRecordSessionOptions, MdbaseRecordSessionSnapshot, MdbaseRecordSessionState } from "./record-session.js";
14
18
  export type { MdbaseFileSource, CollectionFileDescriptor, MdbaseFileDeleteReceipt, MdbaseFileStreamSource, MdbaseFileProgress, MdbaseFileListOptions, MdbaseFileUploadOptions, MdbaseFileStreamUploadOptions, MdbaseFileDownloadOptions, MdbaseFileMoveOptions, MdbaseFileDeleteOptions } from "./files.js";
15
19
  export { parseMdbasePushPayload, parseMdbaseNativeNotificationData, showMdbasePushNotification } from "./notifications.js";
16
20
  export type { MdbaseNotificationRegistrationOptions, MdbaseNotificationRegistration, MdbaseNativeNotificationRegistrationOptions, MdbaseNativeNotificationRegistration, MdbaseNativeNotificationData, MdbasePushPayload } from "./notifications.js";
package/dist/index.js CHANGED
@@ -4,6 +4,8 @@ export { MdbaseConnection } from "./connection.js";
4
4
  export { effectiveCapabilities } from "./capabilities.js";
5
5
  export { MdbaseConnectError, isRetryableConnectError } from "./errors.js";
6
6
  export { MdbaseFileClient } from "./files.js";
7
+ export { MdbaseRecords } from "./records.js";
8
+ export { MdbaseRecordSession } from "./record-session.js";
7
9
  export { parseMdbasePushPayload, parseMdbaseNativeNotificationData, showMdbasePushNotification } from "./notifications.js";
8
10
  export { externalStore } from "./external-store.js";
9
11
  export { MdbaseBrowserSelection, MdbaseMemorySelection } from "./selection.js";
@@ -82,6 +82,8 @@ export interface PendingMutation {
82
82
  operation: CollectionOperation;
83
83
  mutation?: MutationOperationIdentifier;
84
84
  inputFingerprint: string;
85
+ /** `recordDigest(collectionId, path)` of an update's record, never the path itself. */
86
+ recordDigest?: string;
85
87
  requestId: string;
86
88
  envelope?: EncryptedRelayOperationRequest;
87
89
  /** Exact plaintext protocol request for transports that do not use a grant envelope. */
@@ -3,7 +3,7 @@ import { decryptRelayResponse, encryptRelayRequest } from "./crypto.js";
3
3
  import { MdbaseConnectError, connectError, serverConnectError } from "./errors.js";
4
4
  import { GrantFileTransferCipher } from "./file-crypto.js";
5
5
  import { GrantKeyLeaseSet, retainCurrentGrantToken } from "./grant-key-leases.js";
6
- import { directFallbackStatus, loopbackRequest } from "./operation-helpers.js";
6
+ import { directFallbackResponse, loopbackRequest } from "./operation-helpers.js";
7
7
  import { apiError, decodeJsonResponse } from "./runtime-utils.js";
8
8
  import { isConnectorBusy, isExplicitConnectorBusyResponse, MAX_CONNECTOR_BUSY_RETRIES, waitForConnectorAvailability } from "./transient-retry.js";
9
9
  /** Keeps grant-bound file crypto and loopback framing out of the operation transport. */
@@ -42,7 +42,7 @@ export class LocalFileTransport {
42
42
  throw error;
43
43
  this.options.onDirectUnavailable();
44
44
  }
45
- if (response && (!directFallbackStatus(response.status)
45
+ if (response && (!await directFallbackResponse(response)
46
46
  || await isExplicitConnectorBusyResponse(response))) {
47
47
  if (response.ok)
48
48
  this.options.onDirectAvailable();
@@ -134,7 +134,7 @@ export class LocalFileTransport {
134
134
  throw error;
135
135
  this.options.onDirectUnavailable();
136
136
  }
137
- if (response && (!directFallbackStatus(response.status)
137
+ if (response && (!await directFallbackResponse(response)
138
138
  || await isExplicitConnectorBusyResponse(response))) {
139
139
  if (response.ok)
140
140
  this.options.onDirectAvailable();
@@ -180,7 +180,7 @@ export class LocalFileTransport {
180
180
  throw error;
181
181
  this.options.onDirectUnavailable();
182
182
  }
183
- if (response && (!directFallbackStatus(response.status)
183
+ if (response && (!await directFallbackResponse(response)
184
184
  || await isExplicitConnectorBusyResponse(response))) {
185
185
  if (response.ok)
186
186
  this.options.onDirectAvailable();
@@ -7,7 +7,7 @@ export type LoopbackRequestInit = RequestInit & {
7
7
  };
8
8
  export declare function loopbackRequest(init: RequestInit): LoopbackRequestInit;
9
9
  export declare function localNetworkPermission(): Promise<PermissionState | null>;
10
- export declare function directFallbackStatus(status: number): boolean;
10
+ export declare function directFallbackResponse(response: Response): Promise<boolean>;
11
11
  export declare function isMutation(operation: CollectionOperation, input?: unknown): boolean;
12
12
  export declare function withOperationDeadline(request: EncryptedRelayOperationRequest, deadlineUnixMs?: number): EncryptedRelayOperationRequest;
13
13
  export declare function uniqueOperations(operations: CollectionOperation[]): CollectionOperation[];
@@ -16,8 +16,13 @@ export async function localNetworkPermission() {
16
16
  return null;
17
17
  }
18
18
  }
19
- export function directFallbackStatus(status) {
20
- return status === 404 || status === 405 || status === 426 || status >= 500;
19
+ export async function directFallbackResponse(response) {
20
+ const { status } = response;
21
+ if (status === 404 || status === 405 || status === 426 || status >= 500)
22
+ return true;
23
+ // Loopback rejects browser requests before execution with an empty 403.
24
+ // Structured grant/policy rejections must remain terminal, not route failures.
25
+ return status === 403 && (await response.clone().text()).trim() === "";
21
26
  }
22
27
  export function isMutation(operation, input) {
23
28
  return isMutatingOperation(operation, input);
@@ -28,18 +28,18 @@ export type SessionProblemCode = AuthorizationProblemCode | "collection_not_read
28
28
  export declare const SESSION_PROBLEM_CODES: readonly ["discovery_failed", "invalid_operation_response", "invalid_application_manifest", "invalid_request", "manifest_load_failed", "rate_limited", "temporarily_unavailable", "timeout", "access_denied", "application_declaration_mismatch", "application_identity_unavailable", "approval_window_blocked", "authorization_binding_incompatible", "authorization_cancelled", "authorization_replayed", "browser_required", "capability_contract_incompatible", "collection_access_denied", "collection_configuration_invalid", "collection_contracts_missing", "collection_incompatible", "collection_invalid", "collection_kind_unsupported", "collection_mismatch", "collection_not_found", "collection_type_registry_invalid", "collection_version_unsupported", "connector_identity_changed", "connector_offline", "connector_upgrade_required", "device_authorization_failed", "durable_mutation_unsupported", "encryption_required", "expired_token", "invalid_application_authorization", "invalid_callback", "invalid_device_authorization_response", "invalid_token_response", "legacy_scope_reauthorization_required", "scope_denied", "reconnect_required", "token_exchange_failed", "transport_protocol_incompatible", "collection_not_ready", "collection_not_selected", "operation_cancelled", "session_destroyed", "session_not_started", "session_starting", "unknown_collection"];
29
29
  export type CollectionSetupProblemCode = "collection_configuration_invalid" | "collection_invalid" | "collection_type_registry_invalid" | "collection_version_unsupported";
30
30
  export type CollectionDescriptionProblemCode = CommonOperationProblemCode | CollectionSetupProblemCode;
31
- export type CollectionReadProblemCode = CollectionDescriptionProblemCode | "invalid_path" | "operation_invalid";
31
+ export type CollectionReadProblemCode = CollectionDescriptionProblemCode | "file_not_found" | "invalid_path" | "operation_invalid";
32
32
  export type CollectionQueryProblemCode = CollectionReadProblemCode | "cursor_capacity_exhausted" | "generation_expired" | "invalid_read_cursor" | "query_snapshot_changed" | "sandbox_unsupported";
33
33
  export type CollectionMutationProblemCode = CollectionReadProblemCode | "concurrent_modification" | "invalid_preflight" | "pending_mutation_unresolved";
34
34
  export type CollectionChangesProblemCode = CollectionDescriptionProblemCode | "change_cursor_reset";
35
35
  export type CollectionTypeProblemCode = CollectionMutationProblemCode | "type_pack_provision_failed";
36
36
  export declare const COLLECTION_SETUP_PROBLEM_CODES: readonly ["collection_configuration_invalid", "collection_invalid", "collection_type_registry_invalid", "collection_version_unsupported"];
37
37
  export declare const COLLECTION_DESCRIPTION_PROBLEM_CODES: readonly ["access_denied", "access_paused", "application_declaration_mismatch", "authorization_binding_incompatible", "authorization_expired", "capability_contract_incompatible", "collection_access_denied", "connector_identity_changed", "connector_offline", "connector_upgrade_required", "direct_operation_rejected", "durable_mutation_unsupported", "operation_outcome_unknown", "encryption_required", "encrypted_relay_rejected", "hosted_provider_unavailable", "insufficient_access", "invalid_encrypted_response", "invalid_operation_response", "invalid_request", "missing_grant_key", "mutation_recovery_expired", "mutation_request_conflict", "not_authorized", "operation_cancelled", "operation_failed", "rate_limited", "relay_authorization_expired", "relay_unavailable", "temporarily_unavailable", "timeout", "transport_protocol_incompatible", "unsupported_operation", "collection_configuration_invalid", "collection_invalid", "collection_type_registry_invalid", "collection_version_unsupported"];
38
- export declare const COLLECTION_READ_PROBLEM_CODES: readonly ["access_denied", "access_paused", "application_declaration_mismatch", "authorization_binding_incompatible", "authorization_expired", "capability_contract_incompatible", "collection_access_denied", "connector_identity_changed", "connector_offline", "connector_upgrade_required", "direct_operation_rejected", "durable_mutation_unsupported", "operation_outcome_unknown", "encryption_required", "encrypted_relay_rejected", "hosted_provider_unavailable", "insufficient_access", "invalid_encrypted_response", "invalid_operation_response", "invalid_request", "missing_grant_key", "mutation_recovery_expired", "mutation_request_conflict", "not_authorized", "operation_cancelled", "operation_failed", "rate_limited", "relay_authorization_expired", "relay_unavailable", "temporarily_unavailable", "timeout", "transport_protocol_incompatible", "unsupported_operation", "collection_configuration_invalid", "collection_invalid", "collection_type_registry_invalid", "collection_version_unsupported", "invalid_path", "operation_invalid"];
39
- export declare const COLLECTION_QUERY_PROBLEM_CODES: readonly ["access_denied", "access_paused", "application_declaration_mismatch", "authorization_binding_incompatible", "authorization_expired", "capability_contract_incompatible", "collection_access_denied", "connector_identity_changed", "connector_offline", "connector_upgrade_required", "direct_operation_rejected", "durable_mutation_unsupported", "operation_outcome_unknown", "encryption_required", "encrypted_relay_rejected", "hosted_provider_unavailable", "insufficient_access", "invalid_encrypted_response", "invalid_operation_response", "invalid_request", "missing_grant_key", "mutation_recovery_expired", "mutation_request_conflict", "not_authorized", "operation_cancelled", "operation_failed", "rate_limited", "relay_authorization_expired", "relay_unavailable", "temporarily_unavailable", "timeout", "transport_protocol_incompatible", "unsupported_operation", "collection_configuration_invalid", "collection_invalid", "collection_type_registry_invalid", "collection_version_unsupported", "invalid_path", "operation_invalid", "cursor_capacity_exhausted", "generation_expired", "invalid_read_cursor", "query_snapshot_changed", "sandbox_unsupported"];
40
- export declare const COLLECTION_MUTATION_PROBLEM_CODES: readonly ["access_denied", "access_paused", "application_declaration_mismatch", "authorization_binding_incompatible", "authorization_expired", "capability_contract_incompatible", "collection_access_denied", "connector_identity_changed", "connector_offline", "connector_upgrade_required", "direct_operation_rejected", "durable_mutation_unsupported", "operation_outcome_unknown", "encryption_required", "encrypted_relay_rejected", "hosted_provider_unavailable", "insufficient_access", "invalid_encrypted_response", "invalid_operation_response", "invalid_request", "missing_grant_key", "mutation_recovery_expired", "mutation_request_conflict", "not_authorized", "operation_cancelled", "operation_failed", "rate_limited", "relay_authorization_expired", "relay_unavailable", "temporarily_unavailable", "timeout", "transport_protocol_incompatible", "unsupported_operation", "collection_configuration_invalid", "collection_invalid", "collection_type_registry_invalid", "collection_version_unsupported", "invalid_path", "operation_invalid", "concurrent_modification", "invalid_preflight", "pending_mutation_unresolved"];
38
+ export declare const COLLECTION_READ_PROBLEM_CODES: readonly ["access_denied", "access_paused", "application_declaration_mismatch", "authorization_binding_incompatible", "authorization_expired", "capability_contract_incompatible", "collection_access_denied", "connector_identity_changed", "connector_offline", "connector_upgrade_required", "direct_operation_rejected", "durable_mutation_unsupported", "operation_outcome_unknown", "encryption_required", "encrypted_relay_rejected", "hosted_provider_unavailable", "insufficient_access", "invalid_encrypted_response", "invalid_operation_response", "invalid_request", "missing_grant_key", "mutation_recovery_expired", "mutation_request_conflict", "not_authorized", "operation_cancelled", "operation_failed", "rate_limited", "relay_authorization_expired", "relay_unavailable", "temporarily_unavailable", "timeout", "transport_protocol_incompatible", "unsupported_operation", "collection_configuration_invalid", "collection_invalid", "collection_type_registry_invalid", "collection_version_unsupported", "file_not_found", "invalid_path", "operation_invalid"];
39
+ export declare const COLLECTION_QUERY_PROBLEM_CODES: readonly ["access_denied", "access_paused", "application_declaration_mismatch", "authorization_binding_incompatible", "authorization_expired", "capability_contract_incompatible", "collection_access_denied", "connector_identity_changed", "connector_offline", "connector_upgrade_required", "direct_operation_rejected", "durable_mutation_unsupported", "operation_outcome_unknown", "encryption_required", "encrypted_relay_rejected", "hosted_provider_unavailable", "insufficient_access", "invalid_encrypted_response", "invalid_operation_response", "invalid_request", "missing_grant_key", "mutation_recovery_expired", "mutation_request_conflict", "not_authorized", "operation_cancelled", "operation_failed", "rate_limited", "relay_authorization_expired", "relay_unavailable", "temporarily_unavailable", "timeout", "transport_protocol_incompatible", "unsupported_operation", "collection_configuration_invalid", "collection_invalid", "collection_type_registry_invalid", "collection_version_unsupported", "file_not_found", "invalid_path", "operation_invalid", "cursor_capacity_exhausted", "generation_expired", "invalid_read_cursor", "query_snapshot_changed", "sandbox_unsupported"];
40
+ export declare const COLLECTION_MUTATION_PROBLEM_CODES: readonly ["access_denied", "access_paused", "application_declaration_mismatch", "authorization_binding_incompatible", "authorization_expired", "capability_contract_incompatible", "collection_access_denied", "connector_identity_changed", "connector_offline", "connector_upgrade_required", "direct_operation_rejected", "durable_mutation_unsupported", "operation_outcome_unknown", "encryption_required", "encrypted_relay_rejected", "hosted_provider_unavailable", "insufficient_access", "invalid_encrypted_response", "invalid_operation_response", "invalid_request", "missing_grant_key", "mutation_recovery_expired", "mutation_request_conflict", "not_authorized", "operation_cancelled", "operation_failed", "rate_limited", "relay_authorization_expired", "relay_unavailable", "temporarily_unavailable", "timeout", "transport_protocol_incompatible", "unsupported_operation", "collection_configuration_invalid", "collection_invalid", "collection_type_registry_invalid", "collection_version_unsupported", "file_not_found", "invalid_path", "operation_invalid", "concurrent_modification", "invalid_preflight", "pending_mutation_unresolved"];
41
41
  export declare const COLLECTION_CHANGES_PROBLEM_CODES: readonly ["access_denied", "access_paused", "application_declaration_mismatch", "authorization_binding_incompatible", "authorization_expired", "capability_contract_incompatible", "collection_access_denied", "connector_identity_changed", "connector_offline", "connector_upgrade_required", "direct_operation_rejected", "durable_mutation_unsupported", "operation_outcome_unknown", "encryption_required", "encrypted_relay_rejected", "hosted_provider_unavailable", "insufficient_access", "invalid_encrypted_response", "invalid_operation_response", "invalid_request", "missing_grant_key", "mutation_recovery_expired", "mutation_request_conflict", "not_authorized", "operation_cancelled", "operation_failed", "rate_limited", "relay_authorization_expired", "relay_unavailable", "temporarily_unavailable", "timeout", "transport_protocol_incompatible", "unsupported_operation", "collection_configuration_invalid", "collection_invalid", "collection_type_registry_invalid", "collection_version_unsupported", "change_cursor_reset"];
42
- export declare const COLLECTION_TYPE_PROBLEM_CODES: readonly ["access_denied", "access_paused", "application_declaration_mismatch", "authorization_binding_incompatible", "authorization_expired", "capability_contract_incompatible", "collection_access_denied", "connector_identity_changed", "connector_offline", "connector_upgrade_required", "direct_operation_rejected", "durable_mutation_unsupported", "operation_outcome_unknown", "encryption_required", "encrypted_relay_rejected", "hosted_provider_unavailable", "insufficient_access", "invalid_encrypted_response", "invalid_operation_response", "invalid_request", "missing_grant_key", "mutation_recovery_expired", "mutation_request_conflict", "not_authorized", "operation_cancelled", "operation_failed", "rate_limited", "relay_authorization_expired", "relay_unavailable", "temporarily_unavailable", "timeout", "transport_protocol_incompatible", "unsupported_operation", "collection_configuration_invalid", "collection_invalid", "collection_type_registry_invalid", "collection_version_unsupported", "invalid_path", "operation_invalid", "concurrent_modification", "invalid_preflight", "pending_mutation_unresolved", "type_pack_provision_failed"];
42
+ export declare const COLLECTION_TYPE_PROBLEM_CODES: readonly ["access_denied", "access_paused", "application_declaration_mismatch", "authorization_binding_incompatible", "authorization_expired", "capability_contract_incompatible", "collection_access_denied", "connector_identity_changed", "connector_offline", "connector_upgrade_required", "direct_operation_rejected", "durable_mutation_unsupported", "operation_outcome_unknown", "encryption_required", "encrypted_relay_rejected", "hosted_provider_unavailable", "insufficient_access", "invalid_encrypted_response", "invalid_operation_response", "invalid_request", "missing_grant_key", "mutation_recovery_expired", "mutation_request_conflict", "not_authorized", "operation_cancelled", "operation_failed", "rate_limited", "relay_authorization_expired", "relay_unavailable", "temporarily_unavailable", "timeout", "transport_protocol_incompatible", "unsupported_operation", "collection_configuration_invalid", "collection_invalid", "collection_type_registry_invalid", "collection_version_unsupported", "file_not_found", "invalid_path", "operation_invalid", "concurrent_modification", "invalid_preflight", "pending_mutation_unresolved", "type_pack_provision_failed"];
43
43
  export declare function connectSuccess<Value>(value: Value, diagnostics?: MdbaseDiagnostic[]): ConnectSuccess<Value>;
44
44
  export declare function connectFailure<Code extends ConnectProblemCode>(problem: ConnectProblem<Code>): ConnectFailure<Code>;
45
45
  export declare function captureConnectOutcome<Value, Code extends ConnectProblemCode>(operation: () => Promise<Value>, allowedCodes: readonly Code[]): Promise<ConnectOutcome<Value, Code>>;
package/dist/outcomes.js CHANGED
@@ -129,6 +129,7 @@ export const COLLECTION_DESCRIPTION_PROBLEM_CODES = [
129
129
  ];
130
130
  export const COLLECTION_READ_PROBLEM_CODES = [
131
131
  ...COLLECTION_DESCRIPTION_PROBLEM_CODES,
132
+ "file_not_found",
132
133
  "invalid_path",
133
134
  "operation_invalid"
134
135
  ];
@@ -1,5 +1,6 @@
1
1
  import { type MutationOperationIdentifier } from "@mdbase-dev/connect-protocol";
2
2
  import type { PendingMutation } from "./internal-types.js";
3
+ import type { PendingMutationSummary } from "./operation-types.js";
3
4
  export declare class PendingMutationStore {
4
5
  private readonly storage;
5
6
  private readonly baseKey;
@@ -7,6 +8,8 @@ export declare class PendingMutationStore {
7
8
  list(collectionId: string | null): PendingMutation[];
8
9
  find(collectionId: string | null, requestId: string): PendingMutation | null;
9
10
  identifier(pending: PendingMutation): MutationOperationIdentifier;
11
+ /** A pending write without its authority transport credentials. */
12
+ summary(pending: PendingMutation): PendingMutationSummary;
10
13
  store(pending: PendingMutation): void;
11
14
  take(requestId: string): PendingMutation | null;
12
15
  private key;
@@ -37,6 +37,16 @@ export class PendingMutationStore {
37
37
  identifier(pending) {
38
38
  return pending.mutation ?? mutationOperationIdentifier(pending.operation, pending.request?.input ?? (pending.operation === "sync" ? { action: "mutate" } : {})) ?? (pending.operation === "sync" ? "sync:mutate" : pending.operation);
39
39
  }
40
+ /** A pending write without its authority transport credentials. */
41
+ summary(pending) {
42
+ return {
43
+ requestId: pending.requestId,
44
+ operation: this.identifier(pending),
45
+ fingerprint: pending.inputFingerprint,
46
+ status: "outcome_unknown",
47
+ createdAt: new Date(pending.createdAt).toISOString()
48
+ };
49
+ }
40
50
  store(pending) {
41
51
  this.storage.setItem(this.key(pending.requestId), JSON.stringify(pending));
42
52
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * An equality token for one record in one collection. Stored with a pending
3
+ * update so an editing session can find its own interrupted write after a
4
+ * reload without the record path ever being persisted.
5
+ */
6
+ export declare function recordDigest(collectionId: string, path: string): Promise<string>;
7
+ /** The digest a new pending mutation stores: only updates name a record. */
8
+ export declare function updatedRecordDigest(collectionId: string, operation: string, input: unknown): Promise<string | undefined>;
9
+ /** The request ID of a stored interrupted update to one record, if any. */
10
+ export declare function interruptedUpdate(pending: readonly {
11
+ operation: string;
12
+ requestId: string;
13
+ recordDigest?: string;
14
+ }[], collectionId: string, path: string): Promise<string | null>;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * An equality token for one record in one collection. Stored with a pending
3
+ * update so an editing session can find its own interrupted write after a
4
+ * reload without the record path ever being persisted.
5
+ */
6
+ export async function recordDigest(collectionId, path) {
7
+ const bytes = new TextEncoder().encode(`${collectionId}\n${path}`);
8
+ const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", bytes));
9
+ return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
10
+ }
11
+ /** The digest a new pending mutation stores: only updates name a record. */
12
+ export async function updatedRecordDigest(collectionId, operation, input) {
13
+ const path = input?.path;
14
+ return operation === "update" && typeof path === "string" ? recordDigest(collectionId, path) : undefined;
15
+ }
16
+ /** The request ID of a stored interrupted update to one record, if any. */
17
+ export async function interruptedUpdate(pending, collectionId, path) {
18
+ const digest = await recordDigest(collectionId, path);
19
+ return pending.find((entry) => entry.operation === "update" && entry.recordDigest === digest)?.requestId ?? null;
20
+ }