@danxbot/ui 3.7.0 → 3.8.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/dist/index.js +11304 -10078
- package/dist/index.js.map +1 -1
- package/dist/types/index.d.ts +3 -0
- package/dist/types/lib/reads/hooks.d.ts +49 -0
- package/dist/types/lib/reads/index.d.ts +4 -0
- package/dist/types/lib/reads/refetch.d.ts +52 -0
- package/dist/types/lib/reads/slots.d.ts +52 -0
- package/dist/types/lib/reads/types.d.ts +139 -0
- package/dist/types/lib/realtime/hooks.d.ts +38 -0
- package/dist/types/lib/realtime/index.d.ts +4 -0
- package/dist/types/lib/realtime/provider.d.ts +38 -0
- package/dist/types/lib/realtime/registry.d.ts +172 -0
- package/dist/types/lib/realtime/types.d.ts +202 -0
- package/dist/types/lib/records/define-record-type.d.ts +29 -0
- package/dist/types/lib/records/hooks.d.ts +46 -0
- package/dist/types/lib/records/index.d.ts +5 -0
- package/dist/types/lib/records/store.d.ts +117 -0
- package/dist/types/lib/records/types.d.ts +188 -0
- package/dist/types/lib/records/wire.d.ts +96 -0
- package/package.json +1 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { RecordPayload } from "../records";
|
|
2
|
+
/**
|
|
3
|
+
* What the socket says about itself.
|
|
4
|
+
*
|
|
5
|
+
* IT IS NOT THE SAME QUESTION AS "am I receiving events". A registry entry can
|
|
6
|
+
* lapse server-side while this reads `"connected"` forever, which is exactly
|
|
7
|
+
* why liveness is computed from three inputs and not from this one.
|
|
8
|
+
*/
|
|
9
|
+
export type RealtimeConnectionState = "connecting" | "connected" | "disconnected";
|
|
10
|
+
/**
|
|
11
|
+
* WHICH RECORDS a subscription is asking to hear about.
|
|
12
|
+
*
|
|
13
|
+
* - `ids` — the rows on screen. The backend keys its registry entry per model
|
|
14
|
+
* id, so this is the scope that pins specific records.
|
|
15
|
+
* - `filter` — a server-evaluated predicate. The backend re-runs it per save,
|
|
16
|
+
* which is what makes a `created` event authoritative about set entry. It
|
|
17
|
+
* answers "does this record match my filter" and NOTHING about sort position
|
|
18
|
+
* or page boundary.
|
|
19
|
+
* - `all` — every record of the type for this tenant.
|
|
20
|
+
*/
|
|
21
|
+
export type RealtimeScope = {
|
|
22
|
+
readonly kind: "ids";
|
|
23
|
+
readonly ids: readonly string[];
|
|
24
|
+
} | {
|
|
25
|
+
readonly kind: "filter";
|
|
26
|
+
readonly filter: Readonly<Record<string, unknown>>;
|
|
27
|
+
} | {
|
|
28
|
+
readonly kind: "all";
|
|
29
|
+
};
|
|
30
|
+
/** What a caller may write, before normalization. `"all"` or ids or a filter. */
|
|
31
|
+
export type RealtimeScopeInput = "all" | readonly (string | number)[] | {
|
|
32
|
+
readonly filter: Readonly<Record<string, unknown>>;
|
|
33
|
+
};
|
|
34
|
+
/** One server-side registration, as the transport receives it. */
|
|
35
|
+
export interface SubscriptionRequest {
|
|
36
|
+
/** The NORMALIZED model name — never a `Resource` suffix. */
|
|
37
|
+
readonly type: string;
|
|
38
|
+
readonly scope: RealtimeScope;
|
|
39
|
+
/**
|
|
40
|
+
* The tenant this registration belongs to, passed rather than inferred so a
|
|
41
|
+
* request issued before a team switch can never be attributed to the team
|
|
42
|
+
* that came after it.
|
|
43
|
+
*/
|
|
44
|
+
readonly tenant: string | number | undefined;
|
|
45
|
+
/** A stable identity for this (type, scope) pair. Useful as a request key. */
|
|
46
|
+
readonly key: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* What the server said about a registration.
|
|
50
|
+
*
|
|
51
|
+
* `expiresAt` IS THE WHOLE POINT OF THIS TYPE. The registry holds it and treats
|
|
52
|
+
* a subscription as DEAD once the clock passes it minus a margin — never
|
|
53
|
+
* "letting it expire naturally", which is the prior art's exact wording and its
|
|
54
|
+
* exact bug: two failed keepalives inside one TTL window and that resource
|
|
55
|
+
* stops updating forever, silently, with a healthy socket.
|
|
56
|
+
*/
|
|
57
|
+
export interface SubscriptionLease {
|
|
58
|
+
/** Milliseconds since epoch. Omit only if the server genuinely does not say. */
|
|
59
|
+
readonly expiresAt?: number;
|
|
60
|
+
}
|
|
61
|
+
/** A joined socket channel. One per type per tenant, and no more. */
|
|
62
|
+
export interface RealtimeChannel {
|
|
63
|
+
/**
|
|
64
|
+
* Bind one event name.
|
|
65
|
+
*
|
|
66
|
+
* CALLED AGAIN FOR EVERY NEW EVENT NAME A LATER SUBSCRIBER BRINGS, on the
|
|
67
|
+
* SAME channel object. The prior art returns early when the channel already
|
|
68
|
+
* exists and never reaches its own bind call, so only the FIRST subscriber's
|
|
69
|
+
* event names are ever live — two real subscribers collide on that today and
|
|
70
|
+
* one of them is silently dead in production. A transport implementation must
|
|
71
|
+
* therefore accept `bind` at any time, not only immediately after joining.
|
|
72
|
+
*/
|
|
73
|
+
bind(event: string, handler: (payload: RecordPayload) => void): void;
|
|
74
|
+
/** Leave the channel entirely. Called on a tenant switch and on teardown. */
|
|
75
|
+
leave(): void;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Everything environment-specific, in one injected object: the socket, the
|
|
79
|
+
* three registry endpoints, and the connection observer.
|
|
80
|
+
*
|
|
81
|
+
* ONE CONSTRAINT ON THE IMPLEMENTATION THAT IS NOT EXPRESSIBLE IN THE TYPE, and
|
|
82
|
+
* it is the sharpest one: `subscribe`, `unsubscribe` and `keepalive` MUST NOT
|
|
83
|
+
* route their failures through an application's global 401 handler. A
|
|
84
|
+
* background resubscribe is allowed to fail; if that failure clears the
|
|
85
|
+
* session, a transient auth blip logs somebody out mid-screen — strictly worse
|
|
86
|
+
* than the stale screen the resubscribe existed to prevent. Reusing the app's
|
|
87
|
+
* default HTTP client is the easy thing to do and is exactly the bug. The
|
|
88
|
+
* registry holds up its half: it catches every rejection, backs off, and
|
|
89
|
+
* renders a DEAD state rather than escalating anything.
|
|
90
|
+
*/
|
|
91
|
+
export interface RealtimeTransport {
|
|
92
|
+
/** Register interest server-side. Rejecting is normal and is handled. */
|
|
93
|
+
subscribe(request: SubscriptionRequest): Promise<SubscriptionLease>;
|
|
94
|
+
/**
|
|
95
|
+
* Drop a registration.
|
|
96
|
+
*
|
|
97
|
+
* CALLED ONLY ON A TENANT SWITCH, never on unmount. The server keys its entry
|
|
98
|
+
* by USER, so two browser tabs belonging to one person share one entry — and
|
|
99
|
+
* one tab unmounting a screen would kill the other tab's still-mounted
|
|
100
|
+
* subscription, silently, with that tab's socket perfectly healthy. In-tab
|
|
101
|
+
* refcounting cannot fix that; the collision is above the tab. On unmount the
|
|
102
|
+
* entry is left to lapse against its own TTL, which costs a subscription
|
|
103
|
+
* outliving its component by a few minutes and nothing else.
|
|
104
|
+
*/
|
|
105
|
+
unsubscribe(request: SubscriptionRequest): Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* Renew several registrations at once.
|
|
108
|
+
*
|
|
109
|
+
* Resolve with one lease per request, in the SAME ORDER. Rejecting marks
|
|
110
|
+
* every request in the batch as failed — which is a DEAD subscription, not a
|
|
111
|
+
* warning, and the registry resubscribes rather than waiting.
|
|
112
|
+
*/
|
|
113
|
+
keepalive(requests: readonly SubscriptionRequest[]): Promise<readonly SubscriptionLease[]>;
|
|
114
|
+
/** Join (or return) the channel carrying this type for this tenant. */
|
|
115
|
+
channel(type: string, tenant: string | number | undefined): RealtimeChannel;
|
|
116
|
+
connectionState(): RealtimeConnectionState;
|
|
117
|
+
observeConnection(listener: (state: RealtimeConnectionState) => void): () => void;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Why a subscription is not delivering, in the words a screen can print.
|
|
121
|
+
*
|
|
122
|
+
* A DEBUG PANEL SERVES DEVELOPERS; THE READER NEEDS A WORD ON SCREEN. This app
|
|
123
|
+
* refuses to collapse distinct absences everywhere else it has them, and a
|
|
124
|
+
* silently dead realtime layer is exactly the absence that gets collapsed by
|
|
125
|
+
* default — the prior art's observability is console-only behind a keyboard
|
|
126
|
+
* shortcut, which is why two live bugs in it went unnoticed for years.
|
|
127
|
+
*/
|
|
128
|
+
export type RealtimeStatus =
|
|
129
|
+
/** No transport was provided. Realtime is off and the app still works. */
|
|
130
|
+
"unconfigured"
|
|
131
|
+
/** Configured, nothing subscribed yet. */
|
|
132
|
+
| "idle"
|
|
133
|
+
/** A first subscribe is in flight. */
|
|
134
|
+
| "connecting"
|
|
135
|
+
/** Every active subscription is registered and the socket is connected. */
|
|
136
|
+
| "live"
|
|
137
|
+
/** At least one subscription is not delivering. The screen should say so. */
|
|
138
|
+
| "dead";
|
|
139
|
+
/** One subscription, as a devtools panel or a status line would render it. */
|
|
140
|
+
export interface SubscriptionHealth {
|
|
141
|
+
readonly key: string;
|
|
142
|
+
readonly type: string;
|
|
143
|
+
readonly scope: RealtimeScope;
|
|
144
|
+
/** Bound event names — the UNION of every subscriber's, never the first's. */
|
|
145
|
+
readonly events: readonly string[];
|
|
146
|
+
readonly subscribers: number;
|
|
147
|
+
readonly expiresAt: number | undefined;
|
|
148
|
+
readonly dead: boolean;
|
|
149
|
+
/** Consecutive failed resubscribe attempts. Resets on success. */
|
|
150
|
+
readonly attempts: number;
|
|
151
|
+
readonly lastError: string | undefined;
|
|
152
|
+
}
|
|
153
|
+
/** The renderable liveness state. */
|
|
154
|
+
export interface RealtimeHealth {
|
|
155
|
+
readonly status: RealtimeStatus;
|
|
156
|
+
readonly connection: RealtimeConnectionState;
|
|
157
|
+
readonly subscriptions: readonly SubscriptionHealth[];
|
|
158
|
+
/**
|
|
159
|
+
* Bumps whenever a subscription that WAS dead is restored.
|
|
160
|
+
*
|
|
161
|
+
* It is the "you may have missed events" signal, and it is deliberately a
|
|
162
|
+
* monotonic counter rather than a set of keys: a lapsed registry entry stops
|
|
163
|
+
* broadcasts without saying which ones were dropped, so the only honest claim
|
|
164
|
+
* is that something may be missing. The read binding folds it into a refetch
|
|
165
|
+
* of what is mounted — which keeps the answer on screen and marks it
|
|
166
|
+
* refreshing, rather than blanking to a skeleton.
|
|
167
|
+
*/
|
|
168
|
+
readonly epoch: number;
|
|
169
|
+
readonly lastError: string | undefined;
|
|
170
|
+
}
|
|
171
|
+
/** Something worth recording. Observability only; never control flow. */
|
|
172
|
+
export interface RealtimeDiagnostic {
|
|
173
|
+
readonly kind: "subscribed" | "resubscribed" | "subscribe-failed" | "keepalive-failed" | "expired" | "disconnected" | "event" | "self-event-dropped" | "unsubscribed";
|
|
174
|
+
readonly key?: string;
|
|
175
|
+
readonly type?: string;
|
|
176
|
+
readonly event?: string;
|
|
177
|
+
readonly message?: string;
|
|
178
|
+
readonly at: number;
|
|
179
|
+
}
|
|
180
|
+
/** One broadcast, after the registry has written it into the store. */
|
|
181
|
+
export interface RealtimeEvent {
|
|
182
|
+
/** Normalized model name. */
|
|
183
|
+
readonly type: string;
|
|
184
|
+
/** The event name as bound, e.g. `"created"`. */
|
|
185
|
+
readonly event: string;
|
|
186
|
+
/**
|
|
187
|
+
* `Type:id`, or `undefined` when the payload was not a record OR the store
|
|
188
|
+
* refused the write — a record that did not land must not reach a list
|
|
189
|
+
* either.
|
|
190
|
+
*/
|
|
191
|
+
readonly key: string | undefined;
|
|
192
|
+
/**
|
|
193
|
+
* Whether this is the event that means "entered the set".
|
|
194
|
+
*
|
|
195
|
+
* COMPUTED HERE rather than compared by the consumer, because the name is the
|
|
196
|
+
* backend's (`"created"` by default, configurable on the provider) and a
|
|
197
|
+
* consumer comparing the string would be the second place that fact lives.
|
|
198
|
+
*/
|
|
199
|
+
readonly isCreate: boolean;
|
|
200
|
+
readonly payload: RecordPayload;
|
|
201
|
+
readonly tenant: string | number | undefined;
|
|
202
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { RecordTypeOptions, RecordTypeToken } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Declare a record type.
|
|
4
|
+
*
|
|
5
|
+
* THE NAME IS THE NORMALIZED MODEL NAME — `"WorkflowRun"`, NEVER
|
|
6
|
+
* `"WorkflowRunResource"`. This is the single most likely thing in this module
|
|
7
|
+
* to be built wrong, because both readings are plausible and exactly one
|
|
8
|
+
* matches: the store strips a trailing `Resource` from every incoming `__type`
|
|
9
|
+
* before keying (created payloads carry the suffix, deletes do not), so the
|
|
10
|
+
* caller always writes the model name. The wrong one produces a store that
|
|
11
|
+
* fills up and a screen that never moves, with no error anywhere — which is
|
|
12
|
+
* why passing the suffixed name warns loudly rather than quietly working.
|
|
13
|
+
*
|
|
14
|
+
* @param name normalized model name, e.g. `"WorkflowRun"`
|
|
15
|
+
* @param options `staleFields` — the fields this type's broadcast cannot carry
|
|
16
|
+
*/
|
|
17
|
+
export declare function defineRecordType<T>(name: string, options?: RecordTypeOptions): RecordTypeToken<T>;
|
|
18
|
+
/**
|
|
19
|
+
* What a type declared, for the store's own use.
|
|
20
|
+
*
|
|
21
|
+
* An UNREGISTERED type is not an error: a nested child of a type nobody
|
|
22
|
+
* declared is still a record with an identity, and refusing it would lose data
|
|
23
|
+
* the parent legitimately carries. It simply has no stale fields, which is the
|
|
24
|
+
* correct default — a type that never declared a gap in its broadcast is
|
|
25
|
+
* treated as having none.
|
|
26
|
+
*/
|
|
27
|
+
export declare function staleFieldsFor(type: string): readonly string[] | "*";
|
|
28
|
+
/** Test seam. Nothing in the library calls this. */
|
|
29
|
+
export declare function resetRecordTypes(): void;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ReadOptions, RecordState, RecordTypeToken, StalenessEntry } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Read one record.
|
|
4
|
+
*
|
|
5
|
+
* `needs` IS REQUIRED. It names the field set that must have landed from a
|
|
6
|
+
* FETCH before this key reports `present`; until then the answer is `unknown`,
|
|
7
|
+
* even when a broadcast has already seeded the key with real values. That is
|
|
8
|
+
* the whole guard: a projection function handed a broadcast-seeded record
|
|
9
|
+
* assumes absent means empty and renders a confident, wrong answer.
|
|
10
|
+
*
|
|
11
|
+
* `id` may be `null`/`undefined` for a conditional read — the hook then holds
|
|
12
|
+
* no subscription and reports `unknown`, rather than forcing a caller into two
|
|
13
|
+
* code paths around a rule of hooks.
|
|
14
|
+
*/
|
|
15
|
+
export declare function useRecord<T>(token: RecordTypeToken<T>, id: string | number | null | undefined, options: ReadOptions): RecordState<T>;
|
|
16
|
+
/**
|
|
17
|
+
* Read many records at once, sharing one hydration per key with every other
|
|
18
|
+
* reader of that key.
|
|
19
|
+
*
|
|
20
|
+
* IT TAKES A KEY LIST AND DERIVES A STRING FROM IT rather than depending on the
|
|
21
|
+
* array's identity, so a caller passing a freshly-mapped array on every render
|
|
22
|
+
* — the normal thing to do — does not re-subscribe and does not violate
|
|
23
|
+
* `getSnapshot` identity. The alternative, subscribing to the whole store for a
|
|
24
|
+
* set of records, is the anti-pattern this package already ships once in
|
|
25
|
+
* `createSimStore.useById`; per-key notification is the entire reason this
|
|
26
|
+
* store exists.
|
|
27
|
+
*/
|
|
28
|
+
export declare function useRecords<T>(token: RecordTypeToken<T>, ids: readonly (string | number)[], options: ReadOptions): readonly RecordState<T>[];
|
|
29
|
+
/**
|
|
30
|
+
* The staleness signal: affected ROOTS and their now-stale field sets.
|
|
31
|
+
*
|
|
32
|
+
* ITS ONLY CONSUMER IS THE LAYER THAT PERFORMED THE ORIGINAL FETCH. It
|
|
33
|
+
* re-issues the request it already made — it needs no registry of loaders,
|
|
34
|
+
* because "which field set" is not a question it has to answer; it is the one
|
|
35
|
+
* it already asked. The library contributes the signal and nothing else, which
|
|
36
|
+
* is what keeps every endpoint and every type name out of this package.
|
|
37
|
+
*
|
|
38
|
+
* TWO PROPERTIES THE CONSUMER MUST SUPPLY, because they are not expressible
|
|
39
|
+
* here: coalescing in BREADTH (twenty-five rows going stale in one burst is one
|
|
40
|
+
* list refetch, not twenty-five record refetches) and in TIME (a minimum
|
|
41
|
+
* interval, plus dedup against a refetch already in flight — a second signal
|
|
42
|
+
* arriving mid-flight sets a re-run flag rather than issuing a second request).
|
|
43
|
+
* Breadth alone is not enough: an event with no change guard on the server
|
|
44
|
+
* fires continuously during a long job, so it is a stream rather than a burst.
|
|
45
|
+
*/
|
|
46
|
+
export declare function useStaleness(): readonly StalenessEntry[];
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { defineRecordType, resetRecordTypes } from "./define-record-type";
|
|
2
|
+
export { useRecord, useRecords, useStaleness } from "./hooks";
|
|
3
|
+
export { write, writeAll, peekRecord, readState, readStaleness, subscribeToKey, subscribeToStaleness, retainKeys, sweepRecords, setRecordStoreTenant, getRecordStoreTenant, flushRecordNotifications, resetRecordStore, recordStoreStats, UNKNOWN_STATE, } from "./store";
|
|
4
|
+
export { normalizeTypeName, normalizeTimestamp, recordKey, isRecordRef, type RecordRef } from "./wire";
|
|
5
|
+
export type { ReadOptions, RecordPayload, RecordState, RecordTypeOptions, RecordTypeToken, StalenessEntry, WriteAllResult, WriteOptions, WriteResult, WriteSource, WriteVerdict, } from "./types";
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { RecordPayload, RecordState, StalenessEntry, WriteAllResult, WriteOptions, WriteResult } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Declare which tenant this store is holding records for.
|
|
4
|
+
*
|
|
5
|
+
* Changing it CLEARS the store. The keys are `Type:id` with no tenant in them,
|
|
6
|
+
* so team B's demand #7 would merge onto team A's demand #7 — in an app whose
|
|
7
|
+
* read key is deliberately team-scoped precisely to prevent that. Clearing
|
|
8
|
+
* handles what is already written; the `tenant` parameter on `write` handles
|
|
9
|
+
* what is still in the air.
|
|
10
|
+
*/
|
|
11
|
+
export declare function setRecordStoreTenant(tenant: string | number | undefined): void;
|
|
12
|
+
export declare function getRecordStoreTenant(): string | number | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* The single writer. A fetch response, a mutation's returned item and a
|
|
15
|
+
* broadcast all arrive here, so fetch-versus-broadcast reconciliation lives in
|
|
16
|
+
* ONE place rather than split across two modules that would each hold half the
|
|
17
|
+
* rule.
|
|
18
|
+
*/
|
|
19
|
+
export declare function write(payload: RecordPayload, options: WriteOptions): WriteResult;
|
|
20
|
+
/**
|
|
21
|
+
* The plural form — REQUIRED, not a convenience.
|
|
22
|
+
*
|
|
23
|
+
* A list page is fifty payloads, and fifty singular writes are fifty
|
|
24
|
+
* notification passes and fifty render passes for one page load. Every write
|
|
25
|
+
* in one call coalesces into a single notification.
|
|
26
|
+
*/
|
|
27
|
+
export declare function writeAll(payloads: readonly RecordPayload[], options: WriteOptions): WriteAllResult;
|
|
28
|
+
/**
|
|
29
|
+
* The `unknown` state, as ONE module-level frozen constant.
|
|
30
|
+
*
|
|
31
|
+
* `getServerSnapshot` returns this. A fresh literal there throws under the only
|
|
32
|
+
* condition it exists for.
|
|
33
|
+
*/
|
|
34
|
+
export declare const UNKNOWN_STATE: RecordState<never>;
|
|
35
|
+
export declare function getUnknownState(): RecordState<never>;
|
|
36
|
+
/**
|
|
37
|
+
* What a reader is told about one key, gated on the field set it declared it
|
|
38
|
+
* needs.
|
|
39
|
+
*
|
|
40
|
+
* The gate is what stops a record seeded ONLY by a broadcast from reaching a
|
|
41
|
+
* projection function that assumes absent means empty — which renders a
|
|
42
|
+
* confident "0 documents" for a record with hundreds.
|
|
43
|
+
*/
|
|
44
|
+
export declare function readState(key: string, needs: string): RecordState<unknown>;
|
|
45
|
+
/** Non-reactive read, for callers outside a component. */
|
|
46
|
+
export declare function peekRecord(type: string, id: string | number, needs: string): RecordState<unknown>;
|
|
47
|
+
/**
|
|
48
|
+
* NOTIFICATION IS COALESCED PER ANIMATION FRAME, and that is a requirement.
|
|
49
|
+
*
|
|
50
|
+
* A document transcoding page by page broadcasts once per row. Un-coalesced,
|
|
51
|
+
* every one of those is a separate notification, a separate dep scan per
|
|
52
|
+
* subscriber and a separate render pass. Coalescing collapses a burst into a
|
|
53
|
+
* handful.
|
|
54
|
+
*
|
|
55
|
+
* Exported so a caller that must observe the effect of a write synchronously —
|
|
56
|
+
* a test, or a measurement taken immediately after a write — can flush rather
|
|
57
|
+
* than wait for a frame.
|
|
58
|
+
*/
|
|
59
|
+
export declare function flushRecordNotifications(): void;
|
|
60
|
+
export declare function subscribeToKey(key: string, listener: () => void): () => void;
|
|
61
|
+
export declare function subscribeToStaleness(listener: () => void): () => void;
|
|
62
|
+
/**
|
|
63
|
+
* Affected ROOTS and their now-stale field sets.
|
|
64
|
+
*
|
|
65
|
+
* The signal names ROOTS rather than the key that went stale, because the key
|
|
66
|
+
* that goes stale is often a nested one — a run under a demand — and a list of
|
|
67
|
+
* demands can never match a run's key. Roots come from the hydration memo's
|
|
68
|
+
* deps map, so a root is by definition something a mounted read or a list slot
|
|
69
|
+
* is already holding.
|
|
70
|
+
*
|
|
71
|
+
* The library names no type it understands and no endpoint: the layer that
|
|
72
|
+
* performed the original fetch re-issues it, because it is the only layer that
|
|
73
|
+
* knows which field set it asked for.
|
|
74
|
+
*/
|
|
75
|
+
export declare function readStaleness(): readonly StalenessEntry[];
|
|
76
|
+
export declare function getEmptyStaleness(): readonly StalenessEntry[];
|
|
77
|
+
/**
|
|
78
|
+
* Pin keys as ROOTS for the duration of the returned release.
|
|
79
|
+
*
|
|
80
|
+
* A key is retained if it is REACHABLE from a root, never if it is itself
|
|
81
|
+
* pinned: a file is never independently subscribed — it is reached only by
|
|
82
|
+
* hydrating its demand — so evicting by direct subscription would evict exactly
|
|
83
|
+
* what hydration walks and leave dangling refs everywhere.
|
|
84
|
+
*
|
|
85
|
+
* Release is IDEMPOTENT and exactly paired with acquire, which is what makes
|
|
86
|
+
* StrictMode's mount / unmount / mount survivable. A subscription whose
|
|
87
|
+
* teardown is not idempotent tears down the one it just created — and it
|
|
88
|
+
* misbehaves only in development, which is the worst place for that to live,
|
|
89
|
+
* because that is where it is being debugged.
|
|
90
|
+
*/
|
|
91
|
+
export declare function retainKeys(keys: readonly string[]): () => void;
|
|
92
|
+
/**
|
|
93
|
+
* Mark from the roots, then sweep everything unreachable.
|
|
94
|
+
*
|
|
95
|
+
* AN IDENTITY MAP THAT ONLY GROWS IS A LEAK, and the failure mode is a tab that
|
|
96
|
+
* gets slower over an afternoon with nobody connecting it to this. Browsing ten
|
|
97
|
+
* documents at 435 rows each is roughly 4,000 entries nothing releases.
|
|
98
|
+
*
|
|
99
|
+
* WHEN TO CALL IT IS THE CALLER'S DECISION, and it needs to be more than a
|
|
100
|
+
* navigation boundary: a broadcast can CREATE a key no root reaches, and on a
|
|
101
|
+
* screen nobody navigates away from — a long extraction, say — those accumulate
|
|
102
|
+
* for as long as the tab is open. A navigation boundary AND a bounded schedule.
|
|
103
|
+
* This module deliberately installs no timer of its own; a design-system module
|
|
104
|
+
* that starts an interval on import is a worse defect than the leak.
|
|
105
|
+
*
|
|
106
|
+
* @returns how many keys were dropped
|
|
107
|
+
*/
|
|
108
|
+
export declare function sweepRecords(): number;
|
|
109
|
+
/** Wipes everything, including listeners, roots and the tenant. */
|
|
110
|
+
export declare function resetRecordStore(): void;
|
|
111
|
+
/** What the store holds right now, for a devtools panel or a test. */
|
|
112
|
+
export declare function recordStoreStats(): {
|
|
113
|
+
records: number;
|
|
114
|
+
memos: number;
|
|
115
|
+
roots: number;
|
|
116
|
+
listeners: number;
|
|
117
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A payload as it arrives off the wire — from a fetch response, from a
|
|
3
|
+
* mutation's returned item, or from a broadcast.
|
|
4
|
+
*
|
|
5
|
+
* DELIBERATELY LOOSE. The store requires exactly two things of it (`__type`
|
|
6
|
+
* and `id`) and interprets five more (`__timestamp`, `__deleted_at`,
|
|
7
|
+
* `deleted_at`, `__stale`, `__model`). Everything else is data it carries
|
|
8
|
+
* without understanding — which is the whole of this module's claim to being
|
|
9
|
+
* domain-agnostic.
|
|
10
|
+
*/
|
|
11
|
+
export interface RecordPayload {
|
|
12
|
+
readonly [field: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* How a payload arrived. NOT telemetry — each value changes real behaviour:
|
|
16
|
+
*
|
|
17
|
+
* - `"fetch"` may SATISFY a field-set tag and CLEARS staleness on every field
|
|
18
|
+
* it carries. A broadcast can never do either, because a broadcast is
|
|
19
|
+
* partial by whitelist and can never complete a record.
|
|
20
|
+
* - `"broadcast"` is the only source that MARKS a field stale, using the
|
|
21
|
+
* `staleFields` its type declared. Saying so out loud prevents the obvious
|
|
22
|
+
* wrong reading, in which a fetch that omits a stale-declared field re-marks
|
|
23
|
+
* it and the refetch loop never settles.
|
|
24
|
+
*
|
|
25
|
+
* A mutation's returned item is a `"fetch"`: it is a full server answer to a
|
|
26
|
+
* request this client made, so it may satisfy a tag.
|
|
27
|
+
*/
|
|
28
|
+
export type WriteSource = "fetch" | "broadcast";
|
|
29
|
+
/** Options every write carries. */
|
|
30
|
+
export interface WriteOptions {
|
|
31
|
+
readonly source: WriteSource;
|
|
32
|
+
/**
|
|
33
|
+
* The tenant the REQUEST was issued under — stamped by the caller at request
|
|
34
|
+
* time, never read from module state inside `write`.
|
|
35
|
+
*
|
|
36
|
+
* Reading it inside `write` would read the tenant as it is NOW, which is
|
|
37
|
+
* precisely the bug this parameter exists to prevent: a team-A response that
|
|
38
|
+
* resolves after a switch to team B would write team A's records into a
|
|
39
|
+
* store now being read as team B's. A broadcast is stamped with the tenant
|
|
40
|
+
* whose channel delivered it, which the subscription already knows. So both
|
|
41
|
+
* sources carry it and neither infers it.
|
|
42
|
+
*/
|
|
43
|
+
readonly tenant?: string | number;
|
|
44
|
+
/**
|
|
45
|
+
* An OPAQUE field-set name — `"card"`, `"detail"`, whatever the caller's own
|
|
46
|
+
* api layer already calls the selector it asked for. The store never
|
|
47
|
+
* interprets it; it records which tags a key has satisfied and compares them
|
|
48
|
+
* for equality, so the library still knows nothing about any type's fields.
|
|
49
|
+
*
|
|
50
|
+
* Only a `"fetch"` may satisfy a tag. It is what makes `useRecord(…,
|
|
51
|
+
* {needs:"card"})` report `unknown` for a record seeded only by a broadcast,
|
|
52
|
+
* rather than handing a projection function a record with no `files` and no
|
|
53
|
+
* `workflow_runs` — which renders a confident "0 documents" card for a
|
|
54
|
+
* 434-page demand.
|
|
55
|
+
*/
|
|
56
|
+
readonly tag?: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* What a write did. Every value is observable so a refusal leaves a trace: the
|
|
60
|
+
* prior art in this codebase records nothing at all on rejection, which is why
|
|
61
|
+
* its out-of-order guard was inert for years without anyone noticing.
|
|
62
|
+
*
|
|
63
|
+
* - `applied` — accepted, and something a reader can observe changed.
|
|
64
|
+
* - `unchanged` — accepted, and nothing changed. NOBODY IS NOTIFIED. A
|
|
65
|
+
* redelivered duplicate must not invalidate every hydration memo that
|
|
66
|
+
* touched the key and re-render every subscriber.
|
|
67
|
+
* - `dropped-older` — strictly older than the record's own stored timestamp.
|
|
68
|
+
* - `dropped-tenant` — stamped for a tenant this store is no longer serving.
|
|
69
|
+
* - `rejected-shape` — not a record at all: no `__type`, no `id`, or a
|
|
70
|
+
* `{__stale:true}` ping, which carries neither.
|
|
71
|
+
*/
|
|
72
|
+
export type WriteVerdict = "applied" | "unchanged" | "dropped-older" | "dropped-tenant" | "rejected-shape";
|
|
73
|
+
/** The result of one `write`. */
|
|
74
|
+
export interface WriteResult {
|
|
75
|
+
readonly verdict: WriteVerdict;
|
|
76
|
+
/**
|
|
77
|
+
* Every key this write touched, INCLUDING nested ones — a demand payload
|
|
78
|
+
* carrying 434 files returns 435 keys.
|
|
79
|
+
*
|
|
80
|
+
* The caller that issued a refetch to clear a staleness signal needs this to
|
|
81
|
+
* decide whether the write it just made actually satisfied what it was
|
|
82
|
+
* issued for. Empty for every dropped or rejected verdict.
|
|
83
|
+
*/
|
|
84
|
+
readonly keys: readonly string[];
|
|
85
|
+
/** Why, for the verdicts that are refusals. Present only when it helps. */
|
|
86
|
+
readonly reason?: string;
|
|
87
|
+
}
|
|
88
|
+
/** The result of one `writeAll`. */
|
|
89
|
+
export interface WriteAllResult {
|
|
90
|
+
/** One entry per payload, in the order they were given. */
|
|
91
|
+
readonly results: readonly WriteResult[];
|
|
92
|
+
/** The union of every payload's touched keys, de-duplicated. */
|
|
93
|
+
readonly keys: readonly string[];
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* What a reader is told about one key.
|
|
97
|
+
*
|
|
98
|
+
* FOUR ABSENCES, NOT ONE. Never-fetched, deleted and broadcast-partial are
|
|
99
|
+
* different answers and a screen renders each of them differently. The fourth —
|
|
100
|
+
* a 404 — is deliberately NOT a member: a refusal carries a message and a
|
|
101
|
+
* remedy that the store has no way to build, so it belongs to whatever layer
|
|
102
|
+
* performed the request.
|
|
103
|
+
*
|
|
104
|
+
* `stale` is part of the state rather than a side channel because a write that
|
|
105
|
+
* changes no value but marks a field stale must still notify — and notifying
|
|
106
|
+
* for something no reader can observe is worse than not notifying at all.
|
|
107
|
+
*/
|
|
108
|
+
export type RecordState<T> = {
|
|
109
|
+
readonly status: "unknown";
|
|
110
|
+
} | {
|
|
111
|
+
readonly status: "present";
|
|
112
|
+
readonly record: T;
|
|
113
|
+
/** Bumps on every accepted write that changed something observable. */
|
|
114
|
+
readonly version: number;
|
|
115
|
+
/**
|
|
116
|
+
* Fields the last broadcast could not carry. Sorted, so two structurally
|
|
117
|
+
* equal stale sets compare equal.
|
|
118
|
+
*
|
|
119
|
+
* A held stale value can only be stale-LOW for a monotonic high-water
|
|
120
|
+
* mark, never stale-high, so holding the last number on screen while the
|
|
121
|
+
* refetch lands is honest. Dimming it is available, not mandatory.
|
|
122
|
+
*/
|
|
123
|
+
readonly stale: readonly string[];
|
|
124
|
+
} | {
|
|
125
|
+
readonly status: "deleted";
|
|
126
|
+
readonly at: string;
|
|
127
|
+
};
|
|
128
|
+
/** What `useRecord`/`useRecords` require of their caller. */
|
|
129
|
+
export interface ReadOptions {
|
|
130
|
+
/**
|
|
131
|
+
* REQUIRED, not optional. The tag a `"fetch"` must have satisfied before
|
|
132
|
+
* this key reports `present`.
|
|
133
|
+
*
|
|
134
|
+
* The tag mechanism existed once as an optional parameter and nothing forced
|
|
135
|
+
* a caller to use it, which reopened the exact hole it was introduced to
|
|
136
|
+
* close. One word in the signature is the whole fix.
|
|
137
|
+
*/
|
|
138
|
+
readonly needs: string;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* A registered record type: the runtime string and the compile-time type, so
|
|
142
|
+
* the two can never drift.
|
|
143
|
+
*
|
|
144
|
+
* `_record` is a phantom — it carries `T` through inference and holds no value
|
|
145
|
+
* at runtime.
|
|
146
|
+
*/
|
|
147
|
+
export interface RecordTypeToken<T> {
|
|
148
|
+
/** The NORMALIZED model name. Never carries a `Resource` suffix. */
|
|
149
|
+
readonly type: string;
|
|
150
|
+
/** @see RecordTypeOptions.staleFields */
|
|
151
|
+
readonly staleFields: readonly string[] | "*";
|
|
152
|
+
/** Phantom. Never read. */
|
|
153
|
+
readonly _record?: T;
|
|
154
|
+
}
|
|
155
|
+
/** What a type may declare at registration. */
|
|
156
|
+
export interface RecordTypeOptions {
|
|
157
|
+
/**
|
|
158
|
+
* The fields this type's BROADCAST cannot carry — because they are computed,
|
|
159
|
+
* or deliberately withheld from the whitelist. Everything else in a
|
|
160
|
+
* broadcast merges normally.
|
|
161
|
+
*
|
|
162
|
+
* Staleness is PER FIELD and never a whole-type on/off switch. A per-type
|
|
163
|
+
* binary discards the twelve real fields a broadcast does carry in order to
|
|
164
|
+
* signal the two it does not.
|
|
165
|
+
*
|
|
166
|
+
* `"*"` means every data field — the case where a type's broadcast carries
|
|
167
|
+
* identity and nothing else, and where the field set is not knowable at
|
|
168
|
+
* registration because it is a bag of dynamic attributes. It resolves at
|
|
169
|
+
* write time against the keys the record actually holds; see
|
|
170
|
+
* `resolveStaleFields` in `store.ts` for what that buys and what it costs.
|
|
171
|
+
*/
|
|
172
|
+
readonly staleFields?: readonly string[] | "*";
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* One root affected by a staleness mark, and the fields under it that are now
|
|
176
|
+
* suspect.
|
|
177
|
+
*
|
|
178
|
+
* `key` is a ROOT, not the key that went stale. For a queue, the key that goes
|
|
179
|
+
* stale is a run while the list holds demands; naming the raw key would match
|
|
180
|
+
* no list slot and nothing would ever refetch. Roots are resolved through the
|
|
181
|
+
* hydration memo's deps map, which is safe in a way a maintained reverse index
|
|
182
|
+
* is not: it is rebuilt by the same walk that reads it, so there is no separate
|
|
183
|
+
* maintenance step to get wrong.
|
|
184
|
+
*/
|
|
185
|
+
export interface StalenessEntry {
|
|
186
|
+
readonly key: string;
|
|
187
|
+
readonly fields: readonly string[];
|
|
188
|
+
}
|