@foir/sdk 0.9.0 → 0.11.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +719 -27
  2. package/README.md +121 -2
  3. package/dist/auth/auth.d.ts +72 -1
  4. package/dist/auth/hosted.d.ts +71 -4
  5. package/dist/auth/index.d.ts +3 -3
  6. package/dist/auth/operations.d.ts +84 -0
  7. package/dist/auth/ops.d.ts +18 -0
  8. package/dist/auth.js +209 -47
  9. package/dist/client.js +101 -13
  10. package/dist/codegen/block-registry.d.ts +1 -1
  11. package/dist/codegen/block-scaffold.d.ts +1 -1
  12. package/dist/codegen/index.d.ts +1 -1
  13. package/dist/codegen/item-registry.d.ts +1 -1
  14. package/dist/codegen/item-scaffold.d.ts +1 -1
  15. package/dist/codegen/preset.d.ts +3 -2
  16. package/dist/content/content-renderer.d.ts +4 -5
  17. package/dist/content/index.d.ts +1 -1
  18. package/dist/content/richtext.d.ts +1 -1
  19. package/dist/data/builtins.d.ts +81 -13
  20. package/dist/data/client.d.ts +92 -9
  21. package/dist/data/decimal.d.ts +212 -0
  22. package/dist/data/document.d.ts +14 -1
  23. package/dist/data/index.d.ts +22 -10
  24. package/dist/data/manifest.d.ts +40 -4
  25. package/dist/data/schema.d.ts +18 -11
  26. package/dist/data/selection.d.ts +15 -0
  27. package/dist/data/subscriptions.d.ts +36 -10
  28. package/dist/data/transport.d.ts +363 -24
  29. package/dist/data/ws-transport.d.ts +8 -2
  30. package/dist/data.js +591 -64
  31. package/dist/graphql/cdn-route.d.ts +1 -1
  32. package/dist/graphql/foir-request.d.ts +1 -1
  33. package/dist/index.js +0 -9
  34. package/dist/live/coalesce.d.ts +57 -0
  35. package/dist/live/drift.d.ts +58 -0
  36. package/dist/live/events.d.ts +13 -0
  37. package/dist/live/headers.d.ts +53 -0
  38. package/dist/live/index.d.ts +54 -0
  39. package/dist/live/keys.d.ts +72 -0
  40. package/dist/live/manager.d.ts +77 -0
  41. package/dist/live/react/index.d.ts +143 -0
  42. package/dist/live/reconcile.d.ts +82 -0
  43. package/dist/live/resync.d.ts +59 -0
  44. package/dist/live/store.d.ts +134 -0
  45. package/dist/live/tagIndex.d.ts +29 -0
  46. package/dist/live/tanstack.d.ts +127 -0
  47. package/dist/live/writetoken.d.ts +86 -0
  48. package/dist/live-react.js +395 -0
  49. package/dist/live.js +845 -0
  50. package/dist/next/cacheTags.d.ts +245 -0
  51. package/dist/next/index.d.ts +7 -0
  52. package/dist/next.js +260 -34
  53. package/dist/push.js +163 -36
  54. package/dist/react.js +157 -33
  55. package/dist/realtime/index.d.ts +4 -5
  56. package/dist/server/events-client.d.ts +54 -4
  57. package/dist/server/index.d.ts +4 -2
  58. package/dist/server/operation-types.d.ts +86 -58
  59. package/dist/server/read-dispatch.d.ts +53 -0
  60. package/dist/server/require-scoped-token.d.ts +20 -1
  61. package/dist/server.js +602 -10
  62. package/dist/transport/foir-http.d.ts +168 -15
  63. package/package.json +10 -2
@@ -21,6 +21,6 @@ export { buildCdnUrl, documentHash, hasFoirErrorCode, NOT_SHAREABLE, PERSISTED_Q
21
21
  * `/graphql` when it does — but every condition checked here is one round trip
22
22
  * saved.
23
23
  */
24
- export declare function isCdnEligible(env: FoirEnv, documentText: string, bearerToken?: string): boolean;
24
+ export declare function isCdnEligible(env: FoirEnv, documentText: string, bearerToken?: string, orgId?: string): boolean;
25
25
  /** Print a document once, so eligibility and the URL agree on the same text. */
26
26
  export declare function printDocument<TData, TVariables>(document: TypedDocumentNode<TData, TVariables>): string;
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
26
26
  import { type FoirEnv, type FoirRequestOptions } from '../transport/foir-http';
27
- export { FoirRequestError, isFoirPreview, isFoirRateLimited, isFoirUnauthenticated, isFoirForbidden, isFoirNotFound, readFoirRateLimit, type FoirEnv, type FoirErrorCode, type FoirErrorExtensions, type FoirGraphQLErrorItem, type FoirRateLimitSnapshot, type FoirRequestOptions, } from '../transport/foir-http';
27
+ export { FoirRequestError, isFoirPreview, isFoirRateLimited, isFoirUnauthenticated, isFoirForbidden, isFoirNotFound, readFoirRateLimit, type FoirEnv, type FoirErrorCode, type FoirErrorExtensions, type FoirGraphQLErrorItem, type FoirRateLimitSnapshot, type FoirRequestOptions, type FoirAuthDowngrade, } from '../transport/foir-http';
28
28
  export { withFoirRetry, foirRetryDelayMs, type FoirRetryPolicy, } from '../transport/retry';
29
29
  /** Default Foir CDN origin used to absolutise relative media URLs. */
30
30
  export declare const FOIR_CDN_BASE = "https://cdn.foir.dev/";
package/dist/index.js CHANGED
@@ -256,15 +256,6 @@ function EditorProvider({
256
256
  type: "request-operation",
257
257
  payload: { operationKey, input, requestId, mode: options?.mode ?? "sync" }
258
258
  });
259
- setTimeout(() => {
260
- if (pendingOperationsRef.current.has(requestId)) {
261
- pendingOperationsRef.current.delete(requestId);
262
- resolve({
263
- success: false,
264
- error: { code: "TIMEOUT", message: "Operation timed out" }
265
- });
266
- }
267
- }, 6e4);
268
259
  });
269
260
  },
270
261
  [postMessage]
@@ -0,0 +1,57 @@
1
+ /**
2
+ * live/coalesce.ts — the difference between "live" and "thrashing".
3
+ *
4
+ * The invalidation graph is exact, which is the whole point of it — and exact
5
+ * still means N invalidations for N writes. A bulk import, a scheduled publish
6
+ * sweep or someone holding down a keyboard shortcut fires a burst on one model,
7
+ * and a client that refetched each one would spend the burst doing nothing but
8
+ * refetching, on the exact screen a burst is most likely to be watched from.
9
+ *
10
+ * Three things fix that, and they are three different problems:
11
+ *
12
+ * - **Coalesce.** Fifty invalidations of one key inside a window are one
13
+ * refetch. This is the big one, because a burst usually IS one key.
14
+ * - **Cap.** A burst that spans many keys must not open fifty requests at
15
+ * once — that is a self-inflicted denial of service on the caller's own
16
+ * connection pool, and every extra request slows the ones already running.
17
+ * - **Supersede.** A key invalidated again while its refetch is in flight
18
+ * gets exactly one more refetch afterwards, not one per invalidation. The
19
+ * `seq` last-write-wins rule in the store already makes a late arrival
20
+ * harmless; this is about the WASTE, not the correctness.
21
+ */
22
+ export interface FoirCoalescerOptions {
23
+ /**
24
+ * What to do for one key. Its returned promise, if any, is awaited — that is
25
+ * how the concurrency cap knows a refetch has finished. A query library's
26
+ * `invalidateQueries` returns exactly that promise.
27
+ */
28
+ run: (key: string) => unknown;
29
+ /**
30
+ * How long to gather before running. 25ms by default: long enough to swallow
31
+ * a burst arriving over one socket read, short enough that a single change
32
+ * still feels immediate.
33
+ */
34
+ windowMs?: number;
35
+ /** How many refetches may be in flight at once. */
36
+ maxConcurrent?: number;
37
+ /** Injectable timers, so a test can drive the window without waiting. */
38
+ timers?: {
39
+ setTimeout: (fn: () => void, ms: number) => unknown;
40
+ clearTimeout: (handle: unknown) => void;
41
+ };
42
+ /** Reported rather than thrown: one key's refetch failing must not stop the rest. */
43
+ onError?: (error: unknown, key: string) => void;
44
+ }
45
+ export interface FoirCoalescer {
46
+ /** Ask for a key to be refetched, eventually and at most once per window. */
47
+ invalidate(key: string): void;
48
+ /** Run everything waiting, now. Resolves when the queue is empty. */
49
+ flush(): Promise<void>;
50
+ /** Keys gathered but not yet started. */
51
+ readonly pending: number;
52
+ /** Refetches currently running. */
53
+ readonly inFlight: number;
54
+ /** Forget everything waiting. Does not cancel what is already running. */
55
+ dispose(): void;
56
+ }
57
+ export declare function createFoirCoalescer(options: FoirCoalescerOptions): FoirCoalescer;
@@ -0,0 +1,58 @@
1
+ import type { FoirSchemaDrift } from '../data/transport';
2
+ /**
3
+ * live/drift.ts — the deploy that changes what a cached response MEANS.
4
+ *
5
+ * Every other invalidation in this subpath answers "is this data still
6
+ * current". Drift answers a different question: "is this data still the same
7
+ * SHAPE". A deploy that adds a field, renames one, or changes a scalar's wire
8
+ * form leaves every cached entry perfectly current and quietly wrong — the
9
+ * bytes are what the server sent, and they no longer match what the code
10
+ * reading them expects.
11
+ *
12
+ * Nothing else catches it. The dependency graph is about records, and no record
13
+ * changed. `seq` is about ordering, and nothing is out of order. The entry is
14
+ * fresh, correctly tagged, correctly stamped, and describes a schema that no
15
+ * longer exists — until the tab is reloaded, which on a long-lived admin
16
+ * session can be days.
17
+ *
18
+ * The SDK already detects this: the data client compares the surface version
19
+ * its generated types came from against the one the server is serving, and
20
+ * reports `FoirSchemaDrift`. What was missing is anyone acting on it.
21
+ *
22
+ * ONE HOOK, and it closes a whole class of "worked until the deploy" bugs.
23
+ */
24
+ export interface FoirDriftHandlerOptions {
25
+ /**
26
+ * Throw away everything cached. Usually the store's `resync` and the query
27
+ * client's `clear`, in that order.
28
+ */
29
+ resync: (drift: FoirSchemaDrift) => void;
30
+ /**
31
+ * Called instead of resyncing when the drift is not one that can invalidate
32
+ * anything — see `foirDriftIsActionable`.
33
+ */
34
+ onIgnored?: (drift: FoirSchemaDrift) => void;
35
+ }
36
+ /**
37
+ * Whether a reported drift is worth clearing the cache for.
38
+ *
39
+ * Any surface other than the one the types were generated from is. The versions
40
+ * are content hashes, so there is no ahead or behind: a deploy that added a
41
+ * field and a rollback that removed one are both a different surface, and in
42
+ * both the cache holds responses resolved against a shape the code no longer
43
+ * reads, which is the torn state this exists to remove.
44
+ *
45
+ * What is NOT actionable is equality, or a version missing on either side: a
46
+ * report the detector should not produce, and one that costs nothing to refuse
47
+ * here rather than to clear the world for.
48
+ */
49
+ export declare function foirDriftIsActionable(drift: FoirSchemaDrift): boolean;
50
+ /**
51
+ * Build the handler to hand to `createFoirClient({ onSchemaDrift })`.
52
+ *
53
+ * It clears rather than revalidates, and that is the point: a revalidation
54
+ * would refetch each entry through the same code that no longer matches the
55
+ * schema, one query at a time, while continuing to serve the old shape in
56
+ * between. Whatever is cached is unusable as a set, so it goes as a set.
57
+ */
58
+ export declare function createFoirDriftHandler(options: FoirDriftHandlerOptions): (drift: FoirSchemaDrift) => void;
@@ -0,0 +1,13 @@
1
+ import type { FoirRecordChangedEvent } from '../data/subscriptions';
2
+ /** `rec:{id}` for one record id. */
3
+ export declare function foirRecordTag(recordId: string): string;
4
+ /** `model:{key}:published` for one model. */
5
+ export declare function foirModelTag(modelKey: string): string;
6
+ /**
7
+ * The tags one record change invalidates.
8
+ *
9
+ * An action nobody has taught this about is treated as a membership change,
10
+ * which is the WIDE answer. Over-invalidating costs a refetch; the narrow answer
11
+ * on an action that actually moved a record between lists costs the update.
12
+ */
13
+ export declare function foirTagsForRecordChange(event: FoirRecordChangedEvent): string[];
@@ -0,0 +1,53 @@
1
+ /**
2
+ * live/headers.ts — what a read tells the cache about itself.
3
+ *
4
+ * Two headers, and they arrive for different reasons.
5
+ */
6
+ /**
7
+ * The dependency set a BROWSER may read.
8
+ *
9
+ * `Surrogate-Key` is the CDN's contract and names records a response was shaped
10
+ * by but did not show, so it is deliberately not exposed cross-origin. This
11
+ * header is the same set projected down to records the caller was actually
12
+ * served, and it is the one on api-public's CORS exposed-header list.
13
+ */
14
+ export declare const FOIR_CACHE_TAGS_HEADER = "X-Foir-Cache-Tags";
15
+ /**
16
+ * The CDN's wider set. Present and readable on a SAME-ORIGIN or server-side
17
+ * read, where CORS never applies.
18
+ *
19
+ * Preferred when both are present, because it is the more complete answer to
20
+ * "what would make this response stale" — an entry shaped by a record it
21
+ * filtered out still has to drop when that record changes.
22
+ */
23
+ export declare const FOIR_SURROGATE_KEY_HEADER = "Surrogate-Key";
24
+ /**
25
+ * The project's logical clock at the moment the response was RESOLVED.
26
+ *
27
+ * Absent means UNKNOWN, never zero — a cold project, an unreachable Redis and a
28
+ * cache entry written before the stamp existed all report nothing rather than a
29
+ * position. Zero would mean "before everything", and a client applying
30
+ * monotonic reads to it would discard every response it holds.
31
+ */
32
+ export declare const FOIR_PROJECT_SEQ_HEADER = "X-Foir-Project-Seq";
33
+ /** The minimal header shape this module reads; a real `Headers` satisfies it. */
34
+ export interface FoirHeaderSource {
35
+ get(name: string): string | null | undefined;
36
+ }
37
+ /** What a response says about its own place in the graph and in time. */
38
+ export interface FoirLiveHeaders {
39
+ /** The response's dependency set. Empty when it published none. */
40
+ tags: string[];
41
+ /** The project seq it resolved under, or `null` for "unknown". */
42
+ seq: number | null;
43
+ }
44
+ /**
45
+ * Read a response's dependency set and logical timestamp.
46
+ *
47
+ * A seq that is not a positive integer is treated as unknown rather than
48
+ * coerced. `Number('')` is 0 and `Number('abc')` is NaN, and both would become
49
+ * a position in seq space if this used the plain conversion — the first
50
+ * silently the WORST position, which is the one that makes a client throw away
51
+ * good data.
52
+ */
53
+ export declare function readFoirLiveHeaders(headers: FoirHeaderSource): FoirLiveHeaders;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `@foir/sdk/live` — the client half of Foir's invalidation graph.
3
+ *
4
+ * ## What this is
5
+ *
6
+ * The API already computes, for every cacheable response, the exact set of
7
+ * records and collections it depends on — it has to, to invalidate its own
8
+ * cache — and ships it on a response header. It already streams the change
9
+ * events that make those dependencies stale, each carrying the project's
10
+ * monotonic `seq`. This subpath is the join, on the client: a bidirectional tag
11
+ * index, a store that honours ordering and completeness, and the mapping from
12
+ * one record change to the entries it invalidates.
13
+ *
14
+ * Apollo, urql and TanStack Query all GUESS at that graph — manual query keys or
15
+ * normalised-cache heuristics. Here it is computed by the process that actually
16
+ * resolved the query, which makes invalidation exact and schema-agnostic.
17
+ *
18
+ * ## What this is NOT, and will not become
19
+ *
20
+ * **Not a state-management library.** No local mutation log, no offline mode, no
21
+ * conflict resolution, no optimistic rollback. The server is authoritative:
22
+ * mutations go over the wire and the invalidation comes back.
23
+ *
24
+ * That is a product boundary, not a phasing one. The moment optimistic mutations
25
+ * with rollback are added this becomes a different category of product, with its
26
+ * own roadmap and a permanent support surface. It is written here so it is not
27
+ * filed as a bug.
28
+ *
29
+ * **Framework-free.** Nothing here imports React, and nothing here is a
30
+ * rendering concern. The React binding is a separate, thin adapter over a query
31
+ * library the consumer already has.
32
+ */
33
+ export { foirQueryKey, foirStableKey, foirDecimalKey } from './keys';
34
+ export type { FoirLiveCall } from './keys';
35
+ export { readFoirLiveHeaders, FOIR_CACHE_TAGS_HEADER, FOIR_SURROGATE_KEY_HEADER, FOIR_PROJECT_SEQ_HEADER, } from './headers';
36
+ export type { FoirHeaderSource, FoirLiveHeaders } from './headers';
37
+ export { readFoirWriteToken, foirWriteTokenHeaders, FOIR_WRITE_TOKEN_HEADER, } from './writetoken';
38
+ export { foirTagsForRecordChange, foirRecordTag, foirModelTag } from './events';
39
+ export { createFoirTagIndex } from './tagIndex';
40
+ export type { FoirTagIndex } from './tagIndex';
41
+ export { createFoirLiveStore } from './store';
42
+ export type { FoirLiveEntry, FoirLiveInvalidation, FoirLiveReadOptions, FoirLiveStore, FoirLiveStoreOptions, FoirLiveWrite, FoirWriteFence, } from './store';
43
+ export { foirRequiresResync, foirResumeCursor, FOIR_RESYNC_ERROR_CODES } from './resync';
44
+ export type { FoirResyncErrorCode } from './resync';
45
+ export { createFoirReconciler, foirHeldRecordIds, foirTagsForGoneRecords, } from './reconcile';
46
+ export type { FoirReconcileResolveFn, FoirReconciler, FoirReconcilerOptions, } from './reconcile';
47
+ export { createFoirLiveManager } from './manager';
48
+ export type { FoirLiveManager, FoirLiveManagerOptions, FoirLiveSubscribeFn, FoirLiveSubscribeHandlers, FoirLiveSubscription, } from './manager';
49
+ export { applyFoirRecordChange, foirModelQueryKey, foirQueryKeys, foirTagsForQueryInvalidation, FOIR_QUERY_KEY_ROOT, } from './tanstack';
50
+ export type { FoirApplyOptions, FoirLiveEffect, FoirLiveMode, FoirQueryClientLike, } from './tanstack';
51
+ export { createFoirCoalescer } from './coalesce';
52
+ export type { FoirCoalescer, FoirCoalescerOptions } from './coalesce';
53
+ export { createFoirDriftHandler, foirDriftIsActionable } from './drift';
54
+ export type { FoirDriftHandlerOptions } from './drift';
@@ -0,0 +1,72 @@
1
+ import { FoirDecimal } from '../data/decimal';
2
+ import type { FoirCall, FoirNamespaceCall } from '../data/document';
3
+ /**
4
+ * live/keys.ts — one call, one key.
5
+ *
6
+ * A cache key has exactly one job: two calls that would return the same bytes
7
+ * must produce the same key, and two that would not must never. Everything here
8
+ * is in service of that, and every rule below exists because some ordinary
9
+ * JavaScript would otherwise break it.
10
+ */
11
+ /**
12
+ * Canonicalise a decimal for a KEY, and only for a key.
13
+ *
14
+ * `decimal('1.0')` and `decimal('1.00')` are the same number and different
15
+ * strings, so a key built from `toString()` makes two entries for one logical
16
+ * query. The fix is to key on a canonical representative of the value.
17
+ *
18
+ * MINIMAL SCALE, not a fixed one. Rescaling to a fixed number of places is the
19
+ * obvious move and it is unsafe in the direction that matters: any fixed scale
20
+ * short enough to be useful ROUNDS, and rounding in a key makes two genuinely
21
+ * different values collide — `1.001` and `1.002` at 2dp are one key, and one
22
+ * query is then served the other's cached answer. Stripping trailing zeros is
23
+ * lossless, so two decimals share a key if and only if they are `.equals()`.
24
+ *
25
+ * # KEYS NORMALISE, PAYLOADS NEVER DO
26
+ *
27
+ * This is half of a rule whose other half points the opposite way. Scale is
28
+ * display, not identity: trailing zeros are significant for money, which is why
29
+ * `toString()` preserves them and `equals()` ignores them. Normalising a decimal
30
+ * on the way INTO the store would serve `"1.0"` where the server sent `"1.00"` —
31
+ * a silent change to how a price renders. The store never holds a decimal at
32
+ * all (it holds raw wire JSON, where a decimal is a string), so that half falls
33
+ * out for free. Do not undo it.
34
+ */
35
+ export declare function foirDecimalKey(value: FoirDecimal): string;
36
+ /**
37
+ * A stable, injective string for any value a call argument can hold.
38
+ *
39
+ * `JSON.stringify` is not enough on its own for three reasons, and each one is a
40
+ * wrong cache hit rather than a cosmetic difference:
41
+ *
42
+ * - **Key order.** `{a:1,b:2}` and `{b:2,a:1}` are the same argument and
43
+ * stringify differently. Object keys are sorted here.
44
+ * - **`undefined`.** `stringify` DROPS an undefined property, so
45
+ * `{a:1,b:undefined}` and `{a:1}` collapse to one key — usually right, and
46
+ * wrong the moment a caller distinguishes "not set" from "set to nothing".
47
+ * Both are written explicitly.
48
+ * - **Type collisions.** `1` and `"1"` stringify to `1` and `"1"`, which is
49
+ * fine, but `null` and the string `"null"` are one keystroke apart in a
50
+ * hand-built key. Every scalar is tagged with its type.
51
+ *
52
+ * `FoirDecimal` gets its own tag and its own canonical form: it is a class with
53
+ * `#`-private fields, so `JSON.stringify` would reach `toJSON()` and produce the
54
+ * scale-preserving string this function exists to normalise away.
55
+ */
56
+ export declare function foirStableKey(value: unknown): string;
57
+ /** A model or namespace call, as the live store keys it. */
58
+ export type FoirLiveCall = FoirCall | FoirNamespaceCall;
59
+ /**
60
+ * The cache key for one call.
61
+ *
62
+ * Every input that changes the RESPONSE is in it, and nothing that does not.
63
+ * `omit` and `pick` are in it deliberately: two calls differing only by a narrow
64
+ * are two different response shapes, and folding them together is how a caller
65
+ * asking for the whole record gets served one with a field missing. The
66
+ * completeness mask in the store is the second line of defence for the case
67
+ * where an entry is reachable through something other than its own key.
68
+ *
69
+ * `omit` and `pick` are SORTED — they are sets, and a caller who writes them in
70
+ * a different order has not written a different query.
71
+ */
72
+ export declare function foirQueryKey(call: FoirLiveCall): string;
@@ -0,0 +1,77 @@
1
+ import type { FoirRecordChangedEvent } from '../data/subscriptions';
2
+ import type { FoirRequestError } from '../transport/foir-http';
3
+ /**
4
+ * live/manager.ts — one subscription per model, however many things are
5
+ * watching it.
6
+ *
7
+ * Framework-free on purpose. What it claims — that the Nth watcher of a model
8
+ * opens no second stream, that the last one to leave closes the first, and that
9
+ * a reconnect resumes from where the stream got to — is a statement about
10
+ * refcounts and cursors, provable without React, a socket or a server.
11
+ */
12
+ /** One live subscription, as the data client's `onChange` returns it. */
13
+ export interface FoirLiveSubscription {
14
+ unsubscribe(): void;
15
+ }
16
+ /** The handlers the manager wires onto each subscription. */
17
+ export interface FoirLiveSubscribeHandlers {
18
+ onData: (event: FoirRecordChangedEvent) => void;
19
+ onError?: (error: FoirRequestError) => void;
20
+ onReconnect?: () => void;
21
+ onComplete?: () => void;
22
+ }
23
+ /**
24
+ * How the manager opens a stream. Injected rather than taking a client, so the
25
+ * refcounting can be tested for what it is.
26
+ *
27
+ * `after` is the resume cursor and is absent on a first subscribe: a watcher
28
+ * joining now has missed nothing, and asking the server to replay history it
29
+ * does not want would spend the backlog budget for no reason.
30
+ */
31
+ export type FoirLiveSubscribeFn = (modelKey: string, options: {
32
+ after?: number;
33
+ }, handlers: FoirLiveSubscribeHandlers) => FoirLiveSubscription;
34
+ export interface FoirLiveManagerOptions {
35
+ subscribe: FoirLiveSubscribeFn;
36
+ /** Every admitted change, for every watched model. */
37
+ onChange: (event: FoirRecordChangedEvent) => void;
38
+ /**
39
+ * The cache can no longer be trusted and must be thrown away — the server
40
+ * refused the resume cursor. See `resync.ts` for why those two refusals are
41
+ * the only signal of this kind there is.
42
+ */
43
+ onResync?: (reason: FoirRequestError, modelKey: string) => void;
44
+ /** A subscription failed for a reason that is not a resync. */
45
+ onError?: (error: FoirRequestError, modelKey: string) => void;
46
+ /**
47
+ * Re-read the record ids this client holds for a model, because the cursor
48
+ * cannot report what VANISHED.
49
+ *
50
+ * Called on every reconnect, alongside resuming the cursor rather than
51
+ * instead of it — the two answer different questions. The cursor replays what
52
+ * CHANGED while the socket was down. This reports what is no longer there,
53
+ * which by construction produced no event to replay: on a caller-partitioned
54
+ * model the server's confined read is an INNER join to `records`, and a
55
+ * deleted or lost record does not match it. See `reconcile.ts`.
56
+ *
57
+ * Optional because a consumer that caches nothing has nothing to reconcile.
58
+ * Omitting it on a confined model means deletes are never observed.
59
+ */
60
+ reconcile?: (modelKey: string) => void;
61
+ }
62
+ export interface FoirLiveManager {
63
+ /**
64
+ * Watch a model, and return the release. The FIRST caller opens the stream;
65
+ * the LAST release closes it.
66
+ */
67
+ retain(modelKey: string): () => void;
68
+ /** The highest seq applied for a model, or `null` before anything arrives. */
69
+ cursorFor(modelKey: string): number | null;
70
+ /** How many watchers a model has. */
71
+ watchers(modelKey: string): number;
72
+ /** The models with a live stream open. */
73
+ readonly watching: string[];
74
+ /** Close everything. */
75
+ dispose(): void;
76
+ }
77
+ export declare function createFoirLiveManager(options: FoirLiveManagerOptions): FoirLiveManager;
@@ -0,0 +1,143 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { FoirRecordChangedEvent } from '../../data/subscriptions';
3
+ import type { FoirRequestError } from '../../transport/foir-http';
4
+ import { type FoirLiveManager, type FoirLiveSubscribeFn } from '../manager';
5
+ import { foirModelQueryKey, type FoirApplyOptions, type FoirLiveEffect, type FoirLiveMode, type FoirQueryClientLike } from '../tanstack';
6
+ /**
7
+ * `@foir/sdk/live/react` — one socket, and the React lifetime that owns it.
8
+ *
9
+ * ## What this is, and what it is not
10
+ *
11
+ * An ADAPTER over a query library the consumer already has. `<FoirLive>` opens
12
+ * one stream per watched model and turns each change into the invalidation the
13
+ * server's own dependency graph says it is. It renders nothing, owns no cache,
14
+ * and replaces no hook.
15
+ *
16
+ * There is no local mutation log, no offline mode, no conflict resolution and no
17
+ * optimistic rollback. **The server is authoritative.** That is a product
18
+ * boundary rather than a phase: the moment optimistic mutations with rollback
19
+ * are added this becomes a different category of library with its own roadmap
20
+ * and a permanent support surface.
21
+ *
22
+ * ## Live is OPT-IN per model
23
+ *
24
+ * Nothing subscribes because a provider is mounted. A component says
25
+ * `useFoirLive('product')` and the model is watched for as long as something is
26
+ * saying it; the last one to leave closes the stream.
27
+ *
28
+ * That default is not timidity. The shared CDN lane is a major asset, and a
29
+ * blanket "everything is live" pushes read traffic off it onto the origin — so
30
+ * liveness is something a screen asks for, on the models where it earns the
31
+ * connection.
32
+ *
33
+ * ## The limitation to document rather than defeat
34
+ *
35
+ * A query ordered by a MUTABLE sort key — `updatedAt`, or a `position` a user
36
+ * drags — can have a record move between pages when it is updated, so it may
37
+ * appear twice or vanish until the next full refetch. Detecting that needs
38
+ * client-side knowledge of the sort fields, and the fully correct alternative
39
+ * turns a routine edit into an N-page refetch on most feeds. It is a known
40
+ * limitation, not a bug.
41
+ */
42
+ export interface FoirLiveContextValue {
43
+ manager: FoirLiveManager;
44
+ /** Watch a model for the caller's lifetime; returns the release. */
45
+ retain(modelKey: string): () => void;
46
+ /** Subscribe to the effect stream (for `useSyncExternalStore`). */
47
+ subscribeEffects(listener: () => void): () => void;
48
+ /** The current snapshot, stable between changes. */
49
+ snapshot(): FoirLiveSnapshot;
50
+ }
51
+ /**
52
+ * What a UI can render that the adapter deliberately does not do for it.
53
+ *
54
+ * `newCounts` is the create case: a create cannot be spliced into a loaded page
55
+ * — record events carry no body, and even with one you could not place the row
56
+ * without knowing the query's sort — and refetching the head page instead
57
+ * prepends under a reader mid-scroll. So the count is the answer, and "3 new
58
+ * products — show" is the UI's decision to make.
59
+ */
60
+ export interface FoirLiveSnapshot {
61
+ /** Records created per model since the last acknowledgement. */
62
+ newCounts: Readonly<Record<string, number>>;
63
+ /** Models whose `totalCount` is now approximate. */
64
+ totalCountStale: readonly string[];
65
+ /** The last change seen, per model. */
66
+ lastEvent: Readonly<Record<string, FoirRecordChangedEvent>>;
67
+ }
68
+ export interface FoirLiveProps extends FoirApplyOptions {
69
+ /**
70
+ * How to open a stream for a model. Usually
71
+ * `(modelKey, options, handlers) => (foir as never)[modelKey].onChange({ ...handlers, ...options })`.
72
+ *
73
+ * Injected rather than taking the client, because the client's model
74
+ * accessors are generated per project and this package cannot name them.
75
+ */
76
+ subscribe: FoirLiveSubscribeFn;
77
+ /** The query client to invalidate. Structurally typed; a real one satisfies it. */
78
+ queryClient: FoirQueryClientLike;
79
+ /** Default liveness for every model. Per-model overrides ride on `useFoirLive`. */
80
+ mode?: FoirLiveMode;
81
+ /**
82
+ * The cache can no longer be trusted — the server refused the resume cursor.
83
+ * Throw away what you hold and refetch; there is no way to know what was
84
+ * missed, which is why this is a callback rather than a quiet retry.
85
+ */
86
+ onResync?: (error: FoirRequestError, modelKey: string) => void;
87
+ onError?: (error: FoirRequestError, modelKey: string) => void;
88
+ /** Called for every applied change, after the query client has been told. */
89
+ onEffect?: (effect: FoirLiveEffect, event: FoirRecordChangedEvent) => void;
90
+ /**
91
+ * Re-read the record ids held for a model, on every reconnect.
92
+ *
93
+ * SUPPLY THIS ON ANY MODEL WHOSE RECORDS ARE PARTITIONED PER CALLER, or you
94
+ * will never see deletes. The server serves those models through a confined
95
+ * read that INNER JOINs `events` to `records` under the caller's own claims,
96
+ * and a deleted record — or one whose ownership moved away — matches nothing,
97
+ * so no event is ever emitted for it. The cursor replays what CHANGED; only
98
+ * this reports what VANISHED.
99
+ *
100
+ * Build it with `createFoirReconciler` from `@foir/sdk/live`, giving it a
101
+ * `resolve` that asks the model's own by-ids query which of the ids are still
102
+ * visible. That call discloses nothing, because the caller supplies the ids.
103
+ *
104
+ * Injected for the same reason `subscribe` is: the op that answers it is
105
+ * published per project in the manifest, so this package cannot name it.
106
+ */
107
+ reconcile?: (modelKey: string) => void;
108
+ /**
109
+ * How long to gather invalidations before refetching, in milliseconds.
110
+ *
111
+ * A burst on one model is one refetch, not fifty. 25ms by default: long
112
+ * enough to swallow a burst arriving over one socket read, short enough that
113
+ * a single change still feels immediate. `0` still coalesces within a tick.
114
+ */
115
+ coalesceWindowMs?: number;
116
+ /**
117
+ * How many refetches may run at once. Four by default — a burst spanning many
118
+ * keys must not open a request per key, which is a self-inflicted denial of
119
+ * service on the caller's own connection pool.
120
+ */
121
+ maxConcurrentRefetches?: number;
122
+ children?: ReactNode;
123
+ }
124
+ export declare function FoirLive(props: FoirLiveProps): ReactNode;
125
+ /**
126
+ * Watch a model for as long as this component is mounted.
127
+ *
128
+ * The FIRST caller opens the stream and the LAST unmount closes it, so N
129
+ * components watching one model cost one subscription — and a screen that
130
+ * navigates away stops paying for one it is not reading.
131
+ */
132
+ export declare function useFoirLive(modelKey: string): void;
133
+ /**
134
+ * What has changed that the adapter deliberately did not apply.
135
+ *
136
+ * `useSyncExternalStore` rather than `useState`, so React 18 concurrent
137
+ * rendering cannot tear: two components reading this in the same render must
138
+ * see the same snapshot, and an event arriving mid-render must not give them
139
+ * different ones.
140
+ */
141
+ export declare function useFoirLiveStatus(): FoirLiveSnapshot;
142
+ /** Every query key for one model, for a caller driving its own refetch. */
143
+ export { foirModelQueryKey };
@@ -0,0 +1,82 @@
1
+ import type { FoirTagIndex } from './tagIndex';
2
+ /**
3
+ * The record ids this client currently holds for one model.
4
+ *
5
+ * Derived from the tag index rather than tracked separately, because the index
6
+ * already IS this bookkeeping: every cached response was written with the
7
+ * server's own `Surrogate-Key`, which names `rec:{id}` for each record it served
8
+ * and `model:{key}:{channel}` for the collection. Keeping a second list beside
9
+ * it would be a mirror with nothing detecting drift, and the direction it would
10
+ * drift in is an id that is quietly never reconciled.
11
+ *
12
+ * Entries are found by the MODEL tag and the ids read off their record tags, so
13
+ * an id only counts as held while some cached entry actually depends on it.
14
+ * Dropping that entry un-indexes it and it stops being reconciled, which is
15
+ * correct: nothing is showing it any more.
16
+ */
17
+ export declare function foirHeldRecordIds(index: FoirTagIndex, modelKey: string): string[];
18
+ /**
19
+ * Ask which of `ids` the caller can still see, for one model.
20
+ *
21
+ * Injected rather than built here, and that is deliberate twice over. The op
22
+ * that answers this is published per project in the manifest, so its NAME is not
23
+ * something this module can know; and injecting it keeps the reconcile logic
24
+ * testable for what it is — a set difference — without a client, a socket or a
25
+ * server.
26
+ *
27
+ * It must resolve to the ids that are STILL VISIBLE. Returning the ones that are
28
+ * gone would invert the result silently, so the contract is stated in the name.
29
+ *
30
+ * Whatever implements it must read under the CALLER's own authority. A resolve
31
+ * that ran with wider reach would report a record as still visible when the
32
+ * caller has lost it, which is precisely the case this exists to catch.
33
+ */
34
+ export type FoirReconcileResolveFn = (modelKey: string, ids: readonly string[]) => Promise<readonly string[]>;
35
+ export interface FoirReconcilerOptions {
36
+ resolve: FoirReconcileResolveFn;
37
+ /**
38
+ * The ids currently held for a model. Defaults to reading them off the tag
39
+ * index, which is the shape every consumer of this module already has.
40
+ */
41
+ heldIds?: (modelKey: string) => readonly string[];
42
+ /**
43
+ * A record the caller can no longer see. Evict it and anything that lists it.
44
+ *
45
+ * Called once per reconcile with every gone id, rather than once per id: the
46
+ * caller almost always wants a single invalidation pass, and N callbacks
47
+ * invite N refetches of the same list.
48
+ */
49
+ onGone: (modelKey: string, goneIds: readonly string[]) => void;
50
+ /**
51
+ * The largest number of ids to resolve in one call. Defaults to 200.
52
+ *
53
+ * A cap rather than an unbounded request, because the held set grows with
54
+ * everything the session has ever paged through, and one enormous request on
55
+ * every reconnect is how a flaky connection turns into a load problem.
56
+ */
57
+ batchSize?: number;
58
+ /**
59
+ * A resolve that failed. The reconcile is ABANDONED rather than treated as
60
+ * "everything is gone" — see below.
61
+ */
62
+ onError?: (error: unknown, modelKey: string) => void;
63
+ }
64
+ export interface FoirReconciler {
65
+ /**
66
+ * Reconcile one model, returning the ids found to be gone.
67
+ *
68
+ * Concurrent calls for the same model collapse onto the first: a reconnect
69
+ * storm must not multiply the read.
70
+ */
71
+ reconcile(modelKey: string): Promise<string[]>;
72
+ }
73
+ export declare function createFoirReconciler(options: FoirReconcilerOptions): FoirReconciler;
74
+ /**
75
+ * The tags a set of gone records invalidates.
76
+ *
77
+ * The record tag for each, plus the MODEL tag once — a disappearance is a
78
+ * membership change, so every cached list on that model is now wrong in a way
79
+ * no `rec:` tag can express. That is the same rule `foirTagsForRecordChange`
80
+ * applies to a create, arrived at from the other end.
81
+ */
82
+ export declare function foirTagsForGoneRecords(modelKey: string, goneIds: readonly string[]): string[];