@marianmeres/ownsuite 1.0.3 → 2.0.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.
@@ -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
  }
@@ -5,6 +5,13 @@
5
5
  * any row shape `TRow` via an injected `OwnedCollectionAdapter`. All CRUD
6
6
  * operations are implicitly scoped to the authenticated subject by the
7
7
  * server — the client never sets `owner_id`.
8
+ *
9
+ * Concurrency model:
10
+ * - Mutations (create/update/delete) serialize through a per-manager chain.
11
+ * - Reads (initialize/refresh) use abort-supersede: a newer read aborts
12
+ * any in-flight older read.
13
+ * - `onSuccess` callbacks read the live store (not a captured snapshot) so
14
+ * interleaving reads do not erase each other's writes.
8
15
  */
9
16
  import type { OwnedCollectionAdapter } from "../types/adapter.js";
10
17
  import type { OwnedCollectionState } from "../types/state.js";
@@ -28,13 +35,31 @@ export declare class OwnedCollectionManager<TRow = Record<string, unknown>, TCre
28
35
  initialize(): Promise<void>;
29
36
  /** Refresh the list from server. Same as initialize but re-entrant. */
30
37
  refresh(query?: Record<string, unknown>): Promise<void>;
31
- /** Fetch a single row by id; returns the row but does not update the list. */
38
+ /**
39
+ * Fetch a single row by id. Does NOT update the list and does NOT
40
+ * transition the domain to `error` on failure — a 404 for an un-owned
41
+ * row or a network blip on a read shouldn't invalidate a healthy list
42
+ * view. Emits `own:row:fetched` on success.
43
+ *
44
+ * Returns `null` on any failure (including missing adapter). Callers
45
+ * that need error detail should wrap this method and inspect the thrown
46
+ * adapter error themselves.
47
+ */
32
48
  getOne(id: string): Promise<TRow | null>;
33
- /** Create a new row. Optimistically prepends to the list. */
49
+ /** Create a new row. Server assigns the id; on success, prepends to the list. */
34
50
  create(data: TCreate): Promise<TRow | null>;
35
- /** Update a row. Optimistically merges into the list. */
51
+ /**
52
+ * Update a row. Optimistically merges `data` into the existing row; on
53
+ * server failure reverts that single row to its pre-call value (without
54
+ * clobbering any interleaved refresh or other mutations).
55
+ *
56
+ * If `id` is not in the current list (e.g., filtered out by an active
57
+ * query or not owned), the optimistic step is a no-op AND the successful
58
+ * server response is NOT inserted — call `refresh()` if you want the
59
+ * row to appear. Emits `own:row:updated` on success regardless.
60
+ */
36
61
  update(id: string, data: TUpdate): Promise<TRow | null>;
37
- /** Delete a row. Optimistically removes from the list. */
62
+ /** Delete a row. Optimistically removes from the list; re-inserts on failure. */
38
63
  delete(id: string): Promise<boolean>;
39
64
  /** Snapshot of current rows (empty array if not loaded). */
40
65
  getRows(): TRow[];