@mdbase-dev/connect 0.1.0-beta.84 → 0.1.0-beta.85

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 (40) hide show
  1. package/README.md +33 -6
  2. package/dist/advanced.d.ts +1 -1
  3. package/dist/application-session.d.ts +31 -10
  4. package/dist/application-session.js +278 -64
  5. package/dist/authorization-refresh.d.ts +14 -0
  6. package/dist/authorization-refresh.js +41 -0
  7. package/dist/authorization-url.d.ts +1 -0
  8. package/dist/authorization-url.js +9 -0
  9. package/dist/browser/integrity.json +1 -1
  10. package/dist/browser/mdbase-connect.min.js +3 -3
  11. package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
  12. package/dist/connect.d.ts +4 -1
  13. package/dist/connect.js +33 -26
  14. package/dist/connection-file-transport.d.ts +3 -0
  15. package/dist/connection-file-transport.js +59 -16
  16. package/dist/connection-transport-internals.d.ts +2 -0
  17. package/dist/connection-transport.d.ts +3 -1
  18. package/dist/connection-transport.js +83 -78
  19. package/dist/grant-key-leases.d.ts +24 -0
  20. package/dist/grant-key-leases.js +144 -0
  21. package/dist/hosted-file-request.d.ts +2 -2
  22. package/dist/hosted-file-request.js +4 -4
  23. package/dist/internal-types.d.ts +2 -0
  24. package/dist/local-file-transport.d.ts +8 -2
  25. package/dist/local-file-transport.js +71 -20
  26. package/dist/mdbase-connect.d.ts +1 -0
  27. package/dist/mdbase-connect.js +3 -0
  28. package/dist/outcomes.d.ts +12 -12
  29. package/dist/outcomes.js +6 -0
  30. package/dist/pending-recovery-transports.d.ts +1 -0
  31. package/dist/pending-recovery-transports.js +7 -3
  32. package/dist/retired-grant-keys.d.ts +9 -0
  33. package/dist/retired-grant-keys.js +56 -0
  34. package/dist/session.d.ts +36 -7
  35. package/dist/session.js +287 -34
  36. package/dist/shared-operation.d.ts +7 -0
  37. package/dist/shared-operation.js +41 -0
  38. package/dist/token-response.d.ts +2 -1
  39. package/dist/token-response.js +2 -1
  40. package/package.json +2 -2
@@ -0,0 +1,56 @@
1
+ import { pendingMutationKeyHandles, pendingMutationsUseKey } from "./pending-recovery-transports.js";
2
+ import { parseStored } from "./runtime-utils.js";
3
+ export function storedTokenKeyHandles(token, additional) {
4
+ const handles = new Set();
5
+ if (additional)
6
+ handles.add(additional);
7
+ if (token?.keyHandle)
8
+ handles.add(token.keyHandle);
9
+ if (Array.isArray(token?.retiredKeyHandles)) {
10
+ for (const handle of token.retiredKeyHandles) {
11
+ if (typeof handle === "string" && handle.length > 0)
12
+ handles.add(handle);
13
+ }
14
+ }
15
+ return handles;
16
+ }
17
+ export function planRetiredGrantKeys(storage, pendingMutationKey, previous, current, leased = [], retainAll = false) {
18
+ const retained = [];
19
+ const disposable = [];
20
+ const candidates = storedTokenKeyHandles(previous);
21
+ for (const handle of pendingMutationKeyHandles(storage, pendingMutationKey)) {
22
+ candidates.add(handle);
23
+ }
24
+ const leasedHandles = new Set(leased);
25
+ for (const handle of leasedHandles)
26
+ candidates.add(handle);
27
+ for (const handle of candidates) {
28
+ if (handle === current)
29
+ continue;
30
+ (retainAll || leasedHandles.has(handle) || pendingMutationsUseKey(storage, pendingMutationKey, handle)
31
+ ? retained
32
+ : disposable).push(handle);
33
+ }
34
+ return { retained, disposable };
35
+ }
36
+ export function removeRetiredGrantKeyMetadata(storage, tokenKey, handle) {
37
+ const token = parseStored(storage.getItem(tokenKey));
38
+ if (!token || !Array.isArray(token.retiredKeyHandles))
39
+ return;
40
+ const retiredKeyHandles = token.retiredKeyHandles.filter((candidate) => candidate !== handle);
41
+ if (retiredKeyHandles.length === token.retiredKeyHandles.length)
42
+ return;
43
+ if (retiredKeyHandles.length > 0)
44
+ token.retiredKeyHandles = retiredKeyHandles;
45
+ else
46
+ delete token.retiredKeyHandles;
47
+ storage.setItem(tokenKey, JSON.stringify(token));
48
+ }
49
+ export async function deleteUnusedGrantKey(storage, keyStore, pendingMutationKey, tokenKey, handle) {
50
+ const current = parseStored(storage.getItem(tokenKey));
51
+ if (current?.keyHandle === handle
52
+ || pendingMutationsUseKey(storage, pendingMutationKey, handle))
53
+ return;
54
+ await keyStore.delete(handle);
55
+ removeRetiredGrantKeyMetadata(storage, tokenKey, handle);
56
+ }
package/dist/session.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CollectionOperation, JsonObject } from "@mdbase-dev/connect-protocol";
1
+ import type { CollectionOperation, ConnectProblem, JsonObject } from "@mdbase-dev/connect-protocol";
2
2
  import { MdbaseConnection, type MdbaseAuthorizationOutcome, type MdbaseAuthorizationResult, type MdbaseAuthorizeOptions } from "./connection.js";
3
3
  import type { MdbaseAuthorizationCapabilities, MdbaseConnectionInfo } from "./connection-types.js";
4
4
  import { type AuthorizationProblemCode, type ConnectOutcome, type SessionProblemCode } from "./outcomes.js";
@@ -19,6 +19,19 @@ export interface MdbaseSessionOptions {
19
19
  }
20
20
  export type MdbaseUnavailableReason = "not_authorized" | "authorization_lost" | "invalid_stored_grant";
21
21
  export type MdbaseSessionSnapshot<Frontmatter extends JsonObject = JsonObject> = {
22
+ status: "not_started";
23
+ connections: MdbaseConnectionInfo[];
24
+ } | {
25
+ status: "starting";
26
+ connections: MdbaseConnectionInfo[];
27
+ } | {
28
+ status: "start_failed";
29
+ problem: ConnectProblem<SessionProblemCode>;
30
+ connections: MdbaseConnectionInfo[];
31
+ } | {
32
+ status: "destroyed";
33
+ connections: MdbaseConnectionInfo[];
34
+ } | {
22
35
  status: "unselected";
23
36
  connections: MdbaseConnectionInfo[];
24
37
  } | {
@@ -48,22 +61,30 @@ export declare class MdbaseSession<Frontmatter extends JsonObject = JsonObject>
48
61
  private activeCollectionId;
49
62
  private unavailableReason;
50
63
  private started;
64
+ private destroyed;
51
65
  private refreshing;
52
66
  private refreshQueued;
53
67
  private transactionDepth;
54
- private callbackPromise;
68
+ private lifecycleGeneration;
69
+ private readonly operationControllers;
70
+ private startOperation;
71
+ private readonly callbackCompletions;
55
72
  constructor(connect: MdbaseSessionConnect<Frontmatter>, options: MdbaseSessionOptions);
56
73
  start(options?: ConnectRequestOptions): Promise<ConnectOutcome<MdbaseSessionSnapshot<Frontmatter>, SessionProblemCode>>;
74
+ private beginStart;
75
+ private waitForStart;
76
+ private performStart;
57
77
  destroy(): void;
78
+ private stopLifecycleSubscriptions;
58
79
  getSnapshot(): MdbaseSessionSnapshot<Frontmatter>;
59
80
  subscribe(listener: () => void): () => void;
60
81
  select(collectionId: string, options?: {
61
82
  history?: MdbaseSelectionHistory;
62
- }): ConnectOutcome<MdbaseConnection<Frontmatter>, "unknown_collection">;
83
+ }): ConnectOutcome<MdbaseConnection<Frontmatter>, SessionProblemCode>;
63
84
  clearSelection(options?: {
64
85
  history?: MdbaseSelectionHistory;
65
- }): void;
66
- forget(collectionId: string): void;
86
+ }): ConnectOutcome<void, SessionProblemCode>;
87
+ forget(collectionId: string): ConnectOutcome<void, SessionProblemCode>;
67
88
  authorize(target: "choose" | "selected" | {
68
89
  collectionId: string;
69
90
  }, options?: Omit<MdbaseAuthorizeOptions, "operations" | "returnTo" | "target">): Promise<ConnectOutcome<MdbaseAuthorizationOutcome<Frontmatter>, SessionProblemCode>>;
@@ -71,8 +92,16 @@ export declare class MdbaseSession<Frontmatter extends JsonObject = JsonObject>
71
92
  kind: "unchanged";
72
93
  connection: MdbaseConnection<Frontmatter>;
73
94
  }, SessionProblemCode>>;
74
- handleAuthorizationCallback(callbackUrl: string, options?: ConnectRequestOptions): Promise<ConnectOutcome<MdbaseConnection<Frontmatter>, AuthorizationProblemCode>>;
95
+ handleAuthorizationCallback(callbackUrl: string, options?: ConnectRequestOptions): Promise<ConnectOutcome<MdbaseConnection<Frontmatter>, SessionProblemCode>>;
96
+ private completeAuthorizationCallback;
97
+ private beginCallbackCompletion;
98
+ private waitForCallbackCompletion;
99
+ private lifecycleProblem;
100
+ private beginLifecycleOperation;
101
+ private lifecycleCurrent;
102
+ private destroyedOutcome;
75
103
  private autoSelectOnlyConnection;
76
104
  private refresh;
77
- private refreshOnce;
105
+ private publishCurrent;
106
+ private publish;
78
107
  }
package/dist/session.js CHANGED
@@ -1,8 +1,9 @@
1
- import { authorizationReturnToFromProblem, isAuthorizationCallbackUrl } from "./authorization-url.js";
2
- import { connectProblem } from "./errors.js";
1
+ import { authorizationCallbackState, authorizationReturnToFromProblem, isAuthorizationCallbackUrl } from "./authorization-url.js";
2
+ import { MdbaseConnectError, connectProblem } from "./errors.js";
3
3
  import { DEFAULT_OPERATIONS } from "./internal-types.js";
4
4
  import { uniqueOperations } from "./operation-helpers.js";
5
5
  import { connectFailure, connectSuccess } from "./outcomes.js";
6
+ import { DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_STARTUP_TIMEOUT_MS, requestAbortReason, withRequestBudget } from "./request-budget.js";
6
7
  export class MdbaseSession {
7
8
  connect;
8
9
  operations;
@@ -17,39 +18,139 @@ export class MdbaseSession {
17
18
  activeCollectionId = null;
18
19
  unavailableReason = "not_authorized";
19
20
  started = false;
21
+ destroyed = false;
20
22
  refreshing = false;
21
23
  refreshQueued = false;
22
24
  transactionDepth = 0;
23
- callbackPromise = null;
25
+ lifecycleGeneration = 0;
26
+ operationControllers = new Set();
27
+ startOperation = null;
28
+ callbackCompletions = new Map();
24
29
  constructor(connect, options) {
25
30
  this.connect = connect;
26
31
  this.operations = uniqueOperations(options.operations ?? DEFAULT_OPERATIONS);
27
32
  this.selection = options.selection;
28
33
  this.autoSelect = options.autoSelect ?? "only";
29
- this.snapshot = { status: "unselected", connections: [] };
30
- this.refresh();
34
+ this.snapshot = { status: "not_started", connections: [] };
31
35
  }
32
- async start(options) {
33
- if (!this.started) {
36
+ start(options) {
37
+ if (this.destroyed) {
38
+ return Promise.resolve(connectFailure(connectProblem("session_destroyed", "This session has been destroyed.")));
39
+ }
40
+ if (this.started && !this.startOperation) {
41
+ return Promise.resolve(connectSuccess(this.snapshot));
42
+ }
43
+ const operation = this.startOperation ?? this.beginStart();
44
+ return this.waitForStart(operation, options);
45
+ }
46
+ beginStart() {
47
+ const controller = new AbortController();
48
+ const generation = ++this.lifecycleGeneration;
49
+ this.publish({ status: "starting", connections: this.connect.connections() });
50
+ const operation = {
51
+ promise: this.performStart({ signal: controller.signal, timeoutMs: null }, generation),
52
+ controller,
53
+ waiters: 0,
54
+ generation
55
+ };
56
+ this.startOperation = operation;
57
+ const settled = () => {
58
+ if (this.startOperation === operation)
59
+ this.startOperation = null;
60
+ };
61
+ operation.promise.then(settled, settled);
62
+ return operation;
63
+ }
64
+ async waitForStart(operation, options) {
65
+ operation.waiters += 1;
66
+ try {
67
+ return await withRequestBudget(options, DEFAULT_STARTUP_TIMEOUT_MS, () => operation.promise);
68
+ }
69
+ catch (error) {
70
+ if (error instanceof MdbaseConnectError) {
71
+ return connectFailure(error.problem);
72
+ }
73
+ throw error;
74
+ }
75
+ finally {
76
+ operation.waiters -= 1;
77
+ if (operation.waiters === 0 && this.startOperation === operation) {
78
+ operation.controller.abort();
79
+ this.startOperation = null;
80
+ this.lifecycleGeneration += 1;
81
+ this.stopLifecycleSubscriptions();
82
+ this.started = false;
83
+ this.publish({ status: "not_started", connections: this.connect.connections() });
84
+ }
85
+ }
86
+ }
87
+ async performStart(options, generation) {
88
+ try {
34
89
  this.started = true;
35
90
  this.stopConnections = this.connect.onConnectionsChange(() => this.refresh());
36
91
  this.stopSelection = this.selection.subscribe(() => {
37
92
  this.unavailableReason = "not_authorized";
38
93
  this.refresh();
39
94
  });
95
+ const callback = this.selection.authorizationCallback();
96
+ if (callback) {
97
+ const completed = await this.completeAuthorizationCallback(callback, options);
98
+ if (!completed.ok) {
99
+ if (generation === this.lifecycleGeneration && !this.destroyed) {
100
+ this.stopLifecycleSubscriptions();
101
+ this.started = false;
102
+ this.publish({
103
+ status: "start_failed",
104
+ problem: completed.problem,
105
+ connections: this.connect.connections()
106
+ });
107
+ }
108
+ return completed;
109
+ }
110
+ }
111
+ else
112
+ this.autoSelectOnlyConnection();
113
+ if (generation !== this.lifecycleGeneration || this.destroyed) {
114
+ return connectFailure(connectProblem("session_destroyed", "This session has been destroyed."));
115
+ }
116
+ this.publishCurrent();
117
+ return connectSuccess(this.snapshot);
40
118
  }
41
- const callback = this.selection.authorizationCallback();
42
- if (callback) {
43
- const completed = await this.handleAuthorizationCallback(callback, options);
44
- if (!completed.ok)
45
- return completed;
119
+ catch (error) {
120
+ if (error instanceof MdbaseConnectError) {
121
+ const problem = error.problem;
122
+ if (generation === this.lifecycleGeneration && !this.destroyed) {
123
+ this.stopLifecycleSubscriptions();
124
+ this.started = false;
125
+ this.publish({ status: "start_failed", problem, connections: this.connect.connections() });
126
+ }
127
+ return connectFailure(problem);
128
+ }
129
+ if (generation === this.lifecycleGeneration && !this.destroyed) {
130
+ this.stopLifecycleSubscriptions();
131
+ this.started = false;
132
+ this.publish({ status: "not_started", connections: this.connect.connections() });
133
+ }
134
+ throw error;
46
135
  }
47
- else
48
- this.autoSelectOnlyConnection();
49
- this.refresh();
50
- return connectSuccess(this.snapshot);
51
136
  }
52
137
  destroy() {
138
+ if (this.destroyed)
139
+ return;
140
+ this.lifecycleGeneration += 1;
141
+ this.startOperation?.controller.abort();
142
+ this.startOperation = null;
143
+ for (const controller of this.operationControllers)
144
+ controller.abort();
145
+ this.operationControllers.clear();
146
+ this.stopLifecycleSubscriptions();
147
+ this.started = false;
148
+ this.destroyed = true;
149
+ this.callbackCompletions.clear();
150
+ this.publish({ status: "destroyed", connections: [] });
151
+ this.listeners.clear();
152
+ }
153
+ stopLifecycleSubscriptions() {
53
154
  this.stopConnections?.();
54
155
  this.stopSelection?.();
55
156
  this.stopActiveConnection?.();
@@ -57,7 +158,6 @@ export class MdbaseSession {
57
158
  this.stopSelection = undefined;
58
159
  this.stopActiveConnection = undefined;
59
160
  this.activeCollectionId = null;
60
- this.started = false;
61
161
  }
62
162
  getSnapshot() {
63
163
  return this.snapshot;
@@ -67,6 +167,9 @@ export class MdbaseSession {
67
167
  return () => this.listeners.delete(listener);
68
168
  }
69
169
  select(collectionId, options = {}) {
170
+ const lifecycle = this.lifecycleProblem();
171
+ if (lifecycle)
172
+ return connectFailure(lifecycle);
70
173
  const connection = this.connect.connection(collectionId);
71
174
  if (!connection) {
72
175
  return connectFailure(connectProblem("unknown_collection", "This collection is not authorized for this application."));
@@ -77,11 +180,18 @@ export class MdbaseSession {
77
180
  return connectSuccess(connection);
78
181
  }
79
182
  clearSelection(options = {}) {
183
+ const lifecycle = this.lifecycleProblem();
184
+ if (lifecycle)
185
+ return connectFailure(lifecycle);
80
186
  this.unavailableReason = "not_authorized";
81
187
  this.selection.select(null, options);
82
188
  this.refresh();
189
+ return connectSuccess(undefined);
83
190
  }
84
191
  forget(collectionId) {
192
+ const lifecycle = this.lifecycleProblem();
193
+ if (lifecycle)
194
+ return connectFailure(lifecycle);
85
195
  this.transactionDepth += 1;
86
196
  try {
87
197
  const selected = this.selection.selectedCollectionId() === collectionId;
@@ -93,8 +203,12 @@ export class MdbaseSession {
93
203
  this.transactionDepth -= 1;
94
204
  this.refresh();
95
205
  }
206
+ return connectSuccess(undefined);
96
207
  }
97
208
  async authorize(target, options = {}) {
209
+ const lifecycle = this.lifecycleProblem();
210
+ if (lifecycle)
211
+ return connectFailure(lifecycle);
98
212
  const selectedCollectionId = this.selection.selectedCollectionId();
99
213
  if (target === "selected" && !selectedCollectionId) {
100
214
  return connectFailure(connectProblem("collection_not_selected", "Choose a collection before updating its access."));
@@ -104,27 +218,34 @@ export class MdbaseSession {
104
218
  : target === "selected"
105
219
  ? selectedCollectionId
106
220
  : null;
221
+ const operation = this.beginLifecycleOperation(options);
107
222
  this.transactionDepth += 1;
108
223
  try {
109
224
  const outcome = await this.connect.authorize({
110
- ...options,
225
+ ...operation.options,
111
226
  operations: this.operations,
112
227
  target: targetCollectionId === null
113
228
  ? { kind: "choose" }
114
229
  : { kind: "collection", collectionId: targetCollectionId },
115
230
  returnTo: this.selection.authorizationReturnTo()
116
231
  });
232
+ if (!this.lifecycleCurrent(operation.generation))
233
+ return this.destroyedOutcome(outcome);
117
234
  if (outcome.ok && outcome.value.kind === "connected") {
118
235
  this.selection.finishAuthorization(outcome.value.returnTo, outcome.value.connection.collectionId);
119
236
  }
120
237
  return outcome;
121
238
  }
122
239
  finally {
240
+ operation.dispose();
123
241
  this.transactionDepth -= 1;
124
242
  this.refresh();
125
243
  }
126
244
  }
127
245
  async ensureOperations(requiredOperations, options) {
246
+ const lifecycle = this.lifecycleProblem();
247
+ if (lifecycle)
248
+ return connectFailure(lifecycle);
128
249
  const current = this.snapshot;
129
250
  if (current.status !== "ready") {
130
251
  return connectFailure(connectProblem("collection_not_ready", "Choose an authorized collection first."));
@@ -134,36 +255,82 @@ export class MdbaseSession {
134
255
  if (capabilities.sufficient) {
135
256
  return connectSuccess({ kind: "unchanged", connection: current.connection });
136
257
  }
258
+ const operation = this.beginLifecycleOperation(options);
137
259
  this.transactionDepth += 1;
138
260
  try {
139
261
  const outcome = await this.connect.authorize({
140
- ...options,
262
+ ...operation.options,
141
263
  operations: uniqueOperations([...capabilities.grantedOperations, ...capabilities.missingOperations]),
142
264
  target: { kind: "collection", collectionId: current.collectionId },
143
265
  returnTo: this.selection.authorizationReturnTo()
144
266
  });
267
+ if (!this.lifecycleCurrent(operation.generation))
268
+ return this.destroyedOutcome(outcome);
145
269
  if (outcome.ok && outcome.value.kind === "connected") {
146
270
  this.selection.finishAuthorization(outcome.value.returnTo, outcome.value.connection.collectionId);
147
271
  }
148
272
  return outcome;
149
273
  }
150
274
  finally {
275
+ operation.dispose();
151
276
  this.transactionDepth -= 1;
152
277
  this.refresh();
153
278
  }
154
279
  }
155
280
  handleAuthorizationCallback(callbackUrl, options) {
281
+ const lifecycle = this.lifecycleProblem();
282
+ if (lifecycle)
283
+ return Promise.resolve(connectFailure(lifecycle));
156
284
  if (!isAuthorizationCallbackUrl(callbackUrl)) {
157
285
  return Promise.resolve(connectFailure(connectProblem("invalid_callback", "This URL is not an authorization callback.")));
158
286
  }
159
- if (this.callbackPromise)
160
- return this.callbackPromise;
287
+ return this.completeAuthorizationCallback(callbackUrl, options);
288
+ }
289
+ completeAuthorizationCallback(callbackUrl, options) {
290
+ if (options?.signal?.aborted) {
291
+ const error = requestAbortReason(options.signal);
292
+ return Promise.resolve(connectFailure(error.problem));
293
+ }
294
+ const state = authorizationCallbackState(callbackUrl);
295
+ if (!state) {
296
+ return Promise.resolve(connectFailure(connectProblem("invalid_callback", "Authorization callback is missing its state.")));
297
+ }
298
+ const operation = this.callbackCompletions.get(state)
299
+ ?? this.beginCallbackCompletion(callbackUrl, state);
300
+ return this.waitForCallbackCompletion(operation, options);
301
+ }
302
+ beginCallbackCompletion(callbackUrl, state) {
303
+ const controller = new AbortController();
304
+ const generation = this.lifecycleGeneration;
161
305
  this.transactionDepth += 1;
162
- const completion = this.connect.completeAuthorization(callbackUrl, options)
306
+ this.operationControllers.add(controller);
307
+ const operation = {
308
+ state,
309
+ controller,
310
+ waiters: 0,
311
+ settled: false,
312
+ abandoned: false,
313
+ promise: undefined
314
+ };
315
+ const completion = this.connect.completeAuthorization(callbackUrl, {
316
+ signal: controller.signal,
317
+ timeoutMs: null
318
+ }).catch((error) => {
319
+ if (error instanceof MdbaseConnectError) {
320
+ return connectFailure(error.problem);
321
+ }
322
+ throw error;
323
+ });
324
+ operation.promise = completion
163
325
  .then((outcome) => {
326
+ if (generation !== this.lifecycleGeneration || this.destroyed) {
327
+ return this.destroyedOutcome(outcome);
328
+ }
164
329
  if (!outcome.ok) {
165
- const returnTo = authorizationReturnToFromProblem(outcome.problem);
166
- this.selection.clearAuthorizationCallback(returnTo);
330
+ if (!operation.abandoned && outcome.problem.recovery !== "retry") {
331
+ const returnTo = authorizationReturnToFromProblem(outcome.problem);
332
+ this.selection.clearAuthorizationCallback(returnTo);
333
+ }
167
334
  this.refresh();
168
335
  return outcome;
169
336
  }
@@ -174,18 +341,87 @@ export class MdbaseSession {
174
341
  return connectSuccess(result.connection, outcome.diagnostics);
175
342
  })
176
343
  .catch((error) => {
177
- this.selection.clearAuthorizationCallback();
178
- this.refresh();
344
+ if (generation !== this.lifecycleGeneration || this.destroyed) {
345
+ return connectFailure(connectProblem("session_destroyed", "This session was destroyed before authorization completion finished."));
346
+ }
347
+ if (!operation.abandoned) {
348
+ this.selection.clearAuthorizationCallback();
349
+ this.refresh();
350
+ }
179
351
  throw error;
180
352
  })
181
353
  .finally(() => {
354
+ operation.settled = true;
355
+ this.operationControllers.delete(controller);
182
356
  this.transactionDepth -= 1;
183
357
  this.refresh();
184
- if (this.callbackPromise === completion)
185
- this.callbackPromise = null;
358
+ if (this.callbackCompletions.get(state) === operation) {
359
+ this.callbackCompletions.delete(state);
360
+ }
186
361
  });
187
- this.callbackPromise = completion;
188
- return completion;
362
+ this.callbackCompletions.set(state, operation);
363
+ return operation;
364
+ }
365
+ async waitForCallbackCompletion(operation, options) {
366
+ operation.waiters += 1;
367
+ try {
368
+ return await withRequestBudget(options, DEFAULT_REQUEST_TIMEOUT_MS, () => operation.promise);
369
+ }
370
+ catch (error) {
371
+ if (error instanceof MdbaseConnectError) {
372
+ return connectFailure(error.problem);
373
+ }
374
+ throw error;
375
+ }
376
+ finally {
377
+ operation.waiters -= 1;
378
+ if (operation.waiters === 0
379
+ && !operation.settled
380
+ && this.callbackCompletions.get(operation.state) === operation) {
381
+ operation.abandoned = true;
382
+ this.callbackCompletions.delete(operation.state);
383
+ operation.controller.abort();
384
+ }
385
+ }
386
+ }
387
+ lifecycleProblem() {
388
+ if (this.destroyed) {
389
+ return connectProblem("session_destroyed", "This session has been destroyed.");
390
+ }
391
+ if (this.snapshot.status === "starting") {
392
+ return connectProblem("session_starting", "The session is still starting.");
393
+ }
394
+ if (this.snapshot.status === "start_failed")
395
+ return this.snapshot.problem;
396
+ if (!this.started) {
397
+ return connectProblem("session_not_started", "Start the session before using it.");
398
+ }
399
+ return null;
400
+ }
401
+ beginLifecycleOperation(options = {}) {
402
+ const controller = new AbortController();
403
+ const abort = () => controller.abort(options.signal?.reason);
404
+ if (options.signal?.aborted)
405
+ abort();
406
+ else
407
+ options.signal?.addEventListener("abort", abort, { once: true });
408
+ this.operationControllers.add(controller);
409
+ return {
410
+ generation: this.lifecycleGeneration,
411
+ options: { ...options, signal: controller.signal },
412
+ dispose: () => {
413
+ options.signal?.removeEventListener("abort", abort);
414
+ this.operationControllers.delete(controller);
415
+ }
416
+ };
417
+ }
418
+ lifecycleCurrent(generation) {
419
+ return !this.destroyed && generation === this.lifecycleGeneration;
420
+ }
421
+ destroyedOutcome(outcome) {
422
+ if (!outcome.ok && outcome.problem.code === "operation_outcome_unknown")
423
+ return outcome;
424
+ return connectFailure(connectProblem("session_destroyed", "This session was destroyed before the operation finished."));
189
425
  }
190
426
  autoSelectOnlyConnection() {
191
427
  if (this.autoSelect !== "only" || this.selection.selectedCollectionId())
@@ -196,6 +432,11 @@ export class MdbaseSession {
196
432
  }
197
433
  }
198
434
  refresh() {
435
+ if (!this.started
436
+ || this.destroyed
437
+ || this.snapshot.status === "starting"
438
+ || this.snapshot.status === "start_failed")
439
+ return;
199
440
  if (this.transactionDepth > 0) {
200
441
  this.refreshQueued = true;
201
442
  return;
@@ -208,14 +449,14 @@ export class MdbaseSession {
208
449
  try {
209
450
  do {
210
451
  this.refreshQueued = false;
211
- this.refreshOnce();
452
+ this.publishCurrent();
212
453
  } while (this.refreshQueued);
213
454
  }
214
455
  finally {
215
456
  this.refreshing = false;
216
457
  }
217
458
  }
218
- refreshOnce() {
459
+ publishCurrent() {
219
460
  const collectionId = this.selection.selectedCollectionId();
220
461
  const connections = this.connect.connections();
221
462
  const connection = collectionId ? this.connect.connection(collectionId) : null;
@@ -252,16 +493,28 @@ export class MdbaseSession {
252
493
  connections
253
494
  };
254
495
  }
255
- const key = sessionSnapshotKey(next);
496
+ this.publish(next);
497
+ }
498
+ publish(snapshot) {
499
+ if (this.snapshot.status === "destroyed" && snapshot.status !== "destroyed")
500
+ return;
501
+ const key = sessionSnapshotKey(snapshot);
256
502
  if (key === this.snapshotKey)
257
503
  return;
258
- this.snapshot = next;
504
+ this.snapshot = snapshot;
259
505
  this.snapshotKey = key;
260
506
  for (const listener of this.listeners)
261
507
  listener();
262
508
  }
263
509
  }
264
510
  function sessionSnapshotKey(snapshot) {
511
+ if (snapshot.status === "not_started"
512
+ || snapshot.status === "starting"
513
+ || snapshot.status === "destroyed")
514
+ return JSON.stringify(snapshot);
515
+ if (snapshot.status === "start_failed") {
516
+ return JSON.stringify({ status: snapshot.status, problem: snapshot.problem, connections: snapshot.connections });
517
+ }
265
518
  if (snapshot.status === "unselected") {
266
519
  return JSON.stringify({ status: snapshot.status, connections: snapshot.connections });
267
520
  }
@@ -0,0 +1,7 @@
1
+ import type { ConnectRequestOptions } from "./operation-types.js";
2
+ export declare class SharedOperationMap<Result> {
3
+ private readonly operations;
4
+ run(key: string, options: ConnectRequestOptions | undefined, defaultTimeoutMs: number | null, start: (signal: AbortSignal) => Promise<Result>): Promise<Result>;
5
+ private begin;
6
+ private wait;
7
+ }
@@ -0,0 +1,41 @@
1
+ import { requestAbortReason, withRequestBudget } from "./request-budget.js";
2
+ export class SharedOperationMap {
3
+ operations = new Map();
4
+ run(key, options, defaultTimeoutMs, start) {
5
+ if (options?.signal?.aborted)
6
+ return Promise.reject(requestAbortReason(options.signal));
7
+ const operation = this.operations.get(key) ?? this.begin(key, start);
8
+ return this.wait(key, operation, options, defaultTimeoutMs);
9
+ }
10
+ begin(key, start) {
11
+ const controller = new AbortController();
12
+ const operation = {
13
+ controller,
14
+ waiters: 0,
15
+ settled: false,
16
+ promise: undefined
17
+ };
18
+ operation.promise = Promise.resolve()
19
+ .then(() => start(controller.signal))
20
+ .finally(() => {
21
+ operation.settled = true;
22
+ if (this.operations.get(key) === operation)
23
+ this.operations.delete(key);
24
+ });
25
+ this.operations.set(key, operation);
26
+ return operation;
27
+ }
28
+ async wait(key, operation, options, defaultTimeoutMs) {
29
+ operation.waiters += 1;
30
+ try {
31
+ return await withRequestBudget(options, defaultTimeoutMs, () => operation.promise);
32
+ }
33
+ finally {
34
+ operation.waiters -= 1;
35
+ if (operation.waiters === 0 && !operation.settled && this.operations.get(key) === operation) {
36
+ this.operations.delete(key);
37
+ operation.controller.abort();
38
+ }
39
+ }
40
+ }
41
+ }