@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.
|