@braidlabs/angular-data 0.3.0 → 0.5.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.
@@ -1,953 +1,707 @@
1
- import * as i0 from '@angular/core';
2
- import { signal, computed, Injectable, inject, InjectionToken, DestroyRef, makeEnvironmentProviders, provideEnvironmentInitializer, PLATFORM_ID, DOCUMENT } from '@angular/core';
3
- import { indexedDbRecordDriver, memoryRecordDriver, createOutbox, drainOutbox } from '@braidlabs/data';
4
- import { isPlatformBrowser } from '@angular/common';
5
-
6
- /**
7
- * Entity identity.
8
- *
9
- * The gap this closes: `httpResource()` and friends are *per-call* caches. Two
10
- * requests that return the same record produce two independent copies, and
11
- * updating one leaves the other stale. Deduplicating them requires knowing that
12
- * both payloads describe the same thing — which requires a declared identity.
13
- */
14
- /**
15
- * Declares an entity type.
16
- *
17
- * ```ts
18
- * export const Bulletin = entity<Bulletin>({ name: 'bulletin', key: (b) => b.id });
19
- * ```
20
- *
21
- * Declare each type once and export it — identity is only useful if every
22
- * query and mutation agrees on it.
23
- */
1
+ import * as i0 from "@angular/core";
2
+ import { DOCUMENT, DestroyRef, Injectable, InjectionToken, PLATFORM_ID, computed, inject, makeEnvironmentProviders, provideEnvironmentInitializer, signal } from "@angular/core";
3
+ import { createOutbox, drainOutbox, indexedDbRecordDriver, memoryRecordDriver } from "@braidlabs/data";
4
+ import { isPlatformBrowser } from "@angular/common";
24
5
  function entity(definition) {
25
- if (!definition.name) {
26
- throw new TypeError('[skew/data] entity() requires a name');
27
- }
28
- if (typeof definition.key !== 'function') {
29
- throw new TypeError(`[skew/data] entity "${definition.name}" requires a key function`);
30
- }
31
- return { name: definition.name, key: definition.key };
6
+ if (!definition.name) throw new TypeError("[skew/data] entity() requires a name");
7
+ if (typeof definition.key !== "function") throw new TypeError(`[skew/data] entity "${definition.name}" requires a key function`);
8
+ return {
9
+ name: definition.name,
10
+ key: definition.key
11
+ };
32
12
  }
33
- /** Tag helpers, so invalidation reads the same way everywhere. */
34
13
  const tag = {
35
- /** A single record: `bulletin#42`. */
36
- entity(type, id) {
37
- return `${type.name}#${id}`;
38
- },
39
- /** Every record of a type: `bulletin#*`. */
40
- all(type) {
41
- return `${type.name}#*`;
42
- },
43
- /** An arbitrary named collection or view. */
44
- collection(name) {
45
- return name;
46
- },
14
+ entity(type, id) {
15
+ return `${type.name}#${id}`;
16
+ },
17
+ all(type) {
18
+ return `${type.name}#*`;
19
+ },
20
+ collection(name) {
21
+ return name;
22
+ }
47
23
  };
48
-
49
- class PendingWrites {
50
- groups = signal([], /* @ts-ignore */
51
- ...(ngDevMode ? [{ debugName: "groups" }] : /* istanbul ignore next */ []));
52
- /** Every pending overlay on the page, in the order the writes were made. */
53
- overlays = computed(() => this.groups().flatMap((group) => group.overlays), /* @ts-ignore */
54
- ...(ngDevMode ? [{ debugName: "overlays" }] : /* istanbul ignore next */ []));
55
- /** Whether anything on screen is showing work the server has not confirmed. */
56
- hasPending = computed(() => this.groups().length > 0, /* @ts-ignore */
57
- ...(ngDevMode ? [{ debugName: "hasPending" }] : /* istanbul ignore next */ []));
58
- /**
59
- * Records a write's prediction.
60
- *
61
- * Applied synchronously, before the entry reaches storage: a UI that waits for IndexedDB to show
62
- * what the user just typed is a UI that flickers. Storage remains the truth — `syncFromQueue`
63
- * replaces this the moment the queue is next read.
64
- */
65
- add(id, overlays, options) {
66
- if (overlays.length === 0)
67
- return;
68
- this.groups.update((groups) => [
69
- ...groups.filter((group) => group.id !== id),
70
- { id, durable: options.durable, overlays },
71
- ]);
72
- }
73
- /** Drops a group: a confirmation, or a rollback. There is no difference at this level. */
74
- settle(id) {
75
- this.groups.update((groups) => groups.filter((group) => group.id !== id));
76
- }
77
- /** Re-keys a group once its entry id is known, so the queue's copy replaces the same group. */
78
- rename(from, to) {
79
- this.groups.update((groups) => groups.map((group) => (group.id === from ? { ...group, id: to, durable: true } : group)));
80
- }
81
- /**
82
- * Rebuilds every durable group from the queue.
83
- *
84
- * This is the derivation. Called on every outbox refresh — including the one after rehydrating
85
- * from storage, which is how a reload gets the overlay back without anything in memory having
86
- * survived, and how one app comes to show another's unsent edits.
87
- */
88
- syncFromQueue(entries) {
89
- const fromQueue = entries
90
- .filter((entry) => (entry.optimistic ?? []).length > 0)
91
- .map((entry) => ({
92
- id: entry.id,
93
- durable: true,
94
- overlays: (entry.optimistic ?? []).map(fromOutboxOverlay),
95
- }));
96
- this.groups.update((groups) => [...groups.filter((group) => !group.durable), ...fromQueue]);
97
- }
98
- /** Sign-out, and tests. */
99
- clear() {
100
- this.groups.set([]);
101
- }
102
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: PendingWrites, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
103
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: PendingWrites, providedIn: 'root' });
104
- }
105
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: PendingWrites, decorators: [{
106
- type: Injectable,
107
- args: [{ providedIn: 'root' }]
108
- }] });
109
- /** `bulletin#42` — the same key `tag.entity` produces, so one write's tag and overlay agree. */
24
+ var PendingWrites = class PendingWrites {
25
+ groups = signal([], ...ngDevMode ? [{ debugName: "groups" }] : /* istanbul ignore next */ []);
26
+ overlays = computed(() => this.groups().flatMap((group) => group.overlays), ...ngDevMode ? [{ debugName: "overlays" }] : /* istanbul ignore next */ []);
27
+ hasPending = computed(() => this.groups().length > 0, ...ngDevMode ? [{ debugName: "hasPending" }] : /* istanbul ignore next */ []);
28
+ add(id, overlays, options) {
29
+ if (overlays.length === 0) return;
30
+ this.groups.update((groups) => [...groups.filter((group) => group.id !== id), {
31
+ id,
32
+ durable: options.durable,
33
+ overlays
34
+ }]);
35
+ }
36
+ settle(id) {
37
+ this.groups.update((groups) => groups.filter((group) => group.id !== id));
38
+ }
39
+ rename(from, to) {
40
+ this.groups.update((groups) => groups.map((group) => group.id === from ? {
41
+ ...group,
42
+ id: to,
43
+ durable: true
44
+ } : group));
45
+ }
46
+ syncFromQueue(entries) {
47
+ const fromQueue = entries.filter((entry) => (entry.optimistic ?? []).length > 0).map((entry) => ({
48
+ id: entry.id,
49
+ durable: true,
50
+ overlays: (entry.optimistic ?? []).map(fromOutboxOverlay)
51
+ }));
52
+ this.groups.update((groups) => [...groups.filter((group) => !group.durable), ...fromQueue]);
53
+ }
54
+ clear() {
55
+ this.groups.set([]);
56
+ }
57
+ static ɵfac = i0.ɵɵngDeclareFactory({
58
+ minVersion: "12.0.0",
59
+ version: "22.2.3",
60
+ ngImport: i0,
61
+ type: PendingWrites,
62
+ deps: [],
63
+ target: i0.ɵɵFactoryTarget.Injectable
64
+ });
65
+ static ɵprov = i0.ɵɵngDeclareInjectable({
66
+ minVersion: "12.0.0",
67
+ version: "22.2.3",
68
+ ngImport: i0,
69
+ type: PendingWrites,
70
+ providedIn: "root"
71
+ });
72
+ };
73
+ i0.ɵɵngDeclareClassMetadata({
74
+ minVersion: "12.0.0",
75
+ version: "22.2.3",
76
+ ngImport: i0,
77
+ type: PendingWrites,
78
+ decorators: [{
79
+ type: Injectable,
80
+ args: [{ providedIn: "root" }]
81
+ }]
82
+ });
110
83
  function overlayKey(typeName, id) {
111
- return `${typeName}#${id}`;
84
+ return `${typeName}#${id}`;
112
85
  }
113
86
  function toOutboxOverlay(overlay) {
114
- return {
115
- key: overlayKey(overlay.typeName, overlay.id),
116
- patch: overlay.patch,
117
- ...(overlay.removed ? { removed: true } : {}),
118
- };
87
+ return {
88
+ key: overlayKey(overlay.typeName, overlay.id),
89
+ patch: overlay.patch,
90
+ ...overlay.removed ? { removed: true } : {}
91
+ };
119
92
  }
120
- /**
121
- * Splits at the *first* `#`, because an id may contain one and a type name may not.
122
- */
123
93
  function fromOutboxOverlay(overlay) {
124
- const at = overlay.key.indexOf('#');
125
- return {
126
- typeName: at === -1 ? overlay.key : overlay.key.slice(0, at),
127
- id: at === -1 ? '' : overlay.key.slice(at + 1),
128
- patch: overlay.patch,
129
- ...(overlay.removed ? { removed: true } : {}),
130
- };
94
+ const at = overlay.key.indexOf("#");
95
+ return {
96
+ typeName: at === -1 ? overlay.key : overlay.key.slice(0, at),
97
+ id: at === -1 ? "" : overlay.key.slice(at + 1),
98
+ patch: overlay.patch,
99
+ ...overlay.removed ? { removed: true } : {}
100
+ };
131
101
  }
132
- /** Applies the overlays for one record, in order. `undefined` means pending removal. */
133
102
  function applyOverlays(confirmed, overlays) {
134
- let value = confirmed;
135
- for (const overlay of overlays) {
136
- value = overlay.removed ? undefined : { ...(value ?? {}), ...overlay.patch };
137
- }
138
- return value;
103
+ let value = confirmed;
104
+ for (const overlay of overlays) value = overlay.removed ? void 0 : {
105
+ ...value ?? {},
106
+ ...overlay.patch
107
+ };
108
+ return value;
139
109
  }
140
110
  function recordOverlays(apply) {
141
- const recorded = [];
142
- const types = new Map();
143
- const remember = (type) => types.set(type.name, type);
144
- const tx = {
145
- upsert(type, values) {
146
- remember(type);
147
- const list = Array.isArray(values) ? values : [values];
148
- for (const value of list) {
149
- recorded.push({
150
- typeName: type.name,
151
- id: type.key(value),
152
- patch: value,
153
- });
154
- }
155
- },
156
- patch(type, id, partial) {
157
- remember(type);
158
- recorded.push({ typeName: type.name, id, patch: partial });
159
- },
160
- remove(type, id) {
161
- remember(type);
162
- recorded.push({ typeName: type.name, id, patch: {}, removed: true });
163
- },
164
- };
165
- apply(tx);
166
- return { overlays: recorded, types };
167
- }
168
-
169
- class EntityStore {
170
- state = signal(new Map(), /* @ts-ignore */
171
- ...(ngDevMode ? [{ debugName: "state" }] : /* istanbul ignore next */ []));
172
- pending = inject(PendingWrites);
173
- /**
174
- * Memoized per `type#id`. Without this, calling `select()` inside a template
175
- * would mint a fresh computed on every change-detection pass — a subtle and
176
- * expensive footgun.
177
- */
178
- selectCache = new Map();
179
- allCache = new Map();
180
- /** A single record as this page shows it: confirmed, with any pending write applied. */
181
- select(type, id) {
182
- const cacheKey = `${type.name}#${id}`;
183
- const existing = this.selectCache.get(cacheKey);
184
- if (existing)
185
- return existing;
186
- const derived = computed(() => this.view(type, id), /* @ts-ignore */
187
- ...(ngDevMode ? [{ debugName: "derived" }] : /* istanbul ignore next */ []));
188
- this.selectCache.set(cacheKey, derived);
189
- return derived;
190
- }
191
- /**
192
- * Every record of a type, in insertion order, with pending writes applied.
193
- *
194
- * A pending record nothing has confirmed yet still appears, and a pending removal does not: a
195
- * list that omits what the user just created, or keeps what they just deleted, is the same bug
196
- * seen from either end.
197
- */
198
- selectAll(type) {
199
- const existing = this.allCache.get(type.name);
200
- if (existing)
201
- return existing;
202
- const derived = computed(() => {
203
- const table = this.state().get(type.name);
204
- const overlaid = this.pending.overlays().filter((overlay) => overlay.typeName === type.name);
205
- const ids = new Set([...(table?.keys() ?? []), ...overlaid.map((overlay) => overlay.id)]);
206
- const rows = [];
207
- for (const id of ids) {
208
- const value = this.view(type, id);
209
- if (value !== undefined)
210
- rows.push(value);
211
- }
212
- return rows;
213
- }, /* @ts-ignore */
214
- ...(ngDevMode ? [{ debugName: "derived" }] : /* istanbul ignore next */ []));
215
- this.allCache.set(type.name, derived);
216
- return derived;
217
- }
218
- /**
219
- * A filtered view. Not memoized by predicate — call it once and keep the
220
- * result, as you would any other `computed`.
221
- */
222
- query(type, predicate) {
223
- const all = this.selectAll(type);
224
- return computed(() => all().filter(predicate));
225
- }
226
- /**
227
- * Reads a record without subscribing. For use inside effects and handlers.
228
- *
229
- * The same value `select` would give, pending writes included — a read that disagrees with the
230
- * subscribed read depending on which one you happened to call is a trap, not an optimization.
231
- * {@link peekConfirmed} is the one that skips the overlay.
232
- */
233
- peek(type, id) {
234
- return this.view(type, id);
235
- }
236
- /**
237
- * The record as the server last reported it, ignoring anything queued.
238
- *
239
- * What a conflict is measured against: "the server disagreed with you" is a claim about the
240
- * prediction, so comparing against a value that already includes the prediction would answer a
241
- * different question.
242
- */
243
- peekConfirmed(type, id) {
244
- return this.state().get(type.name)?.get(id);
245
- }
246
- upsert(type, values) {
247
- this.applyWrites(this.toWrites(type, values));
248
- }
249
- patch(type, id, partial) {
250
- const current = this.peek(type, id);
251
- if (current === undefined)
252
- return;
253
- this.applyWrites([{ typeName: type.name, id, value: { ...current, ...partial } }]);
254
- }
255
- remove(type, id) {
256
- this.applyWrites([{ typeName: type.name, id, value: undefined }]);
257
- }
258
- /** Empties a type, or the whole store. Mainly for sign-out and tests. */
259
- clear(type) {
260
- if (!type) {
261
- this.state.set(new Map());
262
- return;
263
- }
264
- const next = new Map(this.state());
265
- next.delete(type.name);
266
- this.state.set(next);
267
- }
268
- /**
269
- * Applies writes that can be undone as a unit.
270
- *
271
- * ```ts
272
- * const tx = store.transaction();
273
- * tx.apply((t) => t.patch(Bulletin, id, { status: 'published' }));
274
- * // …server says no…
275
- * tx.rollback();
276
- * ```
277
- */
278
- transaction() {
279
- const undoLog = [];
280
- let rolledBack = false;
281
- const record = (typeName, id) => {
282
- // Only the *first* prior value matters; later writes in the same
283
- // transaction are intermediate states we never want to restore to.
284
- if (undoLog.some((entry) => entry.typeName === typeName && entry.id === id))
285
- return;
286
- undoLog.push({ typeName, id, previous: this.state().get(typeName)?.get(id) });
287
- };
288
- const tx = {
289
- upsert: (type, values) => {
290
- const writes = this.toWrites(type, values);
291
- for (const write of writes)
292
- record(write.typeName, write.id);
293
- this.applyWrites(writes);
294
- },
295
- patch: (type, id, partial) => {
296
- record(type.name, id);
297
- const current = this.peek(type, id);
298
- if (current === undefined)
299
- return;
300
- this.applyWrites([{ typeName: type.name, id, value: { ...current, ...partial } }]);
301
- },
302
- remove: (type, id) => {
303
- record(type.name, id);
304
- this.applyWrites([{ typeName: type.name, id, value: undefined }]);
305
- },
306
- };
307
- return {
308
- apply(fn) {
309
- fn(tx);
310
- },
311
- rollback: () => {
312
- if (rolledBack)
313
- return;
314
- rolledBack = true;
315
- this.applyWrites(
316
- // Restore in reverse so overlapping writes unwind correctly.
317
- [...undoLog].reverse().map((entry) => ({
318
- typeName: entry.typeName,
319
- id: entry.id,
320
- value: entry.previous,
321
- })));
322
- },
323
- get committed() {
324
- return !rolledBack;
325
- },
326
- };
327
- }
328
- // --- internals ---------------------------------------------------------
329
- /** `confirmed ⊕ pending` for one record. The single place the two halves meet. */
330
- view(type, id) {
331
- const confirmed = this.state().get(type.name)?.get(id);
332
- const overlays = this.pending
333
- .overlays()
334
- .filter((overlay) => overlay.typeName === type.name && overlay.id === id);
335
- if (overlays.length === 0)
336
- return confirmed;
337
- return applyOverlays(confirmed, overlays);
338
- }
339
- toWrites(type, values) {
340
- const list = Array.isArray(values) ? values : [values];
341
- return list.map((value) => ({
342
- typeName: type.name,
343
- id: type.key(value),
344
- value,
345
- }));
346
- }
347
- /**
348
- * Single write path, so every mutation produces exactly one signal
349
- * notification regardless of how many records it touched.
350
- */
351
- applyWrites(writes) {
352
- if (writes.length === 0)
353
- return;
354
- const next = new Map(this.state());
355
- const touched = new Map();
356
- for (const { typeName, id, value } of writes) {
357
- let table = touched.get(typeName);
358
- if (!table) {
359
- table = new Map(next.get(typeName) ?? []);
360
- touched.set(typeName, table);
361
- }
362
- if (value === undefined)
363
- table.delete(id);
364
- else
365
- table.set(id, value);
366
- }
367
- for (const [typeName, table] of touched)
368
- next.set(typeName, table);
369
- this.state.set(next);
370
- }
371
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: EntityStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
372
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: EntityStore, providedIn: 'root' });
373
- }
374
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: EntityStore, decorators: [{
375
- type: Injectable,
376
- args: [{ providedIn: 'root' }]
377
- }] });
378
-
379
- /**
380
- * Tag-based invalidation.
381
- *
382
- * Tags rather than TTLs, because a time-to-live is a guess about when data went
383
- * stale, whereas a mutation *knows*. `bulletin#42` and `bulletins` are claims
384
- * about what a query depends on; invalidating a tag re-runs exactly the queries
385
- * that said they cared.
386
- *
387
- * Wildcards are supported in one direction only: invalidating `bulletin#*`
388
- * matches every `bulletin#…` subscriber. A subscriber may also register the
389
- * wildcard itself, in which case any invalidation of that type reaches it.
390
- */
391
- class CacheRegistry {
392
- subscribers = new Map();
393
- /**
394
- * Registers interest in a set of tags. Returns a disposer.
395
- *
396
- * Tags are supplied as a getter and re-read on each invalidation, so a query
397
- * whose tags depend on signals (a route param, a filter) stays correct
398
- * without having to re-subscribe.
399
- */
400
- subscribe(getTags, notify) {
401
- const token = Symbol('skew-cache-subscriber');
402
- this.subscribers.set(token, { getTags, notify });
403
- return () => void this.subscribers.delete(token);
404
- }
405
- /**
406
- * Marks tags stale, notifying every matching subscriber.
407
- *
408
- * Notification is synchronous but failure-isolated: one subscriber throwing
409
- * must not prevent the others from refreshing.
410
- */
411
- invalidate(...tags) {
412
- if (tags.length === 0)
413
- return;
414
- for (const subscriber of [...this.subscribers.values()]) {
415
- let interested = false;
416
- try {
417
- interested = subscriber.getTags().some((owned) => tags.some((t) => tagsMatch(owned, t)));
418
- }
419
- catch {
420
- // A tag getter that throws (a signal read outside its context, say)
421
- // must not take down invalidation for everyone else.
422
- interested = false;
423
- }
424
- if (!interested)
425
- continue;
426
- try {
427
- subscriber.notify();
428
- }
429
- catch {
430
- // A refresh that throws is that query's problem, not this loop's.
431
- }
432
- }
433
- }
434
- /** Subscriber count. Diagnostics and tests only. */
435
- get size() {
436
- return this.subscribers.size;
437
- }
438
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: CacheRegistry, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
439
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: CacheRegistry, providedIn: 'root' });
111
+ const recorded = [];
112
+ const types = /* @__PURE__ */ new Map();
113
+ const remember = (type) => types.set(type.name, type);
114
+ apply({
115
+ upsert(type, values) {
116
+ remember(type);
117
+ const list = Array.isArray(values) ? values : [values];
118
+ for (const value of list) recorded.push({
119
+ typeName: type.name,
120
+ id: type.key(value),
121
+ patch: value
122
+ });
123
+ },
124
+ patch(type, id, partial) {
125
+ remember(type);
126
+ recorded.push({
127
+ typeName: type.name,
128
+ id,
129
+ patch: partial
130
+ });
131
+ },
132
+ remove(type, id) {
133
+ remember(type);
134
+ recorded.push({
135
+ typeName: type.name,
136
+ id,
137
+ patch: {},
138
+ removed: true
139
+ });
140
+ }
141
+ });
142
+ return {
143
+ overlays: recorded,
144
+ types
145
+ };
440
146
  }
441
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: CacheRegistry, decorators: [{
442
- type: Injectable,
443
- args: [{ providedIn: 'root' }]
444
- }] });
445
- /**
446
- * `bulletin#*` matches `bulletin#42` in either position, so a mutation can
447
- * invalidate a whole type without enumerating ids, and a list query can
448
- * subscribe to a whole type without knowing which ids it will receive.
449
- */
147
+ var EntityStore = class EntityStore {
148
+ state = signal(/* @__PURE__ */ new Map(), ...ngDevMode ? [{ debugName: "state" }] : /* istanbul ignore next */ []);
149
+ pending = inject(PendingWrites);
150
+ selectCache = /* @__PURE__ */ new Map();
151
+ allCache = /* @__PURE__ */ new Map();
152
+ select(type, id) {
153
+ const cacheKey = `${type.name}#${id}`;
154
+ const existing = this.selectCache.get(cacheKey);
155
+ if (existing) return existing;
156
+ const derived = computed(() => this.view(type, id), ...ngDevMode ? [{ debugName: "derived" }] : /* istanbul ignore next */ []);
157
+ this.selectCache.set(cacheKey, derived);
158
+ return derived;
159
+ }
160
+ selectAll(type) {
161
+ const existing = this.allCache.get(type.name);
162
+ if (existing) return existing;
163
+ const derived = computed(() => {
164
+ const table = this.state().get(type.name);
165
+ const overlaid = this.pending.overlays().filter((overlay) => overlay.typeName === type.name);
166
+ const ids = /* @__PURE__ */ new Set([...table?.keys() ?? [], ...overlaid.map((overlay) => overlay.id)]);
167
+ const rows = [];
168
+ for (const id of ids) {
169
+ const value = this.view(type, id);
170
+ if (value !== void 0) rows.push(value);
171
+ }
172
+ return rows;
173
+ }, ...ngDevMode ? [{ debugName: "derived" }] : /* istanbul ignore next */ []);
174
+ this.allCache.set(type.name, derived);
175
+ return derived;
176
+ }
177
+ query(type, predicate) {
178
+ const all = this.selectAll(type);
179
+ return computed(() => all().filter(predicate));
180
+ }
181
+ peek(type, id) {
182
+ return this.view(type, id);
183
+ }
184
+ peekConfirmed(type, id) {
185
+ return this.state().get(type.name)?.get(id);
186
+ }
187
+ upsert(type, values) {
188
+ this.applyWrites(this.toWrites(type, values));
189
+ }
190
+ patch(type, id, partial) {
191
+ const current = this.peek(type, id);
192
+ if (current === void 0) return;
193
+ this.applyWrites([{
194
+ typeName: type.name,
195
+ id,
196
+ value: {
197
+ ...current,
198
+ ...partial
199
+ }
200
+ }]);
201
+ }
202
+ remove(type, id) {
203
+ this.applyWrites([{
204
+ typeName: type.name,
205
+ id,
206
+ value: void 0
207
+ }]);
208
+ }
209
+ clear(type) {
210
+ if (!type) {
211
+ this.state.set(/* @__PURE__ */ new Map());
212
+ return;
213
+ }
214
+ const next = new Map(this.state());
215
+ next.delete(type.name);
216
+ this.state.set(next);
217
+ }
218
+ transaction() {
219
+ const undoLog = [];
220
+ let rolledBack = false;
221
+ const record = (typeName, id) => {
222
+ if (undoLog.some((entry) => entry.typeName === typeName && entry.id === id)) return;
223
+ undoLog.push({
224
+ typeName,
225
+ id,
226
+ previous: this.state().get(typeName)?.get(id)
227
+ });
228
+ };
229
+ const tx = {
230
+ upsert: (type, values) => {
231
+ const writes = this.toWrites(type, values);
232
+ for (const write of writes) record(write.typeName, write.id);
233
+ this.applyWrites(writes);
234
+ },
235
+ patch: (type, id, partial) => {
236
+ record(type.name, id);
237
+ const current = this.peek(type, id);
238
+ if (current === void 0) return;
239
+ this.applyWrites([{
240
+ typeName: type.name,
241
+ id,
242
+ value: {
243
+ ...current,
244
+ ...partial
245
+ }
246
+ }]);
247
+ },
248
+ remove: (type, id) => {
249
+ record(type.name, id);
250
+ this.applyWrites([{
251
+ typeName: type.name,
252
+ id,
253
+ value: void 0
254
+ }]);
255
+ }
256
+ };
257
+ return {
258
+ apply(fn) {
259
+ fn(tx);
260
+ },
261
+ rollback: () => {
262
+ if (rolledBack) return;
263
+ rolledBack = true;
264
+ this.applyWrites([...undoLog].reverse().map((entry) => ({
265
+ typeName: entry.typeName,
266
+ id: entry.id,
267
+ value: entry.previous
268
+ })));
269
+ },
270
+ get committed() {
271
+ return !rolledBack;
272
+ }
273
+ };
274
+ }
275
+ view(type, id) {
276
+ const confirmed = this.state().get(type.name)?.get(id);
277
+ const overlays = this.pending.overlays().filter((overlay) => overlay.typeName === type.name && overlay.id === id);
278
+ if (overlays.length === 0) return confirmed;
279
+ return applyOverlays(confirmed, overlays);
280
+ }
281
+ toWrites(type, values) {
282
+ return (Array.isArray(values) ? values : [values]).map((value) => ({
283
+ typeName: type.name,
284
+ id: type.key(value),
285
+ value
286
+ }));
287
+ }
288
+ applyWrites(writes) {
289
+ if (writes.length === 0) return;
290
+ const next = new Map(this.state());
291
+ const touched = /* @__PURE__ */ new Map();
292
+ for (const { typeName, id, value } of writes) {
293
+ let table = touched.get(typeName);
294
+ if (!table) {
295
+ table = new Map(next.get(typeName) ?? []);
296
+ touched.set(typeName, table);
297
+ }
298
+ if (value === void 0) table.delete(id);
299
+ else table.set(id, value);
300
+ }
301
+ for (const [typeName, table] of touched) next.set(typeName, table);
302
+ this.state.set(next);
303
+ }
304
+ static ɵfac = i0.ɵɵngDeclareFactory({
305
+ minVersion: "12.0.0",
306
+ version: "22.2.3",
307
+ ngImport: i0,
308
+ type: EntityStore,
309
+ deps: [],
310
+ target: i0.ɵɵFactoryTarget.Injectable
311
+ });
312
+ static ɵprov = i0.ɵɵngDeclareInjectable({
313
+ minVersion: "12.0.0",
314
+ version: "22.2.3",
315
+ ngImport: i0,
316
+ type: EntityStore,
317
+ providedIn: "root"
318
+ });
319
+ };
320
+ i0.ɵɵngDeclareClassMetadata({
321
+ minVersion: "12.0.0",
322
+ version: "22.2.3",
323
+ ngImport: i0,
324
+ type: EntityStore,
325
+ decorators: [{
326
+ type: Injectable,
327
+ args: [{ providedIn: "root" }]
328
+ }]
329
+ });
330
+ var CacheRegistry = class CacheRegistry {
331
+ subscribers = /* @__PURE__ */ new Map();
332
+ subscribe(getTags, notify) {
333
+ const token = Symbol("skew-cache-subscriber");
334
+ this.subscribers.set(token, {
335
+ getTags,
336
+ notify
337
+ });
338
+ return () => void this.subscribers.delete(token);
339
+ }
340
+ invalidate(...tags) {
341
+ if (tags.length === 0) return;
342
+ for (const subscriber of [...this.subscribers.values()]) {
343
+ let interested = false;
344
+ try {
345
+ interested = subscriber.getTags().some((owned) => tags.some((t) => tagsMatch(owned, t)));
346
+ } catch {
347
+ interested = false;
348
+ }
349
+ if (!interested) continue;
350
+ try {
351
+ subscriber.notify();
352
+ } catch {}
353
+ }
354
+ }
355
+ get size() {
356
+ return this.subscribers.size;
357
+ }
358
+ static ɵfac = i0.ɵɵngDeclareFactory({
359
+ minVersion: "12.0.0",
360
+ version: "22.2.3",
361
+ ngImport: i0,
362
+ type: CacheRegistry,
363
+ deps: [],
364
+ target: i0.ɵɵFactoryTarget.Injectable
365
+ });
366
+ static ɵprov = i0.ɵɵngDeclareInjectable({
367
+ minVersion: "12.0.0",
368
+ version: "22.2.3",
369
+ ngImport: i0,
370
+ type: CacheRegistry,
371
+ providedIn: "root"
372
+ });
373
+ };
374
+ i0.ɵɵngDeclareClassMetadata({
375
+ minVersion: "12.0.0",
376
+ version: "22.2.3",
377
+ ngImport: i0,
378
+ type: CacheRegistry,
379
+ decorators: [{
380
+ type: Injectable,
381
+ args: [{ providedIn: "root" }]
382
+ }]
383
+ });
450
384
  function tagsMatch(a, b) {
451
- if (a === b)
452
- return true;
453
- const wildcardOf = (tag) => (tag.endsWith('#*') ? tag.slice(0, -1) : null);
454
- const aPrefix = wildcardOf(a);
455
- if (aPrefix && b.startsWith(aPrefix))
456
- return true;
457
- const bPrefix = wildcardOf(b);
458
- if (bPrefix && a.startsWith(bPrefix))
459
- return true;
460
- return false;
385
+ if (a === b) return true;
386
+ const wildcardOf = (tag) => tag.endsWith("#*") ? tag.slice(0, -1) : null;
387
+ const aPrefix = wildcardOf(a);
388
+ if (aPrefix && b.startsWith(aPrefix)) return true;
389
+ const bPrefix = wildcardOf(b);
390
+ if (bPrefix && a.startsWith(bPrefix)) return true;
391
+ return false;
461
392
  }
462
-
463
- const DATA_OPTIONS = new InjectionToken('SKEW_DATA_OPTIONS');
464
- const OUTBOX_COLLECTION = 'outbox';
465
- /**
466
- * Whether this environment can persist at all.
467
- *
468
- * False during server rendering, where there is no IndexedDB — and no point in one, since the
469
- * outbox exists to survive a *client* reload. Falling back to memory keeps `persistOutbox: true` a
470
- * safe thing to write in a config shared by both bootstraps, which is where it naturally goes.
471
- */
393
+ const DATA_OPTIONS = new InjectionToken("SKEW_DATA_OPTIONS");
394
+ const OUTBOX_COLLECTION = "outbox";
472
395
  function canPersist() {
473
- return typeof globalThis.indexedDB !== 'undefined';
396
+ return typeof globalThis.indexedDB !== "undefined";
474
397
  }
475
398
  function persistentDriver(input) {
476
- return indexedDbRecordDriver({
477
- collections: [OUTBOX_COLLECTION, ...(input.collections ?? [])],
478
- ...(input.database === undefined ? {} : { database: input.database }),
479
- });
399
+ return indexedDbRecordDriver({
400
+ collections: [OUTBOX_COLLECTION, ...input.collections ?? []],
401
+ ...input.database === void 0 ? {} : { database: input.database }
402
+ });
480
403
  }
481
404
  function resolveDataOptions(input = {}) {
482
- const persisting = input.persistOutbox === true || input.driver !== undefined;
483
- if (persisting && !input.owner) {
484
- throw new Error('[skew/data] a persisted outbox needs `owner`: a string identifying this application.\n' +
485
- 'The outbox is shared across every app on the origin, and ownership is what stops one app ' +
486
- "from replaying or discarding another's queued mutations. Defaulting it would put every app " +
487
- 'under the same name, which is the collision it exists to prevent.');
488
- }
489
- return {
490
- driver: input.driver ?? (input.persistOutbox && canPersist() ? persistentDriver(input) : memoryRecordDriver()),
491
- owner: input.owner ?? 'app',
492
- maxOutboxAttempts: input.maxOutboxAttempts ?? 5,
493
- ...(input.onOutboxError === undefined ? {} : { onOutboxError: input.onOutboxError }),
494
- flushOnReconnect: input.flushOnReconnect ?? true,
495
- ...(input.buildId === undefined ? {} : { buildId: input.buildId }),
496
- };
497
- }
498
-
499
- class OutboxService {
500
- options = inject(DATA_OPTIONS);
501
- pending = inject(PendingWrites);
502
- runners = new Map();
503
- mineSignal = signal([], /* @ts-ignore */
504
- ...(ngDevMode ? [{ debugName: "mineSignal" }] : /* istanbul ignore next */ []));
505
- foreignSignal = signal([], /* @ts-ignore */
506
- ...(ngDevMode ? [{ debugName: "foreignSignal" }] : /* istanbul ignore next */ []));
507
- flushingSignal = signal(false, /* @ts-ignore */
508
- ...(ngDevMode ? [{ debugName: "flushingSignal" }] : /* istanbul ignore next */ []));
509
- /** This app's queued work, oldest first — what it can actually replay. */
510
- entries = this.mineSignal.asReadonly();
511
- /**
512
- * Work queued by another app on this page. Waiting, not stuck: it replays when its owner is
513
- * mounted. Surfaced so an app can render "someone has unsent changes" honestly rather than
514
- * reporting only what it can see of the queue.
515
- */
516
- foreignEntries = this.foreignSignal.asReadonly();
517
- /**
518
- * Unsent work across the whole page, not just this app's.
519
- *
520
- * "Are there unsent changes?" is a page-wide question, and each app answering only for itself is
521
- * how the indicator was wrong before. Use {@link entries} when you need what *this* app owns.
522
- */
523
- pendingCount = computed(() => this.mineSignal().length + this.foreignSignal().length, /* @ts-ignore */
524
- ...(ngDevMode ? [{ debugName: "pendingCount" }] : /* istanbul ignore next */ []));
525
- hasPendingWork = computed(() => this.pendingCount() > 0, /* @ts-ignore */
526
- ...(ngDevMode ? [{ debugName: "hasPendingWork" }] : /* istanbul ignore next */ []));
527
- isFlushing = this.flushingSignal.asReadonly();
528
- outbox = null;
529
- loaded = false;
530
- get queue() {
531
- this.outbox ??= createOutbox({
532
- driver: this.options.driver,
533
- owner: this.options.owner,
534
- collection: OUTBOX_COLLECTION,
535
- ...(this.options.buildId === undefined ? {} : { buildId: this.options.buildId }),
536
- });
537
- return this.outbox;
538
- }
539
- /**
540
- * Registers how a mutation id replays. Called by `mutation()` at creation, which is why outbox
541
- * mutations must be constructed during app start-up rather than lazily inside a click handler —
542
- * otherwise a queued entry has nothing to replay into after a reload.
543
- */
544
- register(mutationId, runner) {
545
- this.runners.set(mutationId, runner);
546
- }
547
- /** Rehydrates from storage. Safe to call repeatedly. */
548
- async load() {
549
- if (this.loaded)
550
- return;
551
- this.loaded = true;
552
- await this.refresh();
553
- }
554
- async enqueue(entry) {
555
- await this.load();
556
- const id = await this.queue.enqueue(entry);
557
- await this.refresh();
558
- const queued = this.mineSignal().find((candidate) => candidate.id === id);
559
- if (!queued)
560
- throw new Error(`[skew/data] the outbox did not retain entry "${id}"`);
561
- return queued;
562
- }
563
- /**
564
- * Drains this app's queue in order.
565
- *
566
- * The rules — sequential, stop at the first failure, one drain per owner across every tab, give up
567
- * loudly — live in `@braidlabs/data`'s `drainOutbox`, because this service used to carry a second
568
- * copy of them and the two had already drifted apart on what to do with an entry nobody can
569
- * replay. What stays here is the Angular half: signals, and re-reading the shared store into them.
570
- *
571
- * Only this app's entries are touched. Another app's queued work is left exactly where it is.
572
- */
573
- async flush() {
574
- await this.load();
575
- if (this.flushingSignal())
576
- return { sent: 0, failed: 0, remaining: this.pendingCount(), skipped: true };
577
- this.flushingSignal.set(true);
578
- try {
579
- const result = await drainOutbox({
580
- outbox: this.queue,
581
- owner: this.options.owner,
582
- runnerFor: (mutationId) => this.runners.get(mutationId),
583
- maxAttempts: this.options.maxOutboxAttempts,
584
- ...(this.options.onOutboxError === undefined ? {} : { onError: this.options.onOutboxError }),
585
- });
586
- // Whatever happened — this app drained, or another tab did — the shared store moved, so the
587
- // signals have to be re-read from it rather than adjusted from the counts.
588
- await this.refresh();
589
- return { ...result, remaining: this.pendingCount() };
590
- }
591
- finally {
592
- this.flushingSignal.set(false);
593
- }
594
- }
595
- /**
596
- * Removes one entry, and with it the overlay derived from it.
597
- *
598
- * What a confirmation does, and equally what a rollback does — at this level they are the same
599
- * operation, which is the point of deriving the optimistic view rather than keeping an undo log.
600
- */
601
- async remove(id) {
602
- await this.load();
603
- await this.queue.remove(id);
604
- await this.refresh();
605
- }
606
- /**
607
- * Discards this app's queued work. Sign-out, or an explicit "abandon my changes".
608
- *
609
- * Another app's entries survive: discarding work this app never queued and cannot replay would be
610
- * deciding on someone else's behalf.
611
- */
612
- async clear() {
613
- await this.load();
614
- for (const entry of await this.queue.mine())
615
- await this.queue.remove(entry.id);
616
- await this.refresh();
617
- }
618
- /**
619
- * Re-reads the shared store into signals. The store is the truth; signals are a view of it.
620
- *
621
- * The optimistic overlay is rebuilt here too, from the same read. That is what makes the overlay
622
- * derived rather than remembered: a reload, another app's write, and a rolled-back entry all reach
623
- * the screen through this one path, because all three are just a different queue.
624
- */
625
- async refresh() {
626
- const [mine, foreign] = await Promise.all([this.queue.mine(), this.queue.foreign()]);
627
- this.mineSignal.set(mine);
628
- this.foreignSignal.set(foreign);
629
- this.pending.syncFromQueue([...mine, ...foreign]);
630
- }
631
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: OutboxService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
632
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: OutboxService, providedIn: 'root' });
405
+ if ((input.persistOutbox === true || input.driver !== void 0) && !input.owner) throw new Error("[skew/data] a persisted outbox needs `owner`: a string identifying this application.\nThe outbox is shared across every app on the origin, and ownership is what stops one app from replaying or discarding another's queued mutations. Defaulting it would put every app under the same name, which is the collision it exists to prevent.");
406
+ return {
407
+ driver: input.driver ?? (input.persistOutbox && canPersist() ? persistentDriver(input) : memoryRecordDriver()),
408
+ owner: input.owner ?? "app",
409
+ maxOutboxAttempts: input.maxOutboxAttempts ?? 5,
410
+ ...input.onOutboxError === void 0 ? {} : { onOutboxError: input.onOutboxError },
411
+ flushOnReconnect: input.flushOnReconnect ?? true,
412
+ ...input.buildId === void 0 ? {} : { buildId: input.buildId }
413
+ };
633
414
  }
634
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: OutboxService, decorators: [{
635
- type: Injectable,
636
- args: [{ providedIn: 'root' }]
637
- }] });
638
-
639
- /**
640
- * Creates a query. Must be called in an injection context.
641
- *
642
- * ```ts
643
- * readonly bulletins = query({
644
- * loader: () => firstValueFrom(this.http.get<Bulletin[]>('/api/bulletins')),
645
- * normalize: Bulletin,
646
- * tags: () => ['bulletins'],
647
- * });
648
- *
649
- * // Read through the store, not through `bulletins.value()`:
650
- * readonly rows = this.store.selectAll(Bulletin);
651
- * ```
652
- */
415
+ var OutboxService = class OutboxService {
416
+ options = inject(DATA_OPTIONS);
417
+ pending = inject(PendingWrites);
418
+ runners = /* @__PURE__ */ new Map();
419
+ mineSignal = signal([], ...ngDevMode ? [{ debugName: "mineSignal" }] : /* istanbul ignore next */ []);
420
+ foreignSignal = signal([], ...ngDevMode ? [{ debugName: "foreignSignal" }] : /* istanbul ignore next */ []);
421
+ flushingSignal = signal(false, ...ngDevMode ? [{ debugName: "flushingSignal" }] : /* istanbul ignore next */ []);
422
+ entries = this.mineSignal.asReadonly();
423
+ foreignEntries = this.foreignSignal.asReadonly();
424
+ pendingCount = computed(() => this.mineSignal().length + this.foreignSignal().length, ...ngDevMode ? [{ debugName: "pendingCount" }] : /* istanbul ignore next */ []);
425
+ hasPendingWork = computed(() => this.pendingCount() > 0, ...ngDevMode ? [{ debugName: "hasPendingWork" }] : /* istanbul ignore next */ []);
426
+ isFlushing = this.flushingSignal.asReadonly();
427
+ outbox = null;
428
+ loaded = false;
429
+ get queue() {
430
+ this.outbox ??= createOutbox({
431
+ driver: this.options.driver,
432
+ owner: this.options.owner,
433
+ collection: OUTBOX_COLLECTION,
434
+ ...this.options.buildId === void 0 ? {} : { buildId: this.options.buildId }
435
+ });
436
+ return this.outbox;
437
+ }
438
+ register(mutationId, runner) {
439
+ this.runners.set(mutationId, runner);
440
+ }
441
+ async load() {
442
+ if (this.loaded) return;
443
+ this.loaded = true;
444
+ await this.refresh();
445
+ }
446
+ async enqueue(entry) {
447
+ await this.load();
448
+ const id = await this.queue.enqueue(entry);
449
+ await this.refresh();
450
+ const queued = this.mineSignal().find((candidate) => candidate.id === id);
451
+ if (!queued) throw new Error(`[skew/data] the outbox did not retain entry "${id}"`);
452
+ return queued;
453
+ }
454
+ async flush() {
455
+ await this.load();
456
+ if (this.flushingSignal()) return {
457
+ sent: 0,
458
+ failed: 0,
459
+ remaining: this.pendingCount(),
460
+ skipped: true
461
+ };
462
+ this.flushingSignal.set(true);
463
+ try {
464
+ const result = await drainOutbox({
465
+ outbox: this.queue,
466
+ owner: this.options.owner,
467
+ runnerFor: (mutationId) => this.runners.get(mutationId),
468
+ maxAttempts: this.options.maxOutboxAttempts,
469
+ ...this.options.onOutboxError === void 0 ? {} : { onError: this.options.onOutboxError }
470
+ });
471
+ await this.refresh();
472
+ return {
473
+ ...result,
474
+ remaining: this.pendingCount()
475
+ };
476
+ } finally {
477
+ this.flushingSignal.set(false);
478
+ }
479
+ }
480
+ async remove(id) {
481
+ await this.load();
482
+ await this.queue.remove(id);
483
+ await this.refresh();
484
+ }
485
+ async clear() {
486
+ await this.load();
487
+ for (const entry of await this.queue.mine()) await this.queue.remove(entry.id);
488
+ await this.refresh();
489
+ }
490
+ async refresh() {
491
+ const [mine, foreign] = await Promise.all([this.queue.mine(), this.queue.foreign()]);
492
+ this.mineSignal.set(mine);
493
+ this.foreignSignal.set(foreign);
494
+ this.pending.syncFromQueue([...mine, ...foreign]);
495
+ }
496
+ static ɵfac = i0.ɵɵngDeclareFactory({
497
+ minVersion: "12.0.0",
498
+ version: "22.2.3",
499
+ ngImport: i0,
500
+ type: OutboxService,
501
+ deps: [],
502
+ target: i0.ɵɵFactoryTarget.Injectable
503
+ });
504
+ static ɵprov = i0.ɵɵngDeclareInjectable({
505
+ minVersion: "12.0.0",
506
+ version: "22.2.3",
507
+ ngImport: i0,
508
+ type: OutboxService,
509
+ providedIn: "root"
510
+ });
511
+ };
512
+ i0.ɵɵngDeclareClassMetadata({
513
+ minVersion: "12.0.0",
514
+ version: "22.2.3",
515
+ ngImport: i0,
516
+ type: OutboxService,
517
+ decorators: [{
518
+ type: Injectable,
519
+ args: [{ providedIn: "root" }]
520
+ }]
521
+ });
653
522
  function query(config) {
654
- const store = inject(EntityStore);
655
- const registry = inject(CacheRegistry);
656
- const destroyRef = inject(DestroyRef);
657
- const value = signal(undefined, /* @ts-ignore */
658
- ...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
659
- const status = signal('idle', /* @ts-ignore */
660
- ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
661
- const error = signal(undefined, /* @ts-ignore */
662
- ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
663
- /**
664
- * Guards against a slow earlier request resolving after a newer one and
665
- * overwriting fresher data — the classic out-of-order response bug.
666
- */
667
- let generation = 0;
668
- async function run() {
669
- const current = ++generation;
670
- status.set('loading');
671
- error.set(undefined);
672
- try {
673
- const result = await config.loader();
674
- if (current !== generation)
675
- return;
676
- if (config.normalize)
677
- normalizeInto(store, config.normalize, result);
678
- value.set(result);
679
- status.set('success');
680
- }
681
- catch (caught) {
682
- if (current !== generation)
683
- return;
684
- error.set(caught);
685
- status.set('error');
686
- }
687
- }
688
- if (config.tags) {
689
- const dispose = registry.subscribe(config.tags, () => void run());
690
- destroyRef.onDestroy(dispose);
691
- }
692
- if (config.immediate ?? true)
693
- void run();
694
- return {
695
- value: value.asReadonly(),
696
- status: status.asReadonly(),
697
- error: error.asReadonly(),
698
- isLoading: computed(() => status() === 'loading'),
699
- reload: run,
700
- };
523
+ const store = inject(EntityStore);
524
+ const registry = inject(CacheRegistry);
525
+ const destroyRef = inject(DestroyRef);
526
+ const value = signal(void 0, ...ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []);
527
+ const status = signal("idle", ...ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []);
528
+ const error = signal(void 0, ...ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []);
529
+ let generation = 0;
530
+ async function run() {
531
+ const current = ++generation;
532
+ status.set("loading");
533
+ error.set(void 0);
534
+ try {
535
+ const result = await config.loader();
536
+ if (current !== generation) return;
537
+ if (config.normalize) normalizeInto(store, config.normalize, result);
538
+ value.set(result);
539
+ status.set("success");
540
+ } catch (caught) {
541
+ if (current !== generation) return;
542
+ error.set(caught);
543
+ status.set("error");
544
+ }
545
+ }
546
+ if (config.tags) {
547
+ const dispose = registry.subscribe(config.tags, () => void run());
548
+ destroyRef.onDestroy(dispose);
549
+ }
550
+ if (config.immediate ?? true) run();
551
+ return {
552
+ value: value.asReadonly(),
553
+ status: status.asReadonly(),
554
+ error: error.asReadonly(),
555
+ isLoading: computed(() => status() === "loading"),
556
+ reload: run
557
+ };
701
558
  }
702
- /**
703
- * Walks a payload and writes every recognised entity into the store.
704
- *
705
- * Handles the shapes real APIs return: a bare record, an array, or an envelope
706
- * such as `{ items: [...] }`. Anything unrecognised is left alone rather than
707
- * guessed at — a wrong write into the shared graph is worse than no write.
708
- */
709
559
  function normalizeInto(store, types, payload) {
710
- const list = Array.isArray(types) ? types : [types];
711
- for (const type of list) {
712
- for (const candidate of collectCandidates(payload)) {
713
- let id;
714
- try {
715
- id = type.key(candidate);
716
- }
717
- catch {
718
- continue; // not this type
719
- }
720
- if (typeof id === 'string' && id.length > 0)
721
- store.upsert(type, candidate);
722
- }
723
- }
560
+ const list = Array.isArray(types) ? types : [types];
561
+ for (const type of list) for (const candidate of collectCandidates(payload)) {
562
+ let id;
563
+ try {
564
+ id = type.key(candidate);
565
+ } catch {
566
+ continue;
567
+ }
568
+ if (typeof id === "string" && id.length > 0) store.upsert(type, candidate);
569
+ }
724
570
  }
725
571
  function collectCandidates(payload) {
726
- if (payload === null || payload === undefined)
727
- return [];
728
- if (Array.isArray(payload))
729
- return payload.filter(isRecord);
730
- if (!isRecord(payload))
731
- return [];
732
- // A single record, plus one level of common envelope shapes.
733
- const nested = Object.values(payload).filter(Array.isArray).flat().filter(isRecord);
734
- return [payload, ...nested];
572
+ if (payload === null || payload === void 0) return [];
573
+ if (Array.isArray(payload)) return payload.filter(isRecord);
574
+ if (!isRecord(payload)) return [];
575
+ return [payload, ...Object.values(payload).filter(Array.isArray).flat().filter(isRecord)];
735
576
  }
736
577
  function isRecord(value) {
737
- return typeof value === 'object' && value !== null && !Array.isArray(value);
578
+ return typeof value === "object" && value !== null && !Array.isArray(value);
738
579
  }
739
-
740
- /**
741
- * Creates a mutation. Must be called in an injection context.
742
- *
743
- * ```ts
744
- * readonly publish = mutation({
745
- * id: 'bulletin.publish',
746
- * operation: (b: Bulletin) => firstValueFrom(this.http.post(`/api/bulletins/${b.id}/publish`, b)),
747
- * optimistic: (tx, b) => tx.patch(Bulletin, b.id, { status: 'published' }),
748
- * invalidates: (b) => [tag.entity(Bulletin, b.id), 'bulletins'],
749
- * durability: 'outbox',
750
- * schemaVersion: 41,
751
- * });
752
- * ```
753
- */
754
580
  function mutation(config) {
755
- const store = inject(EntityStore);
756
- const registry = inject(CacheRegistry);
757
- const outbox = inject(OutboxService);
758
- const pending = inject(PendingWrites);
759
- const durability = config.durability ?? 'memory';
760
- if (durability === 'outbox' && !config.id) {
761
- throw new TypeError('[skew/data] a mutation with durability: "outbox" requires a stable `id`. ' +
762
- 'Queued entries outlive the closure and are matched back to their operation by id.');
763
- }
764
- const status = signal('idle', /* @ts-ignore */
765
- ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
766
- const error = signal(undefined, /* @ts-ignore */
767
- ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
768
- const conflict = signal(null, /* @ts-ignore */
769
- ...(ngDevMode ? [{ debugName: "conflict" }] : /* istanbul ignore next */ []));
770
- const applied = signal(0, /* @ts-ignore */
771
- ...(ngDevMode ? [{ debugName: "applied" }] : /* istanbul ignore next */ []));
772
- // Replay path. Registered eagerly so a queue rehydrated at start-up has
773
- // somewhere to go — this is why outbox mutations must be created during
774
- // bootstrap rather than lazily inside a handler.
775
- if (durability === 'outbox' && config.id) {
776
- outbox.register(config.id, async (input) => config.operation(input));
777
- }
778
- async function mutate(input) {
779
- status.set('pending');
780
- error.set(undefined);
781
- conflict.set(null);
782
- const recorded = config.optimistic
783
- ? recordOverlays((tx) => config.optimistic?.(tx, input))
784
- : { overlays: [], types: new Map() };
785
- // Applied synchronously and only here: the confirmed graph is never touched by a prediction.
786
- let groupId = `local:${crypto.randomUUID()}`;
787
- pending.add(groupId, recorded.overlays, { durable: durability === 'outbox' });
788
- if (recorded.overlays.length > 0)
789
- applied.update((n) => n + 1);
790
- // What the screen showed, captured before the server answers, so a conflict is measured against
791
- // the prediction rather than against whatever the record has become since.
792
- const expected = new Map();
793
- for (const overlay of recorded.overlays) {
794
- const type = recorded.types.get(overlay.typeName);
795
- if (type)
796
- expected.set(overlayId(overlay), store.peek(type, overlay.id));
797
- }
798
- // Queued *before* sending, not on failure. The entry is what readers overlay, so a write only
799
- // recorded once it fails is a write nobody can see while it is in flight — and one that a crash
800
- // mid-request loses entirely.
801
- let entryId = null;
802
- if (durability === 'outbox' && config.id) {
803
- const entry = await outbox.enqueue({
804
- mutationId: config.id,
805
- input,
806
- schemaVersion: config.schemaVersion ?? 1,
807
- optimistic: recorded.overlays.map(toOutboxOverlay),
808
- });
809
- entryId = entry.id;
810
- // Re-keyed so the queue's own copy of this group replaces it rather than doubling it.
811
- pending.rename(groupId, entry.id);
812
- groupId = entry.id;
813
- }
814
- try {
815
- const result = await config.operation(input);
816
- const found = detectConflict(recorded, expected, result, store);
817
- let confirmed = result;
818
- if (found) {
819
- const policy = config.onConflict ?? 'raise';
820
- if (typeof policy === 'function')
821
- confirmed = policy(found);
822
- else if (policy === 'raise')
823
- conflict.set(found);
824
- }
825
- config.onSuccess?.(store, confirmed, input);
826
- // The overlay lifts only after the confirmed value is in the store, so the value on screen
827
- // never drops back to the old one for a frame between the two.
828
- await settle(entryId, groupId);
829
- const tags = config.invalidates?.(input, result);
830
- if (tags?.length)
831
- registry.invalidate(...tags);
832
- status.set('success');
833
- return result;
834
- }
835
- catch (caught) {
836
- if (entryId) {
837
- // Keep the prediction and keep the entry: from the user's point of view the change happened,
838
- // and the queue is what will make that true when the network returns.
839
- status.set('success');
840
- return undefined;
841
- }
842
- // Rollback is deletion. Nothing was written to the confirmed graph, so there is nothing to
843
- // restore and no undo log that could have gone stale while the request was in flight.
844
- await settle(null, groupId);
845
- error.set(caught);
846
- status.set('error');
847
- throw caught;
848
- }
849
- }
850
- /** Drops the group, and the entry behind it when there is one. */
851
- async function settle(entryId, groupId) {
852
- if (entryId)
853
- await outbox.remove(entryId);
854
- pending.settle(groupId);
855
- applied.update((n) => (n > 0 ? n - 1 : 0));
856
- }
857
- return {
858
- mutate,
859
- status: status.asReadonly(),
860
- error: error.asReadonly(),
861
- isPending: computed(() => status() === 'pending'),
862
- hasPendingWrite: computed(() => applied() > 0),
863
- conflict: conflict.asReadonly(),
864
- acknowledgeConflict: () => conflict.set(null),
865
- };
581
+ const store = inject(EntityStore);
582
+ const registry = inject(CacheRegistry);
583
+ const outbox = inject(OutboxService);
584
+ const pending = inject(PendingWrites);
585
+ const durability = config.durability ?? "memory";
586
+ if (durability === "outbox" && !config.id) throw new TypeError("[skew/data] a mutation with durability: \"outbox\" requires a stable `id`. Queued entries outlive the closure and are matched back to their operation by id.");
587
+ const status = signal("idle", ...ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []);
588
+ const error = signal(void 0, ...ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []);
589
+ const conflict = signal(null, ...ngDevMode ? [{ debugName: "conflict" }] : /* istanbul ignore next */ []);
590
+ const applied = signal(0, ...ngDevMode ? [{ debugName: "applied" }] : /* istanbul ignore next */ []);
591
+ if (durability === "outbox" && config.id) outbox.register(config.id, async (input) => config.operation(input));
592
+ async function mutate(input) {
593
+ status.set("pending");
594
+ error.set(void 0);
595
+ conflict.set(null);
596
+ const recorded = config.optimistic ? recordOverlays((tx) => config.optimistic?.(tx, input)) : {
597
+ overlays: [],
598
+ types: /* @__PURE__ */ new Map()
599
+ };
600
+ let groupId = `local:${crypto.randomUUID()}`;
601
+ pending.add(groupId, recorded.overlays, { durable: durability === "outbox" });
602
+ if (recorded.overlays.length > 0) applied.update((n) => n + 1);
603
+ const expected = /* @__PURE__ */ new Map();
604
+ for (const overlay of recorded.overlays) {
605
+ const type = recorded.types.get(overlay.typeName);
606
+ if (type) expected.set(overlayId(overlay), store.peek(type, overlay.id));
607
+ }
608
+ let entryId = null;
609
+ if (durability === "outbox" && config.id) {
610
+ const entry = await outbox.enqueue({
611
+ mutationId: config.id,
612
+ input,
613
+ schemaVersion: config.schemaVersion ?? 1,
614
+ optimistic: recorded.overlays.map(toOutboxOverlay)
615
+ });
616
+ entryId = entry.id;
617
+ pending.rename(groupId, entry.id);
618
+ groupId = entry.id;
619
+ }
620
+ try {
621
+ const result = await config.operation(input);
622
+ const found = detectConflict(recorded, expected, result, store);
623
+ let confirmed = result;
624
+ if (found) {
625
+ const policy = config.onConflict ?? "raise";
626
+ if (typeof policy === "function") confirmed = policy(found);
627
+ else if (policy === "raise") conflict.set(found);
628
+ }
629
+ config.onSuccess?.(store, confirmed, input);
630
+ await settle(entryId, groupId);
631
+ const tags = config.invalidates?.(input, result);
632
+ if (tags?.length) registry.invalidate(...tags);
633
+ status.set("success");
634
+ return result;
635
+ } catch (caught) {
636
+ if (entryId) {
637
+ status.set("success");
638
+ return;
639
+ }
640
+ await settle(null, groupId);
641
+ error.set(caught);
642
+ status.set("error");
643
+ throw caught;
644
+ }
645
+ }
646
+ async function settle(entryId, groupId) {
647
+ if (entryId) await outbox.remove(entryId);
648
+ pending.settle(groupId);
649
+ applied.update((n) => n > 0 ? n - 1 : 0);
650
+ }
651
+ return {
652
+ mutate,
653
+ status: status.asReadonly(),
654
+ error: error.asReadonly(),
655
+ isPending: computed(() => status() === "pending"),
656
+ hasPendingWrite: computed(() => applied() > 0),
657
+ conflict: conflict.asReadonly(),
658
+ acknowledgeConflict: () => conflict.set(null)
659
+ };
866
660
  }
867
661
  const overlayId = (overlay) => `${overlay.typeName}#${overlay.id}`;
868
- /**
869
- * Finds the disagreement, if the server's answer is one that can disagree.
870
- *
871
- * A conflict is only decidable when the response *is* the record: an operation resolving with
872
- * `void`, an id, or a receipt has not contradicted anything, and reporting one would be inventing a
873
- * disagreement out of a shape mismatch.
874
- */
875
662
  function detectConflict(recorded, expected, result, store) {
876
- if (result === null || typeof result !== 'object')
877
- return null;
878
- for (const overlay of recorded.overlays) {
879
- if (overlay.removed)
880
- continue;
881
- const type = recorded.types.get(overlay.typeName);
882
- if (!type || !describes(type, overlay.id, result))
883
- continue;
884
- const predicted = (expected.get(overlayId(overlay)) ?? store.peekConfirmed(type, overlay.id));
885
- const actual = result;
886
- const paths = Object.keys(overlay.patch).filter((path) => !same(predicted?.[path], actual[path]));
887
- if (paths.length > 0) {
888
- return {
889
- expected: predicted,
890
- actual: result,
891
- paths,
892
- entity: { typeName: overlay.typeName, id: overlay.id },
893
- };
894
- }
895
- }
896
- return null;
663
+ if (result === null || typeof result !== "object") return null;
664
+ for (const overlay of recorded.overlays) {
665
+ if (overlay.removed) continue;
666
+ const type = recorded.types.get(overlay.typeName);
667
+ if (!type || !describes(type, overlay.id, result)) continue;
668
+ const predicted = expected.get(overlayId(overlay)) ?? store.peekConfirmed(type, overlay.id);
669
+ const actual = result;
670
+ const paths = Object.keys(overlay.patch).filter((path) => !same(predicted?.[path], actual[path]));
671
+ if (paths.length > 0) return {
672
+ expected: predicted,
673
+ actual: result,
674
+ paths,
675
+ entity: {
676
+ typeName: overlay.typeName,
677
+ id: overlay.id
678
+ }
679
+ };
680
+ }
681
+ return null;
897
682
  }
898
- /** Whether the response is this record. A `key` that throws on a foreign shape is a "no". */
899
683
  function describes(type, id, result) {
900
- try {
901
- return type.key(result) === id;
902
- }
903
- catch {
904
- return false;
905
- }
684
+ try {
685
+ return type.key(result) === id;
686
+ } catch {
687
+ return false;
688
+ }
906
689
  }
907
- /**
908
- * Structural equality, by serialization — adequate because both sides are values that came off the
909
- * wire moments ago, and a deep-equality helper would be code defending against inputs this path
910
- * cannot receive.
911
- */
912
690
  function same(a, b) {
913
- return a === b || JSON.stringify(a) === JSON.stringify(b);
691
+ return a === b || JSON.stringify(a) === JSON.stringify(b);
914
692
  }
915
-
916
- /**
917
- * Enables the data layer.
918
- *
919
- * ```ts
920
- * provideSkewData({
921
- * owner: 'bulletin',
922
- * persistOutbox: true,
923
- * buildId: BUILD_ID,
924
- * onOutboxError: (message, detail) => telemetry.error(message, detail),
925
- * });
926
- * ```
927
- */
928
693
  function provideSkewData(input = {}) {
929
- const options = resolveDataOptions(input);
930
- return makeEnvironmentProviders([
931
- { provide: DATA_OPTIONS, useValue: options },
932
- provideEnvironmentInitializer(() => {
933
- const outbox = inject(OutboxService);
934
- const isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
935
- if (!isBrowser)
936
- return;
937
- // Rehydrate immediately: work queued before the last reload should reach
938
- // the server as soon as this build is running, not on the next mutation.
939
- void outbox.load().then(() => outbox.flush());
940
- if (options.flushOnReconnect) {
941
- const view = inject(DOCUMENT).defaultView;
942
- view?.addEventListener('online', () => void outbox.flush());
943
- }
944
- }),
945
- ]);
694
+ const options = resolveDataOptions(input);
695
+ return makeEnvironmentProviders([{
696
+ provide: DATA_OPTIONS,
697
+ useValue: options
698
+ }, provideEnvironmentInitializer(() => {
699
+ const outbox = inject(OutboxService);
700
+ if (!isPlatformBrowser(inject(PLATFORM_ID))) return;
701
+ outbox.load().then(() => outbox.flush());
702
+ if (options.flushOnReconnect) inject(DOCUMENT).defaultView?.addEventListener("online", () => void outbox.flush());
703
+ })]);
946
704
  }
947
-
948
- /**
949
- * Generated bundle index. Do not edit.
950
- */
951
-
952
705
  export { CacheRegistry, DATA_OPTIONS, EntityStore, OUTBOX_COLLECTION, OutboxService, PendingWrites, applyOverlays, entity, fromOutboxOverlay, mutation, normalizeInto, overlayKey, provideSkewData, query, recordOverlays, resolveDataOptions, tag, tagsMatch, toOutboxOverlay };
953
- //# sourceMappingURL=braidlabs-angular-data.mjs.map
706
+
707
+ //# sourceMappingURL=braidlabs-angular-data.mjs.map