@braidlabs/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/package.json +41 -0
- package/src/index.d.ts +34 -0
- package/src/index.js +23 -0
- package/src/index.js.map +1 -0
- package/src/lib/adapters.d.ts +74 -0
- package/src/lib/adapters.js +10 -0
- package/src/lib/adapters.js.map +1 -0
- package/src/lib/events.d.ts +141 -0
- package/src/lib/events.js +337 -0
- package/src/lib/events.js.map +1 -0
- package/src/lib/flush.d.ts +49 -0
- package/src/lib/flush.js +60 -0
- package/src/lib/flush.js.map +1 -0
- package/src/lib/indexeddb-driver.d.ts +29 -0
- package/src/lib/indexeddb-driver.js +160 -0
- package/src/lib/indexeddb-driver.js.map +1 -0
- package/src/lib/intents.d.ts +83 -0
- package/src/lib/intents.js +136 -0
- package/src/lib/intents.js.map +1 -0
- package/src/lib/invalidation.d.ts +63 -0
- package/src/lib/invalidation.js +136 -0
- package/src/lib/invalidation.js.map +1 -0
- package/src/lib/locks.d.ts +43 -0
- package/src/lib/locks.js +78 -0
- package/src/lib/locks.js.map +1 -0
- package/src/lib/memory-driver.d.ts +10 -0
- package/src/lib/memory-driver.js +53 -0
- package/src/lib/memory-driver.js.map +1 -0
- package/src/lib/outbox.d.ts +146 -0
- package/src/lib/outbox.js +89 -0
- package/src/lib/outbox.js.map +1 -0
- package/src/lib/partitions.d.ts +70 -0
- package/src/lib/partitions.js +62 -0
- package/src/lib/partitions.js.map +1 -0
- package/src/lib/query.d.ts +235 -0
- package/src/lib/query.js +430 -0
- package/src/lib/query.js.map +1 -0
- package/src/lib/record-store.d.ts +125 -0
- package/src/lib/record-store.js +85 -0
- package/src/lib/record-store.js.map +1 -0
- package/src/lib/tenancy.d.ts +121 -0
- package/src/lib/tenancy.js +220 -0
- package/src/lib/tenancy.js.map +1 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import type { SkewFailureReason, VersionedSchema } from '@braidlabs/skew';
|
|
2
|
+
import type { RecordDriver } from './record-store.js';
|
|
3
|
+
import { type Invalidator } from './invalidation.js';
|
|
4
|
+
import type { Outbox } from './outbox.js';
|
|
5
|
+
import type { PushConnection } from './adapters.js';
|
|
6
|
+
import { type FlushResult, type MutationRunner } from './flush.js';
|
|
7
|
+
/**
|
|
8
|
+
* The read path: reactive queries over a shared, persisted, skew-tolerant cache.
|
|
9
|
+
*
|
|
10
|
+
* Three things make this different from an ordinary query cache, and each is the reason for a
|
|
11
|
+
* decision below:
|
|
12
|
+
*
|
|
13
|
+
* 1. **The cache is shared across applications**, because it lives in storage rather than in one
|
|
14
|
+
* app's memory. Two independently deployed apps asking for the same record fetch it once
|
|
15
|
+
* between them.
|
|
16
|
+
* 2. **Records are projected per reader.** The store holds what the writer wrote; each reader
|
|
17
|
+
* migrates it to its own contract version on the way out, in either direction.
|
|
18
|
+
* 3. **Provenance travels with the value.** A reader can tell a field the server reported from one
|
|
19
|
+
* a migration guessed, which is the entire reason to build on the migration engine rather than
|
|
20
|
+
* on a plain cache.
|
|
21
|
+
*
|
|
22
|
+
* A fourth follows from the outbox: **the optimistic view is derived, never stored.**
|
|
23
|
+
*
|
|
24
|
+
* view(record) = confirmed(record) ⊕ pending(record)
|
|
25
|
+
*
|
|
26
|
+
* Both inputs are shared, so every app and every tab derives the same view without reconciling
|
|
27
|
+
* anything, rollback is deletion rather than an undo record to keep consistent, and the queue and
|
|
28
|
+
* the overlay cannot disagree because they are one thing.
|
|
29
|
+
*/
|
|
30
|
+
export type QueryStatus = 'idle' | 'loading' | 'ready' | 'error';
|
|
31
|
+
/**
|
|
32
|
+
* A confirmation that disagreed with what the overlay predicted.
|
|
33
|
+
*
|
|
34
|
+
* The stored record always becomes the server's value — it is the source of truth, and there is no
|
|
35
|
+
* client-wins option because you cannot make a server hold your value without another mutation.
|
|
36
|
+
* What is configurable is whether the *user* is told.
|
|
37
|
+
*/
|
|
38
|
+
export interface Conflict<T> {
|
|
39
|
+
/** What the overlay predicted: the confirmed value with this mutation's patch applied. */
|
|
40
|
+
expected: T;
|
|
41
|
+
/** What the server actually stored. */
|
|
42
|
+
actual: T;
|
|
43
|
+
/** The patched paths the server disagreed about. */
|
|
44
|
+
paths: readonly string[];
|
|
45
|
+
}
|
|
46
|
+
export interface QueryState<T> {
|
|
47
|
+
data: T | undefined;
|
|
48
|
+
status: QueryStatus;
|
|
49
|
+
error?: unknown;
|
|
50
|
+
/** True while a fetch is in flight behind data that is already displayable. */
|
|
51
|
+
refreshing: boolean;
|
|
52
|
+
/** True when this value came from the shared cache rather than the network. */
|
|
53
|
+
fromCache: boolean;
|
|
54
|
+
/** Set when the stored record was older than this reader and was migrated up. */
|
|
55
|
+
migratedFrom: number | null;
|
|
56
|
+
/** Set when it was newer and was projected down — the value is honest but lossy. */
|
|
57
|
+
downgradedFrom: number | null;
|
|
58
|
+
/** Paths whose values are the migration's guesses rather than what the writer recorded. */
|
|
59
|
+
derivedPaths: readonly string[];
|
|
60
|
+
/** Paths a down-projection discarded. */
|
|
61
|
+
lossyPaths: readonly string[];
|
|
62
|
+
/**
|
|
63
|
+
* Set when the stored record could not be read by this reader, and why.
|
|
64
|
+
*
|
|
65
|
+
* The interesting value is `'ahead'`: a record written by a build newer than this one, with no
|
|
66
|
+
* down-migration available. The record is perfectly good and merely from the future — so the
|
|
67
|
+
* honest move is to say so and refetch at this reader's own version, rather than discard it as
|
|
68
|
+
* corrupt or guess at fields the writer never sent.
|
|
69
|
+
*/
|
|
70
|
+
unreadable: SkewFailureReason | null;
|
|
71
|
+
/** True while an optimistic overlay is applied — `data` includes work the server has not seen. */
|
|
72
|
+
pending: boolean;
|
|
73
|
+
/** Set when a confirmation disagreed with the overlay, under the `'raise'` policy. */
|
|
74
|
+
conflict: Conflict<T> | null;
|
|
75
|
+
}
|
|
76
|
+
export interface Query<T> {
|
|
77
|
+
readonly current: QueryState<T>;
|
|
78
|
+
subscribe(listener: (state: QueryState<T>) => void): () => void;
|
|
79
|
+
/** Forces a fetch, bypassing the cache. */
|
|
80
|
+
refetch(): Promise<void>;
|
|
81
|
+
dispose(): void;
|
|
82
|
+
}
|
|
83
|
+
export interface QueryDefinition<T> {
|
|
84
|
+
/** Cache key. Identifies the record, and is what makes two apps share one fetch. */
|
|
85
|
+
key: string;
|
|
86
|
+
/** What this query depends on, for invalidation. */
|
|
87
|
+
tags?: string[];
|
|
88
|
+
schema: VersionedSchema<T>;
|
|
89
|
+
fetch: (signal: AbortSignal) => Promise<unknown>;
|
|
90
|
+
/**
|
|
91
|
+
* Serve the cached value immediately and refresh behind it. Defaults to true — a warm partition
|
|
92
|
+
* is the point of persisting, and waiting on the network to show data you already have discards
|
|
93
|
+
* it.
|
|
94
|
+
*/
|
|
95
|
+
staleWhileRevalidate?: boolean;
|
|
96
|
+
}
|
|
97
|
+
export interface DataClientOptions {
|
|
98
|
+
driver: RecordDriver;
|
|
99
|
+
/**
|
|
100
|
+
* The tenant partition: `hash(userId, actingAs)`.
|
|
101
|
+
*
|
|
102
|
+
* Read on every access rather than captured, so switching tenants is a pointer move and the
|
|
103
|
+
* previous partition stays warm on disk.
|
|
104
|
+
*/
|
|
105
|
+
partition: () => string;
|
|
106
|
+
collection?: string;
|
|
107
|
+
buildId?: string;
|
|
108
|
+
/**
|
|
109
|
+
* Let invalidation reach other JavaScript contexts — other tabs, and **other realms on this
|
|
110
|
+
* page**. Off by default.
|
|
111
|
+
*
|
|
112
|
+
* Turn it on whenever this app is composed with others: a Braid realm is its own context, so
|
|
113
|
+
* without the channel a sibling fragment never hears that a record went stale, and staleness is
|
|
114
|
+
* invisible.
|
|
115
|
+
*/
|
|
116
|
+
crossContextInvalidation?: boolean;
|
|
117
|
+
/** Called whenever the network is actually hit. What the demo's "fetched once" counter reads. */
|
|
118
|
+
onFetch?: (key: string) => void;
|
|
119
|
+
invalidator?: Invalidator;
|
|
120
|
+
/**
|
|
121
|
+
* The queue that also supplies the optimistic overlay. Without one, `mutate` has nowhere to
|
|
122
|
+
* record a write it cannot yet send, so queries show only confirmed values.
|
|
123
|
+
*/
|
|
124
|
+
outbox?: Outbox;
|
|
125
|
+
/**
|
|
126
|
+
* How many times a queued write is retried before it is abandoned.
|
|
127
|
+
*
|
|
128
|
+
* Abandoned loudly through `onFlushError`, never silently: the user was told it saved.
|
|
129
|
+
*/
|
|
130
|
+
maxAttempts?: number;
|
|
131
|
+
/** Reports a queued write that could not be replayed. Wire it to telemetry. */
|
|
132
|
+
onFlushError?: (message: string, detail?: unknown) => void;
|
|
133
|
+
/**
|
|
134
|
+
* Flush the queue when the browser comes back online, and once at construction.
|
|
135
|
+
*
|
|
136
|
+
* Defaults to true whenever an outbox is supplied — a queue nothing drains is durability without
|
|
137
|
+
* delivery, which is the worse half of the feature.
|
|
138
|
+
*/
|
|
139
|
+
autoFlush?: boolean;
|
|
140
|
+
}
|
|
141
|
+
export type { FlushResult, MutationRunner };
|
|
142
|
+
export interface MutationRegistration {
|
|
143
|
+
/** Marked stale after a successful replay, as `mutate` would have done at the time. */
|
|
144
|
+
tags?: string[];
|
|
145
|
+
}
|
|
146
|
+
/** What to do when the server's value disagrees with what the overlay predicted. */
|
|
147
|
+
export type ConflictPolicy<T> = 'raise' | 'accept' | ((conflict: Conflict<T>) => T);
|
|
148
|
+
export interface MutationDefinition<T> {
|
|
149
|
+
/** The record this changes. The same key its queries read, or nothing overlays. */
|
|
150
|
+
key: string;
|
|
151
|
+
schema: VersionedSchema<T>;
|
|
152
|
+
/**
|
|
153
|
+
* Identifies the mutation *kind*. A queued entry outlives the closure that made it, and after a
|
|
154
|
+
* reload this is how it finds its runner again.
|
|
155
|
+
*/
|
|
156
|
+
mutationId: string;
|
|
157
|
+
/** Replayable input for that runner. */
|
|
158
|
+
input: unknown;
|
|
159
|
+
/** Applied over the confirmed record until the server answers. */
|
|
160
|
+
patch: Partial<T>;
|
|
161
|
+
/** Set when the write deletes the record: readers show it gone before the server agrees. */
|
|
162
|
+
removes?: boolean;
|
|
163
|
+
/**
|
|
164
|
+
* Sends it. Resolves with the server's version of the record.
|
|
165
|
+
*
|
|
166
|
+
* Takes the input rather than closing over it, because this same function is what replays the
|
|
167
|
+
* write if the send fails: a queued entry stores data, and the runner it is handed back to must
|
|
168
|
+
* be able to work from that data alone.
|
|
169
|
+
*/
|
|
170
|
+
send: (input: unknown) => Promise<unknown>;
|
|
171
|
+
/** Tags to mark stale once the write lands. */
|
|
172
|
+
tags?: string[];
|
|
173
|
+
/**
|
|
174
|
+
* Defaults to `'raise'`, because the silent version changes the screen under someone who just
|
|
175
|
+
* typed something. "I set it to X, why does it say Y?" is a support ticket, and in an advisory
|
|
176
|
+
* context it is a support ticket about someone's money. Silence is opted into per mutation, by a
|
|
177
|
+
* team that knows the field is server-authoritative.
|
|
178
|
+
*/
|
|
179
|
+
onConflict?: ConflictPolicy<T>;
|
|
180
|
+
/** Contract version of `input`, carried on the queued entry. */
|
|
181
|
+
schemaVersion?: number;
|
|
182
|
+
}
|
|
183
|
+
export interface MutationOutcome<T> {
|
|
184
|
+
/** `'queued'` means the send failed and the entry is waiting; the overlay stays applied. */
|
|
185
|
+
status: 'confirmed' | 'queued';
|
|
186
|
+
/** The outbox entry this created. Still queued when `status` is `'queued'`. */
|
|
187
|
+
entryId: string;
|
|
188
|
+
/** What was stored. Absent while queued — nothing has been confirmed yet. */
|
|
189
|
+
value?: T;
|
|
190
|
+
conflict?: Conflict<T>;
|
|
191
|
+
/** Why the send failed, when it did. */
|
|
192
|
+
error?: unknown;
|
|
193
|
+
}
|
|
194
|
+
export interface DataClient {
|
|
195
|
+
query<T>(definition: QueryDefinition<T>): Query<T>;
|
|
196
|
+
/**
|
|
197
|
+
* Writes: queue, overlay, send, confirm.
|
|
198
|
+
*
|
|
199
|
+
* Optimistic and offline are the same mechanism here rather than two features that must be kept
|
|
200
|
+
* in agreement — a mutation whose send fails is simply one whose overlay outlives the attempt.
|
|
201
|
+
*/
|
|
202
|
+
mutate<T>(definition: MutationDefinition<T>): Promise<MutationOutcome<T>>;
|
|
203
|
+
/** Dismisses a raised conflict, once the user has seen it. */
|
|
204
|
+
acknowledgeConflict(key: string): void;
|
|
205
|
+
/**
|
|
206
|
+
* Teaches the client how to replay a kind of write.
|
|
207
|
+
*
|
|
208
|
+
* `mutate` registers its own `send` automatically, so the live path needs no call. This exists
|
|
209
|
+
* for the path that has no live closure left: **after a reload**, a queued entry knows its
|
|
210
|
+
* `mutationId` and its input and nothing else, so the application must re-register its mutation
|
|
211
|
+
* kinds during start-up or the queue has nowhere to go.
|
|
212
|
+
*/
|
|
213
|
+
registerMutation(mutationId: string, runner: MutationRunner, options?: MutationRegistration): void;
|
|
214
|
+
/**
|
|
215
|
+
* Drains the queue, oldest first.
|
|
216
|
+
*
|
|
217
|
+
* Called automatically when the browser comes back online unless `autoFlush` is off; call it
|
|
218
|
+
* directly after signing in, or behind a "retry now" button.
|
|
219
|
+
*/
|
|
220
|
+
flush(): Promise<FlushResult>;
|
|
221
|
+
/**
|
|
222
|
+
* Connects a push stream — a subscription, socket, or SSE feed — to the store.
|
|
223
|
+
*
|
|
224
|
+
* Server-pushed records land through the same enveloping path as everything else, and readers are
|
|
225
|
+
* refreshed *from storage* rather than sent back to the network: the pushed record already is the
|
|
226
|
+
* newest thing anyone has. Returns a disposer.
|
|
227
|
+
*/
|
|
228
|
+
connect<T>(connection: PushConnection<T>): () => void;
|
|
229
|
+
/** Marks tags stale across every app on the page. */
|
|
230
|
+
invalidate(...tags: string[]): void;
|
|
231
|
+
/** Drops a tenant's cached records. Sign-out, or a tenant switch that should not stay warm. */
|
|
232
|
+
clearPartition(partition?: string): Promise<void>;
|
|
233
|
+
close(): void;
|
|
234
|
+
}
|
|
235
|
+
export declare function createDataClient(options: DataClientOptions): DataClient;
|
package/src/lib/query.js
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { createRecordStore } from './record-store.js';
|
|
2
|
+
import { sharedInvalidator } from './invalidation.js';
|
|
3
|
+
import { withLock } from './locks.js';
|
|
4
|
+
import { drainOutbox } from './flush.js';
|
|
5
|
+
const DEFAULT_COLLECTION = 'entities';
|
|
6
|
+
/**
|
|
7
|
+
* The tag a key's overlay changes travel on.
|
|
8
|
+
*
|
|
9
|
+
* Ordinary tags mean "this data is stale, go and look again"; this one means "the pending set
|
|
10
|
+
* moved, recompute the view you already have". Sharing one tag space would make every keystroke of
|
|
11
|
+
* optimistic state a network refetch.
|
|
12
|
+
*/
|
|
13
|
+
function pendingTag(key) {
|
|
14
|
+
return `skew:pending#${key}`;
|
|
15
|
+
}
|
|
16
|
+
export function createDataClient(options) {
|
|
17
|
+
const collection = options.collection ?? DEFAULT_COLLECTION;
|
|
18
|
+
// Shared per partition, so a mutation in one application refreshes another's view of the same
|
|
19
|
+
// record. A private invalidator per client would make cross-app invalidation silently not work.
|
|
20
|
+
const invalidator = options.invalidator ??
|
|
21
|
+
sharedInvalidator({
|
|
22
|
+
partition: options.partition(),
|
|
23
|
+
...(options.crossContextInvalidation === undefined
|
|
24
|
+
? {}
|
|
25
|
+
: { crossContext: options.crossContextInvalidation }),
|
|
26
|
+
});
|
|
27
|
+
const stores = new Map();
|
|
28
|
+
/**
|
|
29
|
+
* Raised conflicts, per key, held by the client that wrote.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately not shared: the app whose user typed the value is the one that can explain what
|
|
32
|
+
* happened to it. Broadcasting "someone, somewhere, was disagreed with" to five fragments gives
|
|
33
|
+
* four of them a message they cannot act on.
|
|
34
|
+
*/
|
|
35
|
+
const conflicts = new Map();
|
|
36
|
+
/** Why a key's stored record could not be read, last time anyone tried. */
|
|
37
|
+
const unreadable = new Map();
|
|
38
|
+
/** How to replay each kind of queued write. Populated by `mutate` and by `registerMutation`. */
|
|
39
|
+
const runners = new Map();
|
|
40
|
+
const maxAttempts = options.maxAttempts ?? 5;
|
|
41
|
+
/**
|
|
42
|
+
* One store per schema, because the store *is* the projection: the same bytes read through two
|
|
43
|
+
* chains give two different values, which is the point.
|
|
44
|
+
*/
|
|
45
|
+
function storeFor(schema) {
|
|
46
|
+
let store = stores.get(schema);
|
|
47
|
+
if (!store) {
|
|
48
|
+
store = createRecordStore({
|
|
49
|
+
driver: options.driver,
|
|
50
|
+
collection,
|
|
51
|
+
schema: schema,
|
|
52
|
+
...(options.buildId === undefined ? {} : { buildId: options.buildId }),
|
|
53
|
+
// Recorded per key rather than thrown: a record this reader cannot project is a fact about
|
|
54
|
+
// the pair of them, and the query that asked is the only place that can act on it.
|
|
55
|
+
onReadFailure: (id, failure) => void unreadable.set(id, failure.reason),
|
|
56
|
+
});
|
|
57
|
+
stores.set(schema, store);
|
|
58
|
+
}
|
|
59
|
+
return store;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Drains this client's queue, oldest first, under the same per-owner lock the outbox uses.
|
|
63
|
+
*
|
|
64
|
+
* Three rules, each with a failure behind it:
|
|
65
|
+
*
|
|
66
|
+
* - **Strictly sequential.** Queued writes routinely depend on each other — create a thing, then
|
|
67
|
+
* rename the thing — and replaying them in parallel races them into the wrong order.
|
|
68
|
+
* - **A failure stops the drain** rather than skipping ahead, for the same reason.
|
|
69
|
+
* - **An entry with no runner is not silently dropped.** It means the app did not re-register that
|
|
70
|
+
* mutation kind after a reload, or renamed it between deploys. Dropping it quietly discards a
|
|
71
|
+
* write the user was told had saved, so it is reported and left queued for a build that knows
|
|
72
|
+
* what it is.
|
|
73
|
+
*/
|
|
74
|
+
/**
|
|
75
|
+
* Serializes *this client's* own flushes.
|
|
76
|
+
*
|
|
77
|
+
* The Web Lock declines a flush another tab is already running, which is right: waiting for it
|
|
78
|
+
* would drain a queue that tab has already emptied. But applied to one client's own overlapping
|
|
79
|
+
* calls that rule is a trap — construct a client, queue a write, call `flush()`, and the
|
|
80
|
+
* start-up flush is very likely still finishing, so the caller's flush reports `skipped` and
|
|
81
|
+
* quietly does nothing. Within one client, a second flush waits for the first and then runs,
|
|
82
|
+
* picking up anything queued in between.
|
|
83
|
+
*/
|
|
84
|
+
let flushing = null;
|
|
85
|
+
async function flush() {
|
|
86
|
+
if (flushing)
|
|
87
|
+
await flushing.catch(() => undefined);
|
|
88
|
+
const run = drain();
|
|
89
|
+
flushing = run;
|
|
90
|
+
try {
|
|
91
|
+
return await run;
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
if (flushing === run)
|
|
95
|
+
flushing = null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function drain() {
|
|
99
|
+
const outbox = options.outbox;
|
|
100
|
+
if (!outbox)
|
|
101
|
+
return Promise.resolve({ sent: 0, failed: 0, remaining: 0, skipped: false });
|
|
102
|
+
return drainOutbox({
|
|
103
|
+
outbox,
|
|
104
|
+
// The collection names this application's queue, and the flush lock is per owner.
|
|
105
|
+
owner: options.collection ?? DEFAULT_COLLECTION,
|
|
106
|
+
runnerFor: (mutationId) => runners.get(mutationId)?.run,
|
|
107
|
+
maxAttempts,
|
|
108
|
+
...(options.onFlushError === undefined ? {} : { onError: options.onFlushError }),
|
|
109
|
+
onSent: (entry) => {
|
|
110
|
+
// The overlay lifts with the entry; whatever the write invalidated at the time is marked
|
|
111
|
+
// stale again, now that the server has actually seen it.
|
|
112
|
+
const keys = (entry.optimistic ?? []).map((overlay) => overlay.key);
|
|
113
|
+
invalidator.invalidate(...keys.map(pendingTag), ...(runners.get(entry.mutationId)?.tags ?? []));
|
|
114
|
+
},
|
|
115
|
+
onAbandoned: (entry) => {
|
|
116
|
+
// Nothing will send it now, so the prediction it was showing has to come off the screen.
|
|
117
|
+
const keys = (entry.optimistic ?? []).map((overlay) => overlay.key);
|
|
118
|
+
invalidator.invalidate(...keys.map(pendingTag));
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* A queue nothing drains is durability without delivery, so a client given an outbox flushes on
|
|
124
|
+
* its own: once now, for whatever a previous session left behind, and again whenever the browser
|
|
125
|
+
* says the network is back.
|
|
126
|
+
*
|
|
127
|
+
* `online` is a hint rather than a fact — it fires for a captive portal too — but a flush that
|
|
128
|
+
* finds the network still down simply leaves the queue where it was.
|
|
129
|
+
*/
|
|
130
|
+
if (options.outbox && (options.autoFlush ?? true)) {
|
|
131
|
+
void flush().catch(() => undefined);
|
|
132
|
+
const view = globalThis;
|
|
133
|
+
view.addEventListener?.('online', () => void flush().catch(() => undefined));
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
query(definition) {
|
|
137
|
+
return createQuery(definition, {
|
|
138
|
+
store: storeFor(definition.schema),
|
|
139
|
+
partition: options.partition,
|
|
140
|
+
invalidator,
|
|
141
|
+
conflicts,
|
|
142
|
+
unreadable,
|
|
143
|
+
...(options.onFetch === undefined ? {} : { onFetch: options.onFetch }),
|
|
144
|
+
...(options.outbox === undefined ? {} : { outbox: options.outbox }),
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
async mutate(definition) {
|
|
148
|
+
const outbox = options.outbox;
|
|
149
|
+
if (!outbox) {
|
|
150
|
+
throw new TypeError('[skew/data] mutate() needs an `outbox` on the client: the overlay it applies and the ' +
|
|
151
|
+
'queue that survives a failed send are the same records, so there is nowhere to put ' +
|
|
152
|
+
'the write without one.');
|
|
153
|
+
}
|
|
154
|
+
// Registered on the way in, so a send that fails has a runner waiting for it before anyone
|
|
155
|
+
// asks — including a flush triggered by the network returning a second later.
|
|
156
|
+
runners.set(definition.mutationId, {
|
|
157
|
+
run: definition.send,
|
|
158
|
+
...(definition.tags === undefined ? {} : { tags: definition.tags }),
|
|
159
|
+
});
|
|
160
|
+
const store = storeFor(definition.schema);
|
|
161
|
+
const partition = options.partition();
|
|
162
|
+
const confirmed = (await store.get(definition.key, partition))?.value;
|
|
163
|
+
const expected = { ...(confirmed ?? {}), ...definition.patch };
|
|
164
|
+
// Queued *before* sending, not on failure. The entry is what readers overlay, so a write that
|
|
165
|
+
// is only recorded once it fails is a write nobody can see while it is in flight.
|
|
166
|
+
const entryId = await outbox.enqueue({
|
|
167
|
+
mutationId: definition.mutationId,
|
|
168
|
+
input: definition.input,
|
|
169
|
+
optimistic: [
|
|
170
|
+
{
|
|
171
|
+
key: definition.key,
|
|
172
|
+
// Stamped so the prediction is only ever shown to the tenant it was made for.
|
|
173
|
+
partition,
|
|
174
|
+
patch: definition.patch,
|
|
175
|
+
...(definition.removes ? { removed: true } : {}),
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
...(definition.schemaVersion === undefined ? {} : { schemaVersion: definition.schemaVersion }),
|
|
179
|
+
});
|
|
180
|
+
invalidator.invalidate(pendingTag(definition.key));
|
|
181
|
+
let actual;
|
|
182
|
+
try {
|
|
183
|
+
actual = (await definition.send(definition.input));
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
// The entry stays queued and the overlay stays applied: from the user's point of view the
|
|
187
|
+
// change happened, and the queue is what will eventually make that true.
|
|
188
|
+
return { status: 'queued', entryId, error };
|
|
189
|
+
}
|
|
190
|
+
const policy = definition.onConflict ?? 'raise';
|
|
191
|
+
const paths = disagreements(expected, actual, Object.keys(definition.patch));
|
|
192
|
+
let stored = actual;
|
|
193
|
+
let conflict;
|
|
194
|
+
if (paths.length > 0) {
|
|
195
|
+
if (typeof policy === 'function')
|
|
196
|
+
stored = policy({ expected, actual, paths });
|
|
197
|
+
else if (policy === 'raise')
|
|
198
|
+
conflict = { expected, actual, paths };
|
|
199
|
+
}
|
|
200
|
+
await store.put({ id: definition.key, partition, value: stored });
|
|
201
|
+
await outbox.remove(entryId);
|
|
202
|
+
if (conflict)
|
|
203
|
+
conflicts.set(definition.key, conflict);
|
|
204
|
+
else
|
|
205
|
+
conflicts.delete(definition.key);
|
|
206
|
+
// The pending tag first so the overlay lifts against the value just stored; the caller's tags
|
|
207
|
+
// after, for the queries that depend on this record without reading this key.
|
|
208
|
+
invalidator.invalidate(pendingTag(definition.key), ...(definition.tags ?? []));
|
|
209
|
+
return { status: 'confirmed', entryId, value: stored, ...(conflict ? { conflict } : {}) };
|
|
210
|
+
},
|
|
211
|
+
acknowledgeConflict(key) {
|
|
212
|
+
if (!conflicts.delete(key))
|
|
213
|
+
return;
|
|
214
|
+
invalidator.invalidate(pendingTag(key));
|
|
215
|
+
},
|
|
216
|
+
registerMutation(mutationId, runner, registration) {
|
|
217
|
+
runners.set(mutationId, {
|
|
218
|
+
run: runner,
|
|
219
|
+
...(registration?.tags === undefined ? {} : { tags: registration.tags }),
|
|
220
|
+
});
|
|
221
|
+
},
|
|
222
|
+
flush,
|
|
223
|
+
connect(connection) {
|
|
224
|
+
const store = storeFor(connection.schema);
|
|
225
|
+
const controller = new AbortController();
|
|
226
|
+
const sink = {
|
|
227
|
+
async receive(record) {
|
|
228
|
+
if (controller.signal.aborted)
|
|
229
|
+
return;
|
|
230
|
+
// The stream's own answer wins when it has one: a socket carrying several tenants' events
|
|
231
|
+
// knows which tenant each belongs to, and the reader's current partition does not.
|
|
232
|
+
const target = record.partition ?? options.partition();
|
|
233
|
+
await store.put({ id: record.key, partition: target, value: record.value });
|
|
234
|
+
// The pending tag, not the record's own: this refreshes readers from the store, where the
|
|
235
|
+
// value already is. Marking the record stale would answer a push by fetching the thing the
|
|
236
|
+
// push just delivered.
|
|
237
|
+
invalidator.invalidate(pendingTag(record.key), ...(record.tags ?? connection.tags?.(record.key) ?? []));
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
const disposer = connection.source(sink, controller.signal);
|
|
241
|
+
return () => {
|
|
242
|
+
controller.abort();
|
|
243
|
+
// A stream may report its disposer synchronously or after connecting; both are settled here
|
|
244
|
+
// so a caller never has to know which transport it was handed.
|
|
245
|
+
void Promise.resolve(disposer).then((dispose) => dispose?.());
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
invalidate(...tags) {
|
|
249
|
+
invalidator.invalidate(...tags);
|
|
250
|
+
},
|
|
251
|
+
async clearPartition(partition) {
|
|
252
|
+
await options.driver.clearPartition(collection, partition ?? options.partition());
|
|
253
|
+
},
|
|
254
|
+
close() {
|
|
255
|
+
invalidator.close();
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
function createQuery(definition, context) {
|
|
260
|
+
const listeners = new Set();
|
|
261
|
+
const controller = new AbortController();
|
|
262
|
+
const staleWhileRevalidate = definition.staleWhileRevalidate ?? true;
|
|
263
|
+
let state = {
|
|
264
|
+
data: undefined,
|
|
265
|
+
status: 'idle',
|
|
266
|
+
refreshing: false,
|
|
267
|
+
fromCache: false,
|
|
268
|
+
migratedFrom: null,
|
|
269
|
+
downgradedFrom: null,
|
|
270
|
+
derivedPaths: [],
|
|
271
|
+
lossyPaths: [],
|
|
272
|
+
unreadable: null,
|
|
273
|
+
pending: false,
|
|
274
|
+
conflict: null,
|
|
275
|
+
};
|
|
276
|
+
const emit = (next) => {
|
|
277
|
+
state = { ...state, ...next };
|
|
278
|
+
for (const listener of listeners)
|
|
279
|
+
listener(state);
|
|
280
|
+
};
|
|
281
|
+
/**
|
|
282
|
+
* Reads the view: the stored record with every queued patch for this key applied over it.
|
|
283
|
+
*
|
|
284
|
+
* Patches are applied in queue order, so two edits to the same field settle the way they will
|
|
285
|
+
* settle on the server. The overlay is recomputed from the outbox on every read rather than
|
|
286
|
+
* accumulated, which is what makes rollback a deletion — remove the entry and the next read
|
|
287
|
+
* simply does not include it.
|
|
288
|
+
*/
|
|
289
|
+
async function readView() {
|
|
290
|
+
context.unreadable.delete(definition.key);
|
|
291
|
+
const record = await context.store.get(definition.key, context.partition());
|
|
292
|
+
const failure = context.unreadable.get(definition.key) ?? null;
|
|
293
|
+
// An overlay needs something to sit on. A patch alone is not a record — it names the fields one
|
|
294
|
+
// write changed, not every field this reader's contract requires.
|
|
295
|
+
if (!record) {
|
|
296
|
+
// Reported before returning, so a reader that is behind learns *why* it is about to refetch
|
|
297
|
+
// rather than watching an apparently empty cache go back to the network.
|
|
298
|
+
if (failure)
|
|
299
|
+
emit({ unreadable: failure });
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
const overlays = context.outbox
|
|
303
|
+
? await context.outbox.pendingFor(definition.key, context.partition())
|
|
304
|
+
: [];
|
|
305
|
+
const data = overlays.reduce((value, overlay) => (overlay.removed ? undefined : { ...(value ?? {}), ...overlay.patch }), record.value);
|
|
306
|
+
emit({
|
|
307
|
+
data,
|
|
308
|
+
status: 'ready',
|
|
309
|
+
fromCache: true,
|
|
310
|
+
migratedFrom: record.migratedFrom,
|
|
311
|
+
downgradedFrom: record.downgradedFrom,
|
|
312
|
+
derivedPaths: record.derivedPaths,
|
|
313
|
+
lossyPaths: record.lossyPaths,
|
|
314
|
+
unreadable: null,
|
|
315
|
+
pending: overlays.length > 0,
|
|
316
|
+
conflict: context.conflicts.get(definition.key) ?? null,
|
|
317
|
+
});
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Fetches, under a per-key lock, re-checking the cache once it is held.
|
|
322
|
+
*
|
|
323
|
+
* The lock is what makes "these two apps fetched this once between them" true even when they ask
|
|
324
|
+
* *simultaneously*. Realms are separate JavaScript contexts, so an in-process in-flight map
|
|
325
|
+
* cannot dedupe across them; `navigator.locks` can, and the second app finds the record already
|
|
326
|
+
* written when its turn comes.
|
|
327
|
+
*/
|
|
328
|
+
async function fetchAndStore(force) {
|
|
329
|
+
if (controller.signal.aborted)
|
|
330
|
+
return;
|
|
331
|
+
emit({ refreshing: true, ...(state.data === undefined ? { status: 'loading' } : {}) });
|
|
332
|
+
try {
|
|
333
|
+
await withLock(`skew:data:fetch:${context.partition()}:${definition.key}`, async () => {
|
|
334
|
+
if (controller.signal.aborted)
|
|
335
|
+
return;
|
|
336
|
+
// Double-checked: another context may have fetched while this one waited for the lock.
|
|
337
|
+
if (!force && (await readView()))
|
|
338
|
+
return;
|
|
339
|
+
context.onFetch?.(definition.key);
|
|
340
|
+
const raw = await definition.fetch(controller.signal);
|
|
341
|
+
if (controller.signal.aborted)
|
|
342
|
+
return;
|
|
343
|
+
await context.store.put({ id: definition.key, partition: context.partition(), value: raw });
|
|
344
|
+
await readView();
|
|
345
|
+
emit({ fromCache: false });
|
|
346
|
+
}, { ifAvailable: false });
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
if (!controller.signal.aborted) {
|
|
350
|
+
// Cached data survives a failed refresh: showing something stale beats showing nothing,
|
|
351
|
+
// and `status` says which it is.
|
|
352
|
+
emit({ status: state.data === undefined ? 'error' : 'ready', error });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
if (!controller.signal.aborted)
|
|
357
|
+
emit({ refreshing: false });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const unsubscribe = context.invalidator.subscribe(() => definition.tags ?? [], () => void fetchAndStore(true), { signal: controller.signal });
|
|
361
|
+
/**
|
|
362
|
+
* Overlay changes recompute the view; they never refetch.
|
|
363
|
+
*
|
|
364
|
+
* A separate subscription rather than another tag on the one above, because the pending set moves
|
|
365
|
+
* on every keystroke a user's edits generate and the record it applies to has not gone stale —
|
|
366
|
+
* routing that through the fetch path would answer "someone typed" with a network request.
|
|
367
|
+
*/
|
|
368
|
+
const unsubscribePending = context.invalidator.subscribe(() => [pendingTag(definition.key)], () => void readView(), { signal: controller.signal });
|
|
369
|
+
/**
|
|
370
|
+
* Kick off: serve the cache when there is one, then decide whether to go to the network.
|
|
371
|
+
*
|
|
372
|
+
* A cache miss always fetches. A hit fetches only when revalidating — `staleWhileRevalidate:
|
|
373
|
+
* false` means *do not go back to the network for something already held*, and an earlier version
|
|
374
|
+
* of this had it backwards, force-refetching precisely when told not to. That is invisible when
|
|
375
|
+
* every reader agrees about the data and very visible when they do not: the refetch overwrote a
|
|
376
|
+
* record written by a newer app with the older reader's own shape.
|
|
377
|
+
*/
|
|
378
|
+
void (async () => {
|
|
379
|
+
const hit = await readView();
|
|
380
|
+
if (!hit || staleWhileRevalidate)
|
|
381
|
+
await fetchAndStore(hit);
|
|
382
|
+
})().catch((error) => {
|
|
383
|
+
// Reading the partition is the throw that lands here: a query created after sign-out cannot
|
|
384
|
+
// say where to look. Reported as an error *state* rather than an unhandled rejection, because
|
|
385
|
+
// the caller has a subscriber and no promise to catch — and an app that renders its query
|
|
386
|
+
// states will show this one without being taught anything new.
|
|
387
|
+
if (!controller.signal.aborted)
|
|
388
|
+
emit({ status: 'error', error });
|
|
389
|
+
});
|
|
390
|
+
return {
|
|
391
|
+
get current() {
|
|
392
|
+
return state;
|
|
393
|
+
},
|
|
394
|
+
subscribe(listener) {
|
|
395
|
+
listeners.add(listener);
|
|
396
|
+
listener(state);
|
|
397
|
+
return () => void listeners.delete(listener);
|
|
398
|
+
},
|
|
399
|
+
refetch: () => fetchAndStore(true),
|
|
400
|
+
dispose() {
|
|
401
|
+
controller.abort();
|
|
402
|
+
unsubscribe();
|
|
403
|
+
unsubscribePending();
|
|
404
|
+
listeners.clear();
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Which of the patched fields the server did not agree with.
|
|
410
|
+
*
|
|
411
|
+
* Only the patched ones: a server that also touched `updatedAt` did not disagree with anybody, and
|
|
412
|
+
* reporting that as a conflict would train users to dismiss the ones that matter.
|
|
413
|
+
*/
|
|
414
|
+
function disagreements(expected, actual, paths) {
|
|
415
|
+
const from = (value) => (value ?? {});
|
|
416
|
+
const before = from(expected);
|
|
417
|
+
const after = from(actual);
|
|
418
|
+
return paths.filter((path) => !same(before[path], after[path]));
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Structural equality, by serialization.
|
|
422
|
+
*
|
|
423
|
+
* Adequate because both sides came off the wire or out of an envelope moments ago — values that
|
|
424
|
+
* survive JSON by construction. A deep-equality helper would be more code defending against inputs
|
|
425
|
+
* this path cannot receive.
|
|
426
|
+
*/
|
|
427
|
+
function same(a, b) {
|
|
428
|
+
return a === b || JSON.stringify(a) === JSON.stringify(b);
|
|
429
|
+
}
|
|
430
|
+
//# sourceMappingURL=query.js.map
|