@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.
- package/AGENTS.md +122 -16
- package/API.md +78 -17
- package/README.md +21 -2
- package/dist/adapters/mock.d.ts +10 -3
- package/dist/adapters/mock.js +79 -25
- package/dist/domains/base.d.ts +66 -10
- package/dist/domains/base.js +165 -13
- package/dist/domains/owned-collection.d.ts +29 -4
- package/dist/domains/owned-collection.js +240 -120
- package/dist/ownsuite.d.ts +37 -5
- package/dist/ownsuite.js +92 -10
- package/dist/types/adapter.d.ts +4 -0
- package/dist/types/state.d.ts +10 -0
- package/docs/future-improvements.md +81 -0
- package/package.json +15 -6
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
|
}
|
|
@@ -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
|
-
/**
|
|
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.
|
|
49
|
+
/** Create a new row. Server assigns the id; on success, prepends to the list. */
|
|
34
50
|
create(data: TCreate): Promise<TRow | null>;
|
|
35
|
-
/**
|
|
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[];
|