@marianmeres/ownsuite 1.0.3 → 2.1.0

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.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @module domains/auth
3
+ *
4
+ * AuthManager — verbs for the account lifecycle. Holds no state of its own:
5
+ * - credentials flow through to the adapter,
6
+ * - server responses are piped into the SessionManager,
7
+ * - successful session changes trigger a refresh of owner-scoped domains
8
+ * via a caller-supplied `switchIdentity` hook.
9
+ *
10
+ * Non-goals (explicit): password strength UI, form validation, rendering,
11
+ * i18n strings, CSRF/PKCE (server-side), refresh-token rotation, cookie
12
+ * management.
13
+ */
14
+ import { type PubSub } from "@marianmeres/pubsub";
15
+ import type { AuthAdapter, AuthTokenResult, OAuthInitOptions, OAuthProvider, OwnsuiteContext, ProfileAdapter } from "../types/mod.js";
16
+ import type { SessionManager } from "./session.js";
17
+ import type { ProfileManager } from "./profile.js";
18
+ export interface AuthManagerOptions {
19
+ adapter: AuthAdapter;
20
+ session: SessionManager;
21
+ /** Profile manager — auth hydrates the session subject from `/me`
22
+ * immediately after login so subscribers see roles/isVerified/etc.
23
+ * without a second await. */
24
+ profile: ProfileManager;
25
+ pubsub?: PubSub;
26
+ /** Called after a successful identity change (register/login/OAuth
27
+ * login/logout). The orchestrator wires this to reset + re-init every
28
+ * owner-scoped domain with the fresh context. */
29
+ onIdentityChanged?: (ctx: OwnsuiteContext) => Promise<void> | void;
30
+ /** Also passed into adapter calls (correlation id, feature flags, etc.).
31
+ * The JWT + signal are added per-op by the manager. */
32
+ context?: OwnsuiteContext;
33
+ /** When the adapter provides a profile adapter explicitly, we pass it
34
+ * through to resolving the subject. Kept optional for mock-driven
35
+ * tests that don't need a separate profile fetch. */
36
+ profileAdapter?: ProfileAdapter;
37
+ }
38
+ export declare class AuthManager {
39
+ #private;
40
+ constructor(options: AuthManagerOptions);
41
+ setContext(ctx: OwnsuiteContext): void;
42
+ replaceContext(ctx: OwnsuiteContext): void;
43
+ register(input: {
44
+ email: string;
45
+ password: string;
46
+ password_confirm: string;
47
+ roles?: string[];
48
+ extras?: Record<string, unknown>;
49
+ }): Promise<AuthTokenResult>;
50
+ login(input: {
51
+ email: string;
52
+ password: string;
53
+ }): Promise<AuthTokenResult>;
54
+ logout(): Promise<void>;
55
+ resendVerification(input: {
56
+ email: string;
57
+ lang?: string;
58
+ }): Promise<void>;
59
+ requestPasswordReset(input: {
60
+ email: string;
61
+ lang?: string;
62
+ }): Promise<void>;
63
+ changePassword(input: {
64
+ current_password?: string;
65
+ new_password: string;
66
+ confirm_password: string;
67
+ token?: string;
68
+ }): Promise<void>;
69
+ deleteAccount(input: {
70
+ password?: string;
71
+ confirm?: boolean;
72
+ }): Promise<void>;
73
+ /**
74
+ * Begin an OAuth flow. Returns:
75
+ * - For popup mode: a Promise that resolves when the popup posts back
76
+ * an auth result.
77
+ * - For redirect mode: nothing useful; the top window navigates away.
78
+ */
79
+ initiateOAuth(provider: OAuthProvider, opts: OAuthInitOptions): Promise<AuthTokenResult | void>;
80
+ /** For the redirect-mode callback page: delegate to the adapter if
81
+ * available to extract the result from the current URL/page state. */
82
+ handleOAuthCallback(): Promise<AuthTokenResult | void>;
83
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * @module domains/auth
3
+ *
4
+ * AuthManager — verbs for the account lifecycle. Holds no state of its own:
5
+ * - credentials flow through to the adapter,
6
+ * - server responses are piped into the SessionManager,
7
+ * - successful session changes trigger a refresh of owner-scoped domains
8
+ * via a caller-supplied `switchIdentity` hook.
9
+ *
10
+ * Non-goals (explicit): password strength UI, form validation, rendering,
11
+ * i18n strings, CSRF/PKCE (server-side), refresh-token rotation, cookie
12
+ * management.
13
+ */
14
+ import { createPubSub } from "@marianmeres/pubsub";
15
+ import { openOAuthPopup } from "../oauth/popup.js";
16
+ export class AuthManager {
17
+ #pubsub;
18
+ #adapter;
19
+ #session;
20
+ #profile;
21
+ #onIdentityChanged;
22
+ #context;
23
+ constructor(options) {
24
+ this.#adapter = options.adapter;
25
+ this.#session = options.session;
26
+ this.#profile = options.profile;
27
+ this.#pubsub = options.pubsub ?? createPubSub();
28
+ this.#onIdentityChanged = options.onIdentityChanged;
29
+ this.#context = options.context ?? {};
30
+ }
31
+ setContext(ctx) {
32
+ this.#context = { ...this.#context, ...ctx };
33
+ }
34
+ replaceContext(ctx) {
35
+ this.#context = { ...ctx };
36
+ }
37
+ #ctx(signal) {
38
+ const jwt = this.#session.getJwt();
39
+ return {
40
+ ...this.#context,
41
+ ...(jwt ? { jwt } : {}),
42
+ ...(signal ? { signal } : {}),
43
+ };
44
+ }
45
+ /** Flip the session state based on an AuthTokenResult. When the JWT is
46
+ * present, we pull the profile to build a complete subject. When the
47
+ * server returned requiresVerification, we surface the "unverified"
48
+ * status without any profile fetch. Triggers identity-change hook on
49
+ * actual login. */
50
+ async #applyAuthResult(result) {
51
+ if (result.requiresVerification || !result.jwt) {
52
+ this.#session.setUnverified(result.email);
53
+ this.#pubsub.publish("auth:verification:required", {
54
+ type: "auth:verification:required",
55
+ timestamp: Date.now(),
56
+ email: result.email,
57
+ });
58
+ return result;
59
+ }
60
+ // Set a provisional subject so subsequent calls (including the profile
61
+ // fetch itself) see the JWT on the session.
62
+ const provisional = {
63
+ id: "",
64
+ email: result.email,
65
+ roles: result.roles ?? [],
66
+ isVerified: result.isVerified ?? false,
67
+ hasPassword: true, // corrected by profile fetch below
68
+ };
69
+ this.#session.setAuthenticated({
70
+ jwt: result.jwt,
71
+ subject: provisional,
72
+ expiresAt: result.validUntil ?? null,
73
+ });
74
+ // Best-effort hydrate the subject from /me. If this fails we keep the
75
+ // provisional subject; consumers can retry via profile.fetch().
76
+ try {
77
+ await this.#profile.fetch();
78
+ }
79
+ catch {
80
+ // swallow — session stays authenticated with the provisional subject
81
+ }
82
+ if (this.#onIdentityChanged) {
83
+ try {
84
+ await this.#onIdentityChanged(this.#ctx());
85
+ }
86
+ catch {
87
+ // swallow — hook errors must not destabilize auth state
88
+ }
89
+ }
90
+ return result;
91
+ }
92
+ // ─────────────────────── verbs ──────────────────────────────────────────
93
+ async register(input) {
94
+ const result = await this.#adapter.register(input, this.#ctx());
95
+ this.#pubsub.publish("auth:register", {
96
+ type: "auth:register",
97
+ timestamp: Date.now(),
98
+ email: input.email,
99
+ requiresVerification: Boolean(result.requiresVerification),
100
+ });
101
+ return await this.#applyAuthResult(result);
102
+ }
103
+ async login(input) {
104
+ const result = await this.#adapter.login(input, this.#ctx());
105
+ this.#pubsub.publish("auth:login", {
106
+ type: "auth:login",
107
+ timestamp: Date.now(),
108
+ email: input.email,
109
+ });
110
+ return await this.#applyAuthResult(result);
111
+ }
112
+ async logout() {
113
+ const subjectId = this.#session.get().subject?.id;
114
+ try {
115
+ await this.#adapter.logout(this.#ctx());
116
+ }
117
+ catch {
118
+ // server logout is best-effort — still clear locally
119
+ }
120
+ this.#session.clear();
121
+ this.#pubsub.publish("auth:logout", {
122
+ type: "auth:logout",
123
+ timestamp: Date.now(),
124
+ subjectId,
125
+ });
126
+ if (this.#onIdentityChanged) {
127
+ try {
128
+ await this.#onIdentityChanged(this.#ctx());
129
+ }
130
+ catch {
131
+ // swallow
132
+ }
133
+ }
134
+ }
135
+ async resendVerification(input) {
136
+ await this.#adapter.resendVerification(input, this.#ctx());
137
+ }
138
+ async requestPasswordReset(input) {
139
+ await this.#adapter.requestPasswordReset(input, this.#ctx());
140
+ }
141
+ async changePassword(input) {
142
+ await this.#adapter.changePassword(input, this.#ctx());
143
+ }
144
+ async deleteAccount(input) {
145
+ await this.#adapter.deleteAccount(input, this.#ctx());
146
+ // Clear session locally and fire logout/identity-change.
147
+ this.#session.clear();
148
+ this.#pubsub.publish("auth:logout", {
149
+ type: "auth:logout",
150
+ timestamp: Date.now(),
151
+ });
152
+ if (this.#onIdentityChanged) {
153
+ try {
154
+ await this.#onIdentityChanged(this.#ctx());
155
+ }
156
+ catch {
157
+ // swallow
158
+ }
159
+ }
160
+ }
161
+ /**
162
+ * Begin an OAuth flow. Returns:
163
+ * - For popup mode: a Promise that resolves when the popup posts back
164
+ * an auth result.
165
+ * - For redirect mode: nothing useful; the top window navigates away.
166
+ */
167
+ async initiateOAuth(provider, opts) {
168
+ const url = this.#adapter.oauthInitUrl(provider, opts, this.#ctx());
169
+ const mode = opts.mode ?? "popup";
170
+ if (mode === "redirect") {
171
+ if (typeof globalThis !== "undefined" && "location" in globalThis) {
172
+ globalThis.location.href = url;
173
+ }
174
+ return;
175
+ }
176
+ // popup mode — wait for postMessage.
177
+ const message = await openOAuthPopup(url);
178
+ if (message.type === "oauth_link_success") {
179
+ this.#pubsub.publish("oauth:linked", {
180
+ type: "oauth:linked",
181
+ timestamp: Date.now(),
182
+ connection: { provider },
183
+ });
184
+ // Refresh profile so the new connection appears.
185
+ try {
186
+ await this.#profile.fetch();
187
+ }
188
+ catch {
189
+ // swallow
190
+ }
191
+ return;
192
+ }
193
+ // login result
194
+ const result = {
195
+ jwt: message.jwt,
196
+ email: message.email,
197
+ roles: message.roles ?? [],
198
+ isVerified: true,
199
+ };
200
+ return await this.#applyAuthResult(result);
201
+ }
202
+ /** For the redirect-mode callback page: delegate to the adapter if
203
+ * available to extract the result from the current URL/page state. */
204
+ async handleOAuthCallback() {
205
+ if (!this.#adapter.handleOAuthCallback) {
206
+ throw new Error("AuthManager: handleOAuthCallback is not implemented by the adapter");
207
+ }
208
+ const result = await this.#adapter.handleOAuthCallback(this.#ctx());
209
+ return await this.#applyAuthResult(result);
210
+ }
211
+ }
@@ -2,9 +2,10 @@
2
2
  * @module domains/base
3
3
  *
4
4
  * Base domain manager. Provides reactive state, state-machine transitions,
5
- * optimistic update pattern, and event emission. Mirrors the shape of
6
- * `@marianmeres/ecsuite`'s `BaseDomainManager` so consumers already familiar
7
- * with ecsuite can read/subscribe to ownsuite domains identically.
5
+ * optimistic update pattern, mutation serialization, abort-supersede reads,
6
+ * and event emission. Mirrors the shape of `@marianmeres/ecsuite`'s
7
+ * `BaseDomainManager` so consumers already familiar with ecsuite can
8
+ * read/subscribe to ownsuite domains identically.
8
9
  */
9
10
  import { type Clog } from "@marianmeres/clog";
10
11
  import { type StoreLike } from "@marianmeres/store";
@@ -25,6 +26,7 @@ export interface BaseDomainOptions {
25
26
  * @typeParam TAdapter - The adapter interface type for server communication.
26
27
  */
27
28
  export declare abstract class BaseDomainManager<TData, TAdapter> {
29
+ #private;
28
30
  protected readonly store: StoreLike<DomainStateWrapper<TData>>;
29
31
  protected readonly pubsub: PubSub;
30
32
  protected readonly domainName: DomainName;
@@ -36,9 +38,17 @@ export declare abstract class BaseDomainManager<TData, TAdapter> {
36
38
  get subscribe(): StoreLike<DomainStateWrapper<TData>>["subscribe"];
37
39
  /** Get current state synchronously. */
38
40
  get(): DomainStateWrapper<TData>;
41
+ /** True after `destroy()` has been called. */
42
+ get isDestroyed(): boolean;
39
43
  setAdapter(adapter: TAdapter): void;
40
44
  getAdapter(): TAdapter | null;
45
+ /**
46
+ * Merge `ctx` into the current context. Keys not present in `ctx` are
47
+ * preserved. To replace the context entirely use `replaceContext`.
48
+ */
41
49
  setContext(context: OwnsuiteContext): void;
50
+ /** Replace the context object entirely (no merge with existing). */
51
+ replaceContext(context: OwnsuiteContext): void;
42
52
  getContext(): OwnsuiteContext;
43
53
  /** Transition to a new state. */
44
54
  protected setState(state: DomainState): void;
@@ -51,14 +61,60 @@ export declare abstract class BaseDomainManager<TData, TAdapter> {
51
61
  /** Emit an event via pubsub. */
52
62
  protected emit(event: OwnsuiteEvent): void;
53
63
  /**
54
- * Execute an async operation with the optimistic-update pattern:
55
- * 1. capture current data for rollback
56
- * 2. apply optimistic update immediately
57
- * 3. flip to "syncing"
58
- * 4. on success: mark synced, call onSuccess
59
- * 5. on error: restore previous data, set error, call onError
64
+ * Create a new AbortController registered with this manager. `destroy()`
65
+ * and `reset()` abort all active controllers. Call `releaseController`
66
+ * when the operation is done (success or failure) to let the controller
67
+ * be garbage-collected.
68
+ */
69
+ protected newController(): AbortController;
70
+ /** Stop tracking a controller. Call after the associated op completes. */
71
+ protected releaseController(ctrl: AbortController): void;
72
+ /** Abort every active controller (reads, mutations, other). */
73
+ protected abortAll(reason?: string): void;
74
+ /**
75
+ * Serialize mutations. Each call queues behind any in-flight mutation on
76
+ * this manager. Rejections are swallowed on the chain so subsequent
77
+ * callers always proceed (their own fn can still throw/reject and the
78
+ * caller sees it).
79
+ */
80
+ protected serializeMutation<T>(fn: () => Promise<T>): Promise<T>;
81
+ /**
82
+ * Run a read with abort-supersede semantics. Calling a second read
83
+ * aborts the first (its signal flips to aborted before/after the
84
+ * adapter resolves). The callback receives the signal and should check
85
+ * `signal.aborted` after any async step to skip state writes that would
86
+ * overwrite a fresher response.
60
87
  */
61
- protected withOptimisticUpdate<T>(operation: string, optimisticUpdate: () => void, serverSync: () => Promise<T>, onSuccess?: (result: T) => void, onError?: (error: DomainError) => void): Promise<void>;
88
+ protected serializeRead(fn: (signal: AbortSignal) => Promise<void>): Promise<void>;
89
+ /**
90
+ * Execute an async mutation with the optimistic-update pattern:
91
+ * 1. apply optimistic update immediately
92
+ * 2. flip to "syncing"
93
+ * 3. on success: mark synced, call onSuccess
94
+ * 4. on error: call onError for caller-driven rollback, then set error
95
+ *
96
+ * Callers provide both the optimistic mutation and its inverse (via
97
+ * `onError`). The inverse runs against the *live* store, which matters
98
+ * when a refresh landed between the optimistic write and the failure.
99
+ *
100
+ * A snapshot is captured via `safeClone` and passed to `onError` for
101
+ * callers that prefer a whole-data restore over per-change inversion.
102
+ */
103
+ protected withOptimisticUpdate<T>(operation: string, optimisticUpdate: () => void, serverSync: () => Promise<T>, onSuccess?: (result: T) => void, onError?: (error: DomainError, snapshot: TData | null) => void): Promise<void>;
62
104
  abstract initialize(): Promise<void>;
105
+ /**
106
+ * Reset to `initializing` state. Aborts any in-flight reads/mutations
107
+ * (their completions become no-ops once they observe `signal.aborted`),
108
+ * clears cached data, and emits `domain:state:changed`.
109
+ */
63
110
  reset(): void;
111
+ /**
112
+ * Dispose of this manager: abort in-flight ops, drop the adapter
113
+ * reference, and mark destroyed. Subsequent method calls are a best-
114
+ * effort no-op (they observe aborted controllers and return early).
115
+ *
116
+ * Note: the shared pubsub is NOT cleared — other consumers may still
117
+ * hold subscriptions against it. `Ownsuite.destroy()` owns that.
118
+ */
119
+ destroy(): void;
64
120
  }
@@ -2,13 +2,35 @@
2
2
  * @module domains/base
3
3
  *
4
4
  * Base domain manager. Provides reactive state, state-machine transitions,
5
- * optimistic update pattern, and event emission. Mirrors the shape of
6
- * `@marianmeres/ecsuite`'s `BaseDomainManager` so consumers already familiar
7
- * with ecsuite can read/subscribe to ownsuite domains identically.
5
+ * optimistic update pattern, mutation serialization, abort-supersede reads,
6
+ * and event emission. Mirrors the shape of `@marianmeres/ecsuite`'s
7
+ * `BaseDomainManager` so consumers already familiar with ecsuite can
8
+ * read/subscribe to ownsuite domains identically.
8
9
  */
9
10
  import { createClog } from "@marianmeres/clog";
10
11
  import { createStore } from "@marianmeres/store";
11
12
  import { createPubSub } from "@marianmeres/pubsub";
13
+ /**
14
+ * Deep-clone helper with fallback. Uses `structuredClone` where available;
15
+ * if a payload contains non-cloneable values (functions, class instances),
16
+ * falls back to a JSON round-trip. A final fallback returns the original
17
+ * reference (preserves pre-cloning behavior rather than throwing).
18
+ */
19
+ function safeClone(value) {
20
+ if (value === null || value === undefined)
21
+ return value;
22
+ try {
23
+ return structuredClone(value);
24
+ }
25
+ catch {
26
+ try {
27
+ return JSON.parse(JSON.stringify(value));
28
+ }
29
+ catch {
30
+ return value;
31
+ }
32
+ }
33
+ }
12
34
  /**
13
35
  * Abstract base class for ownsuite domain managers.
14
36
  *
@@ -22,6 +44,13 @@ export class BaseDomainManager {
22
44
  clog;
23
45
  adapter = null;
24
46
  context = {};
47
+ /** Mutation chain head. Each create/update/delete appends itself here. */
48
+ #mutationChain = Promise.resolve();
49
+ /** Controller for the currently-active read (initialize/refresh). */
50
+ #readController = null;
51
+ /** All active controllers created via `newController()`, for bulk abort. */
52
+ #activeControllers = new Set();
53
+ #destroyed = false;
25
54
  constructor(domainName, options = {}) {
26
55
  this.domainName = domainName;
27
56
  this.clog = createClog(`ownsuite:${domainName}`, { color: "auto" });
@@ -43,15 +72,27 @@ export class BaseDomainManager {
43
72
  get() {
44
73
  return this.store.get();
45
74
  }
75
+ /** True after `destroy()` has been called. */
76
+ get isDestroyed() {
77
+ return this.#destroyed;
78
+ }
46
79
  setAdapter(adapter) {
47
80
  this.adapter = adapter;
48
81
  }
49
82
  getAdapter() {
50
83
  return this.adapter;
51
84
  }
85
+ /**
86
+ * Merge `ctx` into the current context. Keys not present in `ctx` are
87
+ * preserved. To replace the context entirely use `replaceContext`.
88
+ */
52
89
  setContext(context) {
53
90
  this.context = { ...this.context, ...context };
54
91
  }
92
+ /** Replace the context object entirely (no merge with existing). */
93
+ replaceContext(context) {
94
+ this.context = { ...context };
95
+ }
55
96
  getContext() {
56
97
  return { ...this.context };
57
98
  }
@@ -111,15 +152,91 @@ export class BaseDomainManager {
111
152
  this.pubsub.publish(event.type, event);
112
153
  }
113
154
  /**
114
- * Execute an async operation with the optimistic-update pattern:
115
- * 1. capture current data for rollback
116
- * 2. apply optimistic update immediately
117
- * 3. flip to "syncing"
118
- * 4. on success: mark synced, call onSuccess
119
- * 5. on error: restore previous data, set error, call onError
155
+ * Create a new AbortController registered with this manager. `destroy()`
156
+ * and `reset()` abort all active controllers. Call `releaseController`
157
+ * when the operation is done (success or failure) to let the controller
158
+ * be garbage-collected.
159
+ */
160
+ newController() {
161
+ const ctrl = new AbortController();
162
+ this.#activeControllers.add(ctrl);
163
+ return ctrl;
164
+ }
165
+ /** Stop tracking a controller. Call after the associated op completes. */
166
+ releaseController(ctrl) {
167
+ this.#activeControllers.delete(ctrl);
168
+ }
169
+ /** Abort every active controller (reads, mutations, other). */
170
+ abortAll(reason) {
171
+ for (const c of this.#activeControllers) {
172
+ try {
173
+ c.abort(reason);
174
+ }
175
+ catch {
176
+ // ignore — abort() is idempotent in practice
177
+ }
178
+ }
179
+ this.#activeControllers.clear();
180
+ this.#readController = null;
181
+ }
182
+ /**
183
+ * Serialize mutations. Each call queues behind any in-flight mutation on
184
+ * this manager. Rejections are swallowed on the chain so subsequent
185
+ * callers always proceed (their own fn can still throw/reject and the
186
+ * caller sees it).
187
+ */
188
+ async serializeMutation(fn) {
189
+ const prev = this.#mutationChain;
190
+ // Chain tail intentionally swallows rejection — serial order only.
191
+ const mine = prev.then(() => fn(), () => fn());
192
+ this.#mutationChain = mine.then(() => undefined, () => undefined);
193
+ return mine;
194
+ }
195
+ /**
196
+ * Run a read with abort-supersede semantics. Calling a second read
197
+ * aborts the first (its signal flips to aborted before/after the
198
+ * adapter resolves). The callback receives the signal and should check
199
+ * `signal.aborted` after any async step to skip state writes that would
200
+ * overwrite a fresher response.
201
+ */
202
+ async serializeRead(fn) {
203
+ // Supersede: abort the previous read if any.
204
+ if (this.#readController) {
205
+ try {
206
+ this.#readController.abort("superseded");
207
+ }
208
+ catch {
209
+ // ignore
210
+ }
211
+ this.#activeControllers.delete(this.#readController);
212
+ }
213
+ const ctrl = this.newController();
214
+ this.#readController = ctrl;
215
+ try {
216
+ await fn(ctrl.signal);
217
+ }
218
+ finally {
219
+ if (this.#readController === ctrl)
220
+ this.#readController = null;
221
+ this.releaseController(ctrl);
222
+ }
223
+ }
224
+ /**
225
+ * Execute an async mutation with the optimistic-update pattern:
226
+ * 1. apply optimistic update immediately
227
+ * 2. flip to "syncing"
228
+ * 3. on success: mark synced, call onSuccess
229
+ * 4. on error: call onError for caller-driven rollback, then set error
230
+ *
231
+ * Callers provide both the optimistic mutation and its inverse (via
232
+ * `onError`). The inverse runs against the *live* store, which matters
233
+ * when a refresh landed between the optimistic write and the failure.
234
+ *
235
+ * A snapshot is captured via `safeClone` and passed to `onError` for
236
+ * callers that prefer a whole-data restore over per-change inversion.
120
237
  */
121
238
  async withOptimisticUpdate(operation, optimisticUpdate, serverSync, onSuccess, onError) {
122
- const previousData = this.store.get().data;
239
+ const snapshot = safeClone(this.store.get().data);
123
240
  optimisticUpdate();
124
241
  this.setState("syncing");
125
242
  try {
@@ -128,24 +245,59 @@ export class BaseDomainManager {
128
245
  onSuccess?.(result);
129
246
  }
130
247
  catch (e) {
131
- if (previousData !== null)
132
- this.setData(previousData, false);
133
248
  const error = {
134
249
  code: "SYNC_FAILED",
135
250
  message: e instanceof Error ? e.message : "Unknown error",
136
251
  originalError: e,
137
252
  operation,
138
253
  };
254
+ if (onError) {
255
+ onError(error, snapshot);
256
+ }
257
+ else if (snapshot !== null) {
258
+ // Default rollback: restore full snapshot (pre-1.1.0 behavior).
259
+ this.setData(snapshot, false);
260
+ }
139
261
  this.setError(error);
140
- onError?.(error);
141
262
  }
142
263
  }
264
+ /**
265
+ * Reset to `initializing` state. Aborts any in-flight reads/mutations
266
+ * (their completions become no-ops once they observe `signal.aborted`),
267
+ * clears cached data, and emits `domain:state:changed`.
268
+ */
143
269
  reset() {
270
+ this.abortAll("reset");
271
+ const prev = this.store.get().state;
144
272
  this.store.set({
145
273
  state: "initializing",
146
274
  data: null,
147
275
  error: null,
148
276
  lastSyncedAt: null,
149
277
  });
278
+ if (prev !== "initializing") {
279
+ this.emit({
280
+ type: "domain:state:changed",
281
+ domain: this.domainName,
282
+ timestamp: Date.now(),
283
+ previousState: prev,
284
+ newState: "initializing",
285
+ });
286
+ }
287
+ }
288
+ /**
289
+ * Dispose of this manager: abort in-flight ops, drop the adapter
290
+ * reference, and mark destroyed. Subsequent method calls are a best-
291
+ * effort no-op (they observe aborted controllers and return early).
292
+ *
293
+ * Note: the shared pubsub is NOT cleared — other consumers may still
294
+ * hold subscriptions against it. `Ownsuite.destroy()` owns that.
295
+ */
296
+ destroy() {
297
+ if (this.#destroyed)
298
+ return;
299
+ this.#destroyed = true;
300
+ this.abortAll("destroyed");
301
+ this.adapter = null;
150
302
  }
151
303
  }
@@ -1,2 +1,5 @@
1
1
  export * from "./base.js";
2
2
  export * from "./owned-collection.js";
3
+ export * from "./session.js";
4
+ export * from "./profile.js";
5
+ export * from "./auth.js";
@@ -1,2 +1,5 @@
1
1
  export * from "./base.js";
2
2
  export * from "./owned-collection.js";
3
+ export * from "./session.js";
4
+ export * from "./profile.js";
5
+ export * from "./auth.js";