@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.
- package/CHANGELOG.md +719 -27
- package/README.md +121 -2
- package/dist/auth/auth.d.ts +72 -1
- package/dist/auth/hosted.d.ts +71 -4
- package/dist/auth/index.d.ts +3 -3
- package/dist/auth/operations.d.ts +84 -0
- package/dist/auth/ops.d.ts +18 -0
- package/dist/auth.js +209 -47
- package/dist/client.js +101 -13
- package/dist/codegen/block-registry.d.ts +1 -1
- package/dist/codegen/block-scaffold.d.ts +1 -1
- package/dist/codegen/index.d.ts +1 -1
- package/dist/codegen/item-registry.d.ts +1 -1
- package/dist/codegen/item-scaffold.d.ts +1 -1
- package/dist/codegen/preset.d.ts +3 -2
- package/dist/content/content-renderer.d.ts +4 -5
- package/dist/content/index.d.ts +1 -1
- package/dist/content/richtext.d.ts +1 -1
- package/dist/data/builtins.d.ts +81 -13
- package/dist/data/client.d.ts +92 -9
- package/dist/data/decimal.d.ts +212 -0
- package/dist/data/document.d.ts +14 -1
- package/dist/data/index.d.ts +22 -10
- package/dist/data/manifest.d.ts +40 -4
- package/dist/data/schema.d.ts +18 -11
- package/dist/data/selection.d.ts +15 -0
- package/dist/data/subscriptions.d.ts +36 -10
- package/dist/data/transport.d.ts +363 -24
- package/dist/data/ws-transport.d.ts +8 -2
- package/dist/data.js +591 -64
- package/dist/graphql/cdn-route.d.ts +1 -1
- package/dist/graphql/foir-request.d.ts +1 -1
- package/dist/index.js +0 -9
- package/dist/live/coalesce.d.ts +57 -0
- package/dist/live/drift.d.ts +58 -0
- package/dist/live/events.d.ts +13 -0
- package/dist/live/headers.d.ts +53 -0
- package/dist/live/index.d.ts +54 -0
- package/dist/live/keys.d.ts +72 -0
- package/dist/live/manager.d.ts +77 -0
- package/dist/live/react/index.d.ts +143 -0
- package/dist/live/reconcile.d.ts +82 -0
- package/dist/live/resync.d.ts +59 -0
- package/dist/live/store.d.ts +134 -0
- package/dist/live/tagIndex.d.ts +29 -0
- package/dist/live/tanstack.d.ts +127 -0
- package/dist/live/writetoken.d.ts +86 -0
- package/dist/live-react.js +395 -0
- package/dist/live.js +845 -0
- package/dist/next/cacheTags.d.ts +245 -0
- package/dist/next/index.d.ts +7 -0
- package/dist/next.js +260 -34
- package/dist/push.js +163 -36
- package/dist/react.js +157 -33
- package/dist/realtime/index.d.ts +4 -5
- package/dist/server/events-client.d.ts +54 -4
- package/dist/server/index.d.ts +4 -2
- package/dist/server/operation-types.d.ts +86 -58
- package/dist/server/read-dispatch.d.ts +53 -0
- package/dist/server/require-scoped-token.d.ts +20 -1
- package/dist/server.js +602 -10
- package/dist/transport/foir-http.d.ts +168 -15
- package/package.json +10 -2
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@foir/sdk/next` — the bridge between Foir's own dependency graph and Next's
|
|
3
|
+
* cache tags.
|
|
4
|
+
*
|
|
5
|
+
* ## The idea, in one line
|
|
6
|
+
*
|
|
7
|
+
* The API already computes, for every cacheable response, the exact set of
|
|
8
|
+
* records and collections that response depends on — it has to, to invalidate
|
|
9
|
+
* its own cache. It ships that set on `Surrogate-Key`. And it already streams
|
|
10
|
+
* the change events that make those dependencies stale. **Nothing joined the
|
|
11
|
+
* two.** This module is the join: tags in on the read, `revalidateTag` out on
|
|
12
|
+
* the event.
|
|
13
|
+
*
|
|
14
|
+
* That is a materially different thing from what a client-side cache can do.
|
|
15
|
+
* Query-key conventions and normalised-cache heuristics both GUESS at which
|
|
16
|
+
* cached entries a change affects. Here the graph is computed by the process
|
|
17
|
+
* that actually resolved the query.
|
|
18
|
+
*
|
|
19
|
+
* ## Next is INJECTED, never imported
|
|
20
|
+
*
|
|
21
|
+
* Nothing in `@foir/sdk` imports from `next` or `next/*`, and this module keeps
|
|
22
|
+
* it that way: `cacheTag` and `revalidateTag` arrive as function arguments.
|
|
23
|
+
*
|
|
24
|
+
* That is not tidiness. `cacheTag` is `unstable_cacheTag` in Next 15, requires
|
|
25
|
+
* `experimental.useCache`, and may be renamed or gated differently in 16.
|
|
26
|
+
* Importing it would make the SDK's install and its type-check depend on a Next
|
|
27
|
+
* version and a feature flag; injecting it means the SDK inherits none of that
|
|
28
|
+
* instability, and a consumer on any Next version wires up whatever their
|
|
29
|
+
* version calls it.
|
|
30
|
+
*
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { unstable_cacheTag as cacheTag } from 'next/cache';
|
|
33
|
+
* import { createFoirTaggingFetch } from '@foir/sdk/next';
|
|
34
|
+
*
|
|
35
|
+
* const foir = createFoirClient({
|
|
36
|
+
* apiUrl, apiKey, generated,
|
|
37
|
+
* fetch: createFoirTaggingFetch({ cacheTag }),
|
|
38
|
+
* });
|
|
39
|
+
*
|
|
40
|
+
* export async function getProducts() {
|
|
41
|
+
* 'use cache';
|
|
42
|
+
* return foir.products.list(); // tags registered during the fetch
|
|
43
|
+
* }
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* ## Why the fetch is wrapped rather than the transport changed
|
|
47
|
+
*
|
|
48
|
+
* `@foir/sdk/data`'s transport returns the parsed envelope and nothing else —
|
|
49
|
+
* no caller can reach a response header through it, on any path. The client
|
|
50
|
+
* config already accepts an injected `fetch`, so the bridge reads the headers
|
|
51
|
+
* there. The transport is a shared file on a lane several other changes are
|
|
52
|
+
* live in; this needs none of it.
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* `Surrogate-Key` is the CDN's contract: every handle a purge might need,
|
|
56
|
+
* including records a response was SHAPED BY but did not show.
|
|
57
|
+
*
|
|
58
|
+
* That is the right input for a cache tag and the wrong one for a browser, and
|
|
59
|
+
* the distinction is why the API ships a second, narrower header for page
|
|
60
|
+
* JavaScript. A Next `use cache` entry is a SERVER-side cache, so it wants the
|
|
61
|
+
* wider set: an entry whose content was influenced by a record it filtered out
|
|
62
|
+
* still has to drop when that record changes.
|
|
63
|
+
*/
|
|
64
|
+
export declare const FOIR_SURROGATE_KEY_HEADER = "Surrogate-Key";
|
|
65
|
+
/**
|
|
66
|
+
* Next's own limits, as of 15.5 (`NEXT_CACHE_TAG_MAX_ITEMS`,
|
|
67
|
+
* `NEXT_CACHE_TAG_MAX_LENGTH`).
|
|
68
|
+
*
|
|
69
|
+
* They are mirrored rather than imported for the same reason `cacheTag` is
|
|
70
|
+
* injected, and they are the reason {@link foirCacheTags} has a policy at all:
|
|
71
|
+
* Next's behaviour on exceeding either is a `console.warn` and a SILENT DROP of
|
|
72
|
+
* the excess. A dropped tag is not a smaller feature, it is an entry that can
|
|
73
|
+
* never be invalidated for the records whose tags went missing.
|
|
74
|
+
*/
|
|
75
|
+
export declare const FOIR_NEXT_TAG_MAX_ITEMS = 128;
|
|
76
|
+
export declare const FOIR_NEXT_TAG_MAX_LENGTH = 256;
|
|
77
|
+
/**
|
|
78
|
+
* The tag a response with NO dependency set is filed under.
|
|
79
|
+
*
|
|
80
|
+
* Some reads emit no `Surrogate-Key` at all — a draft-channel read, a preview
|
|
81
|
+
* selection, a `NO_CACHE` model, a client `no-store`, a traced read. Wrapping
|
|
82
|
+
* one of those in `use cache` stores an entry with ZERO tags, which is strictly
|
|
83
|
+
* worse than not caching it: nothing can ever invalidate it, and it goes on
|
|
84
|
+
* being served until Next's own time-based revalidation.
|
|
85
|
+
*
|
|
86
|
+
* Reporting an empty array would leave that to be noticed. This gives the entry
|
|
87
|
+
* one coarse handle instead, so a consumer that discovers the problem has a
|
|
88
|
+
* `revalidateTag(FOIR_UNTAGGED_TAG)` to reach for — and {@link FoirCacheTags}
|
|
89
|
+
* says `kind: 'untagged'` so it can be noticed before then.
|
|
90
|
+
*/
|
|
91
|
+
export declare const FOIR_UNTAGGED_TAG = "foir:untagged";
|
|
92
|
+
/**
|
|
93
|
+
* The minimal header shape this module reads. Structural on purpose — a real
|
|
94
|
+
* `Headers` satisfies it, and so does a plain object wrapper — so the SDK does
|
|
95
|
+
* not depend on a particular fetch implementation's types.
|
|
96
|
+
*/
|
|
97
|
+
export interface FoirHeaderSource {
|
|
98
|
+
get(name: string): string | null | undefined;
|
|
99
|
+
}
|
|
100
|
+
/** A response's dependency set, after the cap policy has been applied. */
|
|
101
|
+
export type FoirCacheTags = {
|
|
102
|
+
kind: 'tagged';
|
|
103
|
+
/** What to hand to `cacheTag`. Never empty. */
|
|
104
|
+
tags: string[];
|
|
105
|
+
/**
|
|
106
|
+
* True when the per-record tags were dropped in favour of the coarse ones
|
|
107
|
+
* — see {@link foirCacheTags} for why that is the safe direction.
|
|
108
|
+
*/
|
|
109
|
+
coarse: boolean;
|
|
110
|
+
/** How many `rec:` tags the response actually carried, before any policy. */
|
|
111
|
+
recordCount: number;
|
|
112
|
+
} | {
|
|
113
|
+
kind: 'untagged';
|
|
114
|
+
/** Always the single {@link FOIR_UNTAGGED_TAG}. */
|
|
115
|
+
tags: [typeof FOIR_UNTAGGED_TAG];
|
|
116
|
+
/**
|
|
117
|
+
* `no-surrogate-key` — the response carried no dependency set at all.
|
|
118
|
+
* `all-tags-unusable` — it carried one, and nothing in it survived Next's
|
|
119
|
+
* length limit. Two different problems: the first is a read that opted out
|
|
120
|
+
* of caching, the second is a schema whose tags Next cannot hold.
|
|
121
|
+
*/
|
|
122
|
+
reason: 'no-surrogate-key' | 'all-tags-unusable';
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Split `Surrogate-Key` into its tags: whitespace-separated, empties dropped,
|
|
126
|
+
* order preserved, duplicates removed.
|
|
127
|
+
*
|
|
128
|
+
* Returns `[]` when the header is absent — which is a real and load-bearing
|
|
129
|
+
* case, not a parse failure. Use {@link foirCacheTags} rather than this if you
|
|
130
|
+
* are about to cache something: it is the one that knows what an empty set
|
|
131
|
+
* means.
|
|
132
|
+
*/
|
|
133
|
+
export declare function readFoirCacheTags(headers: FoirHeaderSource): string[];
|
|
134
|
+
export interface FoirCacheTagsOptions {
|
|
135
|
+
/** Defaults to Next's own {@link FOIR_NEXT_TAG_MAX_ITEMS}. */
|
|
136
|
+
maxTags?: number;
|
|
137
|
+
/** Defaults to Next's own {@link FOIR_NEXT_TAG_MAX_LENGTH}. */
|
|
138
|
+
maxTagLength?: number;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Apply the cap policy to a response's dependency set.
|
|
142
|
+
*
|
|
143
|
+
* ## The cap is a CORRECTNESS problem, not a limit
|
|
144
|
+
*
|
|
145
|
+
* A response carries one `rec:` tag per record, so a 100-item list is fine and a
|
|
146
|
+
* 200-item list is over Next's 128. Next's answer to that is a `console.warn`
|
|
147
|
+
* and a silent drop of the excess — so the records whose tags were dropped stay
|
|
148
|
+
* stale until the entry's own TTL, with nothing to say which ones they were.
|
|
149
|
+
*
|
|
150
|
+
* **This never truncates.** Over the cap, the per-record tags are dropped
|
|
151
|
+
* ENTIRELY and only the coarse ones are kept. The two failures are not
|
|
152
|
+
* symmetric: truncation loses invalidation for the dropped records permanently
|
|
153
|
+
* and silently, while falling back to `model:` / `lookup:` / `proj:`
|
|
154
|
+
* over-invalidates, which costs a refetch. A big list is exactly the entry a
|
|
155
|
+
* change is most likely to affect, so it is also the entry least able to afford
|
|
156
|
+
* a hole.
|
|
157
|
+
*
|
|
158
|
+
* A tag past the LENGTH cap is handled the same way, because Next drops it the
|
|
159
|
+
* same way: a `rec:` tag that will not fit escalates the whole set to coarse
|
|
160
|
+
* rather than leaving one record unreachable.
|
|
161
|
+
*
|
|
162
|
+
* And if even the coarse set will not fit — which needs a genuinely strange
|
|
163
|
+
* schema — the broadest tags win, `proj:` first.
|
|
164
|
+
*/
|
|
165
|
+
export declare function foirCacheTags(headers: FoirHeaderSource, options?: FoirCacheTagsOptions): FoirCacheTags;
|
|
166
|
+
/** What {@link createFoirTaggingFetch} reports for each response it saw. */
|
|
167
|
+
export interface FoirTaggingReport {
|
|
168
|
+
/** The tag decision for this response. */
|
|
169
|
+
tags: FoirCacheTags;
|
|
170
|
+
/**
|
|
171
|
+
* Whether `cacheTag` accepted them. False when the read happened OUTSIDE a
|
|
172
|
+
* `use cache` scope, which is normal and not an error — `cacheTag` throws
|
|
173
|
+
* there, nothing is being cached, and there is nothing to tag.
|
|
174
|
+
*/
|
|
175
|
+
applied: boolean;
|
|
176
|
+
/** The error `cacheTag` threw, when it threw one. */
|
|
177
|
+
error?: unknown;
|
|
178
|
+
}
|
|
179
|
+
export interface FoirTaggingFetchOptions extends FoirCacheTagsOptions {
|
|
180
|
+
/** Next's `cacheTag` (`unstable_cacheTag` on 15.x). */
|
|
181
|
+
cacheTag: (...tags: string[]) => void;
|
|
182
|
+
/** Defaults to `globalThis.fetch`. */
|
|
183
|
+
fetch?: typeof globalThis.fetch;
|
|
184
|
+
/**
|
|
185
|
+
* Called once per response, after the attempt. The hook for the two cases
|
|
186
|
+
* worth knowing about — `tags.kind === 'untagged'` and `tags.coarse` — both of
|
|
187
|
+
* which are silent in Next itself.
|
|
188
|
+
*/
|
|
189
|
+
onTags?: (report: FoirTaggingReport) => void;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Wrap a `fetch` so every Foir response registers its own cache tags.
|
|
193
|
+
*
|
|
194
|
+
* The response is returned untouched: this reads headers and calls `cacheTag`,
|
|
195
|
+
* and changes nothing about the body, the status, or how the transport parses
|
|
196
|
+
* it.
|
|
197
|
+
*
|
|
198
|
+
* `cacheTag` throwing is caught and reported rather than propagated, because the
|
|
199
|
+
* common cause is not a bug: it throws outside a `use cache` scope, and one
|
|
200
|
+
* client is used for both cached and uncached reads. A read that is not being
|
|
201
|
+
* cached has nothing to tag, and failing it over that would make this wrapper
|
|
202
|
+
* unusable on the same client as an ordinary read.
|
|
203
|
+
*/
|
|
204
|
+
export declare function createFoirTaggingFetch(options: FoirTaggingFetchOptions): typeof globalThis.fetch;
|
|
205
|
+
/**
|
|
206
|
+
* The record-change event shape `recordChanged` delivers, narrowed to what the
|
|
207
|
+
* tag mapping reads.
|
|
208
|
+
*/
|
|
209
|
+
export interface FoirRecordChange {
|
|
210
|
+
recordId: string;
|
|
211
|
+
modelKey: string;
|
|
212
|
+
/** `created`, `updated`, `published`, `unpublished` or `deleted`. */
|
|
213
|
+
action: string;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* The tags one record change invalidates.
|
|
217
|
+
*
|
|
218
|
+
* # ONE RULE, TWO CACHES
|
|
219
|
+
*
|
|
220
|
+
* This delegates to `@foir/sdk/live` rather than restating the mapping, and the
|
|
221
|
+
* delegation is the point. The same decision governs a Next server cache and a
|
|
222
|
+
* client-side query cache, and a second copy of it is the kind that drifts
|
|
223
|
+
* silently: if one side started treating a create as a `rec:`-only
|
|
224
|
+
* invalidation, its lists would stop seeing new records and nothing would
|
|
225
|
+
* error — no exception, no failed request, just a page that never updates.
|
|
226
|
+
*
|
|
227
|
+
* The rule itself, and why it is shaped this way, is documented at the source.
|
|
228
|
+
* In short: `updated` matches on the record so one edit does not refetch every
|
|
229
|
+
* list on the model, and everything else takes the model tag too because
|
|
230
|
+
* membership changed and a create belongs to no cached list yet.
|
|
231
|
+
*/
|
|
232
|
+
export declare function foirTagsForRecordEvent(event: FoirRecordChange): string[];
|
|
233
|
+
/**
|
|
234
|
+
* Turn a record change into `revalidateTag` calls — the inverse of the read
|
|
235
|
+
* side, and the other half of the join.
|
|
236
|
+
*
|
|
237
|
+
* `revalidateTag` is injected for the same reason `cacheTag` is. Where the event
|
|
238
|
+
* reaches the server is the consumer's choice and deliberately not decided here:
|
|
239
|
+
* a route handler wired to a Foir operations webhook is the honest v1 (they are
|
|
240
|
+
* already Ed25519-signed and delivered from a durable outbox), and a
|
|
241
|
+
* server-held subscription is possible but puts a long-lived socket in a
|
|
242
|
+
* serverless runtime, which is a decision to take on purpose rather than to
|
|
243
|
+
* default into.
|
|
244
|
+
*/
|
|
245
|
+
export declare function revalidateFoirRecordEvent(event: FoirRecordChange, revalidateTag: (tag: string) => void): string[];
|
package/dist/next/index.d.ts
CHANGED
|
@@ -3,12 +3,19 @@
|
|
|
3
3
|
* (the BFF pattern): an HttpOnly-cookie session adapter, server-side session
|
|
4
4
|
* helpers, a provider-callback exchange, and a route-protection middleware.
|
|
5
5
|
*
|
|
6
|
+
* It also carries the cache-tag bridge: a Foir read's own dependency set
|
|
7
|
+
* (`Surrogate-Key`) registered with Next's `cacheTag`, and a record-change event
|
|
8
|
+
* turned back into `revalidateTag` calls. `cacheTag` / `revalidateTag` are
|
|
9
|
+
* INJECTED there, never imported — see `./cacheTags` for why.
|
|
10
|
+
*
|
|
6
11
|
* This is the server counterpart to `@foir/sdk/react`. `next` is a peer
|
|
7
12
|
* dependency (for the middleware helper).
|
|
8
13
|
*/
|
|
9
14
|
export { createFoirCookieAdapter, foirCookieName, } from './cookieAdapter';
|
|
10
15
|
export type { FoirCookieStore, CookieAdapterOptions } from './cookieAdapter';
|
|
11
16
|
export { getFoirSession, getFoirToken, foirSignOut, foirSignInWithPassword, foirRegisterWithPassword, foirSignInWithOtp, completeFoirProviderLogin, } from './session';
|
|
17
|
+
export { readFoirCacheTags, foirCacheTags, createFoirTaggingFetch, foirTagsForRecordEvent, revalidateFoirRecordEvent, FOIR_SURROGATE_KEY_HEADER, FOIR_NEXT_TAG_MAX_ITEMS, FOIR_NEXT_TAG_MAX_LENGTH, FOIR_UNTAGGED_TAG, } from './cacheTags';
|
|
18
|
+
export type { FoirHeaderSource, FoirCacheTags, FoirCacheTagsOptions, FoirTaggingReport, FoirTaggingFetchOptions, FoirRecordChange, } from './cacheTags';
|
|
12
19
|
export { createFoirMiddleware } from './middleware';
|
|
13
20
|
export type { FoirMiddlewareConfig, FoirMiddlewareRequest } from './middleware';
|
|
14
21
|
export type { FoirCustomer, SessionAdapter } from '../auth';
|
package/dist/next.js
CHANGED
|
@@ -30,11 +30,30 @@ function isFoirPreview(env) {
|
|
|
30
30
|
function resolveFoirApiKey(env) {
|
|
31
31
|
return isFoirPreview(env) ? env.PLATFORM_PREVIEW_API_KEY ?? env.PLATFORM_API_KEY : env.PLATFORM_API_KEY;
|
|
32
32
|
}
|
|
33
|
+
var MALFORMED_PRUNE_REASONS = /* @__PURE__ */ new Set([
|
|
34
|
+
"unknown_field",
|
|
35
|
+
"subselections_on_scalar",
|
|
36
|
+
"missing_subselections"
|
|
37
|
+
]);
|
|
33
38
|
var FoirRequestError = class extends Error {
|
|
34
39
|
/** HTTP status code, when the request failed at the HTTP layer. */
|
|
35
40
|
status;
|
|
36
41
|
/** GraphQL errors, when the request failed with a no-data error envelope. */
|
|
37
42
|
graphQLErrors;
|
|
43
|
+
/**
|
|
44
|
+
* The `data` the server did return, when a STRICT read refused a partial
|
|
45
|
+
* response. Present only on that path: without it, opting into strictness
|
|
46
|
+
* would trade a silent wrong answer for a thrown one that discards the answer,
|
|
47
|
+
* and a caller that wants to log what it nearly rendered could not.
|
|
48
|
+
*/
|
|
49
|
+
partialData;
|
|
50
|
+
/**
|
|
51
|
+
* The selections the server stripped, when the refusal was a pruned document
|
|
52
|
+
* rather than a partial error. Same purpose as `partialData`: a caller that
|
|
53
|
+
* wants to log what it nearly rendered, or name the offending path in a
|
|
54
|
+
* developer-facing message, has it without re-parsing anything.
|
|
55
|
+
*/
|
|
56
|
+
prunedSelections;
|
|
38
57
|
cause;
|
|
39
58
|
constructor(message, options) {
|
|
40
59
|
super(message);
|
|
@@ -42,6 +61,8 @@ var FoirRequestError = class extends Error {
|
|
|
42
61
|
this.cause = options?.cause;
|
|
43
62
|
this.status = options?.status;
|
|
44
63
|
this.graphQLErrors = options?.graphQLErrors;
|
|
64
|
+
this.partialData = options?.partialData;
|
|
65
|
+
this.prunedSelections = options?.prunedSelections;
|
|
45
66
|
}
|
|
46
67
|
/**
|
|
47
68
|
* The code of the first error, if the response carried one.
|
|
@@ -80,16 +101,46 @@ var FoirRequestError = class extends Error {
|
|
|
80
101
|
return typeof seconds === "number" && seconds > 0 ? seconds * 1e3 : void 0;
|
|
81
102
|
}
|
|
82
103
|
};
|
|
104
|
+
var DISPATCH_RESERVED_HEADERS = /* @__PURE__ */ new Set([
|
|
105
|
+
"x-api-key",
|
|
106
|
+
"authorization",
|
|
107
|
+
"x-foir-token",
|
|
108
|
+
"x-foir-context"
|
|
109
|
+
]);
|
|
110
|
+
function refuseCredentialHeaders(extra) {
|
|
111
|
+
if (!extra) return;
|
|
112
|
+
for (const name of Object.keys(extra)) {
|
|
113
|
+
if (DISPATCH_RESERVED_HEADERS.has(name.toLowerCase())) {
|
|
114
|
+
throw new FoirRequestError(
|
|
115
|
+
`[foir] a client built from a dispatch token cannot send "${name}" in \`headers\`. The dispatch token is the credential, and an api key or a bearer beside it would make the request two actors at once. Build a separate client for the other credential.`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
83
120
|
function buildFoirHeaders(input) {
|
|
84
121
|
const headers = {
|
|
85
|
-
"Content-Type": "application/json"
|
|
86
|
-
"x-api-key": input.apiKey
|
|
122
|
+
"Content-Type": "application/json"
|
|
87
123
|
};
|
|
88
|
-
if (input.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
124
|
+
if (input.dispatchToken !== void 0) {
|
|
125
|
+
refuseCredentialHeaders(input.extra);
|
|
126
|
+
headers["X-Foir-Token"] = input.dispatchToken;
|
|
127
|
+
if (input.dispatchContext) {
|
|
128
|
+
headers["X-Foir-Context"] = input.dispatchContext;
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
headers["x-api-key"] = input.apiKey;
|
|
132
|
+
if (input.draftChannel) {
|
|
133
|
+
headers["X-Foir-Schema-Channel"] = "draft";
|
|
134
|
+
}
|
|
135
|
+
if (input.bearerToken) {
|
|
136
|
+
headers["Authorization"] = `Bearer ${input.bearerToken}`;
|
|
137
|
+
}
|
|
138
|
+
if (input.orgId) {
|
|
139
|
+
headers["X-Org-Id"] = input.orgId;
|
|
140
|
+
}
|
|
141
|
+
if (input.orgUrl) {
|
|
142
|
+
headers["X-Org-Url"] = input.orgUrl;
|
|
143
|
+
}
|
|
93
144
|
}
|
|
94
145
|
if (input.extra) {
|
|
95
146
|
for (const [key, value] of Object.entries(input.extra)) {
|
|
@@ -105,14 +156,48 @@ function foirApiEndpoint(apiUrl, path) {
|
|
|
105
156
|
}
|
|
106
157
|
return base + path;
|
|
107
158
|
}
|
|
108
|
-
|
|
159
|
+
var warnedDowngrade = null;
|
|
160
|
+
function reportAuthDowngrade(envelope) {
|
|
161
|
+
const downgrade = envelope.extensions?.auth;
|
|
162
|
+
if (!downgrade) {
|
|
163
|
+
warnedDowngrade = null;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (warnedDowngrade === downgrade.reason) return;
|
|
167
|
+
warnedDowngrade = downgrade.reason;
|
|
168
|
+
console.warn(
|
|
169
|
+
`[foir] your customer token was ${downgrade.customerToken} (${downgrade.reason}) \u2014 this request was served as ${downgrade.servedAs}, so auth-targeted content resolved to its anonymous variant. ` + (downgrade.reason === "expired" ? "Re-exchange the token and retry." : "Check which credential you are sending.")
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
function refusePrunedDocument(json, options) {
|
|
173
|
+
const selections = json.extensions?.pruned?.selections;
|
|
174
|
+
if (!selections?.length) return;
|
|
175
|
+
const describe = (s) => `${s.path}${s.type ? ` on ${s.type}` : ""} (${s.reason})`;
|
|
176
|
+
const malformed = selections.filter((s) => MALFORMED_PRUNE_REASONS.has(s.reason));
|
|
177
|
+
if (malformed.length && options?.strict !== false) {
|
|
178
|
+
throw new FoirRequestError(
|
|
179
|
+
`[foir] the server could not serve part of this query and returned the rest without it: ${malformed.map(describe).join(", ")}. Those selections do not exist on the schema this project is serving. Pass strict: false to get the partial answer instead of this error.`,
|
|
180
|
+
{ prunedSelections: selections }
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
console.warn(
|
|
184
|
+
`[foir] selections stripped from this query: ${selections.map(describe).join(", ")}`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
function answeredAnything(data) {
|
|
188
|
+
return typeof data === "object" && data !== null && Object.values(data).some((value) => value !== null && value !== void 0);
|
|
189
|
+
}
|
|
190
|
+
function readFoirEnvelope(json, options) {
|
|
191
|
+
reportAuthDowngrade(json);
|
|
192
|
+
refusePrunedDocument(json, options);
|
|
109
193
|
if (json.errors?.length) {
|
|
110
194
|
const msg = json.errors.map((e) => e.message).join("; ");
|
|
111
|
-
if (json.data) {
|
|
195
|
+
if (answeredAnything(json.data) && !options?.strict) {
|
|
112
196
|
console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
|
|
113
197
|
} else {
|
|
114
198
|
throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
|
|
115
|
-
graphQLErrors: json.errors
|
|
199
|
+
graphQLErrors: json.errors,
|
|
200
|
+
partialData: json.data ?? void 0
|
|
116
201
|
});
|
|
117
202
|
}
|
|
118
203
|
}
|
|
@@ -626,10 +711,44 @@ async function resolveBearerToken(config) {
|
|
|
626
711
|
if (typeof source === "function") return await source() ?? void 0;
|
|
627
712
|
return source ?? void 0;
|
|
628
713
|
}
|
|
714
|
+
async function resolveDispatchToken(dispatch) {
|
|
715
|
+
const token = (await dispatch.token())?.trim();
|
|
716
|
+
if (!token) {
|
|
717
|
+
throw new FoirRequestError(
|
|
718
|
+
"[foir] the dispatch token provider returned no token. A client built from a dispatch calls Foir as that operation and holds no other credential. Build it from the token the endpoint was handed: `(await requireScopedToken(request)).dispatch`."
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
return token;
|
|
722
|
+
}
|
|
723
|
+
async function resolveFoirAuth(config) {
|
|
724
|
+
if (config.dispatch !== void 0) {
|
|
725
|
+
return {
|
|
726
|
+
headers: buildFoirHeaders({
|
|
727
|
+
dispatchToken: await resolveDispatchToken(config.dispatch),
|
|
728
|
+
dispatchContext: config.dispatch.context ?? void 0,
|
|
729
|
+
extra: config.headers
|
|
730
|
+
})
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
const bearerToken = await resolveBearerToken(config);
|
|
734
|
+
return {
|
|
735
|
+
headers: buildFoirHeaders({
|
|
736
|
+
apiKey: config.apiKey,
|
|
737
|
+
draftChannel: config.channel === "draft",
|
|
738
|
+
bearerToken,
|
|
739
|
+
orgId: config.orgId,
|
|
740
|
+
orgUrl: config.orgUrl,
|
|
741
|
+
extra: config.headers
|
|
742
|
+
}),
|
|
743
|
+
key: { apiKey: config.apiKey, bearerToken }
|
|
744
|
+
};
|
|
745
|
+
}
|
|
629
746
|
function foirRoute(input) {
|
|
630
747
|
if (!input.apiKey.startsWith("pk_")) return { lane: "origin", reason: "key" };
|
|
631
748
|
if (input.bearerToken) return { lane: "origin", reason: "bearer" };
|
|
749
|
+
if (input.orgId || input.orgUrl) return { lane: "origin", reason: "org" };
|
|
632
750
|
if (input.mutation) return { lane: "origin", reason: "operation" };
|
|
751
|
+
if (input.writeToken) return { lane: "origin", reason: "floor" };
|
|
633
752
|
if (input.channel === "draft") return { lane: "origin", reason: "channel" };
|
|
634
753
|
return { lane: "shared" };
|
|
635
754
|
}
|
|
@@ -642,23 +761,19 @@ async function executeFoirDocument(config, plan) {
|
|
|
642
761
|
}
|
|
643
762
|
return data[plan.rootField];
|
|
644
763
|
}
|
|
645
|
-
async function runFoirDocument(config, document, mutation, variables) {
|
|
764
|
+
async function runFoirDocument(config, document, mutation, variables, options) {
|
|
646
765
|
const doFetch = config.fetch ?? globalThis.fetch;
|
|
647
766
|
const endpoint = foirDataEndpoint(config.apiUrl, "/graphql");
|
|
648
|
-
const
|
|
649
|
-
const headers = buildFoirHeaders({
|
|
650
|
-
apiKey: config.apiKey,
|
|
651
|
-
draftChannel: config.channel === "draft",
|
|
652
|
-
bearerToken,
|
|
653
|
-
extra: config.headers
|
|
654
|
-
});
|
|
767
|
+
const { headers, key } = await resolveFoirAuth(config);
|
|
655
768
|
const hash = await documentHash(document);
|
|
656
|
-
const
|
|
657
|
-
apiKey:
|
|
658
|
-
bearerToken,
|
|
769
|
+
const sharedKey = key !== void 0 && foirRoute({
|
|
770
|
+
apiKey: key.apiKey,
|
|
771
|
+
bearerToken: key.bearerToken,
|
|
772
|
+
orgId: config.orgId,
|
|
773
|
+
orgUrl: config.orgUrl,
|
|
659
774
|
mutation,
|
|
660
775
|
channel: config.channel
|
|
661
|
-
});
|
|
776
|
+
}).lane === "shared" ? key.apiKey : void 0;
|
|
662
777
|
const post = (body) => doFetch(endpoint, {
|
|
663
778
|
method: "POST",
|
|
664
779
|
headers,
|
|
@@ -668,8 +783,8 @@ async function runFoirDocument(config, document, mutation, variables) {
|
|
|
668
783
|
const extensions = persistedQueryExtension(hash);
|
|
669
784
|
let res;
|
|
670
785
|
try {
|
|
671
|
-
if (
|
|
672
|
-
res = await doFetch(buildCdnUrl(endpoint,
|
|
786
|
+
if (sharedKey !== void 0) {
|
|
787
|
+
res = await doFetch(buildCdnUrl(endpoint, sharedKey, hash, variables), {
|
|
673
788
|
method: "GET",
|
|
674
789
|
headers,
|
|
675
790
|
...config.requestInit
|
|
@@ -695,7 +810,7 @@ async function runFoirDocument(config, document, mutation, variables) {
|
|
|
695
810
|
});
|
|
696
811
|
}
|
|
697
812
|
const json = await res.json();
|
|
698
|
-
return readFoirEnvelope(json);
|
|
813
|
+
return readFoirEnvelope(json, options);
|
|
699
814
|
}
|
|
700
815
|
async function readErrorBody(res) {
|
|
701
816
|
try {
|
|
@@ -707,19 +822,16 @@ async function readErrorBody(res) {
|
|
|
707
822
|
}
|
|
708
823
|
|
|
709
824
|
// src/data/manifest.ts
|
|
710
|
-
async function fetchFoirManifest(config) {
|
|
711
|
-
const
|
|
825
|
+
async function fetchFoirManifest(config, options = {}) {
|
|
826
|
+
const base = foirDataEndpoint(config.apiUrl, "/v1/manifest");
|
|
827
|
+
const url = options.operation === void 0 ? base : `${base}?operation=${encodeURIComponent(options.operation)}`;
|
|
712
828
|
const doFetch = config.fetch ?? globalThis.fetch;
|
|
829
|
+
const { headers } = await resolveFoirAuth(config);
|
|
713
830
|
let res;
|
|
714
831
|
try {
|
|
715
832
|
res = await doFetch(url, {
|
|
716
833
|
method: "GET",
|
|
717
|
-
headers
|
|
718
|
-
apiKey: config.apiKey,
|
|
719
|
-
draftChannel: config.channel === "draft",
|
|
720
|
-
bearerToken: await resolveBearerToken(config),
|
|
721
|
-
extra: config.headers
|
|
722
|
-
}),
|
|
834
|
+
headers,
|
|
723
835
|
...config.requestInit
|
|
724
836
|
});
|
|
725
837
|
} catch (err) {
|
|
@@ -759,6 +871,16 @@ var AUTH_OPS = {
|
|
|
759
871
|
customerUpdatePassword: "customerUpdatePassword",
|
|
760
872
|
customerVerifyEmail: "customerVerifyEmail",
|
|
761
873
|
deleteMyAccount: "deleteMyAccount",
|
|
874
|
+
// The operator's organization context. TWO ops and no third: one to read
|
|
875
|
+
// which organizations the caller may stand in, one to say which. There is
|
|
876
|
+
// deliberately no per-call org argument anywhere in this SDK — the claim the
|
|
877
|
+
// row layer reads is resolved from the caller's own membership row on every
|
|
878
|
+
// request, so an application that never names an org cannot name the wrong
|
|
879
|
+
// one.
|
|
880
|
+
myOrganizations: "myOrganizations",
|
|
881
|
+
selectMyOrganization: "selectMyOrganization",
|
|
882
|
+
// Founding one. Present in a manifest only when the project opted in.
|
|
883
|
+
registerOperatorOrganization: "registerOperatorOrganization",
|
|
762
884
|
// External identity providers.
|
|
763
885
|
customerLoginWithProvider: "customerLoginWithProvider",
|
|
764
886
|
customerProviderCallback: "customerProviderCallback",
|
|
@@ -780,6 +902,8 @@ function authClientConfig(env, options) {
|
|
|
780
902
|
apiKey,
|
|
781
903
|
channel: isFoirPreview(env) ? "draft" : "published",
|
|
782
904
|
bearerToken: options?.bearerToken,
|
|
905
|
+
orgId: options?.orgId,
|
|
906
|
+
orgUrl: options?.orgUrl,
|
|
783
907
|
headers: options?.headers,
|
|
784
908
|
fetch: options?.fetch
|
|
785
909
|
};
|
|
@@ -927,6 +1051,99 @@ async function completeFoirProviderLogin(opts) {
|
|
|
927
1051
|
);
|
|
928
1052
|
}
|
|
929
1053
|
|
|
1054
|
+
// src/live/events.ts
|
|
1055
|
+
var RECORD_TAG_PREFIX = "rec:";
|
|
1056
|
+
var CACHED_CHANNEL = "published";
|
|
1057
|
+
function foirRecordTag(recordId) {
|
|
1058
|
+
return `${RECORD_TAG_PREFIX}${recordId}`;
|
|
1059
|
+
}
|
|
1060
|
+
function foirModelTag(modelKey) {
|
|
1061
|
+
return `model:${modelKey}:${CACHED_CHANNEL}`;
|
|
1062
|
+
}
|
|
1063
|
+
function foirTagsForRecordChange(event) {
|
|
1064
|
+
const record = foirRecordTag(event.recordId);
|
|
1065
|
+
if (event.action === "updated") return [record];
|
|
1066
|
+
return [record, foirModelTag(event.modelKey)];
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// src/next/cacheTags.ts
|
|
1070
|
+
var FOIR_SURROGATE_KEY_HEADER = "Surrogate-Key";
|
|
1071
|
+
var FOIR_NEXT_TAG_MAX_ITEMS = 128;
|
|
1072
|
+
var FOIR_NEXT_TAG_MAX_LENGTH = 256;
|
|
1073
|
+
var FOIR_UNTAGGED_TAG = "foir:untagged";
|
|
1074
|
+
var RECORD_TAG_PREFIX2 = "rec:";
|
|
1075
|
+
var COARSE_PREFIXES = ["proj:", "schema:", "model:", "lookup:"];
|
|
1076
|
+
function readFoirCacheTags(headers) {
|
|
1077
|
+
const raw = headers.get(FOIR_SURROGATE_KEY_HEADER);
|
|
1078
|
+
if (!raw) return [];
|
|
1079
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1080
|
+
const out = [];
|
|
1081
|
+
for (const tag of raw.split(/\s+/)) {
|
|
1082
|
+
if (!tag || seen.has(tag)) continue;
|
|
1083
|
+
seen.add(tag);
|
|
1084
|
+
out.push(tag);
|
|
1085
|
+
}
|
|
1086
|
+
return out;
|
|
1087
|
+
}
|
|
1088
|
+
function foirCacheTags(headers, options = {}) {
|
|
1089
|
+
const maxTags = options.maxTags ?? FOIR_NEXT_TAG_MAX_ITEMS;
|
|
1090
|
+
const maxTagLength = options.maxTagLength ?? FOIR_NEXT_TAG_MAX_LENGTH;
|
|
1091
|
+
const all = readFoirCacheTags(headers);
|
|
1092
|
+
if (all.length === 0) {
|
|
1093
|
+
return { kind: "untagged", tags: [FOIR_UNTAGGED_TAG], reason: "no-surrogate-key" };
|
|
1094
|
+
}
|
|
1095
|
+
const records = all.filter((t) => t.startsWith(RECORD_TAG_PREFIX2));
|
|
1096
|
+
const coarse = all.filter((t) => !t.startsWith(RECORD_TAG_PREFIX2));
|
|
1097
|
+
const recordCount = records.length;
|
|
1098
|
+
const recordsFit = records.length > 0 && records.every((t) => t.length <= maxTagLength);
|
|
1099
|
+
const fitting = coarse.filter((t) => t.length <= maxTagLength);
|
|
1100
|
+
if (recordsFit && records.length + fitting.length <= maxTags) {
|
|
1101
|
+
return { kind: "tagged", tags: [...fitting, ...records], coarse: false, recordCount };
|
|
1102
|
+
}
|
|
1103
|
+
if (fitting.length === 0) {
|
|
1104
|
+
return { kind: "untagged", tags: [FOIR_UNTAGGED_TAG], reason: "all-tags-unusable" };
|
|
1105
|
+
}
|
|
1106
|
+
return {
|
|
1107
|
+
kind: "tagged",
|
|
1108
|
+
tags: byBreadth(fitting).slice(0, maxTags),
|
|
1109
|
+
coarse: true,
|
|
1110
|
+
recordCount
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
function byBreadth(tags) {
|
|
1114
|
+
const rank = (t) => {
|
|
1115
|
+
const i = COARSE_PREFIXES.findIndex((p) => t.startsWith(p));
|
|
1116
|
+
return i === -1 ? COARSE_PREFIXES.length : i;
|
|
1117
|
+
};
|
|
1118
|
+
return [...tags].sort((a, b) => rank(a) - rank(b));
|
|
1119
|
+
}
|
|
1120
|
+
function createFoirTaggingFetch(options) {
|
|
1121
|
+
const { cacheTag, fetch: baseFetch, onTags, ...tagOptions } = options;
|
|
1122
|
+
const doFetch = baseFetch ?? globalThis.fetch;
|
|
1123
|
+
return async (input, init) => {
|
|
1124
|
+
const res = await doFetch(input, init);
|
|
1125
|
+
const tags = foirCacheTags(res.headers, tagOptions);
|
|
1126
|
+
let applied = true;
|
|
1127
|
+
let error;
|
|
1128
|
+
try {
|
|
1129
|
+
cacheTag(...tags.tags);
|
|
1130
|
+
} catch (err) {
|
|
1131
|
+
applied = false;
|
|
1132
|
+
error = err;
|
|
1133
|
+
}
|
|
1134
|
+
onTags?.(error === void 0 ? { tags, applied } : { tags, applied, error });
|
|
1135
|
+
return res;
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
function foirTagsForRecordEvent(event) {
|
|
1139
|
+
return foirTagsForRecordChange(event);
|
|
1140
|
+
}
|
|
1141
|
+
function revalidateFoirRecordEvent(event, revalidateTag) {
|
|
1142
|
+
const tags = foirTagsForRecordEvent(event);
|
|
1143
|
+
for (const tag of tags) revalidateTag(tag);
|
|
1144
|
+
return tags;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
930
1147
|
// src/next/middleware.ts
|
|
931
1148
|
function createFoirMiddleware(config) {
|
|
932
1149
|
const cookieName = config.cookieName ?? foirCookieName("foir:token");
|
|
@@ -947,14 +1164,23 @@ function createFoirMiddleware(config) {
|
|
|
947
1164
|
};
|
|
948
1165
|
}
|
|
949
1166
|
export {
|
|
1167
|
+
FOIR_NEXT_TAG_MAX_ITEMS,
|
|
1168
|
+
FOIR_NEXT_TAG_MAX_LENGTH,
|
|
1169
|
+
FOIR_SURROGATE_KEY_HEADER,
|
|
1170
|
+
FOIR_UNTAGGED_TAG,
|
|
950
1171
|
completeFoirProviderLogin,
|
|
951
1172
|
createFoirCookieAdapter,
|
|
952
1173
|
createFoirMiddleware,
|
|
1174
|
+
createFoirTaggingFetch,
|
|
1175
|
+
foirCacheTags,
|
|
953
1176
|
foirCookieName,
|
|
954
1177
|
foirRegisterWithPassword,
|
|
955
1178
|
foirSignInWithOtp,
|
|
956
1179
|
foirSignInWithPassword,
|
|
957
1180
|
foirSignOut,
|
|
1181
|
+
foirTagsForRecordEvent,
|
|
958
1182
|
getFoirSession,
|
|
959
|
-
getFoirToken
|
|
1183
|
+
getFoirToken,
|
|
1184
|
+
readFoirCacheTags,
|
|
1185
|
+
revalidateFoirRecordEvent
|
|
960
1186
|
};
|