@foir/sdk 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +550 -28
  2. package/README.md +111 -0
  3. package/dist/auth/auth.d.ts +72 -1
  4. package/dist/auth/hosted.d.ts +93 -4
  5. package/dist/auth/index.d.ts +4 -4
  6. package/dist/auth/operations.d.ts +84 -0
  7. package/dist/auth/ops.d.ts +10 -0
  8. package/dist/auth.js +138 -19
  9. package/dist/client.js +63 -6
  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/content/content-renderer.d.ts +4 -5
  16. package/dist/content/index.d.ts +1 -1
  17. package/dist/content/richtext.d.ts +1 -1
  18. package/dist/data/builtins.d.ts +131 -12
  19. package/dist/data/client.d.ts +82 -1
  20. package/dist/data/decimal.d.ts +212 -0
  21. package/dist/data/document.d.ts +1 -1
  22. package/dist/data/index.d.ts +11 -9
  23. package/dist/data/manifest.d.ts +33 -6
  24. package/dist/data/schema.d.ts +2 -2
  25. package/dist/data/subscriptions.d.ts +36 -10
  26. package/dist/data/transport.d.ts +120 -3
  27. package/dist/data.js +377 -16
  28. package/dist/graphql/cdn-route.d.ts +1 -1
  29. package/dist/graphql/foir-request.d.ts +1 -1
  30. package/dist/live/coalesce.d.ts +57 -0
  31. package/dist/live/drift.d.ts +59 -0
  32. package/dist/live/events.d.ts +13 -0
  33. package/dist/live/headers.d.ts +53 -0
  34. package/dist/live/index.d.ts +54 -0
  35. package/dist/live/keys.d.ts +72 -0
  36. package/dist/live/manager.d.ts +77 -0
  37. package/dist/live/react/index.d.ts +143 -0
  38. package/dist/live/reconcile.d.ts +82 -0
  39. package/dist/live/resync.d.ts +59 -0
  40. package/dist/live/store.d.ts +134 -0
  41. package/dist/live/tagIndex.d.ts +29 -0
  42. package/dist/live/tanstack.d.ts +127 -0
  43. package/dist/live/writetoken.d.ts +86 -0
  44. package/dist/live-react.js +395 -0
  45. package/dist/live.js +845 -0
  46. package/dist/next/cacheTags.d.ts +245 -0
  47. package/dist/next/index.d.ts +7 -0
  48. package/dist/next.js +179 -6
  49. package/dist/push.js +81 -8
  50. package/dist/react.js +76 -5
  51. package/dist/realtime/index.d.ts +4 -5
  52. package/dist/server/events-client.d.ts +54 -4
  53. package/dist/server/index.d.ts +3 -2
  54. package/dist/server/require-scoped-token.d.ts +1 -1
  55. package/dist/server.js +454 -2
  56. package/dist/transport/foir-http.d.ts +113 -10
  57. 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[];
@@ -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.
@@ -91,6 +112,9 @@ function buildFoirHeaders(input) {
91
112
  if (input.bearerToken) {
92
113
  headers["Authorization"] = `Bearer ${input.bearerToken}`;
93
114
  }
115
+ if (input.orgId) {
116
+ headers["X-Org-Id"] = input.orgId;
117
+ }
94
118
  if (input.extra) {
95
119
  for (const [key, value] of Object.entries(input.extra)) {
96
120
  headers[key] = value;
@@ -105,14 +129,45 @@ function foirApiEndpoint(apiUrl, path) {
105
129
  }
106
130
  return base + path;
107
131
  }
108
- function readFoirEnvelope(json) {
132
+ var warnedDowngrade = null;
133
+ function reportAuthDowngrade(envelope) {
134
+ const downgrade = envelope.extensions?.auth;
135
+ if (!downgrade) {
136
+ warnedDowngrade = null;
137
+ return;
138
+ }
139
+ if (warnedDowngrade === downgrade.reason) return;
140
+ warnedDowngrade = downgrade.reason;
141
+ console.warn(
142
+ `[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.")
143
+ );
144
+ }
145
+ function refusePrunedDocument(json, options) {
146
+ const selections = json.extensions?.pruned?.selections;
147
+ if (!selections?.length) return;
148
+ const describe = (s) => `${s.path}${s.type ? ` on ${s.type}` : ""} (${s.reason})`;
149
+ const malformed = selections.filter((s) => MALFORMED_PRUNE_REASONS.has(s.reason));
150
+ if (malformed.length && options?.strict !== false) {
151
+ throw new FoirRequestError(
152
+ `[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.`,
153
+ { prunedSelections: selections }
154
+ );
155
+ }
156
+ console.warn(
157
+ `[foir] selections stripped from this query: ${selections.map(describe).join(", ")}`
158
+ );
159
+ }
160
+ function readFoirEnvelope(json, options) {
161
+ reportAuthDowngrade(json);
162
+ refusePrunedDocument(json, options);
109
163
  if (json.errors?.length) {
110
164
  const msg = json.errors.map((e) => e.message).join("; ");
111
- if (json.data) {
165
+ if (json.data && !options?.strict) {
112
166
  console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
113
167
  } else {
114
168
  throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
115
- graphQLErrors: json.errors
169
+ graphQLErrors: json.errors,
170
+ partialData: json.data ?? void 0
116
171
  });
117
172
  }
118
173
  }
@@ -629,7 +684,9 @@ async function resolveBearerToken(config) {
629
684
  function foirRoute(input) {
630
685
  if (!input.apiKey.startsWith("pk_")) return { lane: "origin", reason: "key" };
631
686
  if (input.bearerToken) return { lane: "origin", reason: "bearer" };
687
+ if (input.orgId) return { lane: "origin", reason: "org" };
632
688
  if (input.mutation) return { lane: "origin", reason: "operation" };
689
+ if (input.writeToken) return { lane: "origin", reason: "floor" };
633
690
  if (input.channel === "draft") return { lane: "origin", reason: "channel" };
634
691
  return { lane: "shared" };
635
692
  }
@@ -642,7 +699,7 @@ async function executeFoirDocument(config, plan) {
642
699
  }
643
700
  return data[plan.rootField];
644
701
  }
645
- async function runFoirDocument(config, document, mutation, variables) {
702
+ async function runFoirDocument(config, document, mutation, variables, options) {
646
703
  const doFetch = config.fetch ?? globalThis.fetch;
647
704
  const endpoint = foirDataEndpoint(config.apiUrl, "/graphql");
648
705
  const bearerToken = await resolveBearerToken(config);
@@ -650,12 +707,14 @@ async function runFoirDocument(config, document, mutation, variables) {
650
707
  apiKey: config.apiKey,
651
708
  draftChannel: config.channel === "draft",
652
709
  bearerToken,
710
+ orgId: config.orgId,
653
711
  extra: config.headers
654
712
  });
655
713
  const hash = await documentHash(document);
656
714
  const route = foirRoute({
657
715
  apiKey: config.apiKey,
658
716
  bearerToken,
717
+ orgId: config.orgId,
659
718
  mutation,
660
719
  channel: config.channel
661
720
  });
@@ -695,7 +754,7 @@ async function runFoirDocument(config, document, mutation, variables) {
695
754
  });
696
755
  }
697
756
  const json = await res.json();
698
- return readFoirEnvelope(json);
757
+ return readFoirEnvelope(json, options);
699
758
  }
700
759
  async function readErrorBody(res) {
701
760
  try {
@@ -718,6 +777,7 @@ async function fetchFoirManifest(config) {
718
777
  apiKey: config.apiKey,
719
778
  draftChannel: config.channel === "draft",
720
779
  bearerToken: await resolveBearerToken(config),
780
+ orgId: config.orgId,
721
781
  extra: config.headers
722
782
  }),
723
783
  ...config.requestInit
@@ -759,6 +819,16 @@ var AUTH_OPS = {
759
819
  customerUpdatePassword: "customerUpdatePassword",
760
820
  customerVerifyEmail: "customerVerifyEmail",
761
821
  deleteMyAccount: "deleteMyAccount",
822
+ // The operator's organization context. TWO ops and no third: one to read
823
+ // which organizations the caller may stand in, one to say which. There is
824
+ // deliberately no per-call org argument anywhere in this SDK — the claim the
825
+ // row layer reads is resolved from the caller's own membership row on every
826
+ // request, so an application that never names an org cannot name the wrong
827
+ // one.
828
+ myOrganizations: "myOrganizations",
829
+ selectMyOrganization: "selectMyOrganization",
830
+ // Founding one. Present in a manifest only when the project opted in.
831
+ registerOperatorOrganization: "registerOperatorOrganization",
762
832
  // External identity providers.
763
833
  customerLoginWithProvider: "customerLoginWithProvider",
764
834
  customerProviderCallback: "customerProviderCallback",
@@ -780,6 +850,7 @@ function authClientConfig(env, options) {
780
850
  apiKey,
781
851
  channel: isFoirPreview(env) ? "draft" : "published",
782
852
  bearerToken: options?.bearerToken,
853
+ orgId: options?.orgId,
783
854
  headers: options?.headers,
784
855
  fetch: options?.fetch
785
856
  };
@@ -927,6 +998,99 @@ async function completeFoirProviderLogin(opts) {
927
998
  );
928
999
  }
929
1000
 
1001
+ // src/live/events.ts
1002
+ var RECORD_TAG_PREFIX = "rec:";
1003
+ var CACHED_CHANNEL = "published";
1004
+ function foirRecordTag(recordId) {
1005
+ return `${RECORD_TAG_PREFIX}${recordId}`;
1006
+ }
1007
+ function foirModelTag(modelKey) {
1008
+ return `model:${modelKey}:${CACHED_CHANNEL}`;
1009
+ }
1010
+ function foirTagsForRecordChange(event) {
1011
+ const record = foirRecordTag(event.recordId);
1012
+ if (event.action === "updated") return [record];
1013
+ return [record, foirModelTag(event.modelKey)];
1014
+ }
1015
+
1016
+ // src/next/cacheTags.ts
1017
+ var FOIR_SURROGATE_KEY_HEADER = "Surrogate-Key";
1018
+ var FOIR_NEXT_TAG_MAX_ITEMS = 128;
1019
+ var FOIR_NEXT_TAG_MAX_LENGTH = 256;
1020
+ var FOIR_UNTAGGED_TAG = "foir:untagged";
1021
+ var RECORD_TAG_PREFIX2 = "rec:";
1022
+ var COARSE_PREFIXES = ["proj:", "schema:", "model:", "lookup:"];
1023
+ function readFoirCacheTags(headers) {
1024
+ const raw = headers.get(FOIR_SURROGATE_KEY_HEADER);
1025
+ if (!raw) return [];
1026
+ const seen = /* @__PURE__ */ new Set();
1027
+ const out = [];
1028
+ for (const tag of raw.split(/\s+/)) {
1029
+ if (!tag || seen.has(tag)) continue;
1030
+ seen.add(tag);
1031
+ out.push(tag);
1032
+ }
1033
+ return out;
1034
+ }
1035
+ function foirCacheTags(headers, options = {}) {
1036
+ const maxTags = options.maxTags ?? FOIR_NEXT_TAG_MAX_ITEMS;
1037
+ const maxTagLength = options.maxTagLength ?? FOIR_NEXT_TAG_MAX_LENGTH;
1038
+ const all = readFoirCacheTags(headers);
1039
+ if (all.length === 0) {
1040
+ return { kind: "untagged", tags: [FOIR_UNTAGGED_TAG], reason: "no-surrogate-key" };
1041
+ }
1042
+ const records = all.filter((t) => t.startsWith(RECORD_TAG_PREFIX2));
1043
+ const coarse = all.filter((t) => !t.startsWith(RECORD_TAG_PREFIX2));
1044
+ const recordCount = records.length;
1045
+ const recordsFit = records.length > 0 && records.every((t) => t.length <= maxTagLength);
1046
+ const fitting = coarse.filter((t) => t.length <= maxTagLength);
1047
+ if (recordsFit && records.length + fitting.length <= maxTags) {
1048
+ return { kind: "tagged", tags: [...fitting, ...records], coarse: false, recordCount };
1049
+ }
1050
+ if (fitting.length === 0) {
1051
+ return { kind: "untagged", tags: [FOIR_UNTAGGED_TAG], reason: "all-tags-unusable" };
1052
+ }
1053
+ return {
1054
+ kind: "tagged",
1055
+ tags: byBreadth(fitting).slice(0, maxTags),
1056
+ coarse: true,
1057
+ recordCount
1058
+ };
1059
+ }
1060
+ function byBreadth(tags) {
1061
+ const rank = (t) => {
1062
+ const i = COARSE_PREFIXES.findIndex((p) => t.startsWith(p));
1063
+ return i === -1 ? COARSE_PREFIXES.length : i;
1064
+ };
1065
+ return [...tags].sort((a, b) => rank(a) - rank(b));
1066
+ }
1067
+ function createFoirTaggingFetch(options) {
1068
+ const { cacheTag, fetch: baseFetch, onTags, ...tagOptions } = options;
1069
+ const doFetch = baseFetch ?? globalThis.fetch;
1070
+ return async (input, init) => {
1071
+ const res = await doFetch(input, init);
1072
+ const tags = foirCacheTags(res.headers, tagOptions);
1073
+ let applied = true;
1074
+ let error;
1075
+ try {
1076
+ cacheTag(...tags.tags);
1077
+ } catch (err) {
1078
+ applied = false;
1079
+ error = err;
1080
+ }
1081
+ onTags?.(error === void 0 ? { tags, applied } : { tags, applied, error });
1082
+ return res;
1083
+ };
1084
+ }
1085
+ function foirTagsForRecordEvent(event) {
1086
+ return foirTagsForRecordChange(event);
1087
+ }
1088
+ function revalidateFoirRecordEvent(event, revalidateTag) {
1089
+ const tags = foirTagsForRecordEvent(event);
1090
+ for (const tag of tags) revalidateTag(tag);
1091
+ return tags;
1092
+ }
1093
+
930
1094
  // src/next/middleware.ts
931
1095
  function createFoirMiddleware(config) {
932
1096
  const cookieName = config.cookieName ?? foirCookieName("foir:token");
@@ -947,14 +1111,23 @@ function createFoirMiddleware(config) {
947
1111
  };
948
1112
  }
949
1113
  export {
1114
+ FOIR_NEXT_TAG_MAX_ITEMS,
1115
+ FOIR_NEXT_TAG_MAX_LENGTH,
1116
+ FOIR_SURROGATE_KEY_HEADER,
1117
+ FOIR_UNTAGGED_TAG,
950
1118
  completeFoirProviderLogin,
951
1119
  createFoirCookieAdapter,
952
1120
  createFoirMiddleware,
1121
+ createFoirTaggingFetch,
1122
+ foirCacheTags,
953
1123
  foirCookieName,
954
1124
  foirRegisterWithPassword,
955
1125
  foirSignInWithOtp,
956
1126
  foirSignInWithPassword,
957
1127
  foirSignOut,
1128
+ foirTagsForRecordEvent,
958
1129
  getFoirSession,
959
- getFoirToken
1130
+ getFoirToken,
1131
+ readFoirCacheTags,
1132
+ revalidateFoirRecordEvent
960
1133
  };