@foir/sdk 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +497 -27
- package/README.md +111 -0
- package/dist/auth/auth.d.ts +72 -1
- package/dist/auth/hosted.d.ts +71 -4
- package/dist/auth/index.d.ts +3 -3
- package/dist/auth/operations.d.ts +84 -0
- package/dist/auth/ops.d.ts +10 -0
- package/dist/auth.js +127 -19
- package/dist/client.js +63 -6
- package/dist/codegen/block-registry.d.ts +1 -1
- package/dist/codegen/block-scaffold.d.ts +1 -1
- package/dist/codegen/index.d.ts +1 -1
- package/dist/codegen/item-registry.d.ts +1 -1
- package/dist/codegen/item-scaffold.d.ts +1 -1
- package/dist/content/content-renderer.d.ts +4 -5
- package/dist/content/index.d.ts +1 -1
- package/dist/content/richtext.d.ts +1 -1
- package/dist/data/builtins.d.ts +64 -12
- package/dist/data/client.d.ts +82 -1
- package/dist/data/decimal.d.ts +212 -0
- package/dist/data/document.d.ts +1 -1
- package/dist/data/index.d.ts +10 -8
- package/dist/data/manifest.d.ts +9 -1
- package/dist/data/schema.d.ts +2 -2
- package/dist/data/subscriptions.d.ts +36 -10
- package/dist/data/transport.d.ts +120 -3
- package/dist/data.js +377 -16
- package/dist/graphql/cdn-route.d.ts +1 -1
- package/dist/graphql/foir-request.d.ts +1 -1
- package/dist/live/coalesce.d.ts +57 -0
- package/dist/live/drift.d.ts +59 -0
- package/dist/live/events.d.ts +13 -0
- package/dist/live/headers.d.ts +53 -0
- package/dist/live/index.d.ts +54 -0
- package/dist/live/keys.d.ts +72 -0
- package/dist/live/manager.d.ts +77 -0
- package/dist/live/react/index.d.ts +143 -0
- package/dist/live/reconcile.d.ts +82 -0
- package/dist/live/resync.d.ts +59 -0
- package/dist/live/store.d.ts +134 -0
- package/dist/live/tagIndex.d.ts +29 -0
- package/dist/live/tanstack.d.ts +127 -0
- package/dist/live/writetoken.d.ts +86 -0
- package/dist/live-react.js +395 -0
- package/dist/live.js +845 -0
- package/dist/next/cacheTags.d.ts +245 -0
- package/dist/next/index.d.ts +7 -0
- package/dist/next.js +179 -6
- package/dist/push.js +81 -8
- package/dist/react.js +76 -5
- package/dist/realtime/index.d.ts +4 -5
- package/dist/server/events-client.d.ts +54 -4
- package/dist/server/index.d.ts +3 -2
- package/dist/server/require-scoped-token.d.ts +1 -1
- package/dist/server.js +454 -2
- package/dist/transport/foir-http.d.ts +113 -10
- package/package.json +10 -2
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { FoirRequestError } from '../transport/foir-http';
|
|
2
|
+
/**
|
|
3
|
+
* live/resync.ts — when the cache has to admit it cannot be repaired.
|
|
4
|
+
*
|
|
5
|
+
* # There is no gap to detect, and that is the point
|
|
6
|
+
*
|
|
7
|
+
* The obvious design for "did I miss a message" is a contiguous counter, and it
|
|
8
|
+
* does not work on this stream: `seq` is the project's clock and record changes
|
|
9
|
+
* are only some of what advances it, so two consecutive `recordChanged`
|
|
10
|
+
* messages are LEGITIMATELY non-adjacent. "I missed one" and "nothing else
|
|
11
|
+
* happened" are indistinguishable by counting.
|
|
12
|
+
*
|
|
13
|
+
* A cursor needs only ordering, which `seq` has. So the subscription hands back
|
|
14
|
+
* the highest seq it applied and the server replays what came after it — a drop
|
|
15
|
+
* anywhere upstream is repaired by the next connect rather than noticed by a
|
|
16
|
+
* counter, and there is no gap signal to act on because there is no gap.
|
|
17
|
+
*
|
|
18
|
+
* # What is left is the two refusals
|
|
19
|
+
*
|
|
20
|
+
* The server refuses a cursor it cannot honour rather than replaying part of it,
|
|
21
|
+
* because a cache handed a partial backlog believes it is caught up — which is
|
|
22
|
+
* exactly the silent staleness the cursor removes. Those refusals are the only
|
|
23
|
+
* "your cache is unreliable now" the client ever gets, and each is a HARD
|
|
24
|
+
* RESYNC: throw the cache away and refetch, because there is no way to know what
|
|
25
|
+
* was missed.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* `cursor_expired` — the cursor is older than the log's retention window.
|
|
29
|
+
* `backlog_too_large` — the cursor is further behind than the surface will page.
|
|
30
|
+
*
|
|
31
|
+
* Both arrive as a graphql-ws `error` frame with the code in `extensions`, and
|
|
32
|
+
* both are refusals to HANDLE rather than failures to report. Branch on the
|
|
33
|
+
* code, never on the message: the sentence is documentation and will be
|
|
34
|
+
* reworded, the code is the contract.
|
|
35
|
+
*/
|
|
36
|
+
export declare const FOIR_RESYNC_ERROR_CODES: readonly ["cursor_expired", "backlog_too_large"];
|
|
37
|
+
export type FoirResyncErrorCode = (typeof FOIR_RESYNC_ERROR_CODES)[number];
|
|
38
|
+
/**
|
|
39
|
+
* Whether a terminal subscription error means the cache must be thrown away.
|
|
40
|
+
*
|
|
41
|
+
* Anything else is a refusal about THIS subscription — a missing scope, an
|
|
42
|
+
* unwatchable model, a transport that gave up — and says nothing about whether
|
|
43
|
+
* what is already cached is still true. Resyncing on those would turn a
|
|
44
|
+
* permission error into a full refetch storm on every reconnect attempt.
|
|
45
|
+
*/
|
|
46
|
+
export declare function foirRequiresResync(error: Pick<FoirRequestError, 'code'>): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* The cursor to resume from after a refusal, when the server named one.
|
|
49
|
+
*
|
|
50
|
+
* `cursor_expired` carries `resumeFrom` — one below the oldest event still
|
|
51
|
+
* retained, so resuming from it loses nothing that still exists. Resuming there
|
|
52
|
+
* is only sound AFTER a resync: it skips everything that fell out of the window,
|
|
53
|
+
* and a cache that kept its entries would keep them wrong forever.
|
|
54
|
+
*
|
|
55
|
+
* `backlog_too_large` names no resume position on purpose. The honest recovery
|
|
56
|
+
* is to refetch and start from the seq that read reports, which is a value the
|
|
57
|
+
* client obtains from the response rather than from the refusal.
|
|
58
|
+
*/
|
|
59
|
+
export declare function foirResumeCursor(error: Pick<FoirRequestError, 'graphQLErrors'>): number | null;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { FoirRecordChangedEvent } from '../data/subscriptions';
|
|
2
|
+
import { type FoirTagIndex } from './tagIndex';
|
|
3
|
+
/**
|
|
4
|
+
* live/store.ts — the client's half of the invalidation graph.
|
|
5
|
+
*
|
|
6
|
+
* # IT STORES THE RAW WIRE PAYLOAD, and hydration happens at the read boundary
|
|
7
|
+
*
|
|
8
|
+
* This is the load-bearing decision in the file and it is forced, not chosen.
|
|
9
|
+
* `FoirDecimal` is a class with `#`-private fields, and every ordinary cache
|
|
10
|
+
* reflex breaks on it QUIETLY:
|
|
11
|
+
*
|
|
12
|
+
* - `Object.keys(d)` is `[]`. A structural deep-equal walking own enumerable
|
|
13
|
+
* properties sees `{}` for every decimal, so it reports `decimal('1.00')`
|
|
14
|
+
* equal to `decimal('999.99')`. That is a false NEGATIVE on change
|
|
15
|
+
* detection — the cache serves a stale value and never learns otherwise,
|
|
16
|
+
* which is worse than a false positive by every measure.
|
|
17
|
+
* - `structuredClone(d)` returns `{}` and DOES NOT THROW. The standard deep
|
|
18
|
+
* clone a cache reaches for destroys the value in silence.
|
|
19
|
+
* - `[Symbol.toPrimitive]` throws on the number and default hints, so any
|
|
20
|
+
* comparison doing `a < b` or `Number(a)` for ordering is a `TypeError`.
|
|
21
|
+
* - `toJSON()` emits the canonical string and `JSON.parse` does not revive it,
|
|
22
|
+
* so persistence is asymmetric by type.
|
|
23
|
+
* - `equals()` is scale-insensitive while `toString()` is scale-preserving, so
|
|
24
|
+
* even a correct comparison cannot be reference- or text-based.
|
|
25
|
+
*
|
|
26
|
+
* Raw wire JSON has none of those properties, because a decimal there is just a
|
|
27
|
+
* string. Change detection becomes an exact string compare, persistence is
|
|
28
|
+
* correct by construction (persist raw, rehydrate through the same path as a
|
|
29
|
+
* fresh response), and the completeness mask operates on the raw keys `omit`
|
|
30
|
+
* already operates on.
|
|
31
|
+
*
|
|
32
|
+
* So: **never cache hydrated entities.** There is no `structuredClone` and no
|
|
33
|
+
* generic deep-equal anywhere below, and their absence is the design rather than
|
|
34
|
+
* an omission.
|
|
35
|
+
*
|
|
36
|
+
* # Keys normalise, payloads never do
|
|
37
|
+
*
|
|
38
|
+
* The converse hazard, and the more expensive one. Dedupe entities by value and
|
|
39
|
+
* keep the first instance and you serve `"1.0"` where the server sent `"1.00"` —
|
|
40
|
+
* a silent change to how a price renders, because trailing zeros are significant
|
|
41
|
+
* for money. The store never holds a decimal instance at all, so this half falls
|
|
42
|
+
* out for free. `live/keys.ts` does the normalising, on keys only.
|
|
43
|
+
*/
|
|
44
|
+
/** One cached response, exactly as it came off the wire. */
|
|
45
|
+
export interface FoirLiveEntry {
|
|
46
|
+
/**
|
|
47
|
+
* The RAW payload. Not hydrated, not cloned, not normalised — see the file
|
|
48
|
+
* comment. Treat as immutable: it is handed out by reference, and a consumer
|
|
49
|
+
* that mutates it corrupts every future read of this key.
|
|
50
|
+
*/
|
|
51
|
+
payload: unknown;
|
|
52
|
+
/** The response's dependency set, as the server computed it. */
|
|
53
|
+
tags: readonly string[];
|
|
54
|
+
/** The project seq this response resolved under, or `null` for unknown. */
|
|
55
|
+
seq: number | null;
|
|
56
|
+
/**
|
|
57
|
+
* The fields the call asked the server to leave out. The COMPLETENESS MASK.
|
|
58
|
+
* Empty means the entry is whole.
|
|
59
|
+
*/
|
|
60
|
+
omitted: readonly string[];
|
|
61
|
+
/** When it was written, for a consumer applying its own staleness floor. */
|
|
62
|
+
storedAt: number;
|
|
63
|
+
}
|
|
64
|
+
export interface FoirLiveWrite {
|
|
65
|
+
payload: unknown;
|
|
66
|
+
tags?: readonly string[];
|
|
67
|
+
seq?: number | null;
|
|
68
|
+
/** The call's `omit`, verbatim. */
|
|
69
|
+
omit?: readonly string[];
|
|
70
|
+
}
|
|
71
|
+
export interface FoirLiveReadOptions {
|
|
72
|
+
/**
|
|
73
|
+
* Fields this read needs present. An entry that declares any of them omitted
|
|
74
|
+
* is a MISS rather than a partial answer.
|
|
75
|
+
*/
|
|
76
|
+
needs?: readonly string[];
|
|
77
|
+
}
|
|
78
|
+
/** What changed, so a consumer can act on it without diffing the store. */
|
|
79
|
+
export interface FoirLiveInvalidation {
|
|
80
|
+
/** The keys dropped by this event. */
|
|
81
|
+
keys: string[];
|
|
82
|
+
/** The tags the event resolved to. */
|
|
83
|
+
tags: string[];
|
|
84
|
+
/** True when the event was ignored as already applied. */
|
|
85
|
+
ignored: boolean;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The write half of one in-flight request, handed to the function that issues
|
|
89
|
+
* it.
|
|
90
|
+
*
|
|
91
|
+
* It exists because the fence CANNOT be established at write time. A write that
|
|
92
|
+
* belongs to a request issued before a resync has to be recognisable as such
|
|
93
|
+
* when it lands, and by then the store looks exactly as it does for a fresh one
|
|
94
|
+
* — so the moment that has to be captured is the moment the request STARTS.
|
|
95
|
+
* Handing the fence to the running function is what makes that unforgettable:
|
|
96
|
+
* the natural way to store a response is through the object you were given.
|
|
97
|
+
*/
|
|
98
|
+
export interface FoirWriteFence {
|
|
99
|
+
/** Write this request's response, unless the store has resynced since. */
|
|
100
|
+
write(write: FoirLiveWrite): boolean;
|
|
101
|
+
/** Whether this request is still current. */
|
|
102
|
+
readonly current: boolean;
|
|
103
|
+
}
|
|
104
|
+
export interface FoirLiveStore {
|
|
105
|
+
/** Read an entry, honouring the completeness mask. */
|
|
106
|
+
read(key: string, options?: FoirLiveReadOptions): FoirLiveEntry | undefined;
|
|
107
|
+
/**
|
|
108
|
+
* Write a response, UNFENCED. Refuses a result older than the one already
|
|
109
|
+
* applied, but knows nothing about resyncs — use the fence `dedupe` hands the
|
|
110
|
+
* running function for anything that came off the network.
|
|
111
|
+
*/
|
|
112
|
+
write(key: string, write: FoirLiveWrite): boolean;
|
|
113
|
+
/** Drop one key. */
|
|
114
|
+
drop(key: string): void;
|
|
115
|
+
/**
|
|
116
|
+
* Share one in-flight request between concurrent callers of the same key, and
|
|
117
|
+
* hand the runner a fence bound to this request's epoch.
|
|
118
|
+
*/
|
|
119
|
+
dedupe<T>(key: string, run: (fence: FoirWriteFence) => Promise<T>): Promise<T>;
|
|
120
|
+
/** Apply a record change: drop everything that depended on it. */
|
|
121
|
+
applyRecordChange(event: FoirRecordChangedEvent): FoirLiveInvalidation;
|
|
122
|
+
/** Throw everything away, and fence anything already in flight. */
|
|
123
|
+
resync(): void;
|
|
124
|
+
/** Called after every invalidation and every resync. */
|
|
125
|
+
onInvalidate(listener: (invalidation: FoirLiveInvalidation) => void): () => void;
|
|
126
|
+
readonly size: number;
|
|
127
|
+
/** Test seam and introspection: the graph itself. */
|
|
128
|
+
readonly tags: FoirTagIndex;
|
|
129
|
+
}
|
|
130
|
+
export interface FoirLiveStoreOptions {
|
|
131
|
+
/** Clock, injectable so a test can pin `storedAt`. */
|
|
132
|
+
now?: () => number;
|
|
133
|
+
}
|
|
134
|
+
export declare function createFoirLiveStore(options?: FoirLiveStoreOptions): FoirLiveStore;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* live/tagIndex.ts — the dependency graph, both ways round.
|
|
3
|
+
*
|
|
4
|
+
* The server already knows which records and collections every response depends
|
|
5
|
+
* on; it has to, to invalidate its own cache. This is that graph held on the
|
|
6
|
+
* client: `key → tags` so an entry can be un-indexed when it is dropped, and
|
|
7
|
+
* `tag → keys` so an event can name what it invalidates without walking the
|
|
8
|
+
* store.
|
|
9
|
+
*
|
|
10
|
+
* Both directions, because keeping only one turns the other question into a
|
|
11
|
+
* linear scan of everything cached — and the read side runs on every response
|
|
12
|
+
* while the write side runs on every event. It is the same reason a database
|
|
13
|
+
* keeps an index rather than sorting on demand.
|
|
14
|
+
*/
|
|
15
|
+
export interface FoirTagIndex {
|
|
16
|
+
/** Replace the tag set for one key. */
|
|
17
|
+
set(key: string, tags: readonly string[]): void;
|
|
18
|
+
/** Forget a key entirely. */
|
|
19
|
+
remove(key: string): void;
|
|
20
|
+
/** The tags recorded for a key. */
|
|
21
|
+
tagsFor(key: string): readonly string[];
|
|
22
|
+
/** Every key that carries any of `tags`, deduped. */
|
|
23
|
+
keysForTags(tags: readonly string[]): string[];
|
|
24
|
+
/** Forget everything. */
|
|
25
|
+
clear(): void;
|
|
26
|
+
/** How many keys are indexed. */
|
|
27
|
+
readonly size: number;
|
|
28
|
+
}
|
|
29
|
+
export declare function createFoirTagIndex(): FoirTagIndex;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { FoirRecordChangedEvent } from '../data/subscriptions';
|
|
2
|
+
import { type FoirLiveCall } from './keys';
|
|
3
|
+
/**
|
|
4
|
+
* live/tanstack.ts — the adapter, and it is deliberately an adapter.
|
|
5
|
+
*
|
|
6
|
+
* Users keep the query library they already have. What is exported here is the
|
|
7
|
+
* query-key derivation and the rule for what a record change does to loaded
|
|
8
|
+
* pages; we own nothing of the rendering layer and no part of the cache.
|
|
9
|
+
*
|
|
10
|
+
* The differentiation is entirely in the invalidation graph, and an adapter
|
|
11
|
+
* captures all of it. A store of our own would add a permanent React support
|
|
12
|
+
* surface for no differentiated gain.
|
|
13
|
+
*
|
|
14
|
+
* ## Structurally typed, so `@tanstack/react-query` is not a dependency
|
|
15
|
+
*
|
|
16
|
+
* The three methods below are the whole of what this needs from a query client.
|
|
17
|
+
* A real `QueryClient` satisfies the interface, and the SDK neither imports nor
|
|
18
|
+
* pins a version of it — the same reason the Next adapter takes `cacheTag` as a
|
|
19
|
+
* function.
|
|
20
|
+
*/
|
|
21
|
+
export interface FoirQueryClientLike {
|
|
22
|
+
invalidateQueries(filters: {
|
|
23
|
+
queryKey: readonly unknown[];
|
|
24
|
+
exact?: boolean;
|
|
25
|
+
}): unknown;
|
|
26
|
+
getQueriesData(filters: {
|
|
27
|
+
queryKey: readonly unknown[];
|
|
28
|
+
}): Array<[readonly unknown[], unknown]>;
|
|
29
|
+
setQueriesData(filters: {
|
|
30
|
+
queryKey: readonly unknown[];
|
|
31
|
+
}, updater: (previous: unknown) => unknown): unknown;
|
|
32
|
+
}
|
|
33
|
+
/** The root every Foir query key starts with, so a filter can reach all of them. */
|
|
34
|
+
export declare const FOIR_QUERY_KEY_ROOT: "foir";
|
|
35
|
+
/**
|
|
36
|
+
* The query key for one call.
|
|
37
|
+
*
|
|
38
|
+
* An ARRAY, because that is what a query library filters on hierarchically:
|
|
39
|
+
* `['foir']` reaches every Foir query, `['foir', 'product']` reaches one model's,
|
|
40
|
+
* and the full key reaches one call. The last segment is `foirQueryKey`'s
|
|
41
|
+
* string, which is where the real work is — stable ordering, `undefined` kept
|
|
42
|
+
* distinct from absent, and decimals canonicalised so `1.0` and `1.00` are one
|
|
43
|
+
* entry rather than two.
|
|
44
|
+
*/
|
|
45
|
+
export declare function foirQueryKeys(call: FoirLiveCall): readonly unknown[];
|
|
46
|
+
/** Every query for one model. */
|
|
47
|
+
export declare function foirModelQueryKey(modelKey: string): readonly unknown[];
|
|
48
|
+
/**
|
|
49
|
+
* How live a query is.
|
|
50
|
+
*
|
|
51
|
+
* `auto` — apply the full rule below.
|
|
52
|
+
* `notify` — never touch the data; report that something changed and let the UI
|
|
53
|
+
* decide. The honest setting for a reader mid-scroll.
|
|
54
|
+
* `off` — ignore the stream entirely.
|
|
55
|
+
*/
|
|
56
|
+
export type FoirLiveMode = 'auto' | 'notify' | 'off';
|
|
57
|
+
/** What one record change did, so a UI can render the parts it cannot do itself. */
|
|
58
|
+
export interface FoirLiveEffect {
|
|
59
|
+
/** Queries whose data was refetched (an `updated` record they hold). */
|
|
60
|
+
invalidated: ReadonlyArray<readonly unknown[]>;
|
|
61
|
+
/** Queries a row was removed from without any fetch (a `deleted` record). */
|
|
62
|
+
removed: ReadonlyArray<readonly unknown[]>;
|
|
63
|
+
/**
|
|
64
|
+
* The model gained a record. NOT applied to any query — see below for why a
|
|
65
|
+
* create cannot be spliced — so a UI shows "N new" and refetches on a click.
|
|
66
|
+
*/
|
|
67
|
+
created: boolean;
|
|
68
|
+
/** `totalCount` on this model's loaded queries is now approximate. */
|
|
69
|
+
totalCountStale: boolean;
|
|
70
|
+
}
|
|
71
|
+
export interface FoirApplyOptions {
|
|
72
|
+
mode?: FoirLiveMode;
|
|
73
|
+
/**
|
|
74
|
+
* The field a record's id arrives under. `_id` is the platform's own
|
|
75
|
+
* spelling and is what the generated types emit.
|
|
76
|
+
*/
|
|
77
|
+
idField?: string;
|
|
78
|
+
/**
|
|
79
|
+
* Where an `updated` invalidation goes. Defaults to calling the query client
|
|
80
|
+
* straight away.
|
|
81
|
+
*
|
|
82
|
+
* The seam exists so a burst can be COALESCED: fifty writes on one model fire
|
|
83
|
+
* fifty invalidations, and refetching each one turns the exact screen a burst
|
|
84
|
+
* is most likely to be watched from into nothing but refetching. The
|
|
85
|
+
* coalescer takes the key here and runs one refetch per key per window.
|
|
86
|
+
*
|
|
87
|
+
* Only the update path is routed through it. A delete is applied in memory
|
|
88
|
+
* with no fetch at all, and a create deliberately fetches nothing — neither
|
|
89
|
+
* has anything to coalesce.
|
|
90
|
+
*/
|
|
91
|
+
invalidate?: (queryKey: readonly unknown[]) => void;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Apply one record change to the loaded queries for its model.
|
|
95
|
+
*
|
|
96
|
+
* # Pagination, decided — and the three cases are handled ASYMMETRICALLY
|
|
97
|
+
* # because that is what the data supports
|
|
98
|
+
*
|
|
99
|
+
* Foir is pure Relay cursor pagination with no `offset` or `skip`, so loaded
|
|
100
|
+
* pages keep their relative order and `endCursor` stays correct. Only three
|
|
101
|
+
* things actually break:
|
|
102
|
+
*
|
|
103
|
+
* - **`deleted` → remove the row by id, with NO FETCH.** The event carries
|
|
104
|
+
* the id, so this is instantly correct and costs a round trip nothing.
|
|
105
|
+
* - **`created` → expose a COUNT, do not refetch.** A create cannot be
|
|
106
|
+
* spliced: record events carry no body, and even with one you could not
|
|
107
|
+
* place the row without knowing the query's sort. Refetching the head page
|
|
108
|
+
* instead was considered and rejected — it prepends under a reader
|
|
109
|
+
* mid-scroll, which is the exact disruption this avoids.
|
|
110
|
+
* - **`updated` → refetch the queries holding that record.** Membership is
|
|
111
|
+
* unchanged, so the cursors hold.
|
|
112
|
+
*
|
|
113
|
+
* `totalCount` goes stale on any membership change and is flagged rather than
|
|
114
|
+
* corrected: correcting it would need a refetch, which is the thing being
|
|
115
|
+
* avoided.
|
|
116
|
+
*
|
|
117
|
+
* # Mutable sort keys are a DOCUMENTED LIMITATION, not a bug to fix
|
|
118
|
+
*
|
|
119
|
+
* A query ordered by `updatedAt`, or by a `position` a user drags, can have a
|
|
120
|
+
* record move between pages on update — appearing twice, or vanishing. There is
|
|
121
|
+
* no detection here and there should not be: it needs client-side knowledge of
|
|
122
|
+
* the sort fields, and the fully-correct alternative turns a routine edit into
|
|
123
|
+
* an N-page refetch on most feeds. Say it in the docs; do not build for it.
|
|
124
|
+
*/
|
|
125
|
+
export declare function applyFoirRecordChange(queryClient: FoirQueryClientLike, event: FoirRecordChangedEvent, options?: FoirApplyOptions): FoirLiveEffect;
|
|
126
|
+
/** The tags one change would invalidate, for a consumer driving its own cache. */
|
|
127
|
+
export declare function foirTagsForQueryInvalidation(event: FoirRecordChangedEvent): string[];
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { FoirHeaderSource } from './headers';
|
|
2
|
+
/**
|
|
3
|
+
* live/writetoken.ts — read your own write without throwing the cache away.
|
|
4
|
+
*
|
|
5
|
+
* # The problem it replaces
|
|
6
|
+
*
|
|
7
|
+
* The documented answer for reading your own write used to be
|
|
8
|
+
* `Cache-Control: no-cache`: skip the entry, resolve fresh, repopulate. It
|
|
9
|
+
* works, and it discards every warm entry on that path — so a storefront that
|
|
10
|
+
* does one write makes the next visitor pay a cold read on that query.
|
|
11
|
+
*
|
|
12
|
+
* A floor is the precise version. The mutation hands back a token naming where
|
|
13
|
+
* it committed; a later read carries it; the server serves a cached entry only
|
|
14
|
+
* if that entry resolved at or above the write's position, and resolves fresh
|
|
15
|
+
* otherwise. Same guarantee, and entries that are already fresh enough survive.
|
|
16
|
+
*
|
|
17
|
+
* # The token is opaque, and it is not a seq
|
|
18
|
+
*
|
|
19
|
+
* It names an EVENT, not a position, and the difference is why the mutation does
|
|
20
|
+
* not have to wait. `seq` is stamped asynchronously, by a different process on a
|
|
21
|
+
* different tick, so a mutation that waited for its own stamp could not complete
|
|
22
|
+
* unless a background loop elsewhere was alive and current — trading a
|
|
23
|
+
* permanently reliable write for a sometimes-faster read.
|
|
24
|
+
*
|
|
25
|
+
* So the read path resolves the token instead, and degrades correctly when it
|
|
26
|
+
* cannot: an unstamped event states no ordering yet, so the read simply resolves
|
|
27
|
+
* fresh, which is exactly the old `no-cache` behaviour for the tens of
|
|
28
|
+
* milliseconds before the stamp lands. Nothing here should try to parse it.
|
|
29
|
+
*
|
|
30
|
+
* # Why this is a primitive rather than automatic
|
|
31
|
+
*
|
|
32
|
+
* Holding "the last write token" is application state — which write, for how
|
|
33
|
+
* long, and across which of a user's tabs are questions this package cannot
|
|
34
|
+
* answer, and guessing produces either a floor that expires too early to help or
|
|
35
|
+
* one that pins reads to origin long after the write stopped mattering.
|
|
36
|
+
*
|
|
37
|
+
* So the SDK names the header and reads it; the consumer decides the lifetime.
|
|
38
|
+
* `readFoirLiveHeaders` takes the same line for the same reason.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* The header carrying the write token, in BOTH directions: out of a mutation's
|
|
42
|
+
* response, back in on a read's request.
|
|
43
|
+
*
|
|
44
|
+
* One name rather than a pair, because it is one value making a round trip and
|
|
45
|
+
* two names would be two things to keep in step.
|
|
46
|
+
*
|
|
47
|
+
* A header rather than a schema field, deliberately: a field would pull in
|
|
48
|
+
* foir-proto, a regen, the sync-gen job and the vocabulary mirrors, for a value
|
|
49
|
+
* that is transport bookkeeping and never part of anyone's data model.
|
|
50
|
+
*/
|
|
51
|
+
export declare const FOIR_WRITE_TOKEN_HEADER = "X-Foir-Write-Token";
|
|
52
|
+
/**
|
|
53
|
+
* The write token a mutation's response carried, or `null`.
|
|
54
|
+
*
|
|
55
|
+
* Absent is the ordinary case for anything that did not write, and for a write
|
|
56
|
+
* whose emit produced no event. `null` means "no floor to state", never "floor
|
|
57
|
+
* of zero" — a zero floor would be satisfied by every cached entry ever, which
|
|
58
|
+
* is silently the opposite of what a caller asking for one wants.
|
|
59
|
+
*
|
|
60
|
+
* # It is readable from a browser, and that is not accidental
|
|
61
|
+
*
|
|
62
|
+
* A response header is invisible to cross-origin JS unless the server lists it
|
|
63
|
+
* in `Access-Control-Expose-Headers`, and api-public lists this one — alongside
|
|
64
|
+
* the matching entry on the ALLOWED REQUEST headers, so the token can also be
|
|
65
|
+
* sent back. Both halves are needed and the request half is the one that is easy
|
|
66
|
+
* to forget, because its absence is a preflight failure that names the header
|
|
67
|
+
* and not the file.
|
|
68
|
+
*
|
|
69
|
+
* What can still take it away is a proxy of the consumer's own sitting in front
|
|
70
|
+
* of the API. Stripped from the response, this returns `null` and looks exactly
|
|
71
|
+
* like a mutation that emitted nothing; stripped from the request, the floor is
|
|
72
|
+
* silently never applied.
|
|
73
|
+
*/
|
|
74
|
+
export declare function readFoirWriteToken(headers: FoirHeaderSource): string | null;
|
|
75
|
+
/**
|
|
76
|
+
* The request headers a floored read should carry, or an empty object.
|
|
77
|
+
*
|
|
78
|
+
* Empty rather than a header with an empty value, because the two are different
|
|
79
|
+
* requests: a header present but blank is a floor the server has to interpret,
|
|
80
|
+
* and the honest way to say "no floor" is to say nothing.
|
|
81
|
+
*
|
|
82
|
+
* Sending this also changes the LANE — see `foirRoute`, which reports `floor`
|
|
83
|
+
* for a token-bearing read. A floor the shared edge cannot honour is worse than
|
|
84
|
+
* no floor at all, because it reads as satisfied.
|
|
85
|
+
*/
|
|
86
|
+
export declare function foirWriteTokenHeaders(token: string | null | undefined): Record<string, string>;
|