@braidlabs/angular-data 0.1.1
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.
|
@@ -0,0 +1,953 @@
|
|
|
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
|
+
*/
|
|
24
|
+
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 };
|
|
32
|
+
}
|
|
33
|
+
/** Tag helpers, so invalidation reads the same way everywhere. */
|
|
34
|
+
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
|
+
},
|
|
47
|
+
};
|
|
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. */
|
|
110
|
+
function overlayKey(typeName, id) {
|
|
111
|
+
return `${typeName}#${id}`;
|
|
112
|
+
}
|
|
113
|
+
function toOutboxOverlay(overlay) {
|
|
114
|
+
return {
|
|
115
|
+
key: overlayKey(overlay.typeName, overlay.id),
|
|
116
|
+
patch: overlay.patch,
|
|
117
|
+
...(overlay.removed ? { removed: true } : {}),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Splits at the *first* `#`, because an id may contain one and a type name may not.
|
|
122
|
+
*/
|
|
123
|
+
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
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** Applies the overlays for one record, in order. `undefined` means pending removal. */
|
|
133
|
+
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;
|
|
139
|
+
}
|
|
140
|
+
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' });
|
|
440
|
+
}
|
|
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
|
+
*/
|
|
450
|
+
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;
|
|
461
|
+
}
|
|
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
|
+
*/
|
|
472
|
+
function canPersist() {
|
|
473
|
+
return typeof globalThis.indexedDB !== 'undefined';
|
|
474
|
+
}
|
|
475
|
+
function persistentDriver(input) {
|
|
476
|
+
return indexedDbRecordDriver({
|
|
477
|
+
collections: [OUTBOX_COLLECTION, ...(input.collections ?? [])],
|
|
478
|
+
...(input.database === undefined ? {} : { database: input.database }),
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
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' });
|
|
633
|
+
}
|
|
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
|
+
*/
|
|
653
|
+
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
|
+
};
|
|
701
|
+
}
|
|
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
|
+
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
|
+
}
|
|
724
|
+
}
|
|
725
|
+
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];
|
|
735
|
+
}
|
|
736
|
+
function isRecord(value) {
|
|
737
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
738
|
+
}
|
|
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
|
+
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
|
+
};
|
|
866
|
+
}
|
|
867
|
+
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
|
+
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;
|
|
897
|
+
}
|
|
898
|
+
/** Whether the response is this record. A `key` that throws on a foreign shape is a "no". */
|
|
899
|
+
function describes(type, id, result) {
|
|
900
|
+
try {
|
|
901
|
+
return type.key(result) === id;
|
|
902
|
+
}
|
|
903
|
+
catch {
|
|
904
|
+
return false;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
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
|
+
function same(a, b) {
|
|
913
|
+
return a === b || JSON.stringify(a) === JSON.stringify(b);
|
|
914
|
+
}
|
|
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
|
+
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
|
+
]);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Generated bundle index. Do not edit.
|
|
950
|
+
*/
|
|
951
|
+
|
|
952
|
+
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
|