@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.
package/README.md ADDED
@@ -0,0 +1,307 @@
1
+ # @braidlabs/angular-data
2
+
3
+ Normalized entity store, tag-based invalidation, and a **durable mutation outbox** for Angular.
4
+
5
+ Signals throughout. Zoneless-safe. No NgModules.
6
+
7
+ ---
8
+
9
+ ## Why this exists
10
+
11
+ Angular's `resource()` / `httpResource()` are good _reads_, but they are per-call caches:
12
+
13
+ ```ts
14
+ const list = httpResource(() => '/api/bulletins'); // Bulletin[]
15
+ const one = httpResource(() => `/api/bulletins/${id()}`); // Bulletin
16
+ ```
17
+
18
+ These share no identity. Updating `one` leaves the matching row inside `list` stale, and there is no integration point that lets a third-party store observe what either fetched. There is also **no write primitive at all** — no optimistic update, no rollback, and nothing that survives a reload.
19
+
20
+ This package supplies the missing half.
21
+
22
+ ---
23
+
24
+ ## Setup
25
+
26
+ ```ts
27
+ provideSkewData({
28
+ owner: 'bulletin', // this app's name in the shared outbox — required when persisting
29
+ persistOutbox: true, // queued writes survive a reload
30
+ buildId: BUILD_ID, // from @braidlabs/build
31
+ onOutboxError: (message, detail) => telemetry.error(message, detail),
32
+ });
33
+ ```
34
+
35
+ `owner` is required whenever the outbox persists, and is not defaulted on purpose. The outbox is
36
+ stored per **origin**, not per application, so several apps on one page share it — ownership is what
37
+ stops one from replaying or discarding another's queued mutations. A default would put every app
38
+ under the same name, which is exactly the collision it exists to prevent, and it would fail silently
39
+ on someone's unsent work.
40
+
41
+ Queued entries are stored one record per entry, so appending never reads the queue first and two
42
+ apps cannot lose each other's writes.
43
+
44
+ ---
45
+
46
+ ## Identity first
47
+
48
+ ```ts
49
+ export const Bulletin = entity<Bulletin>({ name: 'bulletin', key: (b) => b.id });
50
+ ```
51
+
52
+ Declare each type once and export it. Identity is only useful if every query and mutation agrees on it.
53
+
54
+ ## Reading
55
+
56
+ ```ts
57
+ readonly bulletins = query({
58
+ loader: () => firstValueFrom(this.http.get<Bulletin[]>('/api/bulletins')),
59
+ normalize: Bulletin,
60
+ tags: () => ['bulletins'],
61
+ });
62
+
63
+ // Read through the store — NOT through bulletins.value()
64
+ readonly rows = this.store.selectAll(Bulletin);
65
+ readonly published = this.store.query(Bulletin, (b) => b.status === 'published');
66
+ readonly current = this.store.select(Bulletin, this.id);
67
+ ```
68
+
69
+ > **The mistake to avoid.** If a component holds `bulletins.value()`, it owns a private copy. Writing an updated record into the store changes nothing it can observe, and normalization buys you exactly nothing. Read through `select` / `selectAll` / `query`.
70
+
71
+ `normalize` handles the shapes real APIs return — a bare record, an array, or an envelope like `{ items: [...] }`. Anything unrecognised is left alone rather than guessed at.
72
+
73
+ ## Writing
74
+
75
+ ```ts
76
+ readonly publish = mutation({
77
+ id: 'bulletin.publish',
78
+ operation: (b: Bulletin) => firstValueFrom(this.http.post(`/api/bulletins/${b.id}/publish`, b)),
79
+ optimistic: (tx, b) => tx.patch(Bulletin, b.id, { status: 'published' }),
80
+ invalidates: (b) => [tag.entity(Bulletin, b.id), 'bulletins'],
81
+ durability: 'outbox',
82
+ schemaVersion: 41,
83
+ });
84
+
85
+ await this.publish.mutate(bulletin);
86
+ ```
87
+
88
+ ### The optimistic view is derived, never stored
89
+
90
+ ```
91
+ view(record) = confirmed(record) ⊕ pending(record)
92
+ ```
93
+
94
+ `optimistic` **describes** the change; it does not apply it. The description is queued, and every read through `select` / `selectAll` / `peek` returns the confirmed record with the queue's predictions on top. Three things fall out of that, none of them available to a design that patches the store and keeps an undo log:
95
+
96
+ - **Rollback is deletion.** A failed mutation's entry is dropped and the view recomputes. There is no undo record to keep in agreement with anything.
97
+ - **It survives a reload.** The prediction lives in the same storage as the queue, so a user who queues an edit offline and refreshes still sees their edit — not the value they replaced, with their change invisibly waiting to send.
98
+ - **Every app on the page sees it.** The queue is shared per origin, so one app showing another's unsent edit is a property of where the overlay lives rather than of any coordination between them.
99
+
100
+ `peekConfirmed` is the escape hatch for when you specifically want the server's last word.
101
+
102
+ ### When the server disagrees
103
+
104
+ ```ts
105
+ readonly publish = mutation({
106
+ // …
107
+ onConflict: 'raise', // default │ 'accept' │ (conflict) => valueToStore
108
+ });
109
+
110
+ this.publish.conflict(); // Signal<MutationConflict | null>
111
+ this.publish.hasPendingWrite(); // Signal<boolean>
112
+ ```
113
+
114
+ A server that accepts a write and stores something else — trimmed, title-cased, resolved against a rule the client does not know — has not failed. `'raise'` reports it as `{ expected, actual, paths, entity }` so the UI can say so. The stored record becomes the server's value regardless, because you cannot make a server hold your value without another mutation; the only question is whether the user is told, and silence is opted into per mutation by a team that knows the field is server-authoritative.
115
+
116
+ A conflict is only reported when the response *is* the record. An operation resolving with `void`, an id, or a receipt has not contradicted anything.
117
+
118
+ ---
119
+
120
+ ## The outbox
121
+
122
+ The piece that cannot be built with in-flight request machinery. A mutation queued while offline has to survive a page reload — and after a reload there is no pending `Promise` to retry, no `HttpRequest` to intercept, and no closure left alive. Only something persisted can be replayed.
123
+
124
+ Which forces one API constraint:
125
+
126
+ ```ts
127
+ mutation({ durability: 'outbox' }) // ✗ throws
128
+ mutation({ id: 'bulletin.publish', … }) // ✓ replayed by id
129
+ ```
130
+
131
+ Operations are closures and closures don't serialise, so a queued entry finds its operation again by **id**. This also means outbox mutations must be created during bootstrap rather than lazily inside a click handler — a queue rehydrated at start-up needs somewhere to go.
132
+
133
+ **Behaviour worth knowing:**
134
+
135
+ - **Strictly sequential.** Entries frequently depend on each other (create a thing, then publish it); parallel flushing would race them. A failure stops the drain rather than skipping ahead.
136
+ - **Optimistic state is kept on queue.** From the user's point of view the change happened; it reaches the server when the network returns. It is kept by being *derived from* the queue, so the two cannot disagree — see the overlay above.
137
+ - **Permanently-failing entries are dropped, loudly.** After `maxOutboxAttempts` the entry is abandoned and reported through `onOutboxError` — never silently, because the user already navigated away believing it saved.
138
+ - **An entry with no registered mutation is _kept_, loudly.** It means this build did not register that mutation kind (or renamed it), so dropping it would discard a write the user was told had saved. It is reported through `onOutboxError` and left for a build that knows what it is; `clear()` is the deliberate way to abandon it.
139
+ - **The drain itself lives in `@braidlabs/data`.** Ordering, the cross-tab lock, retry, and give-up are one implementation (`drainOutbox`) shared with every binding — this service keeps the signals and the Angular ergonomics.
140
+ - **A queue written by a newer build is left untouched.** Replaying it would send payloads this build doesn't understand. `@braidlabs/skew` surfaces that as `ahead` rather than discarding the user's work.
141
+
142
+ ```ts
143
+ const outbox = inject(OutboxService);
144
+ outbox.pendingCount(); // Signal<number>
145
+ outbox.hasPendingWork(); // Signal<boolean> → "3 changes waiting to sync"
146
+ outbox.isFlushing();
147
+ ```
148
+
149
+ ---
150
+
151
+ ## The whole flow, end to end
152
+
153
+ One read, one write, and the same write again with the network gone. Every arrow below is a real
154
+ call in this library — the numbering is there so the prose elsewhere in this README can point at a
155
+ step. Tags are written `bulletin/42` here only because `#` is awkward inside a Mermaid label; in
156
+ code they are `bulletin#42`.
157
+
158
+ ```mermaid
159
+ sequenceDiagram
160
+ autonumber
161
+ actor User
162
+ participant UI as Component
163
+ participant Query as query()
164
+ participant Store as Confirmed store<br/>(IndexedDB, enveloped)
165
+ participant Pend as PendingWrites<br/>(the overlay)
166
+ participant Out as Outbox<br/>(shared per origin)
167
+ participant Tags as Invalidation<br/>(CacheRegistry)
168
+ participant API as Server
169
+
170
+ rect rgb(238, 245, 255)
171
+ Note over UI,API: Read — the cache lives in storage, so two apps fetch it once between them
172
+ UI->>Query: subscribe to bulletin 42
173
+ Query->>Store: get
174
+ Store-->>Query: miss
175
+ Query->>Query: take the per-key lock
176
+ Note right of Query: A second app asking at the same moment<br/>waits here and finds the record written,<br/>rather than fetching it again
177
+ Query->>API: GET /bulletins/42
178
+ API-->>Query: record
179
+ Query->>Store: put, enveloped and stamped
180
+ Query->>Pend: any pending overlays for this key
181
+ Pend-->>Query: none
182
+ Query-->>UI: view = confirmed, pending false
183
+ end
184
+
185
+ rect rgb(240, 250, 240)
186
+ Note over User,API: Write, online — queue first, send second
187
+ User->>UI: publish
188
+ UI->>Query: mutate
189
+ Query->>Query: run optimistic() against a recording transaction
190
+ Note right of Query: The callback describes the change.<br/>Nothing is written to the confirmed store.
191
+ Query->>Pend: add overlay, status published
192
+ Pend-->>UI: view = confirmed + overlay, pending true
193
+ Query->>Out: enqueue entry with its overlay
194
+ Note right of Out: Queued before sending, so a crash<br/>mid-request cannot lose the write
195
+ Query->>API: POST /bulletins/42/publish
196
+ API-->>Query: stored record, title normalized
197
+ Query->>Query: compare predicted fields with what came back
198
+ alt server agreed
199
+ Query->>Store: put the server record
200
+ else server stored something else
201
+ Query->>Store: put the server record
202
+ Query-->>UI: conflict, expected vs actual, on the title field
203
+ Note right of UI: onConflict raise is the default.<br/>The server value is stored either way —<br/>the choice is whether the user is told.
204
+ end
205
+ Query->>Out: remove the entry
206
+ Query->>Pend: drop the overlay
207
+ Note right of Pend: The confirmed record already says it,<br/>so the value on screen never flickers back
208
+ Query->>Tags: invalidate bulletin/42 and bulletins
209
+ Tags-->>Query: every query that declared those tags refetches
210
+ Query->>API: GET /bulletins/42
211
+ API-->>Query: fresh record
212
+ Query-->>UI: view = confirmed, pending false
213
+ end
214
+
215
+ rect rgb(255, 247, 235)
216
+ Note over User,API: Write, offline — the same path, stopping one step short
217
+ User->>UI: publish
218
+ UI->>Query: mutate
219
+ Query->>Pend: add overlay
220
+ Query->>Out: enqueue entry with its overlay
221
+ Query->>API: POST /bulletins/42/publish
222
+ API--xQuery: network error
223
+ Note right of Query: The entry stays queued and the overlay stays on.<br/>From the user's point of view it saved.
224
+ Query-->>UI: view still shows published, pending true
225
+ end
226
+
227
+ rect rgb(248, 240, 255)
228
+ Note over User,API: Reload — nothing in memory survives, and the edit is still there
229
+ User->>UI: refresh the page
230
+ UI->>Out: load
231
+ Out->>Store: read the queue
232
+ Store-->>Out: one entry, owned by this app, carrying its overlay
233
+ Out->>Pend: rebuild the overlay from the queue
234
+ Pend-->>UI: view still shows published, pending true
235
+ Note right of Pend: This is the step an in-memory undo log<br/>cannot perform. The prediction was never<br/>in memory to lose.
236
+ end
237
+
238
+ rect rgb(240, 250, 240)
239
+ Note over User,API: Sync — one tab sends, everyone sees the result
240
+ User->>UI: back online, flush
241
+ UI->>Out: flush
242
+ Out->>Out: take the flush lock for this owner
243
+ Note right of Out: Held across tabs and realms. Without it every<br/>open tab drains the same queue at once,<br/>against a server that is just coming back.
244
+ Out->>API: POST, replayed by mutation id
245
+ API-->>Out: accepted
246
+ Out->>Out: delete the entry
247
+ Out->>Pend: re-derive — the overlay is gone with its entry
248
+ Out->>Tags: invalidate bulletin/42
249
+ Tags-->>Query: refetch
250
+ Query->>API: GET /bulletins/42
251
+ API-->>Query: the published record
252
+ Query->>Store: put
253
+ Query-->>UI: view = confirmed, pending false
254
+ end
255
+ ```
256
+
257
+ Three properties of that picture are worth stating on their own, because each is a bug the obvious
258
+ design ships with:
259
+
260
+ - **The overlay is never written to the confirmed store.** Settling a write is removing its entry
261
+ and re-deriving (steps 23–24, and again at 48–49 when the flush sends it) — so a *failed* write
262
+ needs no separate path, and there is no undo log to keep in agreement with a record that moved
263
+ underneath it.
264
+ - **The queue is the overlay.** They are one set of records, so they cannot drift apart — the
265
+ reload above works for the same reason the offline write does.
266
+ - **Both halves are shared per origin.** Another app on the page reads the same confirmed store and
267
+ the same queue, so it shows the pending edit too, and its own queued work is left alone rather
268
+ than replayed by whoever happens to flush.
269
+
270
+ ---
271
+
272
+ ## Invalidation
273
+
274
+ Tags, not TTLs. A time-to-live is a guess about when data went stale; a mutation _knows_.
275
+
276
+ ```ts
277
+ tag.entity(Bulletin, '42'); // 'bulletin#42'
278
+ tag.all(Bulletin); // 'bulletin#*' — matches every bulletin#…
279
+ tag.collection('bulletins'); // 'bulletins'
280
+
281
+ inject(CacheRegistry).invalidate('bulletins');
282
+ ```
283
+
284
+ Tags are supplied as a getter and re-read on each invalidation, so a query whose tags depend on signals (a route param, a filter) stays correct without re-subscribing. A subscriber that throws is isolated — one bad query can't stop the others refreshing.
285
+
286
+ ---
287
+
288
+ ## API
289
+
290
+ | Export | Purpose |
291
+ | ------------------------------------------- | ----------------------------------------------------------------------------------------- |
292
+ | `entity<T>({ name, key })` | Declare identity |
293
+ | `tag.entity` / `tag.all` / `tag.collection` | Build invalidation tags |
294
+ | `EntityStore` | `select` · `selectAll` · `query` · `peek` · `peekConfirmed` · `upsert` · `patch` · `remove` · `transaction` |
295
+ | `query(config)` | Read + normalize + subscribe to tags |
296
+ | `mutation(config)` | Write + optimistic overlay + conflict reporting + durability |
297
+ | `PendingWrites` | `overlays` · `hasPending` — the pending half of every read |
298
+ | `OutboxService` | `entries` · `pendingCount` · `flush` · `remove` · `clear` |
299
+ | `CacheRegistry` | `invalidate` · `subscribe` |
300
+ | `provideSkewData(options)` | Wire it up |
301
+
302
+ ---
303
+
304
+ ## Known gaps
305
+
306
+ - **`resource()` can't normalize into this store**, so `query()` is a parallel primitive rather than an extension. Documented duplication we'd happily delete if Angular grew the hook.
307
+ - **Server-driven invalidation isn't shipped yet.** Tags currently work per-tab. The wire contract (`{ invalidate: string[] }` over SSE) is designed in planning.