@danxbot/ui 3.7.0 → 3.9.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.
@@ -0,0 +1,139 @@
1
+ import type { RecordPayload, RecordState } from "../records";
2
+ /**
3
+ * A refusal.
4
+ *
5
+ * IT LIVES HERE AND NOT IN THE STORE, deliberately. A 404 is not one of the
6
+ * store's record states, because a refusal carries a message and a remedy the
7
+ * store has no way to build — so a `404` member there would be permanently
8
+ * unreachable. It belongs to the layer that performed the request, which is
9
+ * this one.
10
+ */
11
+ export interface ReadError {
12
+ readonly message: string;
13
+ /** Whatever was thrown. The app's own error wrapper reads it. */
14
+ readonly cause: unknown;
15
+ }
16
+ /** What a fetch of one record must resolve to: the payload itself. */
17
+ export type RecordFetch = (signal?: AbortSignal) => Promise<RecordPayload>;
18
+ /** What a fetch of a page must resolve to. */
19
+ export interface ListFetchResult<TMeta = unknown> {
20
+ readonly items: readonly RecordPayload[];
21
+ /**
22
+ * Pagination, totals, anything the page carries that is NOT a record.
23
+ *
24
+ * IT LIVES IN THE SLOT BECAUSE IT CANNOT LIVE ANYWHERE ELSE. `meta.total` has
25
+ * no `__type` and no `id`, so it can never be a record — and a normalized
26
+ * store without a place for it is the half of this design that gets omitted
27
+ * and then discovered from a screen that cannot render its own page count.
28
+ */
29
+ readonly meta?: TMeta;
30
+ }
31
+ export type ListFetch<TMeta = unknown> = (signal?: AbortSignal) => Promise<ListFetchResult<TMeta>>;
32
+ /** Options common to both reads. */
33
+ interface CommonReadOptions {
34
+ /**
35
+ * The opaque field-set name this request asks for — `"card"`, `"detail"`,
36
+ * whatever the app's own api layer already calls its selector.
37
+ *
38
+ * The library never interprets it. It is compared for equality and nothing
39
+ * else, which is what lets `needs` gate a read on a completion the library
40
+ * cannot compute.
41
+ */
42
+ readonly tag: string;
43
+ /**
44
+ * The tenant to stamp writes with. Defaults to the store's current tenant,
45
+ * READ AT REQUEST TIME.
46
+ *
47
+ * Request time is the whole point: stamping on ARRIVAL reads the tenant as it
48
+ * is now, which defeats the check entirely — a team-A response that resolves
49
+ * after a switch to team B would be stamped B and merged into B's store.
50
+ */
51
+ readonly tenant?: string | number;
52
+ /** Skip the fetch entirely. The read reports `loading: false`. */
53
+ readonly enabled?: boolean;
54
+ /**
55
+ * The floor between two refetches of THIS read. Default 1000ms.
56
+ *
57
+ * A TIME FLOOR IS NOT OPTIONAL, and breadth-coalescing alone is not enough:
58
+ * at least one real broadcast source has no change guard at all and fires on
59
+ * every related save, so during a long job it is a continuous STREAM rather
60
+ * than a burst. Without a floor that is one refetch per frame, of whatever
61
+ * the heaviest read on the screen happens to be.
62
+ */
63
+ readonly minRefetchIntervalMs?: number;
64
+ }
65
+ export interface RecordReadOptions extends CommonReadOptions {
66
+ readonly fetch: RecordFetch;
67
+ }
68
+ export interface ListReadOptions<TMeta = unknown> extends CommonReadOptions {
69
+ /**
70
+ * The serialized query — the slot's identity.
71
+ *
72
+ * Two components asking the same question share one slot; two different
73
+ * questions never collide. It is the caller's serialization because only the
74
+ * caller knows which of its parameters are part of the question.
75
+ */
76
+ readonly key: string;
77
+ readonly fetch: ListFetch<TMeta>;
78
+ /**
79
+ * Whether a `created` broadcast for this type may append to this slot.
80
+ * Default `true`.
81
+ *
82
+ * SET IT FALSE FOR A SLOT WHOSE MEMBERSHIP THIS CLIENT CANNOT VERIFY. The
83
+ * backend evaluates a filter subscription server-side per save, so a
84
+ * `created` event arriving on such a subscription IS authoritative that the
85
+ * record matches — but the library cannot tell WHICH of several slots of the
86
+ * same type it matches when a screen holds more than one differently-filtered
87
+ * list. Appending to all of them would be a guess; this flag is where the app
88
+ * says which one owns the event.
89
+ */
90
+ readonly acceptsCreated?: boolean;
91
+ }
92
+ /** The loading / refreshing / error triple, shared by both reads. */
93
+ interface ReadStatus {
94
+ /** No answer yet AND a fetch is in flight. This is the skeleton. */
95
+ readonly loading: boolean;
96
+ /**
97
+ * An answer is on screen AND a fetch is in flight.
98
+ *
99
+ * SEPARATE FROM `loading` ON PURPOSE. Collapsing the two blanks a populated
100
+ * screen every time anything invalidates — which is exactly what recovering
101
+ * from staleness by blanking the screen would do, and it is a worse
102
+ * experience than the staleness.
103
+ */
104
+ readonly refreshing: boolean;
105
+ readonly error: ReadError | undefined;
106
+ /** Re-issue the request now, subject to the refetch floor. */
107
+ readonly refetch: () => void;
108
+ }
109
+ export interface RecordReadResult<T> extends ReadStatus {
110
+ /** The store's answer: `unknown`, `present` or `deleted`. Never a maybe. */
111
+ readonly state: RecordState<T>;
112
+ /** `state.record` when present, for the common case. */
113
+ readonly data: T | undefined;
114
+ }
115
+ export interface ListReadResult<T, TMeta = unknown> extends ReadStatus {
116
+ /** The fetched page, in the server's order, tombstoned rows removed. */
117
+ readonly items: readonly T[];
118
+ /**
119
+ * Rows that entered the set through a `created` broadcast, in arrival order.
120
+ *
121
+ * THEY ARE NOT SPLICED INTO `items`, and that is the honest form of the rule
122
+ * rather than a limitation: sort position and page boundary are decided by
123
+ * the server, so guessing where a new row goes is precisely the lie the rule
124
+ * exists to prevent. A screen renders "N new" and the next fetch places them.
125
+ */
126
+ readonly pending: readonly T[];
127
+ /**
128
+ * How many rows are waiting, INCLUDING ones not yet renderable.
129
+ *
130
+ * THIS IS NOT `pending.length`, and the difference is the whole reason it
131
+ * exists. A `created` broadcast is partial by whitelist, so it can never
132
+ * satisfy the field-set tag — meaning the appended record is countable but
133
+ * not renderable until a fetch fills it in. Reading "N new" off `pending`
134
+ * would print zero for exactly the case the marker was added to cover.
135
+ */
136
+ readonly pendingCount: number;
137
+ readonly meta: TMeta | undefined;
138
+ }
139
+ export {};
@@ -0,0 +1,38 @@
1
+ import type { RecordTypeToken } from "../records";
2
+ import type { RealtimeHealth, RealtimeScopeInput } from "./types";
3
+ /**
4
+ * Declare that this component is looking at these records and wants these
5
+ * events.
6
+ *
7
+ * COALESCED. Twenty-five components asking for the same scope produce ONE
8
+ * server-side registration, and the union of everyone's event names is bound —
9
+ * never the first caller's only, which is the live defect this module was
10
+ * written against.
11
+ *
12
+ * NO-OP WITHOUT A PROVIDER. An application with realtime switched off keeps
13
+ * fetching and keeps rendering; nothing here throws.
14
+ *
15
+ * @param token the record type, from `defineRecordType`
16
+ * @param scope `"all"`, an id list, or `{filter}` the server evaluates
17
+ * @param events event names to bind, e.g. `["created", "updated", "deleted"]`
18
+ */
19
+ export declare function useRealtime<T>(token: RecordTypeToken<T>, scope: RealtimeScopeInput, events: readonly string[]): void;
20
+ /**
21
+ * The renderable liveness state.
22
+ *
23
+ * IT IS RENDERABLE ON PURPOSE. A dead subscription is indistinguishable from a
24
+ * quiet one, and the prior art's answer — a console panel behind a keyboard
25
+ * shortcut — is why two real bugs in it went unnoticed for years. This is the
26
+ * equivalent of `refreshing`: a word the reader can see, not a log line.
27
+ */
28
+ export declare function useRealtimeHealth(): RealtimeHealth;
29
+ /**
30
+ * The "you may have missed events" counter, for a consumer that wants to fold
31
+ * it into its own invalidation rather than use the read binding.
32
+ *
33
+ * IT IS A REVISION, NEVER A READ KEY. A key change blanks the screen to a
34
+ * skeleton; a revision keeps the answer on screen and marks it refreshing. This
35
+ * signal exists because a subscription went dead and came back — recovering
36
+ * from that by blanking the screen is a worse experience than the staleness.
37
+ */
38
+ export declare function useRealtimeEpoch(): number;
@@ -0,0 +1,4 @@
1
+ export { RealtimeProvider, useRealtimeRegistry, type RealtimeProviderProps } from "./provider";
2
+ export { useRealtime, useRealtimeEpoch, useRealtimeHealth } from "./hooks";
3
+ export { SubscriptionRegistry, normalizeScope, scopeKey, readRealtimeEpoch, subscribeToRealtimeEpoch, subscribeToRealtimeEvents, resetRealtimeSignals, type RegistryOptions, } from "./registry";
4
+ export type { RealtimeChannel, RealtimeConnectionState, RealtimeDiagnostic, RealtimeEvent, RealtimeHealth, RealtimeScope, RealtimeScopeInput, RealtimeStatus, RealtimeTransport, SubscriptionHealth, SubscriptionLease, SubscriptionRequest, } from "./types";
@@ -0,0 +1,38 @@
1
+ import { type ReactNode } from "react";
2
+ import { SubscriptionRegistry, type RegistryOptions } from "./registry";
3
+ export interface RealtimeProviderProps extends Omit<RegistryOptions, "transport" | "tenant" | "now"> {
4
+ /**
5
+ * Everything environment-specific in one object: socket, endpoints,
6
+ * connection observer. INJECTED, never imported — see `types.ts` for why, and
7
+ * for the one constraint on its implementation that a type cannot express.
8
+ */
9
+ readonly transport: RegistryOptions["transport"];
10
+ /**
11
+ * The team, org or account these records belong to.
12
+ *
13
+ * CHANGING IT IS A REAL EVENT, not a re-render: every server-side
14
+ * registration is dropped, every channel is left, the record store is
15
+ * cleared, and everything still mounted re-subscribes under the new tenant.
16
+ */
17
+ readonly tenant?: string | number;
18
+ /**
19
+ * Whether this provider owns the record store's tenant. Default `true`.
20
+ *
21
+ * Set it false only when something else already calls
22
+ * `setRecordStoreTenant` — two writers of one value is how they disagree.
23
+ */
24
+ readonly ownsStoreTenant?: boolean;
25
+ readonly children: ReactNode;
26
+ }
27
+ export declare function RealtimeProvider({ transport, tenant, ownsStoreTenant, children, ...options }: RealtimeProviderProps): import("react").JSX.Element;
28
+ /**
29
+ * The registry, or `null` when no provider is mounted.
30
+ *
31
+ * THIS PACKAGE'S USUAL CONVENTION IS INVERTED HERE, DELIBERATELY. Every other
32
+ * provider in this library throws when its hook is used outside it, and that is
33
+ * right for a capability a component cannot work without. Realtime is not one:
34
+ * an application with no transport configured must degrade to plain fetching,
35
+ * never crash. A design system that hard-fails because an OPTIONAL capability
36
+ * is absent is not offering an optional capability.
37
+ */
38
+ export declare function useRealtimeRegistry(): SubscriptionRegistry | null;
@@ -0,0 +1,172 @@
1
+ import type { RealtimeDiagnostic, RealtimeEvent, RealtimeHealth, RealtimeScope, RealtimeScopeInput, RealtimeTransport } from "./types";
2
+ import type { RecordPayload } from "../records";
3
+ export interface RegistryOptions {
4
+ readonly transport: RealtimeTransport;
5
+ readonly tenant?: string | number;
6
+ readonly keepaliveIntervalMs?: number;
7
+ readonly expiryMarginMs?: number;
8
+ readonly baseBackoffMs?: number;
9
+ readonly maxBackoffMs?: number;
10
+ readonly maxAttempts?: number;
11
+ /**
12
+ * The event name that means "this record just entered the set".
13
+ *
14
+ * Configurable because the name is the backend's, not this library's, and
15
+ * hard-coding one would be the only domain fact in this module.
16
+ */
17
+ readonly createdEventName?: string;
18
+ /**
19
+ * Drop a broadcast this client caused.
20
+ *
21
+ * A PREDICATE RATHER THAN A FIELD NAME, because the field that says who
22
+ * caused a change belongs to a wire format, not to a design system. Supplied
23
+ * by the app or absent; absent means every broadcast is written.
24
+ *
25
+ * IT APPLIES TO BROADCASTS ONLY, structurally — a fetch and a mutation's
26
+ * returned item never pass through here at all. That matters: a mutation
27
+ * response is self-caused BY DEFINITION, and dropping it would discard the
28
+ * server-computed fields the client asked for.
29
+ */
30
+ readonly isSelfEvent?: (payload: RecordPayload) => boolean;
31
+ readonly onDiagnostic?: (diagnostic: RealtimeDiagnostic) => void;
32
+ /** Test seam. Defaults to `Date.now`. */
33
+ readonly now?: () => number;
34
+ }
35
+ export declare function readRealtimeEpoch(): number;
36
+ export declare function subscribeToRealtimeEpoch(listener: () => void): () => void;
37
+ /** Every broadcast, AFTER it has been written to the store. */
38
+ export declare function subscribeToRealtimeEvents(listener: (event: RealtimeEvent) => void): () => void;
39
+ /** Test seam. Nothing in the library calls this. */
40
+ export declare function resetRealtimeSignals(): void;
41
+ /**
42
+ * Normalize a caller's scope and give it a stable key.
43
+ *
44
+ * IDS ARE SORTED AND STRINGIFIED so two components asking for the same rows in
45
+ * a different order coalesce onto one subscription rather than opening two —
46
+ * and so a numeric id and its string form never split an entry, exactly as the
47
+ * store keys records.
48
+ */
49
+ export declare function normalizeScope(input: RealtimeScopeInput): RealtimeScope;
50
+ export declare function scopeKey(scope: RealtimeScope): string;
51
+ export declare class SubscriptionRegistry {
52
+ private readonly transport;
53
+ private readonly entries;
54
+ private readonly channels;
55
+ private readonly options;
56
+ private readonly healthListeners;
57
+ private tenant;
58
+ private connection;
59
+ private stopConnectionObserver;
60
+ private timer;
61
+ private healthSnapshot;
62
+ private lastError;
63
+ private started;
64
+ private disposed;
65
+ constructor(options: RegistryOptions);
66
+ /**
67
+ * Begin observing the connection and renewing leases.
68
+ *
69
+ * THE TIMER STARTS HERE, NEVER ON IMPORT. A design-system module that starts
70
+ * an interval because somebody imported it is a worse defect than the leak it
71
+ * would be preventing: it runs in every consumer, including the ones that
72
+ * never touch realtime at all.
73
+ */
74
+ start(): void;
75
+ /**
76
+ * Stop the timers. REVERSIBLE, and it keeps every entry and every channel.
77
+ *
78
+ * THIS IS WHAT STRICTMODE REQUIRES. React mounts every effect, unmounts it,
79
+ * and mounts it again in development — so a teardown that dropped the entries
80
+ * would make the remount issue a SECOND subscribe for a registration that is
81
+ * still perfectly live, and the whole misbehaviour would exist only in
82
+ * development, which is the worst place for a realtime bug to live because it
83
+ * is where the thing is being debugged.
84
+ *
85
+ * IT DOES NOT UNSUBSCRIBE, and it does not leave the socket channels. Unmount
86
+ * never touches the server-side registry — the entry lapses against its own
87
+ * TTL — and the socket belongs to the application that injected it. A tenant
88
+ * switch is the one place either of those happens, and it has its own method.
89
+ */
90
+ stop(): void;
91
+ /**
92
+ * Full teardown: stop, leave every channel, forget every entry.
93
+ *
94
+ * NOT what a component unmount calls — see `stop`. This exists for an
95
+ * application deliberately switching realtime off, which is a decision no
96
+ * lifecycle can infer.
97
+ */
98
+ dispose(): void;
99
+ /**
100
+ * Switch tenants: tear the server-side registrations down, clear the socket,
101
+ * and re-establish everything still mounted under the new tenant.
102
+ *
103
+ * THIS IS THE ONE PLACE `unsubscribe` IS CALLED. Never-unsubscribe applies to
104
+ * UNMOUNT, where tearing down can kill another tab's subscription; a
105
+ * subscription left lapsing on the PREVIOUS tenant's channel is a leak of a
106
+ * different kind, and one this library can close on its own.
107
+ */
108
+ setTenant(tenant: string | number | undefined): void;
109
+ getTenant(): string | number | undefined;
110
+ /**
111
+ * Declare interest in a (type, scope) with a set of event names.
112
+ *
113
+ * THE RETURNED RELEASE IS IDEMPOTENT AND EXACTLY PAIRED, which is what makes
114
+ * StrictMode's mount / unmount / mount leave exactly ONE subscription: the
115
+ * refcount goes 1, 0, 1 while the server-side registration is never dropped,
116
+ * so the remount reuses the live lease instead of issuing a second subscribe.
117
+ */
118
+ acquire(type: string, scopeInput: RealtimeScopeInput, events: readonly string[]): () => void;
119
+ private subscribeEntry;
120
+ /**
121
+ * Bounded exponential backoff, then a terminal DEAD state.
122
+ *
123
+ * NOTHING IS ESCALATED, EVER. The failure that motivates this is a 401 on a
124
+ * background resubscribe: routed through an app's ordinary HTTP error path it
125
+ * clears the session and logs somebody out mid-screen. Here it is a number in
126
+ * `attempts`, a string in `lastError`, and a status a screen can print.
127
+ */
128
+ private scheduleRetry;
129
+ private clearRetry;
130
+ /**
131
+ * Is this subscription failing to deliver?
132
+ *
133
+ * THREE INPUTS, ANY ONE SUFFICIENT, and the first is the one no reconnect
134
+ * hook can see: the lease simply runs out while everything else looks
135
+ * healthy.
136
+ */
137
+ private isDead;
138
+ /**
139
+ * One pass: renew what is live, revive what is dead.
140
+ *
141
+ * Exposed so a consumer — or a test — can run it without waiting for the
142
+ * interval. Everything it does is idempotent.
143
+ */
144
+ tick(): void;
145
+ private keepalive;
146
+ private onConnectionState;
147
+ private ensureChannel;
148
+ private leaveAllChannels;
149
+ /**
150
+ * One broadcast: filter, write, announce.
151
+ *
152
+ * THE TENANT IS THE ONE THE CHANNEL WAS JOINED UNDER, captured at bind time
153
+ * and never read at delivery time. Reading it on arrival reads the tenant as
154
+ * it is NOW, which is precisely how a payload from the team you just left
155
+ * lands in the store as the team you just joined.
156
+ */
157
+ private deliver;
158
+ /** The event name that means "entered the set", for the read binding. */
159
+ get createdEventName(): string;
160
+ subscribeToHealth: (listener: () => void) => (() => void);
161
+ /**
162
+ * The renderable state, memoized so `getSnapshot` returns the identical
163
+ * object when nothing changed — the constraint every store in this package is
164
+ * written around.
165
+ */
166
+ readHealth: () => RealtimeHealth;
167
+ private deriveStatus;
168
+ private notifyHealth;
169
+ private requestFor;
170
+ private callTransport;
171
+ private diagnose;
172
+ }
@@ -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[];