@mdbase-dev/connect 0.1.0-beta.125 → 0.1.0-beta.147

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/dist/application-session.d.ts +1 -1
  2. package/dist/application-session.js +1 -1
  3. package/dist/async.d.ts +6 -0
  4. package/dist/async.js +39 -0
  5. package/dist/authorization-refresh.d.ts +2 -0
  6. package/dist/authorization-refresh.js +13 -1
  7. package/dist/browser/integrity.json +1 -1
  8. package/dist/browser/mdbase-connect.min.js +4 -4
  9. package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
  10. package/dist/capabilities.d.ts +1 -1
  11. package/dist/collection-client.d.ts +2 -0
  12. package/dist/collection-client.js +8 -4
  13. package/dist/connect-options.d.ts +1 -1
  14. package/dist/connect.d.ts +1 -1
  15. package/dist/connect.js +1 -1
  16. package/dist/connection.d.ts +2 -1
  17. package/dist/connection.js +3 -1
  18. package/dist/control-timers.d.ts +43 -0
  19. package/dist/control-timers.js +290 -0
  20. package/dist/errors.d.ts +2 -28
  21. package/dist/errors.js +2 -52
  22. package/dist/file-transfer-internals.d.ts +2 -1
  23. package/dist/file-transfer-internals.js +10 -3
  24. package/dist/files.d.ts +4 -0
  25. package/dist/files.js +17 -18
  26. package/dist/index.d.ts +2 -1
  27. package/dist/index.js +1 -0
  28. package/dist/internal-types.d.ts +2 -2
  29. package/dist/internal-types.js +1 -1
  30. package/dist/mdbase-connect.d.ts +1 -1
  31. package/dist/observe.d.ts +81 -0
  32. package/dist/observe.js +390 -0
  33. package/dist/operation-types.d.ts +3 -1
  34. package/dist/outcomes.d.ts +13 -13
  35. package/dist/outcomes.js +17 -0
  36. package/dist/query-pagination-internals.d.ts +1 -1
  37. package/dist/query-pagination-internals.js +4 -2
  38. package/dist/query-pagination.js +1 -1
  39. package/dist/query-wire.d.ts +16 -10
  40. package/dist/query-wire.js +44 -13
  41. package/dist/read-many.js +4 -2
  42. package/dist/runtime-utils.d.ts +1 -1
  43. package/dist/saved-view-pagination.js +1 -1
  44. package/dist/session.d.ts +1 -1
  45. package/dist/session.js +1 -1
  46. package/dist/structured-readiness.d.ts +1 -1
  47. package/package.json +7 -3
  48. package/dist/application-contract.d.ts +0 -22
  49. package/dist/application-contract.js +0 -75
@@ -1 +1 @@
1
- sha384-JJDoYra3MuCFJxyktOaw/6TQwPZCx8ZC2YcJhJxOZqXF8xU67X+tyXiEm5KKZwcf
1
+ sha384-rwS4Cs8c/aA1ZdOgYHyRuQz90zVNe1GLeCYRKxXrHsuo777O2T/z+Bu0Mjpf0tBD
@@ -1,5 +1,5 @@
1
1
  import { type ApplicationCapabilityRequirements, type LegacyApplicationCapabilityRequirements } from "@mdbase-dev/connect-protocol";
2
- import type { MdbaseApplicationCapabilityId as ApplicationCapabilityId, MdbaseApplicationManifest as MdbaseAppManifest } from "./application-contract.js";
2
+ import type { MdbaseApplicationCapabilityId as ApplicationCapabilityId, MdbaseApplicationManifest as MdbaseAppManifest } from "@mdbase-dev/connect-protocol";
3
3
  import type { MdbaseConnectionInfo } from "./connection-types.js";
4
4
  export type MdbaseCapabilityState = "available" | "requires_authorization" | "requires_setup" | "temporarily_unavailable" | "unsupported";
5
5
  export interface MdbaseCapabilityEvidence {
@@ -1,4 +1,5 @@
1
1
  import type { CollectionOperation, CollectionTypeDocument, JsonObject } from "@mdbase-dev/connect-protocol";
2
+ import { MdbaseQueryObserver, type ObserveOptions } from "./observe.js";
2
3
  import { type CollectionChangesProblemCode, type CollectionDescriptionProblemCode, type CollectionMutationProblemCode, type CollectionQueryProblemCode, type CollectionReadProblemCode, type CollectionTypeProblemCode, type ConnectOutcome } from "./outcomes.js";
3
4
  import type { ChangesInput, DescribeOptions, CollectionChange, CollectionChangesPage, CollectionDescription, CollectionSetupApplyResult, CollectionSetupAssessment, AssessCollectionSetupInput, AssessTypePackInput, ApplyCollectionSetupInput, ApplyTypePackInput, CreateInput, CreateTypeInput, CreateViewSourceInput, DeleteInput, DeletePreflightResult, DeleteResult, DeleteViewSourceInput, DeleteViewSourceResult, ExecuteViewInput, MdbaseCollectionTransport, MdbaseDesiredTimer, MdbaseTimer, MdbaseTimerList, MdbaseTimerReconciliation, ConnectRequestOptions, QueryAllOptions, QueryInput, QueryMetadataInput, QueryMetadataRecord, QueryMetadataResult, QueryMetadataPage, QueryPage, QueryPagesOptions, QueryResult, ReadInput, ReadManyOptions, ReadManyResult, ReadTypeInput, ReadViewSourceInput, RenameInput, RenamePreflightResult, RenameResult, RecordDocument, SavedViewExecution, SavedViewPage, SavedViewPagesOptions, SavedViewList, SavedViewSourceDocument, TypePackApplyResult, TypePackAssessment, UpdateInput, UpdateTypeInput, UpdateViewSourceInput, WatchOptions } from "./operation-types.js";
4
5
  /**
@@ -21,6 +22,7 @@ export declare class MdbaseCollectionClient<Frontmatter extends JsonObject = Jso
21
22
  changes(input?: ChangesInput, options?: ConnectRequestOptions): Promise<ConnectOutcome<CollectionChangesPage, CollectionChangesProblemCode>>;
22
23
  read(input: ReadInput, options?: ConnectRequestOptions): Promise<ConnectOutcome<RecordDocument<Frontmatter>, CollectionReadProblemCode>>;
23
24
  readMany(paths: readonly string[], options?: ReadManyOptions): Promise<ConnectOutcome<ReadManyResult<Frontmatter>, CollectionQueryProblemCode>>;
25
+ observe(query?: QueryInput, options?: ObserveOptions): MdbaseQueryObserver<Frontmatter>;
24
26
  query(input: QueryMetadataInput, options?: ConnectRequestOptions): Promise<ConnectOutcome<QueryMetadataResult, CollectionQueryProblemCode>>;
25
27
  query(input?: QueryInput, options?: ConnectRequestOptions): Promise<ConnectOutcome<QueryResult<Frontmatter>, CollectionQueryProblemCode>>;
26
28
  query(input: QueryInput | QueryMetadataInput, options?: ConnectRequestOptions): Promise<ConnectOutcome<QueryResult<Frontmatter> | QueryMetadataResult, CollectionQueryProblemCode>>;
@@ -1,8 +1,9 @@
1
- import { abortableDelay } from "./async.js";
1
+ import { abortableDelay, pollDelay } from "./async.js";
2
2
  import { invalidatesDescription, normalizeChangesPage } from "./change-events.js";
3
3
  import { CollectionDescriptionCache } from "./description-cache.js";
4
4
  import { coordinatedQueryPages } from "./query-pagination.js";
5
5
  import { readMany as readManyRecords } from "./read-many.js";
6
+ import { MdbaseQueryObserver } from "./observe.js";
6
7
  import { wireDataContractIdentity, wireQueryInput, wireQueryRecord, wireQueryResult } from "./query-wire.js";
7
8
  import { coordinatedSavedViewPages } from "./saved-view-pagination.js";
8
9
  import { MdbaseConnectError, connectError, connectProblem, operationProblem } from "./errors.js";
@@ -54,13 +55,16 @@ export class MdbaseCollectionClient {
54
55
  readMany(paths, options = {}) {
55
56
  return readManyRecords((input, requestOptions) => this.queryAll(input, requestOptions), (selected, includeBody, requestOptions) => this.envelopeOperation("read", { paths: selected, include_body: includeBody, include_document: false }, COLLECTION_READ_PROBLEM_CODES, requestOptions), this.supportsAuthorityFeature, paths, options, this.requestTimeoutMs);
56
57
  }
58
+ observe(query = {}, options = {}) {
59
+ return new MdbaseQueryObserver(this, query, options, this.supportsAuthorityFeature);
60
+ }
57
61
  async query(input = {}, options) {
58
62
  if (input.output !== undefined && input.output !== "metadata") {
59
63
  return connectFailure(connectProblem("invalid_request", "Unknown query output mode."));
60
64
  }
61
65
  if (input.output !== "metadata") {
62
66
  const outcome = await this.envelopeOperation("query", wireQueryInput(input), COLLECTION_QUERY_PROBLEM_CODES, options);
63
- return mapOutcome(outcome, value => wireQueryResult(value, undefined));
67
+ return mapOutcome(outcome, value => wireQueryResult(value, undefined, input));
64
68
  }
65
69
  const captured = await captureConnectOutcome(() => withRequestBudget(options, this.requestTimeoutMs, async (budget) => {
66
70
  const requestOptions = requestOptionsWithinBudget({ ...options, timeoutMs: null }, budget);
@@ -72,7 +76,7 @@ export class MdbaseCollectionClient {
72
76
  if (!support?.value)
73
77
  throw connectError("unsupported_operation", "This authority does not support metadata queries. Use an ordinary query on legacy authorities.");
74
78
  const outcome = await this.envelopeOperation("query", wireQueryInput(input), COLLECTION_QUERY_PROBLEM_CODES, requestOptions);
75
- return mapOutcome(outcome, value => wireQueryResult(value, input.output));
79
+ return mapOutcome(outcome, value => wireQueryResult(value, input.output, input));
76
80
  }), COLLECTION_QUERY_PROBLEM_CODES);
77
81
  return captured.ok ? captured.value : captured;
78
82
  }
@@ -309,7 +313,7 @@ export class MdbaseCollectionClient {
309
313
  cursor = page.cursor;
310
314
  if (!page.hasMore) {
311
315
  try {
312
- await abortableDelay(pollInterval, options.signal);
316
+ await pollDelay(pollInterval, options.signal);
313
317
  }
314
318
  catch {
315
319
  if (!options.signal?.aborted)
@@ -1,4 +1,4 @@
1
- import type { MdbaseApplicationManifest as MdbaseAppManifest } from "./application-contract.js";
1
+ import type { MdbaseApplicationManifest as MdbaseAppManifest } from "@mdbase-dev/connect-protocol";
2
2
  import type { JsonObject } from "@mdbase-dev/connect-protocol";
3
3
  import type { GrantKeyStore } from "./crypto.js";
4
4
  import type { ApplicationIdentityStore } from "./application-identity.js";
package/dist/connect.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { JsonObject } from "@mdbase-dev/connect-protocol";
2
- import { type MdbaseApplicationManifest as MdbaseAppManifest } from "./application-contract.js";
2
+ import { type MdbaseApplicationManifest as MdbaseAppManifest } from "@mdbase-dev/connect-protocol";
3
3
  import { type ApplicationIdentityStore } from "./application-identity.js";
4
4
  import { MdbaseConnection, type MdbaseAuthorizationOutcome, type MdbaseAuthorizationResult, type MdbaseAuthorizeOptions, type MdbaseConnectEnvironment } from "./connection.js";
5
5
  import type { MdbaseConnectOptions } from "./connect-options.js";
package/dist/connect.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { APPLICATION_AUTHORIZATION_PROTOCOL_VERSION, authorizationContractRequirements, DEFAULT_LOOPBACK_PORT, GRANT_ENCRYPTION_PROTOCOL_VERSION, RELAY_ENCRYPTION_SUITE } from "@mdbase-dev/connect-protocol";
2
- import { authorizationOperations, authorizationFiles, semanticContractVersion, validateAuthorizationSelection } from "./application-contract.js";
2
+ import { authorizationOperations, authorizationFiles, semanticContractVersion, validateAuthorizationSelection } from "@mdbase-dev/connect-protocol";
3
3
  import { abortableDelay } from "./async.js";
4
4
  import { authorizationCallbackState } from "./authorization-url.js";
5
5
  import { randomBase64Url } from "./base64.js";
@@ -1,6 +1,6 @@
1
1
  import { MdbasePeopleClient } from "./people-client.js";
2
2
  import type { CollectionOperation, CollectionTypeDocument, FileCapability, GrantScope, JsonObject } from "@mdbase-dev/connect-protocol";
3
- import { type MdbaseApplicationCapabilityId as ApplicationCapabilityId } from "./application-contract.js";
3
+ import { type MdbaseApplicationCapabilityId as ApplicationCapabilityId } from "@mdbase-dev/connect-protocol";
4
4
  import { MdbaseCollectionClient } from "./collection-client.js";
5
5
  import { type ConnectionTransportInternals } from "./connection-transport.js";
6
6
  import type { DirectAccessStatus, MdbaseAuthorizationCapabilities, MdbaseConnectionInfo, MdbaseConnectionRoute, MdbaseSyncConnection } from "./connection-types.js";
@@ -82,6 +82,7 @@ export declare class MdbaseConnection<Frontmatter extends JsonObject = JsonObjec
82
82
  readonly records: MdbaseRecords<Frontmatter>;
83
83
  readonly people: MdbasePeopleClient;
84
84
  readonly readMany: MdbaseCollectionClient<Frontmatter>["readMany"];
85
+ readonly observe: MdbaseCollectionClient<Frontmatter>["observe"];
85
86
  private readonly connectionListeners;
86
87
  constructor(internals: MdbaseConnectionInternals<Frontmatter>, collectionId: string);
87
88
  get displayName(): string;
@@ -1,6 +1,6 @@
1
1
  import { MdbasePeopleClient, requestPeople } from "./people-client.js";
2
2
  import { capabilityOperationsForContractVersion } from "@mdbase-dev/connect-protocol";
3
- import { retainedCapabilities, semanticContractVersion, validateAuthorizationSelection } from "./application-contract.js";
3
+ import { retainedCapabilities, semanticContractVersion, validateAuthorizationSelection } from "@mdbase-dev/connect-protocol";
4
4
  import { MdbaseCollectionClient } from "./collection-client.js";
5
5
  import { AuthorityFeatures, authorityCapabilities } from "./authority-features.js";
6
6
  import { filePage } from "./file-metadata.js";
@@ -28,6 +28,7 @@ export class MdbaseConnection {
28
28
  records;
29
29
  people;
30
30
  readMany;
31
+ observe;
31
32
  connectionListeners = new Set();
32
33
  constructor(internals, collectionId) {
33
34
  this.internals = internals;
@@ -88,6 +89,7 @@ export class MdbaseConnection {
88
89
  operation: (operation, input, requestOptions) => this.transport.performOperation(operation, input, requestOptions)
89
90
  }, internals.timeouts.requestMs), internals.timeouts.requestMs, (id, options) => this.supportsAuthorityFeature(id, options));
90
91
  this.readMany = this.collectionClient.readMany.bind(this.collectionClient);
92
+ this.observe = this.collectionClient.observe.bind(this.collectionClient);
91
93
  this.notifications = new ConnectionNotifications({
92
94
  serverUrl: internals.serverUrl,
93
95
  storage: internals.storage,
@@ -0,0 +1,43 @@
1
+ /** Fixed control-plane timer/channel requests, never data-replica operations. */
2
+ import type { MdbaseConnection } from "./connection.js";
3
+ import type { ConnectRequestOptions } from "./operation-types.js";
4
+ export interface TimerChannelRegistration {
5
+ channelId: string;
6
+ installationId: string;
7
+ criteria: string[];
8
+ }
9
+ export interface WebPushTimerOptions extends ConnectRequestOptions {
10
+ serviceWorker: ServiceWorkerRegistration;
11
+ criteria?: string[];
12
+ installationId?: string;
13
+ }
14
+ export interface FcmTimerOptions extends ConnectRequestOptions {
15
+ token: string;
16
+ criteria?: string[];
17
+ installationId?: string;
18
+ }
19
+ /** Structurally implements the NEXT SDK's AppTimersPort without an SDK dependency. */
20
+ export interface ConnectAppTimersPort {
21
+ list(namespace: string, options: ConnectRequestOptions): Promise<unknown>;
22
+ put(namespace: string, id: string, body: {
23
+ criterion_id: string;
24
+ fire_at: string;
25
+ }, options: ConnectRequestOptions): Promise<unknown>;
26
+ cancel(namespace: string, id: string, generation: number | undefined, options: ConnectRequestOptions): Promise<unknown>;
27
+ reconcile(namespace: string, body: {
28
+ criterion_id: string;
29
+ timers: {
30
+ id: string;
31
+ fire_at: string;
32
+ }[];
33
+ }, options: ConnectRequestOptions): Promise<unknown>;
34
+ registerWebPush(options: WebPushTimerOptions): Promise<TimerChannelRegistration>;
35
+ unregisterWebPush(serviceWorker: ServiceWorkerRegistration | undefined, options: ConnectRequestOptions): Promise<void>;
36
+ registerFcm(options: FcmTimerOptions): Promise<TimerChannelRegistration & {
37
+ transport: "fcm";
38
+ }>;
39
+ unregisterFcm(options: ConnectRequestOptions): Promise<void>;
40
+ close(): void;
41
+ }
42
+ /** Bind one app consent; scope/mode decisions remain the control plane's authority. */
43
+ export declare function appTimers(connection: MdbaseConnection): ConnectAppTimersPort;
@@ -0,0 +1,290 @@
1
+ import { connectError, MdbaseConnectError } from "./errors.js";
2
+ import { base64UrlBytes, bytesToBase64Url, randomBase64Url } from "./base64.js";
3
+ import { requestAbortReason, withCooperativeRequestBudget } from "./request-budget.js";
4
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
5
+ const NS = /^[A-Za-z0-9._-]{1,64}$/u;
6
+ const ID = /^[A-Za-z0-9._:-]{1,128}$/u;
7
+ const fail = () => connectError("invalid_request", "Invalid opaque timer or channel input.");
8
+ function segment(s, pattern) {
9
+ if (typeof s !== "string" || !pattern.test(s))
10
+ throw fail();
11
+ return encodeURIComponent(s);
12
+ }
13
+ function desired(body) {
14
+ if (!body || typeof body.criterion_id !== "string" || !body.criterion_id.length || body.criterion_id.length > 100
15
+ || typeof body.fire_at !== "string" || !Number.isFinite(Date.parse(body.fire_at)))
16
+ throw fail();
17
+ return { criterion_id: body.criterion_id, fire_at: body.fire_at };
18
+ }
19
+ function origin(s) {
20
+ const u = new URL(s);
21
+ if (u.protocol !== "https:" || u.origin !== s.replace(/\/$/u, ""))
22
+ throw fail();
23
+ return u.origin;
24
+ }
25
+ async function boundedJSON(response, signal) {
26
+ if (response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() !== "application/json")
27
+ throw fail();
28
+ const reader = response.body?.getReader();
29
+ if (!reader)
30
+ throw fail();
31
+ const chunks = [];
32
+ let size = 0;
33
+ const abort = () => { void reader.cancel().catch(() => { }); };
34
+ signal.addEventListener("abort", abort, { once: true });
35
+ try {
36
+ if (signal.aborted)
37
+ throw requestAbortReason(signal);
38
+ for (;;) {
39
+ const r = await reader.read();
40
+ if (signal.aborted)
41
+ throw requestAbortReason(signal);
42
+ if (r.done)
43
+ break;
44
+ size += r.value.length;
45
+ if (size > 1024 * 1024)
46
+ throw connectError("invalid_operation_response", "Control-plane metadata exceeds the bounded response budget.");
47
+ chunks.push(r.value);
48
+ }
49
+ const bytes = new Uint8Array(size);
50
+ let at = 0;
51
+ for (const chunk of chunks) {
52
+ bytes.set(chunk, at);
53
+ at += chunk.length;
54
+ }
55
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
56
+ }
57
+ finally {
58
+ chunks.length = 0;
59
+ signal.removeEventListener("abort", abort);
60
+ await reader.cancel().catch(() => { });
61
+ reader.releaseLock();
62
+ }
63
+ }
64
+ /** Bind one app consent; scope/mode decisions remain the control plane's authority. */
65
+ export function appTimers(connection) {
66
+ const transport = connection["transport"], internals = connection["internals"];
67
+ const server = origin(transport["serverUrl"]); // Before reading credentials.
68
+ const initial = transport.currentToken();
69
+ if (!initial?.grantId || !UUID.test(initial.grantId) || !UUID.test(initial.collectionId) || !initial.keyHandle) {
70
+ throw connectError("not_authorized", "A retained app consent is required for timers.");
71
+ }
72
+ const identity = (t) => JSON.stringify([
73
+ t.grantId, t.collectionId, t.clientId, t.keyHandle, t.applicationOrigin,
74
+ t.operations, t.scope, t.encryption, t.nextNoise,
75
+ t.authority && [t.authority.replicaId, t.authority.operationsUrl, t.authority.syncUrl, t.authority.filesUrl, t.authority.proofPublicKey]
76
+ ]);
77
+ const pinned = identity(initial), lifetime = new AbortController();
78
+ const prefix = `/v1/next/collections/${initial.collectionId}/timers`;
79
+ const run = async (options, write, operation) => {
80
+ const signal = options.signal ? AbortSignal.any([options.signal, lifetime.signal]) : lifetime.signal;
81
+ return withCooperativeRequestBudget({
82
+ ...options, signal,
83
+ timeoutMs: options.timeoutMs == null ? 10_000 : Math.min(options.timeoutMs, 10_000)
84
+ }, 10_000, async (budget) => {
85
+ const token = transport.currentToken();
86
+ if (!token || identity(token) !== pinned || !Number.isFinite(token.expiresAt) || token.expiresAt <= Date.now()) {
87
+ throw connectError("not_authorized", "The retained timer consent is unavailable.");
88
+ }
89
+ const snapshot = JSON.stringify(token), leases = transport["grantKeyLeases"]();
90
+ let dispatched = false;
91
+ const check = () => {
92
+ if (budget.signal.aborted)
93
+ throw requestAbortReason(budget.signal);
94
+ const current = transport.currentToken();
95
+ if (!current || !Number.isFinite(current.expiresAt) || current.expiresAt <= Date.now()
96
+ || JSON.stringify(current) !== snapshot || origin(transport["serverUrl"]) !== server) {
97
+ throw connectError("authority_authorization_changed", "The retained timer consent changed.");
98
+ }
99
+ };
100
+ try {
101
+ await leases.retain(token, budget.signal);
102
+ check();
103
+ // Private fixed-route helper: never returned or accepted from the caller.
104
+ const request = async (path, method = "GET", data, noBody = false) => {
105
+ check();
106
+ const body = data === undefined ? undefined : JSON.stringify(data);
107
+ if (body && new TextEncoder().encode(body).length > 2 * 1024 * 1024)
108
+ throw fail();
109
+ dispatched ||= write && method !== "GET";
110
+ const response = await fetch(server + path, { method, signal: budget.signal, redirect: "error", credentials: "omit", cache: "no-store",
111
+ headers: { authorization: `Bearer ${token.accessToken}`, ...(body === undefined ? {} : { "content-type": "application/json" }) },
112
+ ...(body === undefined ? {} : { body }) });
113
+ try {
114
+ check();
115
+ if (!response.ok)
116
+ throw connectError(response.status === 401 || response.status === 403 ? "not_authorized" : "operation_failed", "Control-plane timer or channel request failed.", { status: response.status,
117
+ operationOutcome: dispatched && response.status >= 500 ? "unknown" : "rejected" });
118
+ const result = noBody ? undefined : await boundedJSON(response, budget.signal);
119
+ check();
120
+ return result;
121
+ }
122
+ finally {
123
+ if (!response.body?.locked)
124
+ await response.body?.cancel().catch(() => { });
125
+ }
126
+ };
127
+ const result = await operation(request, check, budget.signal);
128
+ check();
129
+ return result;
130
+ }
131
+ catch (error) {
132
+ if (error instanceof MdbaseConnectError && error.problem.operation_outcome === "rejected")
133
+ throw error;
134
+ if (dispatched)
135
+ throw connectError("operation_failed", "Timer or channel write outcome is unknown.", { operationOutcome: "unknown" });
136
+ if (budget.signal.aborted)
137
+ throw requestAbortReason(budget.signal);
138
+ if (error instanceof MdbaseConnectError)
139
+ throw error;
140
+ throw connectError("operation_failed", "Control-plane timer or channel request failed.", { operationOutcome: "not_sent" });
141
+ }
142
+ finally {
143
+ leases.release();
144
+ }
145
+ });
146
+ };
147
+ const registration = async (options, native) => run(options, true, async (request, check, signal) => {
148
+ const application = await internals.register({ signal, timeoutMs: null });
149
+ check();
150
+ const declared = application.notifications?.criteria.map(c => c.id) ?? [];
151
+ const criteria = [...new Set(options.criteria ?? declared)];
152
+ if (!criteria.length || criteria.length > 100 || criteria.some(c => !declared.includes(c)))
153
+ throw fail();
154
+ if (native && application.notifications?.native_delivery?.mode !== "managed_fcm")
155
+ throw fail();
156
+ const storageKey = `${internals.notificationKey(initial.collectionId, native ? "fcm" : "web_push")}:control-timers`;
157
+ const previous = internals.storage.getItem(storageKey);
158
+ const saved = previous ? JSON.parse(previous) : null;
159
+ const installationId = options.installationId ?? (saved && saved.grantId === initial.grantId ? saved.installationId : undefined) ?? randomBase64Url(24);
160
+ if (typeof installationId !== "string" || !/^[A-Za-z0-9._:-]{1,200}$/u.test(installationId))
161
+ throw fail();
162
+ if (saved && saved.grantId === initial.grantId) {
163
+ if (!saved.channelId || saved.previousTarget !== undefined || saved.pending || !Array.isArray(saved.criteria)) {
164
+ throw connectError("operation_failed", "Channel registration requires reconciliation before replacement.", { operationOutcome: "unknown" });
165
+ }
166
+ if (saved.installationId !== installationId) {
167
+ throw connectError("operation_failed", "Unregister the acknowledged installation before replacing it.", { operationOutcome: "not_sent" });
168
+ }
169
+ }
170
+ let data;
171
+ if (native) {
172
+ const token = options.token;
173
+ if (typeof token !== "string" || !token.length || token.length > 4096)
174
+ throw fail();
175
+ data = { installation_id: installationId, criteria, transport: "fcm", token };
176
+ }
177
+ else {
178
+ const worker = options.serviceWorker;
179
+ const keyBody = await request("/v1/notifications/vapid-public-key");
180
+ if (typeof keyBody.public_key !== "string" || !/^[A-Za-z0-9_-]{87}$/u.test(keyBody.public_key))
181
+ throw fail();
182
+ const vapid = base64UrlBytes(keyBody.public_key);
183
+ if (vapid.length !== 65 || vapid[0] !== 4 || bytesToBase64Url(vapid) !== keyBody.public_key)
184
+ throw fail();
185
+ let subscription = await worker.pushManager.getSubscription();
186
+ check();
187
+ if (!subscription) {
188
+ subscription = await worker.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: vapid });
189
+ try {
190
+ check();
191
+ }
192
+ catch (error) {
193
+ await subscription.unsubscribe().catch(() => false);
194
+ throw error;
195
+ }
196
+ }
197
+ const sub = subscription.toJSON();
198
+ if (!sub.endpoint || !sub.keys?.p256dh || !sub.keys.auth)
199
+ throw fail();
200
+ data = { installation_id: installationId, criteria, subscription: { endpoint: sub.endpoint, expirationTime: sub.expirationTime ?? null, keys: sub.keys } };
201
+ }
202
+ // Persist only this target. A different acknowledged installation must
203
+ // first be explicitly unregistered; uncertain targets cannot be replaced.
204
+ check();
205
+ if (internals.storage.getItem(storageKey) !== previous) {
206
+ throw connectError("authority_authorization_changed", "Channel registration context changed.", { operationOutcome: "not_sent" });
207
+ }
208
+ const operationNonce = randomBase64Url(24);
209
+ const pending = JSON.stringify({
210
+ installationId, grantId: initial.grantId, pending: true, operationNonce,
211
+ ...(saved && saved.grantId === initial.grantId && saved.installationId === installationId && saved.channelId ? { channelId: saved.channelId } : {})
212
+ });
213
+ internals.storage.setItem(storageKey, pending);
214
+ const result = await request("/v1/notifications/channels", "POST", data);
215
+ if (typeof result.channel_id !== "string" || !UUID.test(result.channel_id))
216
+ throw fail();
217
+ check();
218
+ if (internals.storage.getItem(storageKey) !== pending) {
219
+ throw connectError("authority_authorization_changed", "Channel registration context changed.");
220
+ }
221
+ const registration = { channelId: result.channel_id, installationId, criteria };
222
+ internals.storage.setItem(storageKey, JSON.stringify({ ...registration, grantId: initial.grantId, operationNonce }));
223
+ return registration;
224
+ });
225
+ const unregister = async (native, worker, options) => run(options, true, async (request, check) => {
226
+ const key = `${internals.notificationKey(initial.collectionId, native ? "fcm" : "web_push")}:control-timers`;
227
+ const saved = internals.storage.getItem(key);
228
+ if (saved) {
229
+ const r = JSON.parse(saved);
230
+ if (r.grantId !== initial.grantId)
231
+ throw fail();
232
+ if (!r.channelId || r.previousTarget !== undefined || r.pending || !Array.isArray(r.criteria)) {
233
+ // An uncertain registration is not proof that no remote channel exists.
234
+ throw connectError("operation_failed", "Channel registration requires reconciliation before removal.", { operationOutcome: "unknown" });
235
+ }
236
+ if (!UUID.test(r.channelId))
237
+ throw fail();
238
+ // Serialize mutations through a unique pending marker, including DELETE.
239
+ // An old channel ID is not a receipt for an uncertain same-target update.
240
+ const pending = JSON.stringify({ ...r, pending: true, operationNonce: randomBase64Url(24) });
241
+ internals.storage.setItem(key, pending);
242
+ await request(`/v1/notifications/channels/${r.channelId}`, "DELETE", undefined, true);
243
+ check();
244
+ if (internals.storage.getItem(key) !== pending) {
245
+ throw connectError("authority_authorization_changed", "Channel registration context changed.");
246
+ }
247
+ internals.storage.removeItem(key);
248
+ }
249
+ if (worker) {
250
+ const sub = await worker.pushManager.getSubscription();
251
+ check();
252
+ if (internals.storage.getItem(key) !== null) {
253
+ throw connectError("authority_authorization_changed", "Channel registration context changed.");
254
+ }
255
+ if (sub) {
256
+ await sub.unsubscribe();
257
+ check();
258
+ if (internals.storage.getItem(key) !== null) {
259
+ throw connectError("authority_authorization_changed", "Channel registration context changed.");
260
+ }
261
+ }
262
+ }
263
+ });
264
+ return Object.freeze({
265
+ list: (ns, o) => run(o, false, request => request(`${prefix}/${segment(ns, NS)}`)),
266
+ put: (ns, id, body, o) => {
267
+ const path = `${prefix}/${segment(ns, NS)}/${segment(id, ID)}`, input = desired(body);
268
+ return run(o, true, request => request(path, "PUT", input));
269
+ },
270
+ cancel: (ns, id, generation, o) => run(o, true, request => {
271
+ if (generation !== undefined && (!Number.isSafeInteger(generation) || generation < 1))
272
+ throw fail();
273
+ return request(`${prefix}/${segment(ns, NS)}/${segment(id, ID)}${generation === undefined ? "" : `?generation=${generation}`}`, "DELETE");
274
+ }),
275
+ reconcile: (ns, body, o) => {
276
+ const path = `${prefix}/${segment(ns, NS)}/reconcile`, c = body.criterion_id;
277
+ if (typeof c !== "string" || !c.length || c.length > 100 || !Array.isArray(body.timers) || body.timers.length > 10_000)
278
+ throw fail();
279
+ const timers = body.timers.map(t => { segment(t.id, ID); return { id: t.id, fire_at: desired({ criterion_id: c, fire_at: t.fire_at }).fire_at }; });
280
+ if (new Set(timers.map(t => t.id)).size !== timers.length)
281
+ throw fail();
282
+ return run(o, true, request => request(path, "POST", { criterion_id: c, timers }));
283
+ },
284
+ registerWebPush: o => registration({ ...o, criteria: o.criteria ? [...o.criteria] : undefined }, false),
285
+ unregisterWebPush: (worker, o) => unregister(false, worker, o),
286
+ registerFcm: async (o) => ({ ...await registration({ ...o, criteria: o.criteria ? [...o.criteria] : undefined }, true), transport: "fcm" }),
287
+ unregisterFcm: o => unregister(true, undefined, o),
288
+ close: () => lifetime.abort(),
289
+ });
290
+ }
package/dist/errors.d.ts CHANGED
@@ -1,39 +1,14 @@
1
- import { type ConnectOperationOutcome, type ConnectProblem, type ConnectProblemCode, type ConnectProblemDetailsByCode, type KnownConnectProblem, type MdbaseOperationEnvelope } from "@mdbase-dev/connect-protocol";
1
+ import { MdbaseConnectError, type ConnectOperationOutcome, type ConnectProblem, type KnownConnectProblem, type MdbaseOperationEnvelope } from "@mdbase-dev/connect-protocol";
2
2
  interface ProblemOptions {
3
3
  operationOutcome?: ConnectOperationOutcome;
4
4
  traceId?: string;
5
5
  status?: number;
6
6
  cause?: unknown;
7
7
  }
8
- type ProblemOptionsFor<Code extends ConnectProblemCode> = ConnectProblemDetailsByCode[Code] extends undefined ? ProblemOptions & {
9
- details?: never;
10
- } : {} extends ConnectProblemDetailsByCode[Code] ? ProblemOptions & {
11
- details?: ConnectProblemDetailsByCode[Code];
12
- } : ProblemOptions & {
13
- details: ConnectProblemDetailsByCode[Code];
14
- };
15
- type ProblemArguments<Code extends ConnectProblemCode> = ConnectProblemDetailsByCode[Code] extends undefined ? [options?: ProblemOptionsFor<Code>] : {} extends ConnectProblemDetailsByCode[Code] ? [options?: ProblemOptionsFor<Code>] : [options: ProblemOptionsFor<Code>];
16
- export interface ConnectErrorContext {
17
- status?: number;
18
- cause?: unknown;
19
- }
20
- /** Internal carrier used while adapting throwing transports into public outcomes. */
21
- export declare class MdbaseConnectError extends Error {
22
- readonly problem: ConnectProblem;
23
- readonly code: ConnectProblem["code"];
24
- readonly status?: number;
25
- constructor(problem: ConnectProblem, context?: ConnectErrorContext);
26
- get retryable(): boolean;
27
- get requiresAuthorization(): boolean;
28
- get outcomeUnknown(): boolean;
29
- get recovery(): "choose_collection" | "contact_support" | "fix_request" | "none" | "reauthorize" | "refresh" | "repair_collection" | "resolve_conflict" | "resolve_outcome" | "resume_connector_access" | "retry" | "upgrade_application" | "upgrade_collection" | "upgrade_connector";
30
- get details(): unknown;
31
- }
32
- export declare function connectProblem<Code extends ConnectProblemCode>(code: Code, message: string, ...[options]: ProblemArguments<Code>): KnownConnectProblem<Code>;
8
+ export { MdbaseConnectError, connectError, connectProblem, type ConnectErrorContext } from "@mdbase-dev/connect-protocol";
33
9
  export declare function unknownConnectProblem(serverCode: string, message: string, options?: ProblemOptions & {
34
10
  details?: unknown;
35
11
  }): ConnectProblem;
36
- export declare function connectError<Code extends ConnectProblemCode>(code: Code, message: string, ...args: ProblemArguments<Code>): MdbaseConnectError;
37
12
  export declare function serverConnectError(serverCode: string, message: string, options?: ProblemOptions & {
38
13
  details?: unknown;
39
14
  status?: number;
@@ -42,4 +17,3 @@ export declare function serverConnectError(serverCode: string, message: string,
42
17
  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
18
  /** True only when repeating an operation is safe without asking the user. */
44
19
  export declare function isRetryableConnectError(error: unknown): boolean;
45
- export {};
package/dist/errors.js CHANGED
@@ -1,48 +1,5 @@
1
- import { CONNECT_PROBLEM_CATALOG, CONNECT_PROBLEM_VERSION, normalizeConnectProblem } from "@mdbase-dev/connect-protocol";
2
- /** Internal carrier used while adapting throwing transports into public outcomes. */
3
- export class MdbaseConnectError extends Error {
4
- problem;
5
- code;
6
- status;
7
- constructor(problem, context = {}) {
8
- super(problem.message, context.cause === undefined ? undefined : { cause: context.cause });
9
- this.problem = problem;
10
- this.name = "MdbaseConnectError";
11
- this.code = problem.code;
12
- this.status = context.status;
13
- }
14
- get retryable() {
15
- return this.problem.recovery === "retry"
16
- && this.problem.operation_outcome !== "unknown";
17
- }
18
- get requiresAuthorization() {
19
- return this.problem.category === "authorization";
20
- }
21
- get outcomeUnknown() {
22
- return this.problem.operation_outcome === "unknown";
23
- }
24
- get recovery() {
25
- return this.problem.recovery;
26
- }
27
- get details() {
28
- return this.problem.details;
29
- }
30
- }
31
- export function connectProblem(code, message, ...[options = {}]) {
32
- const definition = CONNECT_PROBLEM_CATALOG[code];
33
- return {
34
- problem_version: CONNECT_PROBLEM_VERSION,
35
- code,
36
- category: definition.category,
37
- recovery: definition.recovery,
38
- message,
39
- ...(options.details === undefined ? {} : { details: options.details }),
40
- ...(options.operationOutcome === undefined
41
- ? {}
42
- : { operation_outcome: options.operationOutcome }),
43
- ...(options.traceId === undefined ? {} : { trace_id: options.traceId })
44
- };
45
- }
1
+ import { MdbaseConnectError, connectProblem, normalizeConnectProblem } from "@mdbase-dev/connect-protocol";
2
+ export { MdbaseConnectError, connectError, connectProblem } from "@mdbase-dev/connect-protocol";
46
3
  export function unknownConnectProblem(serverCode, message, options = {}) {
47
4
  return normalizeConnectProblem(serverCode, message, {
48
5
  details: options.details,
@@ -50,13 +7,6 @@ export function unknownConnectProblem(serverCode, message, options = {}) {
50
7
  trace_id: options.traceId
51
8
  });
52
9
  }
53
- export function connectError(code, message, ...args) {
54
- const options = args[0];
55
- return new MdbaseConnectError(connectProblem(code, message, ...args), {
56
- status: options?.status,
57
- cause: options?.cause
58
- });
59
- }
60
10
  export function serverConnectError(serverCode, message, options = {}) {
61
11
  const { status, cause, ...problemOptions } = options;
62
12
  return new MdbaseConnectError(normalizeConnectProblem(serverCode, message, {
@@ -1,6 +1,7 @@
1
1
  import type { FileTransferSession, FileTransferStatus, PreparedFilePart } from "@mdbase-dev/connect-protocol";
2
2
  import { MdbaseConnectError } from "./errors.js";
3
3
  export declare const MAX_OBJECT_ATTEMPTS = 3;
4
+ export declare function validUploadAttempts(value: number | undefined, noAutoAbort?: boolean): number | undefined;
4
5
  export declare const MAX_BUFFERED_DOWNLOAD_BYTES: number;
5
6
  export declare const UUID: RegExp;
6
7
  export declare const SHA256_DIGEST: RegExp;
@@ -15,7 +16,7 @@ export declare function validPageSize(value: number): number;
15
16
  export declare function requireTransferSession(session: FileTransferSession, transferId: string, direction: "upload" | "download"): void;
16
17
  export declare function requireTransferStatus(status: FileTransferStatus, session: FileTransferSession): void;
17
18
  export declare function chunkLength(totalSize: number, partSize: number, index: number): number;
18
- export declare function retryChunk<Result>(work: () => Promise<Result>, signal?: AbortSignal): Promise<Result>;
19
+ export declare function retryChunk<Result>(work: () => Promise<Result>, signal?: AbortSignal, maxAttempts?: number): Promise<Result>;
19
20
  export declare function requirePreparedPart(part: PreparedFilePart, transferId: string, partIndex: number, offset: number, contentLength: number, method: "PUT"): void;
20
21
  export declare function browserObjectHeaders(headers: Record<string, string>): Headers;
21
22
  export declare function mapConcurrent<Result>(count: number, concurrency: number, work: (index: number) => Promise<Result>): Promise<Result[]>;
@@ -5,6 +5,13 @@ const HASH_CHUNK_BYTES = 1024 * 1024;
5
5
  const DEFAULT_CONCURRENCY = 4;
6
6
  const MAX_CONCURRENCY = 8;
7
7
  export const MAX_OBJECT_ATTEMPTS = 3;
8
+ export function validUploadAttempts(value, noAutoAbort) {
9
+ if ((noAutoAbort !== undefined && typeof noAutoAbort !== "boolean")
10
+ || (value !== undefined && (!Number.isInteger(value) || value < 1 || value > MAX_TRANSIENT_CHUNK_ATTEMPTS))) {
11
+ throw connectError("invalid_request", "Invalid upload options.");
12
+ }
13
+ return value;
14
+ }
8
15
  export const MAX_BUFFERED_DOWNLOAD_BYTES = 64 * 1024 * 1024;
9
16
  export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
10
17
  export const SHA256_DIGEST = /^sha256:[0-9a-f]{64}$/u;
@@ -144,15 +151,15 @@ function validTransferStrategy(session) {
144
151
  export function chunkLength(totalSize, partSize, index) {
145
152
  return Math.min(partSize, Math.max(0, totalSize - index * partSize));
146
153
  }
147
- export async function retryChunk(work, signal) {
148
- for (let attempt = 1; attempt <= MAX_TRANSIENT_CHUNK_ATTEMPTS; attempt += 1) {
154
+ export async function retryChunk(work, signal, maxAttempts = MAX_TRANSIENT_CHUNK_ATTEMPTS) {
155
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
149
156
  throwIfAborted(signal);
150
157
  try {
151
158
  return await work();
152
159
  }
153
160
  catch (error) {
154
161
  if (signal?.aborted
155
- || attempt === MAX_TRANSIENT_CHUNK_ATTEMPTS
162
+ || attempt === maxAttempts
156
163
  || (error instanceof MdbaseConnectError && !error.retryable))
157
164
  throw error;
158
165
  await waitForTransientChunkRetry(attempt, signal);