@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.
- package/AGENTS.md +206 -21
- package/API.md +410 -18
- package/README.md +86 -2
- package/dist/adapters/mock-auth.d.ts +38 -0
- package/dist/adapters/mock-auth.js +237 -0
- package/dist/adapters/mock.d.ts +10 -3
- package/dist/adapters/mock.js +79 -25
- package/dist/adapters/mod.d.ts +2 -0
- package/dist/adapters/mod.js +2 -0
- package/dist/adapters/stack-account.d.ts +38 -0
- package/dist/adapters/stack-account.js +149 -0
- package/dist/domains/auth.d.ts +83 -0
- package/dist/domains/auth.js +211 -0
- package/dist/domains/base.d.ts +66 -10
- package/dist/domains/base.js +165 -13
- package/dist/domains/mod.d.ts +3 -0
- package/dist/domains/mod.js +3 -0
- package/dist/domains/owned-collection.d.ts +29 -4
- package/dist/domains/owned-collection.js +240 -120
- package/dist/domains/profile.d.ts +62 -0
- package/dist/domains/profile.js +170 -0
- package/dist/domains/session.d.ts +73 -0
- package/dist/domains/session.js +226 -0
- package/dist/mod.d.ts +1 -0
- package/dist/mod.js +1 -0
- package/dist/oauth/popup.d.ts +64 -0
- package/dist/oauth/popup.js +104 -0
- package/dist/ownsuite.d.ts +58 -5
- package/dist/ownsuite.js +178 -10
- package/dist/types/adapter.d.ts +4 -0
- package/dist/types/auth.d.ts +162 -0
- package/dist/types/auth.js +17 -0
- package/dist/types/events.d.ts +41 -2
- package/dist/types/mod.d.ts +1 -0
- package/dist/types/mod.js +1 -0
- package/dist/types/state.d.ts +17 -0
- package/docs/future-improvements.md +81 -0
- package/package.json +15 -6
|
@@ -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
|
+
}
|
package/dist/domains/base.d.ts
CHANGED
|
@@ -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,
|
|
6
|
-
* `@marianmeres/ecsuite`'s
|
|
7
|
-
*
|
|
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
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
}
|
package/dist/domains/base.js
CHANGED
|
@@ -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,
|
|
6
|
-
* `@marianmeres/ecsuite`'s
|
|
7
|
-
*
|
|
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
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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
|
}
|
package/dist/domains/mod.d.ts
CHANGED