@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
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { isPlatformBrowser } from
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
84
|
+
return `${typeName}#${id}`;
|
|
112
85
|
}
|
|
113
86
|
function toOutboxOverlay(overlay) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
|
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
|
-
|
|
396
|
+
return typeof globalThis.indexedDB !== "undefined";
|
|
474
397
|
}
|
|
475
398
|
function persistentDriver(input) {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
-
|
|
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
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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
|
-
|
|
706
|
+
|
|
707
|
+
//# sourceMappingURL=braidlabs-angular-data.mjs.map
|