@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,624 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { Signal, InjectionToken, EnvironmentProviders } from '@angular/core';
|
|
3
|
+
import { QueuedEntry, OptimisticOverlay, RecordDriver } from '@braidlabs/data';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Entity identity.
|
|
7
|
+
*
|
|
8
|
+
* The gap this closes: `httpResource()` and friends are *per-call* caches. Two
|
|
9
|
+
* requests that return the same record produce two independent copies, and
|
|
10
|
+
* updating one leaves the other stale. Deduplicating them requires knowing that
|
|
11
|
+
* both payloads describe the same thing — which requires a declared identity.
|
|
12
|
+
*/
|
|
13
|
+
interface EntityType<T> {
|
|
14
|
+
/** Stable name, used for store partitioning and tag generation. */
|
|
15
|
+
readonly name: string;
|
|
16
|
+
/** Extracts the primary key. Must be stable and collision-free per type. */
|
|
17
|
+
readonly key: (value: T) => string;
|
|
18
|
+
}
|
|
19
|
+
interface EntityDefinition<T> {
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly key: (value: T) => string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Declares an entity type.
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* export const Bulletin = entity<Bulletin>({ name: 'bulletin', key: (b) => b.id });
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* Declare each type once and export it — identity is only useful if every
|
|
31
|
+
* query and mutation agrees on it.
|
|
32
|
+
*/
|
|
33
|
+
declare function entity<T>(definition: EntityDefinition<T>): EntityType<T>;
|
|
34
|
+
/** Tag helpers, so invalidation reads the same way everywhere. */
|
|
35
|
+
declare const tag: {
|
|
36
|
+
/** A single record: `bulletin#42`. */
|
|
37
|
+
readonly entity: <T>(type: EntityType<T>, id: string) => string;
|
|
38
|
+
/** Every record of a type: `bulletin#*`. */
|
|
39
|
+
readonly all: <T>(type: EntityType<T>) => string;
|
|
40
|
+
/** An arbitrary named collection or view. */
|
|
41
|
+
readonly collection: (name: string) => string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A batch of writes that can be rolled back wholesale.
|
|
46
|
+
*
|
|
47
|
+
* Optimistic updates need this: the write happens before the server has agreed,
|
|
48
|
+
* so a failure must restore precisely what was there — not "roughly re-fetch".
|
|
49
|
+
*/
|
|
50
|
+
interface StoreTransaction {
|
|
51
|
+
upsert<T>(type: EntityType<T>, values: T | readonly T[]): void;
|
|
52
|
+
patch<T>(type: EntityType<T>, id: string, partial: Partial<T>): void;
|
|
53
|
+
remove<T>(type: EntityType<T>, id: string): void;
|
|
54
|
+
}
|
|
55
|
+
declare class EntityStore {
|
|
56
|
+
private readonly state;
|
|
57
|
+
private readonly pending;
|
|
58
|
+
/**
|
|
59
|
+
* Memoized per `type#id`. Without this, calling `select()` inside a template
|
|
60
|
+
* would mint a fresh computed on every change-detection pass — a subtle and
|
|
61
|
+
* expensive footgun.
|
|
62
|
+
*/
|
|
63
|
+
private readonly selectCache;
|
|
64
|
+
private readonly allCache;
|
|
65
|
+
/** A single record as this page shows it: confirmed, with any pending write applied. */
|
|
66
|
+
select<T>(type: EntityType<T>, id: string): Signal<T | undefined>;
|
|
67
|
+
/**
|
|
68
|
+
* Every record of a type, in insertion order, with pending writes applied.
|
|
69
|
+
*
|
|
70
|
+
* A pending record nothing has confirmed yet still appears, and a pending removal does not: a
|
|
71
|
+
* list that omits what the user just created, or keeps what they just deleted, is the same bug
|
|
72
|
+
* seen from either end.
|
|
73
|
+
*/
|
|
74
|
+
selectAll<T>(type: EntityType<T>): Signal<readonly T[]>;
|
|
75
|
+
/**
|
|
76
|
+
* A filtered view. Not memoized by predicate — call it once and keep the
|
|
77
|
+
* result, as you would any other `computed`.
|
|
78
|
+
*/
|
|
79
|
+
query<T>(type: EntityType<T>, predicate: (value: T) => boolean): Signal<readonly T[]>;
|
|
80
|
+
/**
|
|
81
|
+
* Reads a record without subscribing. For use inside effects and handlers.
|
|
82
|
+
*
|
|
83
|
+
* The same value `select` would give, pending writes included — a read that disagrees with the
|
|
84
|
+
* subscribed read depending on which one you happened to call is a trap, not an optimization.
|
|
85
|
+
* {@link peekConfirmed} is the one that skips the overlay.
|
|
86
|
+
*/
|
|
87
|
+
peek<T>(type: EntityType<T>, id: string): T | undefined;
|
|
88
|
+
/**
|
|
89
|
+
* The record as the server last reported it, ignoring anything queued.
|
|
90
|
+
*
|
|
91
|
+
* What a conflict is measured against: "the server disagreed with you" is a claim about the
|
|
92
|
+
* prediction, so comparing against a value that already includes the prediction would answer a
|
|
93
|
+
* different question.
|
|
94
|
+
*/
|
|
95
|
+
peekConfirmed<T>(type: EntityType<T>, id: string): T | undefined;
|
|
96
|
+
upsert<T>(type: EntityType<T>, values: T | readonly T[]): void;
|
|
97
|
+
patch<T>(type: EntityType<T>, id: string, partial: Partial<T>): void;
|
|
98
|
+
remove<T>(type: EntityType<T>, id: string): void;
|
|
99
|
+
/** Empties a type, or the whole store. Mainly for sign-out and tests. */
|
|
100
|
+
clear<T>(type?: EntityType<T>): void;
|
|
101
|
+
/**
|
|
102
|
+
* Applies writes that can be undone as a unit.
|
|
103
|
+
*
|
|
104
|
+
* ```ts
|
|
105
|
+
* const tx = store.transaction();
|
|
106
|
+
* tx.apply((t) => t.patch(Bulletin, id, { status: 'published' }));
|
|
107
|
+
* // …server says no…
|
|
108
|
+
* tx.rollback();
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
transaction(): {
|
|
112
|
+
apply(fn: (tx: StoreTransaction) => void): void;
|
|
113
|
+
rollback(): void;
|
|
114
|
+
committed: boolean;
|
|
115
|
+
};
|
|
116
|
+
/** `confirmed ⊕ pending` for one record. The single place the two halves meet. */
|
|
117
|
+
private view;
|
|
118
|
+
private toWrites;
|
|
119
|
+
/**
|
|
120
|
+
* Single write path, so every mutation produces exactly one signal
|
|
121
|
+
* notification regardless of how many records it touched.
|
|
122
|
+
*/
|
|
123
|
+
private applyWrites;
|
|
124
|
+
static ɵfac: i0.ɵɵFactoryDeclaration<EntityStore, never>;
|
|
125
|
+
static ɵprov: i0.ɵɵInjectableDeclaration<EntityStore>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The optimistic overlay: what the queue predicts, applied over the confirmed graph.
|
|
130
|
+
*
|
|
131
|
+
* view(record) = confirmed(record) ⊕ pending(record)
|
|
132
|
+
*
|
|
133
|
+
* The important word is *derived*. This used to be an apply-then-undo: a write patched the store,
|
|
134
|
+
* an undo log remembered the previous values, and a failure restored them. Two things went wrong
|
|
135
|
+
* with that, and both are the kind of bug nobody reports as one:
|
|
136
|
+
*
|
|
137
|
+
* 1. **The overlay and the queue could disagree.** A queued mutation lives in storage; its
|
|
138
|
+
* optimistic effect lived in memory. Reload the page and the work was still queued while the
|
|
139
|
+
* screen showed the old value — the user's edit apparently lost, then reappearing later when the
|
|
140
|
+
* queue drained.
|
|
141
|
+
* 2. **Only the app that wrote could see it.** The queue is shared per origin; an undo log is not.
|
|
142
|
+
*
|
|
143
|
+
* Deriving fixes both at once. Rollback becomes deleting an entry, a reload rebuilds the overlay
|
|
144
|
+
* from the queue, and another app's unsent edits show up here because they are in the same store.
|
|
145
|
+
*/
|
|
146
|
+
/** One record's worth of prediction, in the entity store's terms. */
|
|
147
|
+
interface EntityOverlay {
|
|
148
|
+
readonly typeName: string;
|
|
149
|
+
readonly id: string;
|
|
150
|
+
/** Applied over the confirmed record. Empty for a removal. */
|
|
151
|
+
readonly patch: Record<string, unknown>;
|
|
152
|
+
/** True when the write deletes the record — absence a patch cannot express. */
|
|
153
|
+
readonly removed?: boolean;
|
|
154
|
+
}
|
|
155
|
+
declare class PendingWrites {
|
|
156
|
+
private readonly groups;
|
|
157
|
+
/** Every pending overlay on the page, in the order the writes were made. */
|
|
158
|
+
readonly overlays: Signal<readonly EntityOverlay[]>;
|
|
159
|
+
/** Whether anything on screen is showing work the server has not confirmed. */
|
|
160
|
+
readonly hasPending: Signal<boolean>;
|
|
161
|
+
/**
|
|
162
|
+
* Records a write's prediction.
|
|
163
|
+
*
|
|
164
|
+
* Applied synchronously, before the entry reaches storage: a UI that waits for IndexedDB to show
|
|
165
|
+
* what the user just typed is a UI that flickers. Storage remains the truth — `syncFromQueue`
|
|
166
|
+
* replaces this the moment the queue is next read.
|
|
167
|
+
*/
|
|
168
|
+
add(id: string, overlays: readonly EntityOverlay[], options: {
|
|
169
|
+
durable: boolean;
|
|
170
|
+
}): void;
|
|
171
|
+
/** Drops a group: a confirmation, or a rollback. There is no difference at this level. */
|
|
172
|
+
settle(id: string): void;
|
|
173
|
+
/** Re-keys a group once its entry id is known, so the queue's copy replaces the same group. */
|
|
174
|
+
rename(from: string, to: string): void;
|
|
175
|
+
/**
|
|
176
|
+
* Rebuilds every durable group from the queue.
|
|
177
|
+
*
|
|
178
|
+
* This is the derivation. Called on every outbox refresh — including the one after rehydrating
|
|
179
|
+
* from storage, which is how a reload gets the overlay back without anything in memory having
|
|
180
|
+
* survived, and how one app comes to show another's unsent edits.
|
|
181
|
+
*/
|
|
182
|
+
syncFromQueue(entries: readonly QueuedEntry[]): void;
|
|
183
|
+
/** Sign-out, and tests. */
|
|
184
|
+
clear(): void;
|
|
185
|
+
static ɵfac: i0.ɵɵFactoryDeclaration<PendingWrites, never>;
|
|
186
|
+
static ɵprov: i0.ɵɵInjectableDeclaration<PendingWrites>;
|
|
187
|
+
}
|
|
188
|
+
/** `bulletin#42` — the same key `tag.entity` produces, so one write's tag and overlay agree. */
|
|
189
|
+
declare function overlayKey(typeName: string, id: string): string;
|
|
190
|
+
declare function toOutboxOverlay(overlay: EntityOverlay): OptimisticOverlay;
|
|
191
|
+
/**
|
|
192
|
+
* Splits at the *first* `#`, because an id may contain one and a type name may not.
|
|
193
|
+
*/
|
|
194
|
+
declare function fromOutboxOverlay(overlay: OptimisticOverlay): EntityOverlay;
|
|
195
|
+
/** Applies the overlays for one record, in order. `undefined` means pending removal. */
|
|
196
|
+
declare function applyOverlays<T>(confirmed: T | undefined, overlays: readonly EntityOverlay[]): T | undefined;
|
|
197
|
+
/**
|
|
198
|
+
* Runs an `optimistic(tx, input)` callback against a transaction that **records** rather than
|
|
199
|
+
* writes, turning the callback into overlay data.
|
|
200
|
+
*
|
|
201
|
+
* This is what lets the existing config surface stay exactly as it is while the mechanism
|
|
202
|
+
* underneath changes: `tx.patch(Bulletin, id, { status: 'published' })` describes a patch perfectly
|
|
203
|
+
* well, and a closure was never the part that mattered — it just happened to be how the description
|
|
204
|
+
* was expressed. Recording it makes the same description survive a reload, which the closure could
|
|
205
|
+
* never do.
|
|
206
|
+
*/
|
|
207
|
+
interface RecordedWrites {
|
|
208
|
+
readonly overlays: EntityOverlay[];
|
|
209
|
+
/**
|
|
210
|
+
* The entity types the callback named, by name.
|
|
211
|
+
*
|
|
212
|
+
* Kept beside the overlays rather than inside them because a type carries a `key` function, and a
|
|
213
|
+
* function cannot be persisted — the overlays go to storage, this map stays in the closure that is
|
|
214
|
+
* waiting on the server.
|
|
215
|
+
*/
|
|
216
|
+
readonly types: Map<string, EntityType<unknown>>;
|
|
217
|
+
}
|
|
218
|
+
declare function recordOverlays(apply: (tx: StoreTransaction) => void): RecordedWrites;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Tag-based invalidation.
|
|
222
|
+
*
|
|
223
|
+
* Tags rather than TTLs, because a time-to-live is a guess about when data went
|
|
224
|
+
* stale, whereas a mutation *knows*. `bulletin#42` and `bulletins` are claims
|
|
225
|
+
* about what a query depends on; invalidating a tag re-runs exactly the queries
|
|
226
|
+
* that said they cared.
|
|
227
|
+
*
|
|
228
|
+
* Wildcards are supported in one direction only: invalidating `bulletin#*`
|
|
229
|
+
* matches every `bulletin#…` subscriber. A subscriber may also register the
|
|
230
|
+
* wildcard itself, in which case any invalidation of that type reaches it.
|
|
231
|
+
*/
|
|
232
|
+
declare class CacheRegistry {
|
|
233
|
+
private readonly subscribers;
|
|
234
|
+
/**
|
|
235
|
+
* Registers interest in a set of tags. Returns a disposer.
|
|
236
|
+
*
|
|
237
|
+
* Tags are supplied as a getter and re-read on each invalidation, so a query
|
|
238
|
+
* whose tags depend on signals (a route param, a filter) stays correct
|
|
239
|
+
* without having to re-subscribe.
|
|
240
|
+
*/
|
|
241
|
+
subscribe(getTags: () => readonly string[], notify: () => void): () => void;
|
|
242
|
+
/**
|
|
243
|
+
* Marks tags stale, notifying every matching subscriber.
|
|
244
|
+
*
|
|
245
|
+
* Notification is synchronous but failure-isolated: one subscriber throwing
|
|
246
|
+
* must not prevent the others from refreshing.
|
|
247
|
+
*/
|
|
248
|
+
invalidate(...tags: readonly string[]): void;
|
|
249
|
+
/** Subscriber count. Diagnostics and tests only. */
|
|
250
|
+
get size(): number;
|
|
251
|
+
static ɵfac: i0.ɵɵFactoryDeclaration<CacheRegistry, never>;
|
|
252
|
+
static ɵprov: i0.ɵɵInjectableDeclaration<CacheRegistry>;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* `bulletin#*` matches `bulletin#42` in either position, so a mutation can
|
|
256
|
+
* invalidate a whole type without enumerating ids, and a list query can
|
|
257
|
+
* subscribe to a whole type without knowing which ids it will receive.
|
|
258
|
+
*/
|
|
259
|
+
declare function tagsMatch(a: string, b: string): boolean;
|
|
260
|
+
|
|
261
|
+
interface DataOptions {
|
|
262
|
+
/**
|
|
263
|
+
* Where the outbox is stored. Shared across every app on the origin.
|
|
264
|
+
*
|
|
265
|
+
* A record-oriented driver rather than a key/value store, and that is the correctness fix: the
|
|
266
|
+
* queue used to live under a single key, so appending meant read-modify-write and two apps
|
|
267
|
+
* interleaving lost each other's entries.
|
|
268
|
+
*/
|
|
269
|
+
readonly driver: RecordDriver;
|
|
270
|
+
/**
|
|
271
|
+
* Which application this is, stamped onto every entry it queues.
|
|
272
|
+
*
|
|
273
|
+
* The store is shared, so ownership is what keeps one app from acting on another's queued work.
|
|
274
|
+
* Only the owner replays an entry; everyone else leaves it alone.
|
|
275
|
+
*/
|
|
276
|
+
readonly owner: string;
|
|
277
|
+
/** Attempts before a queued entry is abandoned. Default 5. */
|
|
278
|
+
readonly maxOutboxAttempts: number;
|
|
279
|
+
/**
|
|
280
|
+
* Reports outbox trouble: an abandoned entry, a mutation this build no longer has, a failed write.
|
|
281
|
+
*
|
|
282
|
+
* Silent by default, but wiring it to telemetry is how you find out that users are losing queued
|
|
283
|
+
* work — the failure is otherwise invisible, because the user already navigated away believing it
|
|
284
|
+
* saved.
|
|
285
|
+
*/
|
|
286
|
+
readonly onOutboxError?: (message: string, detail?: unknown) => void;
|
|
287
|
+
/** Flush the outbox when the browser reports it is back online. Default true. */
|
|
288
|
+
readonly flushOnReconnect: boolean;
|
|
289
|
+
readonly buildId?: string;
|
|
290
|
+
}
|
|
291
|
+
declare const DATA_OPTIONS: InjectionToken<DataOptions>;
|
|
292
|
+
interface DataOptionsInput {
|
|
293
|
+
/**
|
|
294
|
+
* Identifies this application in the shared outbox. **Required when persisting.**
|
|
295
|
+
*
|
|
296
|
+
* Not defaulted, deliberately. A default would be the same string in every app on the page, which
|
|
297
|
+
* is precisely the collision ownership exists to prevent — and it would fail silently, in
|
|
298
|
+
* production, on someone's unsent work.
|
|
299
|
+
*/
|
|
300
|
+
readonly owner?: string;
|
|
301
|
+
/** Supply storage directly. Overrides `persistOutbox`. */
|
|
302
|
+
readonly driver?: RecordDriver;
|
|
303
|
+
readonly maxOutboxAttempts?: number;
|
|
304
|
+
readonly onOutboxError?: DataOptions['onOutboxError'];
|
|
305
|
+
readonly flushOnReconnect?: boolean;
|
|
306
|
+
/**
|
|
307
|
+
* Persist the outbox in IndexedDB, so queued work survives a reload.
|
|
308
|
+
*
|
|
309
|
+
* Off by default: queued work is then lost on reload, which is only appropriate when every
|
|
310
|
+
* mutation is fire-and-forget.
|
|
311
|
+
*/
|
|
312
|
+
readonly persistOutbox?: boolean;
|
|
313
|
+
/** Stamped onto persisted envelopes for attribution. */
|
|
314
|
+
readonly buildId?: string;
|
|
315
|
+
/** Database name when persisting. Defaults to `skew-data`. */
|
|
316
|
+
readonly database?: string;
|
|
317
|
+
/**
|
|
318
|
+
* Extra collections to create alongside the outbox.
|
|
319
|
+
*
|
|
320
|
+
* Declared up front because IndexedDB can only create object stores during a version upgrade —
|
|
321
|
+
* a collection used but not declared fails at the first read, not at configuration time.
|
|
322
|
+
*/
|
|
323
|
+
readonly collections?: readonly string[];
|
|
324
|
+
}
|
|
325
|
+
declare const OUTBOX_COLLECTION = "outbox";
|
|
326
|
+
declare function resolveDataOptions(input?: DataOptionsInput): DataOptions;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The durable mutation outbox, as an Angular service over `@braidlabs/data`.
|
|
330
|
+
*
|
|
331
|
+
* This is the piece that cannot be built with in-flight request machinery. A mutation queued while
|
|
332
|
+
* offline has to survive a page reload — and after a reload there is no pending `Promise` to retry,
|
|
333
|
+
* no `HttpRequest` to intercept, and no closure left alive. Only something persisted can be
|
|
334
|
+
* replayed.
|
|
335
|
+
*
|
|
336
|
+
* Which forces one API constraint: an outbox mutation must have a stable `id`, because the
|
|
337
|
+
* *operation* is a closure and closures do not serialise. On replay we look the operation up by id.
|
|
338
|
+
*
|
|
339
|
+
* **The store is shared and the entries are owned.** Persistence lives in `@braidlabs/data`, one
|
|
340
|
+
* record per entry, in a store every app on the origin reads. This service replays only what it
|
|
341
|
+
* owns. Previously the queue was a single blob under one key, so a second app overwrote the first's
|
|
342
|
+
* and then dropped its entries for having no registered runner — silently, unless `onOutboxError`
|
|
343
|
+
* happened to be wired.
|
|
344
|
+
*/
|
|
345
|
+
/** A queued entry as this app sees it. */
|
|
346
|
+
type OutboxEntry = QueuedEntry;
|
|
347
|
+
type OutboxRunner = (input: unknown, entry: OutboxEntry) => Promise<unknown>;
|
|
348
|
+
interface FlushResult {
|
|
349
|
+
sent: number;
|
|
350
|
+
failed: number;
|
|
351
|
+
remaining: number;
|
|
352
|
+
/**
|
|
353
|
+
* True when this call did no work because someone else was already flushing — another tab, or a
|
|
354
|
+
* re-entrant call.
|
|
355
|
+
*
|
|
356
|
+
* Distinct from `sent === 0`, which also happens when the flush *ran* and everything failed.
|
|
357
|
+
* Conflating them makes a failing server look like a busy tab, which sends whoever is debugging
|
|
358
|
+
* in exactly the wrong direction.
|
|
359
|
+
*/
|
|
360
|
+
skipped: boolean;
|
|
361
|
+
}
|
|
362
|
+
declare class OutboxService {
|
|
363
|
+
private readonly options;
|
|
364
|
+
private readonly pending;
|
|
365
|
+
private readonly runners;
|
|
366
|
+
private readonly mineSignal;
|
|
367
|
+
private readonly foreignSignal;
|
|
368
|
+
private readonly flushingSignal;
|
|
369
|
+
/** This app's queued work, oldest first — what it can actually replay. */
|
|
370
|
+
readonly entries: Signal<readonly OutboxEntry[]>;
|
|
371
|
+
/**
|
|
372
|
+
* Work queued by another app on this page. Waiting, not stuck: it replays when its owner is
|
|
373
|
+
* mounted. Surfaced so an app can render "someone has unsent changes" honestly rather than
|
|
374
|
+
* reporting only what it can see of the queue.
|
|
375
|
+
*/
|
|
376
|
+
readonly foreignEntries: Signal<readonly OutboxEntry[]>;
|
|
377
|
+
/**
|
|
378
|
+
* Unsent work across the whole page, not just this app's.
|
|
379
|
+
*
|
|
380
|
+
* "Are there unsent changes?" is a page-wide question, and each app answering only for itself is
|
|
381
|
+
* how the indicator was wrong before. Use {@link entries} when you need what *this* app owns.
|
|
382
|
+
*/
|
|
383
|
+
readonly pendingCount: Signal<number>;
|
|
384
|
+
readonly hasPendingWork: Signal<boolean>;
|
|
385
|
+
readonly isFlushing: Signal<boolean>;
|
|
386
|
+
private outbox;
|
|
387
|
+
private loaded;
|
|
388
|
+
private get queue();
|
|
389
|
+
/**
|
|
390
|
+
* Registers how a mutation id replays. Called by `mutation()` at creation, which is why outbox
|
|
391
|
+
* mutations must be constructed during app start-up rather than lazily inside a click handler —
|
|
392
|
+
* otherwise a queued entry has nothing to replay into after a reload.
|
|
393
|
+
*/
|
|
394
|
+
register(mutationId: string, runner: OutboxRunner): void;
|
|
395
|
+
/** Rehydrates from storage. Safe to call repeatedly. */
|
|
396
|
+
load(): Promise<void>;
|
|
397
|
+
enqueue(entry: {
|
|
398
|
+
mutationId: string;
|
|
399
|
+
input: unknown;
|
|
400
|
+
schemaVersion?: number;
|
|
401
|
+
/** What readers should show for the records this entry changes, until it is confirmed. */
|
|
402
|
+
optimistic?: OptimisticOverlay[];
|
|
403
|
+
}): Promise<OutboxEntry>;
|
|
404
|
+
/**
|
|
405
|
+
* Drains this app's queue in order.
|
|
406
|
+
*
|
|
407
|
+
* The rules — sequential, stop at the first failure, one drain per owner across every tab, give up
|
|
408
|
+
* loudly — live in `@braidlabs/data`'s `drainOutbox`, because this service used to carry a second
|
|
409
|
+
* copy of them and the two had already drifted apart on what to do with an entry nobody can
|
|
410
|
+
* replay. What stays here is the Angular half: signals, and re-reading the shared store into them.
|
|
411
|
+
*
|
|
412
|
+
* Only this app's entries are touched. Another app's queued work is left exactly where it is.
|
|
413
|
+
*/
|
|
414
|
+
flush(): Promise<FlushResult>;
|
|
415
|
+
/**
|
|
416
|
+
* Removes one entry, and with it the overlay derived from it.
|
|
417
|
+
*
|
|
418
|
+
* What a confirmation does, and equally what a rollback does — at this level they are the same
|
|
419
|
+
* operation, which is the point of deriving the optimistic view rather than keeping an undo log.
|
|
420
|
+
*/
|
|
421
|
+
remove(id: string): Promise<void>;
|
|
422
|
+
/**
|
|
423
|
+
* Discards this app's queued work. Sign-out, or an explicit "abandon my changes".
|
|
424
|
+
*
|
|
425
|
+
* Another app's entries survive: discarding work this app never queued and cannot replay would be
|
|
426
|
+
* deciding on someone else's behalf.
|
|
427
|
+
*/
|
|
428
|
+
clear(): Promise<void>;
|
|
429
|
+
/**
|
|
430
|
+
* Re-reads the shared store into signals. The store is the truth; signals are a view of it.
|
|
431
|
+
*
|
|
432
|
+
* The optimistic overlay is rebuilt here too, from the same read. That is what makes the overlay
|
|
433
|
+
* derived rather than remembered: a reload, another app's write, and a rolled-back entry all reach
|
|
434
|
+
* the screen through this one path, because all three are just a different queue.
|
|
435
|
+
*/
|
|
436
|
+
private refresh;
|
|
437
|
+
static ɵfac: i0.ɵɵFactoryDeclaration<OutboxService, never>;
|
|
438
|
+
static ɵprov: i0.ɵɵInjectableDeclaration<OutboxService>;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* A read that normalizes into the shared store.
|
|
443
|
+
*
|
|
444
|
+
* Angular's own `resource()` / `httpResource()` cannot do this: there is no
|
|
445
|
+
* integration point that lets a third-party store observe what they fetched.
|
|
446
|
+
* So this is a parallel primitive rather than an extension — duplication we
|
|
447
|
+
* would happily delete if the framework grew the hook.
|
|
448
|
+
*/
|
|
449
|
+
type QueryStatus = 'idle' | 'loading' | 'success' | 'error';
|
|
450
|
+
interface QueryConfig<T> {
|
|
451
|
+
/** Fetches the data. Anything promise-returning. */
|
|
452
|
+
readonly loader: () => Promise<T>;
|
|
453
|
+
/**
|
|
454
|
+
* Entity types present in the response. Every matching record is written to
|
|
455
|
+
* the store, so other views of the same entity update automatically.
|
|
456
|
+
*/
|
|
457
|
+
readonly normalize?: EntityType<any> | ReadonlyArray<EntityType<any>>;
|
|
458
|
+
/** Tags this query depends on; invalidating any of them re-runs it. */
|
|
459
|
+
readonly tags?: () => readonly string[];
|
|
460
|
+
/** Run immediately on creation. Default true. */
|
|
461
|
+
readonly immediate?: boolean;
|
|
462
|
+
}
|
|
463
|
+
interface QueryRef<T> {
|
|
464
|
+
readonly value: Signal<T | undefined>;
|
|
465
|
+
readonly status: Signal<QueryStatus>;
|
|
466
|
+
readonly error: Signal<unknown>;
|
|
467
|
+
readonly isLoading: Signal<boolean>;
|
|
468
|
+
/** Re-runs the loader, bypassing any tag bookkeeping. */
|
|
469
|
+
reload(): Promise<void>;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Creates a query. Must be called in an injection context.
|
|
473
|
+
*
|
|
474
|
+
* ```ts
|
|
475
|
+
* readonly bulletins = query({
|
|
476
|
+
* loader: () => firstValueFrom(this.http.get<Bulletin[]>('/api/bulletins')),
|
|
477
|
+
* normalize: Bulletin,
|
|
478
|
+
* tags: () => ['bulletins'],
|
|
479
|
+
* });
|
|
480
|
+
*
|
|
481
|
+
* // Read through the store, not through `bulletins.value()`:
|
|
482
|
+
* readonly rows = this.store.selectAll(Bulletin);
|
|
483
|
+
* ```
|
|
484
|
+
*/
|
|
485
|
+
declare function query<T>(config: QueryConfig<T>): QueryRef<T>;
|
|
486
|
+
/**
|
|
487
|
+
* Walks a payload and writes every recognised entity into the store.
|
|
488
|
+
*
|
|
489
|
+
* Handles the shapes real APIs return: a bare record, an array, or an envelope
|
|
490
|
+
* such as `{ items: [...] }`. Anything unrecognised is left alone rather than
|
|
491
|
+
* guessed at — a wrong write into the shared graph is worse than no write.
|
|
492
|
+
*/
|
|
493
|
+
declare function normalizeInto(store: EntityStore, types: EntityType<any> | ReadonlyArray<EntityType<any>>, payload: unknown): void;
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* A write, with the three things every application ends up reimplementing:
|
|
497
|
+
* optimistic application, rollback on failure, and durability.
|
|
498
|
+
*
|
|
499
|
+
* All three are one mechanism here: the **queue**. A write is queued before it
|
|
500
|
+
* is sent, every reader derives `confirmed ⊕ pending` from that queue, and
|
|
501
|
+
* settling it — confirmed or abandoned — is deleting the entry. There is no undo
|
|
502
|
+
* log, because there is nothing to undo: nothing was written to the confirmed
|
|
503
|
+
* graph in the first place.
|
|
504
|
+
*
|
|
505
|
+
* That is a change from the obvious design, and the reason is what the obvious
|
|
506
|
+
* design cannot do. An optimistic patch applied to the store with an undo log in
|
|
507
|
+
* memory is invisible to the other apps on the page and does not survive a
|
|
508
|
+
* reload — so a user who queues a change offline and refreshes sees the *old*
|
|
509
|
+
* value while their edit sits in storage waiting to send.
|
|
510
|
+
*/
|
|
511
|
+
type MutationStatus = 'idle' | 'pending' | 'success' | 'error';
|
|
512
|
+
/**
|
|
513
|
+
* The server accepted the write and stored something else.
|
|
514
|
+
*
|
|
515
|
+
* Not a failure — servers normalize, resolve, and apply rules the client does
|
|
516
|
+
* not know about — which is exactly why it is so easy to ship a UI that swaps
|
|
517
|
+
* the value under whoever just typed it.
|
|
518
|
+
*/
|
|
519
|
+
interface MutationConflict {
|
|
520
|
+
/** The record as the screen showed it: confirmed, with this write's prediction applied. */
|
|
521
|
+
expected: unknown;
|
|
522
|
+
/** The record the server reported. */
|
|
523
|
+
actual: unknown;
|
|
524
|
+
/** The predicted fields the server disagreed about. */
|
|
525
|
+
paths: readonly string[];
|
|
526
|
+
/** Which record this is about. */
|
|
527
|
+
entity: {
|
|
528
|
+
typeName: string;
|
|
529
|
+
id: string;
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* What to do when the server disagrees.
|
|
534
|
+
*
|
|
535
|
+
* `'raise'` by default. The stored record becomes the server's value either way — you cannot make a
|
|
536
|
+
* server hold your value without another mutation — so the only question is whether the user is
|
|
537
|
+
* told, and the silent answer is a support ticket about someone's money.
|
|
538
|
+
*/
|
|
539
|
+
type ConflictPolicy = 'raise' | 'accept' | ((conflict: MutationConflict) => unknown);
|
|
540
|
+
interface MutationConfig<TInput, TResult> {
|
|
541
|
+
/**
|
|
542
|
+
* Stable identifier. Required when `durability: 'outbox'`, because a queued
|
|
543
|
+
* entry outlives the closure and must find its operation again by name after
|
|
544
|
+
* a reload.
|
|
545
|
+
*/
|
|
546
|
+
readonly id?: string;
|
|
547
|
+
readonly operation: (input: TInput) => Promise<TResult>;
|
|
548
|
+
/**
|
|
549
|
+
* Describes the change locally before the server has agreed.
|
|
550
|
+
*
|
|
551
|
+
* Still a callback taking a transaction, and still written exactly as it was —
|
|
552
|
+
* but the transaction now **records** rather than writes. `tx.patch(Bulletin,
|
|
553
|
+
* id, { status: 'published' })` was always a description; recording it is what
|
|
554
|
+
* lets the same description survive a reload, which a closure never could.
|
|
555
|
+
*/
|
|
556
|
+
readonly optimistic?: (tx: StoreTransaction, input: TInput) => void;
|
|
557
|
+
/**
|
|
558
|
+
* Normalizes the server's response back into the store — the confirmed half.
|
|
559
|
+
*
|
|
560
|
+
* Receives the value chosen by `onConflict` when a resolver returned one, so a
|
|
561
|
+
* team that reconciles a disagreement writes the reconciled record rather than
|
|
562
|
+
* having to re-apply it afterwards.
|
|
563
|
+
*/
|
|
564
|
+
readonly onSuccess?: (store: EntityStore, result: TResult, input: TInput) => void;
|
|
565
|
+
/** Tags to mark stale once the write lands. */
|
|
566
|
+
readonly invalidates?: (input: TInput, result?: TResult) => readonly string[];
|
|
567
|
+
/**
|
|
568
|
+
* `'outbox'` persists the mutation so it survives a reload and replays when
|
|
569
|
+
* connectivity returns. `'memory'` (default) fails fast.
|
|
570
|
+
*
|
|
571
|
+
* The overlay is derived from the queue either way. The difference is what
|
|
572
|
+
* rebuilds it: a persisted entry can be read back after a reload, and an
|
|
573
|
+
* in-memory one correctly dies with the page that made it.
|
|
574
|
+
*/
|
|
575
|
+
readonly durability?: 'memory' | 'outbox';
|
|
576
|
+
/** Payload contract version, carried on queued entries. */
|
|
577
|
+
readonly schemaVersion?: number;
|
|
578
|
+
/** Defaults to `'raise'`. */
|
|
579
|
+
readonly onConflict?: ConflictPolicy;
|
|
580
|
+
}
|
|
581
|
+
interface MutationRef<TInput, TResult> {
|
|
582
|
+
mutate(input: TInput): Promise<TResult>;
|
|
583
|
+
readonly status: Signal<MutationStatus>;
|
|
584
|
+
readonly error: Signal<unknown>;
|
|
585
|
+
readonly isPending: Signal<boolean>;
|
|
586
|
+
/** True while this mutation's prediction is on screen and unconfirmed. */
|
|
587
|
+
readonly hasPendingWrite: Signal<boolean>;
|
|
588
|
+
/** The last disagreement, under the `'raise'` policy. Cleared by the next attempt. */
|
|
589
|
+
readonly conflict: Signal<MutationConflict | null>;
|
|
590
|
+
/** Dismisses a raised conflict, once the user has seen it. */
|
|
591
|
+
acknowledgeConflict(): void;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Creates a mutation. Must be called in an injection context.
|
|
595
|
+
*
|
|
596
|
+
* ```ts
|
|
597
|
+
* readonly publish = mutation({
|
|
598
|
+
* id: 'bulletin.publish',
|
|
599
|
+
* operation: (b: Bulletin) => firstValueFrom(this.http.post(`/api/bulletins/${b.id}/publish`, b)),
|
|
600
|
+
* optimistic: (tx, b) => tx.patch(Bulletin, b.id, { status: 'published' }),
|
|
601
|
+
* invalidates: (b) => [tag.entity(Bulletin, b.id), 'bulletins'],
|
|
602
|
+
* durability: 'outbox',
|
|
603
|
+
* schemaVersion: 41,
|
|
604
|
+
* });
|
|
605
|
+
* ```
|
|
606
|
+
*/
|
|
607
|
+
declare function mutation<TInput, TResult>(config: MutationConfig<TInput, TResult>): MutationRef<TInput, TResult>;
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Enables the data layer.
|
|
611
|
+
*
|
|
612
|
+
* ```ts
|
|
613
|
+
* provideSkewData({
|
|
614
|
+
* owner: 'bulletin',
|
|
615
|
+
* persistOutbox: true,
|
|
616
|
+
* buildId: BUILD_ID,
|
|
617
|
+
* onOutboxError: (message, detail) => telemetry.error(message, detail),
|
|
618
|
+
* });
|
|
619
|
+
* ```
|
|
620
|
+
*/
|
|
621
|
+
declare function provideSkewData(input?: DataOptionsInput): EnvironmentProviders;
|
|
622
|
+
|
|
623
|
+
export { CacheRegistry, DATA_OPTIONS, EntityStore, OUTBOX_COLLECTION, OutboxService, PendingWrites, applyOverlays, entity, fromOutboxOverlay, mutation, normalizeInto, overlayKey, provideSkewData, query, recordOverlays, resolveDataOptions, tag, tagsMatch, toOutboxOverlay };
|
|
624
|
+
export type { ConflictPolicy, DataOptions, DataOptionsInput, EntityDefinition, EntityOverlay, EntityType, FlushResult, MutationConfig, MutationConflict, MutationRef, MutationStatus, OutboxEntry, OutboxRunner, QueryConfig, QueryRef, QueryStatus, RecordedWrites, StoreTransaction };
|