@marianmeres/ownsuite 1.0.2 → 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 +79 -18
- 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
|
@@ -5,13 +5,20 @@
|
|
|
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 { BaseDomainManager } from "./base.js";
|
|
10
17
|
const defaultGetRowId = (row) => {
|
|
11
18
|
const r = row;
|
|
12
19
|
const id = r.model_id ?? r.id;
|
|
13
|
-
if (typeof id !== "string") {
|
|
14
|
-
throw new Error("OwnedCollectionManager: row has no string `model_id` or `id`; " +
|
|
20
|
+
if (typeof id !== "string" || id === "") {
|
|
21
|
+
throw new Error("OwnedCollectionManager: row has no non-empty string `model_id` or `id`; " +
|
|
15
22
|
"pass a custom `getRowId` in options.");
|
|
16
23
|
}
|
|
17
24
|
return id;
|
|
@@ -31,64 +38,98 @@ export class OwnedCollectionManager extends BaseDomainManager {
|
|
|
31
38
|
if (options.adapter)
|
|
32
39
|
this.adapter = options.adapter;
|
|
33
40
|
}
|
|
41
|
+
/** Build the per-op adapter context with the injected signal. */
|
|
42
|
+
#ctx(signal) {
|
|
43
|
+
return { ...this.context, signal };
|
|
44
|
+
}
|
|
45
|
+
/** Current data or an empty shell. */
|
|
46
|
+
#live() {
|
|
47
|
+
return this.store.get().data ?? { rows: [], meta: {} };
|
|
48
|
+
}
|
|
34
49
|
/** Initialize by fetching the list from the server. */
|
|
35
50
|
async initialize() {
|
|
51
|
+
if (this.isDestroyed)
|
|
52
|
+
return;
|
|
36
53
|
if (!this.adapter) {
|
|
37
54
|
this.setState("ready");
|
|
38
55
|
return;
|
|
39
56
|
}
|
|
40
|
-
this.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
await this.serializeRead(async (signal) => {
|
|
58
|
+
this.setState("syncing");
|
|
59
|
+
try {
|
|
60
|
+
const res = await this.adapter.list(this.#ctx(signal));
|
|
61
|
+
if (signal.aborted)
|
|
62
|
+
return;
|
|
63
|
+
this.setData({ rows: res.data, meta: res.meta });
|
|
64
|
+
this.markSynced();
|
|
65
|
+
this.emit({
|
|
66
|
+
type: "own:list:fetched",
|
|
67
|
+
domain: this.domainName,
|
|
68
|
+
timestamp: Date.now(),
|
|
69
|
+
count: res.data.length,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
if (signal.aborted)
|
|
74
|
+
return;
|
|
75
|
+
this.setError({
|
|
76
|
+
code: "FETCH_FAILED",
|
|
77
|
+
message: e instanceof Error ? e.message : "Failed to fetch list",
|
|
78
|
+
originalError: e,
|
|
79
|
+
operation: "initialize",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
});
|
|
60
83
|
}
|
|
61
84
|
/** Refresh the list from server. Same as initialize but re-entrant. */
|
|
62
85
|
async refresh(query) {
|
|
63
|
-
if (!this.adapter)
|
|
86
|
+
if (this.isDestroyed || !this.adapter)
|
|
64
87
|
return;
|
|
65
|
-
this.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
88
|
+
await this.serializeRead(async (signal) => {
|
|
89
|
+
this.setState("syncing");
|
|
90
|
+
try {
|
|
91
|
+
const res = await this.adapter.list(this.#ctx(signal), query);
|
|
92
|
+
if (signal.aborted)
|
|
93
|
+
return;
|
|
94
|
+
this.setData({ rows: res.data, meta: res.meta });
|
|
95
|
+
this.markSynced();
|
|
96
|
+
this.emit({
|
|
97
|
+
type: "own:list:fetched",
|
|
98
|
+
domain: this.domainName,
|
|
99
|
+
timestamp: Date.now(),
|
|
100
|
+
count: res.data.length,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
if (signal.aborted)
|
|
105
|
+
return;
|
|
106
|
+
this.setError({
|
|
107
|
+
code: "FETCH_FAILED",
|
|
108
|
+
message: e instanceof Error ? e.message : "Failed to refresh list",
|
|
109
|
+
originalError: e,
|
|
110
|
+
operation: "refresh",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
});
|
|
85
114
|
}
|
|
86
|
-
/**
|
|
115
|
+
/**
|
|
116
|
+
* Fetch a single row by id. Does NOT update the list and does NOT
|
|
117
|
+
* transition the domain to `error` on failure — a 404 for an un-owned
|
|
118
|
+
* row or a network blip on a read shouldn't invalidate a healthy list
|
|
119
|
+
* view. Emits `own:row:fetched` on success.
|
|
120
|
+
*
|
|
121
|
+
* Returns `null` on any failure (including missing adapter). Callers
|
|
122
|
+
* that need error detail should wrap this method and inspect the thrown
|
|
123
|
+
* adapter error themselves.
|
|
124
|
+
*/
|
|
87
125
|
async getOne(id) {
|
|
88
|
-
if (!this.adapter)
|
|
126
|
+
if (this.isDestroyed || !this.adapter)
|
|
89
127
|
return null;
|
|
128
|
+
const ctrl = this.newController();
|
|
90
129
|
try {
|
|
91
|
-
const res = await this.adapter.getOne(id, this.
|
|
130
|
+
const res = await this.adapter.getOne(id, this.#ctx(ctrl.signal));
|
|
131
|
+
if (ctrl.signal.aborted)
|
|
132
|
+
return null;
|
|
92
133
|
this.emit({
|
|
93
134
|
type: "own:row:fetched",
|
|
94
135
|
domain: this.domainName,
|
|
@@ -98,97 +139,176 @@ export class OwnedCollectionManager extends BaseDomainManager {
|
|
|
98
139
|
return res.data;
|
|
99
140
|
}
|
|
100
141
|
catch (e) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
142
|
+
if (ctrl.signal.aborted)
|
|
143
|
+
return null;
|
|
144
|
+
this.clog.debug("getOne failed", {
|
|
145
|
+
id,
|
|
146
|
+
error: e instanceof Error ? e.message : e,
|
|
106
147
|
});
|
|
107
148
|
return null;
|
|
108
149
|
}
|
|
150
|
+
finally {
|
|
151
|
+
this.releaseController(ctrl);
|
|
152
|
+
}
|
|
109
153
|
}
|
|
110
|
-
/** Create a new row.
|
|
154
|
+
/** Create a new row. Server assigns the id; on success, prepends to the list. */
|
|
111
155
|
async create(data) {
|
|
112
|
-
if (!this.adapter)
|
|
156
|
+
if (this.isDestroyed || !this.adapter)
|
|
113
157
|
return null;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
158
|
+
return this.serializeMutation(async () => {
|
|
159
|
+
let result = null;
|
|
160
|
+
await this.withOptimisticUpdate("create", () => {
|
|
161
|
+
// No optimistic insertion — no client-assigned id. `syncing`
|
|
162
|
+
// transition still happens so subscribers see the pending state.
|
|
163
|
+
}, async () => {
|
|
164
|
+
const ctrl = this.newController();
|
|
165
|
+
try {
|
|
166
|
+
const res = await this.adapter.create(data, this.#ctx(ctrl.signal));
|
|
167
|
+
return res.data;
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
this.releaseController(ctrl);
|
|
171
|
+
}
|
|
172
|
+
}, (serverRow) => {
|
|
173
|
+
result = serverRow;
|
|
174
|
+
// Read live state (not snapshot) so a concurrent delete/refresh is preserved.
|
|
175
|
+
const live = this.#live();
|
|
176
|
+
this.setData({
|
|
177
|
+
rows: [serverRow, ...live.rows],
|
|
178
|
+
meta: live.meta,
|
|
179
|
+
});
|
|
180
|
+
this.emit({
|
|
181
|
+
type: "own:row:created",
|
|
182
|
+
domain: this.domainName,
|
|
183
|
+
timestamp: Date.now(),
|
|
184
|
+
rowId: this.#getRowId(serverRow),
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
// No onError: create has no optimistic state to revert; default
|
|
188
|
+
// rollback (restore snapshot) also not needed since we never mutated.
|
|
189
|
+
() => { });
|
|
190
|
+
return result;
|
|
134
191
|
});
|
|
135
|
-
return result;
|
|
136
192
|
}
|
|
137
|
-
/**
|
|
193
|
+
/**
|
|
194
|
+
* Update a row. Optimistically merges `data` into the existing row; on
|
|
195
|
+
* server failure reverts that single row to its pre-call value (without
|
|
196
|
+
* clobbering any interleaved refresh or other mutations).
|
|
197
|
+
*
|
|
198
|
+
* If `id` is not in the current list (e.g., filtered out by an active
|
|
199
|
+
* query or not owned), the optimistic step is a no-op AND the successful
|
|
200
|
+
* server response is NOT inserted — call `refresh()` if you want the
|
|
201
|
+
* row to appear. Emits `own:row:updated` on success regardless.
|
|
202
|
+
*/
|
|
138
203
|
async update(id, data) {
|
|
139
|
-
if (!this.adapter)
|
|
204
|
+
if (this.isDestroyed || !this.adapter)
|
|
140
205
|
return null;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
206
|
+
return this.serializeMutation(async () => {
|
|
207
|
+
const startData = this.#live();
|
|
208
|
+
const startIdx = startData.rows.findIndex((r) => this.#getRowId(r) === id);
|
|
209
|
+
const originalRow = startIdx !== -1 ? startData.rows[startIdx] : null;
|
|
210
|
+
let result = null;
|
|
211
|
+
await this.withOptimisticUpdate("update", () => {
|
|
212
|
+
if (originalRow === null)
|
|
213
|
+
return;
|
|
214
|
+
const optimistic = {
|
|
215
|
+
...originalRow,
|
|
216
|
+
...data,
|
|
217
|
+
};
|
|
218
|
+
const live = this.#live();
|
|
219
|
+
const liveIdx = live.rows.findIndex((r) => this.#getRowId(r) === id);
|
|
220
|
+
if (liveIdx === -1)
|
|
221
|
+
return;
|
|
222
|
+
const rows = live.rows.slice();
|
|
223
|
+
rows[liveIdx] = optimistic;
|
|
224
|
+
this.setData({ rows, meta: live.meta }, false);
|
|
225
|
+
}, async () => {
|
|
226
|
+
const ctrl = this.newController();
|
|
227
|
+
try {
|
|
228
|
+
const res = await this.adapter.update(id, data, this.#ctx(ctrl.signal));
|
|
229
|
+
return res.data;
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
this.releaseController(ctrl);
|
|
233
|
+
}
|
|
234
|
+
}, (serverRow) => {
|
|
235
|
+
result = serverRow;
|
|
236
|
+
const live = this.#live();
|
|
237
|
+
const liveIdx = live.rows.findIndex((r) => this.#getRowId(r) === id);
|
|
238
|
+
if (liveIdx !== -1) {
|
|
239
|
+
const rows = live.rows.map((r, i) => (i === liveIdx ? serverRow : r));
|
|
240
|
+
this.setData({ rows, meta: live.meta });
|
|
241
|
+
}
|
|
242
|
+
// If liveIdx === -1 the row is not in the current list; do NOT
|
|
243
|
+
// insert (avoids phantom rows when filtered/unknown).
|
|
244
|
+
this.emit({
|
|
245
|
+
type: "own:row:updated",
|
|
246
|
+
domain: this.domainName,
|
|
247
|
+
timestamp: Date.now(),
|
|
248
|
+
rowId: id,
|
|
249
|
+
});
|
|
250
|
+
}, (_error, _snapshot) => {
|
|
251
|
+
// Per-row rollback: restore the one row we mutated (or nothing).
|
|
252
|
+
if (originalRow === null)
|
|
253
|
+
return;
|
|
254
|
+
const live = this.#live();
|
|
255
|
+
const liveIdx = live.rows.findIndex((r) => this.#getRowId(r) === id);
|
|
256
|
+
if (liveIdx === -1)
|
|
257
|
+
return; // row was removed by another op; don't re-add
|
|
258
|
+
const rows = live.rows.slice();
|
|
259
|
+
rows[liveIdx] = originalRow;
|
|
260
|
+
this.setData({ rows, meta: live.meta }, false);
|
|
165
261
|
});
|
|
262
|
+
return result;
|
|
166
263
|
});
|
|
167
|
-
return result;
|
|
168
264
|
}
|
|
169
|
-
/** Delete a row. Optimistically removes from the list. */
|
|
265
|
+
/** Delete a row. Optimistically removes from the list; re-inserts on failure. */
|
|
170
266
|
async delete(id) {
|
|
171
|
-
if (!this.adapter)
|
|
267
|
+
if (this.isDestroyed || !this.adapter)
|
|
172
268
|
return false;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
269
|
+
return this.serializeMutation(async () => {
|
|
270
|
+
const startData = this.#live();
|
|
271
|
+
const startIdx = startData.rows.findIndex((r) => this.#getRowId(r) === id);
|
|
272
|
+
const originalRow = startIdx !== -1 ? startData.rows[startIdx] : null;
|
|
273
|
+
let ok = false;
|
|
274
|
+
await this.withOptimisticUpdate("delete", () => {
|
|
275
|
+
const live = this.#live();
|
|
276
|
+
this.setData({
|
|
277
|
+
rows: live.rows.filter((r) => this.#getRowId(r) !== id),
|
|
278
|
+
meta: live.meta,
|
|
279
|
+
}, false);
|
|
280
|
+
}, async () => {
|
|
281
|
+
const ctrl = this.newController();
|
|
282
|
+
try {
|
|
283
|
+
return await this.adapter.delete(id, this.#ctx(ctrl.signal));
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
this.releaseController(ctrl);
|
|
287
|
+
}
|
|
288
|
+
}, (serverOk) => {
|
|
289
|
+
ok = serverOk;
|
|
290
|
+
this.emit({
|
|
291
|
+
type: "own:row:deleted",
|
|
292
|
+
domain: this.domainName,
|
|
293
|
+
timestamp: Date.now(),
|
|
294
|
+
rowId: id,
|
|
295
|
+
});
|
|
296
|
+
}, (_error, _snapshot) => {
|
|
297
|
+
// Per-row rollback: re-insert the deleted row at its original
|
|
298
|
+
// position, unless it was independently re-added by another op.
|
|
299
|
+
if (originalRow === null)
|
|
300
|
+
return;
|
|
301
|
+
const live = this.#live();
|
|
302
|
+
if (live.rows.some((r) => this.#getRowId(r) === id))
|
|
303
|
+
return;
|
|
304
|
+
const rows = live.rows.slice();
|
|
305
|
+
// Clamp insertion index to current list length.
|
|
306
|
+
const insertAt = Math.min(startIdx, rows.length);
|
|
307
|
+
rows.splice(insertAt < 0 ? 0 : insertAt, 0, originalRow);
|
|
308
|
+
this.setData({ rows, meta: live.meta }, false);
|
|
189
309
|
});
|
|
310
|
+
return ok;
|
|
190
311
|
});
|
|
191
|
-
return ok;
|
|
192
312
|
}
|
|
193
313
|
/** Snapshot of current rows (empty array if not loaded). */
|
|
194
314
|
getRows() {
|
package/dist/ownsuite.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* hook and `@marianmeres/stack-common`'s `ownsuiteOptions()` helper.
|
|
12
12
|
*/
|
|
13
13
|
import { type Subscriber, type Unsubscriber } from "@marianmeres/pubsub";
|
|
14
|
-
import type { OwnsuiteContext } from "./types/state.js";
|
|
14
|
+
import type { DomainError, OwnsuiteContext } from "./types/state.js";
|
|
15
15
|
import type { OwnedCollectionAdapter } from "./types/adapter.js";
|
|
16
16
|
import type { OwnsuiteEventType } from "./types/events.js";
|
|
17
17
|
import { OwnedCollectionManager } from "./domains/owned-collection.js";
|
|
@@ -33,6 +33,13 @@ export interface OwnsuiteConfig {
|
|
|
33
33
|
/** Auto-initialize all registered domains on creation (default: false). */
|
|
34
34
|
autoInitialize?: boolean;
|
|
35
35
|
}
|
|
36
|
+
/** Options for {@link Ownsuite.setContext}. */
|
|
37
|
+
export interface SetContextOptions {
|
|
38
|
+
/** If true, replace the context entirely instead of merging. Default: false (merge). */
|
|
39
|
+
replace?: boolean;
|
|
40
|
+
/** If true, fire `refresh()` on every domain after the context change. Default: false. */
|
|
41
|
+
refresh?: boolean;
|
|
42
|
+
}
|
|
36
43
|
/**
|
|
37
44
|
* Main Ownsuite class — coordinates owner-scoped domain managers.
|
|
38
45
|
*
|
|
@@ -54,6 +61,8 @@ export interface OwnsuiteConfig {
|
|
|
54
61
|
export declare class Ownsuite {
|
|
55
62
|
#private;
|
|
56
63
|
constructor(config?: OwnsuiteConfig);
|
|
64
|
+
/** True after `destroy()` has been called. */
|
|
65
|
+
get isDestroyed(): boolean;
|
|
57
66
|
/** Register a new domain after construction. */
|
|
58
67
|
registerDomain<TRow = any, TCreate = any, TUpdate = any>(name: string, cfg: OwnsuiteDomainConfig<TRow, TCreate, TUpdate>): OwnedCollectionManager<TRow, TCreate, TUpdate>;
|
|
59
68
|
/** Look up a domain manager by name. Throws if unknown. */
|
|
@@ -65,11 +74,20 @@ export declare class Ownsuite {
|
|
|
65
74
|
/**
|
|
66
75
|
* Initialize all registered domains (or a subset). Runs in parallel.
|
|
67
76
|
* Individual domain errors land in that domain's error state — they
|
|
68
|
-
* do not reject the overall promise.
|
|
77
|
+
* do not reject the overall promise. Use `hasErrors()` / `errors()` to
|
|
78
|
+
* inspect the result. Unknown domain names in `names` are logged and
|
|
79
|
+
* skipped.
|
|
69
80
|
*/
|
|
70
81
|
initialize(names?: string[]): Promise<void>;
|
|
71
|
-
/**
|
|
72
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Update shared context and propagate to every domain manager.
|
|
84
|
+
*
|
|
85
|
+
* - `options.replace: true` — replace the context wholesale (no merge).
|
|
86
|
+
* - `options.refresh: true` — fire-and-forget `refresh()` on every
|
|
87
|
+
* domain after the context change (so stale per-subject caches don't
|
|
88
|
+
* linger when, e.g., `subjectId` changes).
|
|
89
|
+
*/
|
|
90
|
+
setContext(ctx: OwnsuiteContext, options?: SetContextOptions): void;
|
|
73
91
|
getContext(): OwnsuiteContext;
|
|
74
92
|
/** Subscribe to a specific event type. */
|
|
75
93
|
on(type: OwnsuiteEventType, subscriber: Subscriber): Unsubscriber;
|
|
@@ -78,8 +96,22 @@ export declare class Ownsuite {
|
|
|
78
96
|
* `{ event: string, data: OwnsuiteEvent }` — see `@marianmeres/pubsub`.
|
|
79
97
|
*/
|
|
80
98
|
onAny(subscriber: Subscriber): Unsubscriber;
|
|
81
|
-
/**
|
|
99
|
+
/** Map of currently-errored domains to their error, empty if none. */
|
|
100
|
+
errors(): Record<string, DomainError>;
|
|
101
|
+
/** True if any domain is currently in `error` state. */
|
|
102
|
+
hasErrors(): boolean;
|
|
103
|
+
/** Reset all domains to initializing state. Aborts in-flight ops. */
|
|
82
104
|
reset(): void;
|
|
105
|
+
/**
|
|
106
|
+
* Dispose of the suite: destroys every registered domain (aborting
|
|
107
|
+
* in-flight requests), drops the domain map, and unsubscribes every
|
|
108
|
+
* listener this suite owns on its pubsub. Safe to call multiple times.
|
|
109
|
+
*
|
|
110
|
+
* Note: if the pubsub was constructed internally (the default), all
|
|
111
|
+
* subscribers are unsubscribed. If consumers passed an external pubsub
|
|
112
|
+
* to managers directly, that shared pubsub is not cleared — they own it.
|
|
113
|
+
*/
|
|
114
|
+
destroy(): void;
|
|
83
115
|
}
|
|
84
116
|
/** Convenience factory matching the ecsuite `createECSuite` convention. */
|
|
85
117
|
export declare function createOwnsuite(config?: OwnsuiteConfig): Ownsuite;
|
package/dist/ownsuite.js
CHANGED
|
@@ -37,6 +37,7 @@ export class Ownsuite {
|
|
|
37
37
|
#context;
|
|
38
38
|
// deno-lint-ignore no-explicit-any
|
|
39
39
|
#domains = new Map();
|
|
40
|
+
#destroyed = false;
|
|
40
41
|
constructor(config = {}) {
|
|
41
42
|
this.#pubsub = createPubSub();
|
|
42
43
|
this.#context = { ...(config.context ?? {}) };
|
|
@@ -44,13 +45,22 @@ export class Ownsuite {
|
|
|
44
45
|
this.registerDomain(name, cfg);
|
|
45
46
|
}
|
|
46
47
|
if (config.autoInitialize) {
|
|
47
|
-
//
|
|
48
|
-
|
|
48
|
+
// `initialize()` is non-rejecting by contract; per-domain errors
|
|
49
|
+
// land in that domain's error state. See `hasErrors()` / `errors()`
|
|
50
|
+
// to detect them after boot.
|
|
51
|
+
void this.initialize();
|
|
49
52
|
}
|
|
50
53
|
}
|
|
54
|
+
/** True after `destroy()` has been called. */
|
|
55
|
+
get isDestroyed() {
|
|
56
|
+
return this.#destroyed;
|
|
57
|
+
}
|
|
51
58
|
/** Register a new domain after construction. */
|
|
52
59
|
// deno-lint-ignore no-explicit-any
|
|
53
60
|
registerDomain(name, cfg) {
|
|
61
|
+
if (this.#destroyed) {
|
|
62
|
+
throw new Error("Ownsuite: cannot register on a destroyed suite");
|
|
63
|
+
}
|
|
54
64
|
if (this.#domains.has(name)) {
|
|
55
65
|
throw new Error(`Ownsuite: domain "${name}" already registered`);
|
|
56
66
|
}
|
|
@@ -82,17 +92,50 @@ export class Ownsuite {
|
|
|
82
92
|
/**
|
|
83
93
|
* Initialize all registered domains (or a subset). Runs in parallel.
|
|
84
94
|
* Individual domain errors land in that domain's error state — they
|
|
85
|
-
* do not reject the overall promise.
|
|
95
|
+
* do not reject the overall promise. Use `hasErrors()` / `errors()` to
|
|
96
|
+
* inspect the result. Unknown domain names in `names` are logged and
|
|
97
|
+
* skipped.
|
|
86
98
|
*/
|
|
87
99
|
async initialize(names) {
|
|
100
|
+
if (this.#destroyed)
|
|
101
|
+
return;
|
|
88
102
|
const targets = names ?? this.domainNames();
|
|
89
|
-
await Promise.all(targets.map((n) =>
|
|
103
|
+
await Promise.all(targets.map((n) => {
|
|
104
|
+
const m = this.#domains.get(n);
|
|
105
|
+
if (!m) {
|
|
106
|
+
this.#clog.warn(`initialize: unknown domain "${n}", skipping`);
|
|
107
|
+
return Promise.resolve();
|
|
108
|
+
}
|
|
109
|
+
return m.initialize();
|
|
110
|
+
}));
|
|
90
111
|
}
|
|
91
|
-
/**
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Update shared context and propagate to every domain manager.
|
|
114
|
+
*
|
|
115
|
+
* - `options.replace: true` — replace the context wholesale (no merge).
|
|
116
|
+
* - `options.refresh: true` — fire-and-forget `refresh()` on every
|
|
117
|
+
* domain after the context change (so stale per-subject caches don't
|
|
118
|
+
* linger when, e.g., `subjectId` changes).
|
|
119
|
+
*/
|
|
120
|
+
setContext(ctx, options = {}) {
|
|
121
|
+
if (this.#destroyed)
|
|
122
|
+
return;
|
|
123
|
+
this.#context = options.replace
|
|
124
|
+
? { ...ctx }
|
|
125
|
+
: { ...this.#context, ...ctx };
|
|
126
|
+
for (const m of this.#domains.values()) {
|
|
127
|
+
if (options.replace)
|
|
128
|
+
m.replaceContext(this.#context);
|
|
129
|
+
else
|
|
130
|
+
m.setContext(this.#context);
|
|
131
|
+
}
|
|
132
|
+
if (options.refresh) {
|
|
133
|
+
for (const m of this.#domains.values()) {
|
|
134
|
+
// Fire-and-forget. refresh() is non-rejecting (lands in error state
|
|
135
|
+
// on failure), but we defensively swallow anything unexpected.
|
|
136
|
+
void m.refresh().catch((e) => this.#clog.error("setContext: refresh failed", e));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
96
139
|
}
|
|
97
140
|
getContext() {
|
|
98
141
|
return { ...this.#context };
|
|
@@ -108,11 +151,50 @@ export class Ownsuite {
|
|
|
108
151
|
onAny(subscriber) {
|
|
109
152
|
return this.#pubsub.subscribe("*", subscriber);
|
|
110
153
|
}
|
|
111
|
-
/**
|
|
154
|
+
/** Map of currently-errored domains to their error, empty if none. */
|
|
155
|
+
errors() {
|
|
156
|
+
const out = {};
|
|
157
|
+
for (const [name, m] of this.#domains) {
|
|
158
|
+
const s = m.get();
|
|
159
|
+
if (s.state === "error" && s.error)
|
|
160
|
+
out[name] = s.error;
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
/** True if any domain is currently in `error` state. */
|
|
165
|
+
hasErrors() {
|
|
166
|
+
for (const m of this.#domains.values()) {
|
|
167
|
+
if (m.get().state === "error")
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
/** Reset all domains to initializing state. Aborts in-flight ops. */
|
|
112
173
|
reset() {
|
|
113
174
|
for (const m of this.#domains.values())
|
|
114
175
|
m.reset();
|
|
115
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Dispose of the suite: destroys every registered domain (aborting
|
|
179
|
+
* in-flight requests), drops the domain map, and unsubscribes every
|
|
180
|
+
* listener this suite owns on its pubsub. Safe to call multiple times.
|
|
181
|
+
*
|
|
182
|
+
* Note: if the pubsub was constructed internally (the default), all
|
|
183
|
+
* subscribers are unsubscribed. If consumers passed an external pubsub
|
|
184
|
+
* to managers directly, that shared pubsub is not cleared — they own it.
|
|
185
|
+
*/
|
|
186
|
+
destroy() {
|
|
187
|
+
if (this.#destroyed)
|
|
188
|
+
return;
|
|
189
|
+
this.#destroyed = true;
|
|
190
|
+
for (const m of this.#domains.values())
|
|
191
|
+
m.destroy();
|
|
192
|
+
this.#domains.clear();
|
|
193
|
+
// Our internal pubsub: clear all subscribers. Best-effort — if a custom
|
|
194
|
+
// pubsub implementation doesn't expose `unsubscribeAll`, skip it.
|
|
195
|
+
const ps = this.#pubsub;
|
|
196
|
+
ps.unsubscribeAll?.();
|
|
197
|
+
}
|
|
116
198
|
}
|
|
117
199
|
/** Convenience factory matching the ecsuite `createECSuite` convention. */
|
|
118
200
|
export function createOwnsuite(config = {}) {
|
package/dist/types/adapter.d.ts
CHANGED
|
@@ -34,6 +34,10 @@ export interface OwnedRowResult<TRow> {
|
|
|
34
34
|
* client. The client can only act on rows it owns.
|
|
35
35
|
* - Errors should throw (ideally `HTTP_ERROR` from `@marianmeres/http-utils`);
|
|
36
36
|
* the manager handles rollback and error state.
|
|
37
|
+
* - `ctx.signal` is populated by the manager on every call — forward it to
|
|
38
|
+
* `fetch(url, { signal: ctx.signal })` to support route-change and
|
|
39
|
+
* destroy cancellation. Ignoring the signal is safe but leaves abandoned
|
|
40
|
+
* requests running to completion (wasted bandwidth; no state corruption).
|
|
37
41
|
*/
|
|
38
42
|
export interface OwnedCollectionAdapter<TRow, TCreate = unknown, TUpdate = unknown> {
|
|
39
43
|
/** List rows owned by the current subject. Query params are implementation-defined. */
|