@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.
@@ -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.setState("syncing");
41
- try {
42
- const res = await this.adapter.list(this.context);
43
- this.setData({ rows: res.data, meta: res.meta });
44
- this.markSynced();
45
- this.emit({
46
- type: "own:list:fetched",
47
- domain: this.domainName,
48
- timestamp: Date.now(),
49
- count: res.data.length,
50
- });
51
- }
52
- catch (e) {
53
- this.setError({
54
- code: "FETCH_FAILED",
55
- message: e instanceof Error ? e.message : "Failed to fetch list",
56
- originalError: e,
57
- operation: "initialize",
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.setState("syncing");
66
- try {
67
- const res = await this.adapter.list(this.context, query);
68
- this.setData({ rows: res.data, meta: res.meta });
69
- this.markSynced();
70
- this.emit({
71
- type: "own:list:fetched",
72
- domain: this.domainName,
73
- timestamp: Date.now(),
74
- count: res.data.length,
75
- });
76
- }
77
- catch (e) {
78
- this.setError({
79
- code: "FETCH_FAILED",
80
- message: e instanceof Error ? e.message : "Failed to refresh list",
81
- originalError: e,
82
- operation: "refresh",
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
- /** Fetch a single row by id; returns the row but does not update the list. */
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.context);
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
- this.setError({
102
- code: "FETCH_FAILED",
103
- message: e instanceof Error ? e.message : "Failed to fetch row",
104
- originalError: e,
105
- operation: "getOne",
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. Optimistically prepends to the list. */
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
- const current = this.store.get().data ?? { rows: [], meta: {} };
115
- let result = null;
116
- await this.withOptimisticUpdate("create", () => {
117
- // No optimistic row insertion: we don't know the server-assigned id.
118
- // Just keep the list unchanged; flip to syncing is handled for us.
119
- }, async () => {
120
- const res = await this.adapter.create(data, this.context);
121
- return res.data;
122
- }, (serverRow) => {
123
- result = serverRow;
124
- this.setData({
125
- rows: [serverRow, ...current.rows],
126
- meta: current.meta,
127
- });
128
- this.emit({
129
- type: "own:row:created",
130
- domain: this.domainName,
131
- timestamp: Date.now(),
132
- rowId: this.#getRowId(serverRow),
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
- /** Update a row. Optimistically merges into the list. */
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
- const current = this.store.get().data ?? { rows: [], meta: {} };
142
- const idx = current.rows.findIndex((r) => this.#getRowId(r) === id);
143
- let result = null;
144
- await this.withOptimisticUpdate("update", () => {
145
- if (idx === -1)
146
- return;
147
- const optimistic = { ...current.rows[idx], ...data };
148
- const rows = current.rows.slice();
149
- rows[idx] = optimistic;
150
- this.setData({ rows, meta: current.meta }, false);
151
- }, async () => {
152
- const res = await this.adapter.update(id, data, this.context);
153
- return res.data;
154
- }, (serverRow) => {
155
- result = serverRow;
156
- const rows = idx === -1
157
- ? [serverRow, ...current.rows]
158
- : current.rows.map((r, i) => (i === idx ? serverRow : r));
159
- this.setData({ rows, meta: current.meta });
160
- this.emit({
161
- type: "own:row:updated",
162
- domain: this.domainName,
163
- timestamp: Date.now(),
164
- rowId: id,
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
- const current = this.store.get().data ?? { rows: [], meta: {} };
174
- let ok = false;
175
- await this.withOptimisticUpdate("delete", () => {
176
- this.setData({
177
- rows: current.rows.filter((r) => this.#getRowId(r) !== id),
178
- meta: current.meta,
179
- }, false);
180
- }, async () => {
181
- return await this.adapter.delete(id, this.context);
182
- }, (serverOk) => {
183
- ok = serverOk;
184
- this.emit({
185
- type: "own:row:deleted",
186
- domain: this.domainName,
187
- timestamp: Date.now(),
188
- rowId: id,
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() {
@@ -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
- /** Update shared context and propagate to all domain managers. */
72
- setContext(ctx: OwnsuiteContext): void;
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
- /** Reset all domains to initializing state. */
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
- // fire-and-forget; consumers who care should await initialize() explicitly
48
- this.initialize().catch((e) => this.#clog.error("autoInitialize", e));
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) => this.#domains.get(n)?.initialize() ?? Promise.resolve()));
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
- /** Update shared context and propagate to all domain managers. */
92
- setContext(ctx) {
93
- this.#context = { ...this.#context, ...ctx };
94
- for (const m of this.#domains.values())
95
- m.setContext(this.#context);
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
- /** Reset all domains to initializing state. */
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 = {}) {
@@ -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. */