@foir/sdk 0.9.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 +497 -27
  2. package/README.md +111 -0
  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 +10 -0
  8. package/dist/auth.js +127 -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 +64 -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 +10 -8
  23. package/dist/data/manifest.d.ts +9 -1
  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
package/dist/push.js CHANGED
@@ -11,11 +11,30 @@ function isFoirPreview(env) {
11
11
  function resolveFoirApiKey(env) {
12
12
  return isFoirPreview(env) ? env.PLATFORM_PREVIEW_API_KEY ?? env.PLATFORM_API_KEY : env.PLATFORM_API_KEY;
13
13
  }
14
+ var MALFORMED_PRUNE_REASONS = /* @__PURE__ */ new Set([
15
+ "unknown_field",
16
+ "subselections_on_scalar",
17
+ "missing_subselections"
18
+ ]);
14
19
  var FoirRequestError = class extends Error {
15
20
  /** HTTP status code, when the request failed at the HTTP layer. */
16
21
  status;
17
22
  /** GraphQL errors, when the request failed with a no-data error envelope. */
18
23
  graphQLErrors;
24
+ /**
25
+ * The `data` the server did return, when a STRICT read refused a partial
26
+ * response. Present only on that path: without it, opting into strictness
27
+ * would trade a silent wrong answer for a thrown one that discards the answer,
28
+ * and a caller that wants to log what it nearly rendered could not.
29
+ */
30
+ partialData;
31
+ /**
32
+ * The selections the server stripped, when the refusal was a pruned document
33
+ * rather than a partial error. Same purpose as `partialData`: a caller that
34
+ * wants to log what it nearly rendered, or name the offending path in a
35
+ * developer-facing message, has it without re-parsing anything.
36
+ */
37
+ prunedSelections;
19
38
  cause;
20
39
  constructor(message, options) {
21
40
  super(message);
@@ -23,6 +42,8 @@ var FoirRequestError = class extends Error {
23
42
  this.cause = options?.cause;
24
43
  this.status = options?.status;
25
44
  this.graphQLErrors = options?.graphQLErrors;
45
+ this.partialData = options?.partialData;
46
+ this.prunedSelections = options?.prunedSelections;
26
47
  }
27
48
  /**
28
49
  * The code of the first error, if the response carried one.
@@ -93,6 +114,9 @@ function buildFoirHeaders(input) {
93
114
  if (input.bearerToken) {
94
115
  headers["Authorization"] = `Bearer ${input.bearerToken}`;
95
116
  }
117
+ if (input.orgId) {
118
+ headers["X-Org-Id"] = input.orgId;
119
+ }
96
120
  if (input.extra) {
97
121
  for (const [key, value] of Object.entries(input.extra)) {
98
122
  headers[key] = value;
@@ -107,14 +131,45 @@ function foirApiEndpoint(apiUrl, path) {
107
131
  }
108
132
  return base + path;
109
133
  }
110
- function readFoirEnvelope(json) {
134
+ var warnedDowngrade = null;
135
+ function reportAuthDowngrade(envelope) {
136
+ const downgrade = envelope.extensions?.auth;
137
+ if (!downgrade) {
138
+ warnedDowngrade = null;
139
+ return;
140
+ }
141
+ if (warnedDowngrade === downgrade.reason) return;
142
+ warnedDowngrade = downgrade.reason;
143
+ console.warn(
144
+ `[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.")
145
+ );
146
+ }
147
+ function refusePrunedDocument(json, options) {
148
+ const selections = json.extensions?.pruned?.selections;
149
+ if (!selections?.length) return;
150
+ const describe = (s) => `${s.path}${s.type ? ` on ${s.type}` : ""} (${s.reason})`;
151
+ const malformed = selections.filter((s) => MALFORMED_PRUNE_REASONS.has(s.reason));
152
+ if (malformed.length && options?.strict !== false) {
153
+ throw new FoirRequestError(
154
+ `[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.`,
155
+ { prunedSelections: selections }
156
+ );
157
+ }
158
+ console.warn(
159
+ `[foir] selections stripped from this query: ${selections.map(describe).join(", ")}`
160
+ );
161
+ }
162
+ function readFoirEnvelope(json, options) {
163
+ reportAuthDowngrade(json);
164
+ refusePrunedDocument(json, options);
111
165
  if (json.errors?.length) {
112
166
  const msg = json.errors.map((e) => e.message).join("; ");
113
- if (json.data) {
167
+ if (json.data && !options?.strict) {
114
168
  console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
115
169
  } else {
116
170
  throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
117
- graphQLErrors: json.errors
171
+ graphQLErrors: json.errors,
172
+ partialData: json.data ?? void 0
118
173
  });
119
174
  }
120
175
  }
@@ -155,8 +210,9 @@ async function hasFoirErrorCode(res, ...codes) {
155
210
  }
156
211
 
157
212
  // src/graphql/cdn-route.ts
158
- function isCdnEligible(env, documentText, bearerToken) {
213
+ function isCdnEligible(env, documentText, bearerToken, orgId) {
159
214
  if (bearerToken) return false;
215
+ if (orgId) return false;
160
216
  if (isFoirPreview(env)) return false;
161
217
  const apiKey = resolveFoirApiKey(env);
162
218
  if (!apiKey?.startsWith("pk_")) return false;
@@ -175,12 +231,13 @@ async function foirRequest(env, document, variables, options) {
175
231
  const headers = buildFoirHeaders({
176
232
  apiKey,
177
233
  draftChannel: isFoirPreview(env),
178
- bearerToken: options?.bearerToken
234
+ bearerToken: options?.bearerToken,
235
+ orgId: options?.orgId
179
236
  });
180
237
  const documentText = print2(document);
181
238
  let res;
182
239
  try {
183
- if (isCdnEligible(env, documentText, options?.bearerToken)) {
240
+ if (isCdnEligible(env, documentText, options?.bearerToken, options?.orgId)) {
184
241
  const hash = await documentHash(documentText);
185
242
  let shared = await fetch(buildCdnUrl(endpoint, apiKey, hash, variables), {
186
243
  method: "GET",
@@ -739,7 +796,9 @@ async function resolveBearerToken(config) {
739
796
  function foirRoute(input) {
740
797
  if (!input.apiKey.startsWith("pk_")) return { lane: "origin", reason: "key" };
741
798
  if (input.bearerToken) return { lane: "origin", reason: "bearer" };
799
+ if (input.orgId) return { lane: "origin", reason: "org" };
742
800
  if (input.mutation) return { lane: "origin", reason: "operation" };
801
+ if (input.writeToken) return { lane: "origin", reason: "floor" };
743
802
  if (input.channel === "draft") return { lane: "origin", reason: "channel" };
744
803
  return { lane: "shared" };
745
804
  }
@@ -752,7 +811,7 @@ async function executeFoirDocument(config, plan) {
752
811
  }
753
812
  return data[plan.rootField];
754
813
  }
755
- async function runFoirDocument(config, document, mutation, variables) {
814
+ async function runFoirDocument(config, document, mutation, variables, options) {
756
815
  const doFetch = config.fetch ?? globalThis.fetch;
757
816
  const endpoint = foirDataEndpoint(config.apiUrl, "/graphql");
758
817
  const bearerToken = await resolveBearerToken(config);
@@ -760,12 +819,14 @@ async function runFoirDocument(config, document, mutation, variables) {
760
819
  apiKey: config.apiKey,
761
820
  draftChannel: config.channel === "draft",
762
821
  bearerToken,
822
+ orgId: config.orgId,
763
823
  extra: config.headers
764
824
  });
765
825
  const hash = await documentHash(document);
766
826
  const route = foirRoute({
767
827
  apiKey: config.apiKey,
768
828
  bearerToken,
829
+ orgId: config.orgId,
769
830
  mutation,
770
831
  channel: config.channel
771
832
  });
@@ -805,7 +866,7 @@ async function runFoirDocument(config, document, mutation, variables) {
805
866
  });
806
867
  }
807
868
  const json = await res.json();
808
- return readFoirEnvelope(json);
869
+ return readFoirEnvelope(json, options);
809
870
  }
810
871
  async function readErrorBody(res) {
811
872
  try {
@@ -828,6 +889,7 @@ async function fetchFoirManifest(config) {
828
889
  apiKey: config.apiKey,
829
890
  draftChannel: config.channel === "draft",
830
891
  bearerToken: await resolveBearerToken(config),
892
+ orgId: config.orgId,
831
893
  extra: config.headers
832
894
  }),
833
895
  ...config.requestInit
@@ -869,6 +931,16 @@ var AUTH_OPS = {
869
931
  customerUpdatePassword: "customerUpdatePassword",
870
932
  customerVerifyEmail: "customerVerifyEmail",
871
933
  deleteMyAccount: "deleteMyAccount",
934
+ // The operator's organization context. TWO ops and no third: one to read
935
+ // which organizations the caller may stand in, one to say which. There is
936
+ // deliberately no per-call org argument anywhere in this SDK — the claim the
937
+ // row layer reads is resolved from the caller's own membership row on every
938
+ // request, so an application that never names an org cannot name the wrong
939
+ // one.
940
+ myOrganizations: "myOrganizations",
941
+ selectMyOrganization: "selectMyOrganization",
942
+ // Founding one. Present in a manifest only when the project opted in.
943
+ registerOperatorOrganization: "registerOperatorOrganization",
872
944
  // External identity providers.
873
945
  customerLoginWithProvider: "customerLoginWithProvider",
874
946
  customerProviderCallback: "customerProviderCallback",
@@ -890,6 +962,7 @@ function authClientConfig(env, options) {
890
962
  apiKey,
891
963
  channel: isFoirPreview(env) ? "draft" : "published",
892
964
  bearerToken: options?.bearerToken,
965
+ orgId: options?.orgId,
893
966
  headers: options?.headers,
894
967
  fetch: options?.fetch
895
968
  };
package/dist/react.js CHANGED
@@ -24,11 +24,30 @@ function isFoirPreview(env) {
24
24
  function resolveFoirApiKey(env) {
25
25
  return isFoirPreview(env) ? env.PLATFORM_PREVIEW_API_KEY ?? env.PLATFORM_API_KEY : env.PLATFORM_API_KEY;
26
26
  }
27
+ var MALFORMED_PRUNE_REASONS = /* @__PURE__ */ new Set([
28
+ "unknown_field",
29
+ "subselections_on_scalar",
30
+ "missing_subselections"
31
+ ]);
27
32
  var FoirRequestError = class extends Error {
28
33
  /** HTTP status code, when the request failed at the HTTP layer. */
29
34
  status;
30
35
  /** GraphQL errors, when the request failed with a no-data error envelope. */
31
36
  graphQLErrors;
37
+ /**
38
+ * The `data` the server did return, when a STRICT read refused a partial
39
+ * response. Present only on that path: without it, opting into strictness
40
+ * would trade a silent wrong answer for a thrown one that discards the answer,
41
+ * and a caller that wants to log what it nearly rendered could not.
42
+ */
43
+ partialData;
44
+ /**
45
+ * The selections the server stripped, when the refusal was a pruned document
46
+ * rather than a partial error. Same purpose as `partialData`: a caller that
47
+ * wants to log what it nearly rendered, or name the offending path in a
48
+ * developer-facing message, has it without re-parsing anything.
49
+ */
50
+ prunedSelections;
32
51
  cause;
33
52
  constructor(message, options) {
34
53
  super(message);
@@ -36,6 +55,8 @@ var FoirRequestError = class extends Error {
36
55
  this.cause = options?.cause;
37
56
  this.status = options?.status;
38
57
  this.graphQLErrors = options?.graphQLErrors;
58
+ this.partialData = options?.partialData;
59
+ this.prunedSelections = options?.prunedSelections;
39
60
  }
40
61
  /**
41
62
  * The code of the first error, if the response carried one.
@@ -85,6 +106,9 @@ function buildFoirHeaders(input) {
85
106
  if (input.bearerToken) {
86
107
  headers["Authorization"] = `Bearer ${input.bearerToken}`;
87
108
  }
109
+ if (input.orgId) {
110
+ headers["X-Org-Id"] = input.orgId;
111
+ }
88
112
  if (input.extra) {
89
113
  for (const [key, value] of Object.entries(input.extra)) {
90
114
  headers[key] = value;
@@ -99,14 +123,45 @@ function foirApiEndpoint(apiUrl, path) {
99
123
  }
100
124
  return base + path;
101
125
  }
102
- function readFoirEnvelope(json) {
126
+ var warnedDowngrade = null;
127
+ function reportAuthDowngrade(envelope) {
128
+ const downgrade = envelope.extensions?.auth;
129
+ if (!downgrade) {
130
+ warnedDowngrade = null;
131
+ return;
132
+ }
133
+ if (warnedDowngrade === downgrade.reason) return;
134
+ warnedDowngrade = downgrade.reason;
135
+ console.warn(
136
+ `[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.")
137
+ );
138
+ }
139
+ function refusePrunedDocument(json, options) {
140
+ const selections = json.extensions?.pruned?.selections;
141
+ if (!selections?.length) return;
142
+ const describe = (s) => `${s.path}${s.type ? ` on ${s.type}` : ""} (${s.reason})`;
143
+ const malformed = selections.filter((s) => MALFORMED_PRUNE_REASONS.has(s.reason));
144
+ if (malformed.length && options?.strict !== false) {
145
+ throw new FoirRequestError(
146
+ `[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.`,
147
+ { prunedSelections: selections }
148
+ );
149
+ }
150
+ console.warn(
151
+ `[foir] selections stripped from this query: ${selections.map(describe).join(", ")}`
152
+ );
153
+ }
154
+ function readFoirEnvelope(json, options) {
155
+ reportAuthDowngrade(json);
156
+ refusePrunedDocument(json, options);
103
157
  if (json.errors?.length) {
104
158
  const msg = json.errors.map((e) => e.message).join("; ");
105
- if (json.data) {
159
+ if (json.data && !options?.strict) {
106
160
  console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
107
161
  } else {
108
162
  throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
109
- graphQLErrors: json.errors
163
+ graphQLErrors: json.errors,
164
+ partialData: json.data ?? void 0
110
165
  });
111
166
  }
112
167
  }
@@ -623,7 +678,9 @@ async function resolveBearerToken(config) {
623
678
  function foirRoute(input) {
624
679
  if (!input.apiKey.startsWith("pk_")) return { lane: "origin", reason: "key" };
625
680
  if (input.bearerToken) return { lane: "origin", reason: "bearer" };
681
+ if (input.orgId) return { lane: "origin", reason: "org" };
626
682
  if (input.mutation) return { lane: "origin", reason: "operation" };
683
+ if (input.writeToken) return { lane: "origin", reason: "floor" };
627
684
  if (input.channel === "draft") return { lane: "origin", reason: "channel" };
628
685
  return { lane: "shared" };
629
686
  }
@@ -636,7 +693,7 @@ async function executeFoirDocument(config, plan) {
636
693
  }
637
694
  return data[plan.rootField];
638
695
  }
639
- async function runFoirDocument(config, document2, mutation, variables) {
696
+ async function runFoirDocument(config, document2, mutation, variables, options) {
640
697
  const doFetch = config.fetch ?? globalThis.fetch;
641
698
  const endpoint = foirDataEndpoint(config.apiUrl, "/graphql");
642
699
  const bearerToken = await resolveBearerToken(config);
@@ -644,12 +701,14 @@ async function runFoirDocument(config, document2, mutation, variables) {
644
701
  apiKey: config.apiKey,
645
702
  draftChannel: config.channel === "draft",
646
703
  bearerToken,
704
+ orgId: config.orgId,
647
705
  extra: config.headers
648
706
  });
649
707
  const hash = await documentHash(document2);
650
708
  const route = foirRoute({
651
709
  apiKey: config.apiKey,
652
710
  bearerToken,
711
+ orgId: config.orgId,
653
712
  mutation,
654
713
  channel: config.channel
655
714
  });
@@ -689,7 +748,7 @@ async function runFoirDocument(config, document2, mutation, variables) {
689
748
  });
690
749
  }
691
750
  const json = await res.json();
692
- return readFoirEnvelope(json);
751
+ return readFoirEnvelope(json, options);
693
752
  }
694
753
  async function readErrorBody(res) {
695
754
  try {
@@ -712,6 +771,7 @@ async function fetchFoirManifest(config) {
712
771
  apiKey: config.apiKey,
713
772
  draftChannel: config.channel === "draft",
714
773
  bearerToken: await resolveBearerToken(config),
774
+ orgId: config.orgId,
715
775
  extra: config.headers
716
776
  }),
717
777
  ...config.requestInit
@@ -753,6 +813,16 @@ var AUTH_OPS = {
753
813
  customerUpdatePassword: "customerUpdatePassword",
754
814
  customerVerifyEmail: "customerVerifyEmail",
755
815
  deleteMyAccount: "deleteMyAccount",
816
+ // The operator's organization context. TWO ops and no third: one to read
817
+ // which organizations the caller may stand in, one to say which. There is
818
+ // deliberately no per-call org argument anywhere in this SDK — the claim the
819
+ // row layer reads is resolved from the caller's own membership row on every
820
+ // request, so an application that never names an org cannot name the wrong
821
+ // one.
822
+ myOrganizations: "myOrganizations",
823
+ selectMyOrganization: "selectMyOrganization",
824
+ // Founding one. Present in a manifest only when the project opted in.
825
+ registerOperatorOrganization: "registerOperatorOrganization",
756
826
  // External identity providers.
757
827
  customerLoginWithProvider: "customerLoginWithProvider",
758
828
  customerProviderCallback: "customerProviderCallback",
@@ -774,6 +844,7 @@ function authClientConfig(env, options) {
774
844
  apiKey,
775
845
  channel: isFoirPreview(env) ? "draft" : "published",
776
846
  bearerToken: options?.bearerToken,
847
+ orgId: options?.orgId,
777
848
  headers: options?.headers,
778
849
  fetch: options?.fetch
779
850
  };
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @foir/sdk/realtime — optional thin web helper over y-websocket (EID-103).
2
+ * @foir/sdk/realtime — optional thin web helper over y-websocket.
3
3
  *
4
4
  * The apollo-link analogue for Foir realtime collaboration: sugar over the
5
5
  * off-the-shelf `y-websocket` provider that adds exactly the wire-protocol
@@ -17,7 +17,7 @@
17
17
  * `yjs` + `y-websocket` are OPTIONAL peer deps gated to this subpath, so a
18
18
  * consumer importing only other `@foir/sdk/*` exports pulls no Yjs.
19
19
  *
20
- * Wire-protocol contract: `docs/realtime-collab-contract.md` (EID-117).
20
+ * Wire-protocol contract: `docs/realtime-collab-contract.md`.
21
21
  */
22
22
  import type * as Y from 'yjs';
23
23
  import { WebsocketProvider } from 'y-websocket';
@@ -36,13 +36,12 @@ export interface RoomAddress {
36
36
  * with a 400).
37
37
  *
38
38
  * That segment is a KEY space because the platform compares it against the
39
- * reserved token `live` to select an EPHEMERAL persistence policy (EID-385):
39
+ * reserved token `live` to select an EPHEMERAL persistence policy:
40
40
  * live co-edit only — no `editing_state`, no autosave, doc destroyed on last
41
41
  * leave. A cuid2 row id can never equal `live`, so sending an id puts that
42
42
  * policy out of reach by construction. Worse, variants are themselves rows in
43
43
  * `records`, so a variant id still passes the join gate: the connection
44
- * succeeds and edits a DIFFERENT record, with nothing reported on either side
45
- * (TTS-953).
44
+ * succeeds and edits a DIFFERENT record, with nothing reported on either side.
46
45
  *
47
46
  * The address to send is the one the API advertises — the `collab` field on
48
47
  * every public record type returns `{ enabled, wsUrl, roomName }`, where
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `@foir/sdk/server`'s event-log client — cursor-resumable consumption of a
3
- * project's durable event history (TTS-983, epic TTS-912).
3
+ * project's durable event history.
4
4
  *
5
5
  * ```ts
6
6
  * import { createEventsClient } from '@foir/sdk/server';
@@ -21,7 +21,7 @@
21
21
  * OPERATOR SURFACE. This is not a customer-facing capability.
22
22
  * ---------------------------------------------------------------------------
23
23
  *
24
- * DECIDED 2026-08-04 (TTS-981). The event log is reachable by an `sk_` api key
24
+ * DECIDED 2026-08-04. The event log is reachable by an `sk_` api key
25
25
  * and by an admin session. It is NOT reachable by a customer token, there is no
26
26
  * customer lane for it on api-public, and a `pk_` key can never be minted
27
27
  * holding `events:list.project` at all.
@@ -73,6 +73,7 @@
73
73
  * needs. It also keeps this module `graphql`-free, like `@foir/sdk/data`.
74
74
  */
75
75
  import { FoirRequestError } from '../transport/foir-http';
76
+ import { type FoirReconnectPolicy, type FoirSubscription, type FoirSubscriptionHandlers, type FoirWebSocketFactory } from '../data/ws-transport';
76
77
  /** The path `GET /v1/events` is mounted on, inside api-public's api-key group. */
77
78
  export declare const FOIR_EVENTS_PATH = "/v1/events";
78
79
  /**
@@ -109,7 +110,10 @@ export interface FoirEvent {
109
110
  * comparing, never for counting.
110
111
  */
111
112
  seq: number;
112
- /** The registry name, e.g. `"record.created"`. */
113
+ /**
114
+ * The registry name, e.g. `"RECORD_CREATED"`. SCREAMING_SNAKE, never the
115
+ * dotted form.
116
+ */
113
117
  event: string;
114
118
  /**
115
119
  * The event's own flat keys, plus `state` and `stateSchema`.
@@ -171,7 +175,9 @@ export interface FoirEventsListParams {
171
175
  /** Page size. The server defaults to 100 and caps at 1000. */
172
176
  limit?: number;
173
177
  /**
174
- * Filter by event name, e.g. `['record.created', 'record.updated']`. Omit for
178
+ * Filter by event name, e.g. `['RECORD_CREATED', 'RECORD_UPDATED']`.
179
+ * SCREAMING_SNAKE: a name in any other casing matches nothing and comes back
180
+ * as an empty page rather than an error, so a wrong spelling is silent. Omit for
175
181
  * every event.
176
182
  */
177
183
  events?: readonly string[];
@@ -213,6 +219,21 @@ export interface FoirEventsClientConfig {
213
219
  * cannot be clobbered by accident.
214
220
  */
215
221
  requestInit?: Omit<RequestInit, 'method' | 'headers' | 'body'>;
222
+ /**
223
+ * Override the WebSocket endpoint `subscribe()` connects to. Normally derived
224
+ * from `apiUrl`; set it when the socket is not on the API origin.
225
+ */
226
+ wsUrl?: string;
227
+ /**
228
+ * WebSocket constructor, called as `(url, protocols)`. Defaults to
229
+ * `globalThis.WebSocket`. Node 18/20 have no global one, so a server process
230
+ * on those versions passes the `ws` package's.
231
+ *
232
+ * Only `subscribe()` uses it; `list()` and `stream()` are plain fetch.
233
+ */
234
+ webSocketImpl?: FoirWebSocketFactory;
235
+ /** How the `subscribe()` socket behaves when it drops. */
236
+ reconnect?: FoirReconnectPolicy;
216
237
  }
217
238
  export interface FoirEventsClient {
218
239
  /** One page of the project's event history. */
@@ -227,6 +248,35 @@ export interface FoirEventsClient {
227
248
  * including mid-page after a crash.
228
249
  */
229
250
  stream(params?: FoirEventsStreamParams): AsyncGenerator<FoirEvent, void, void>;
251
+ /**
252
+ * The same cursor, pushed instead of pulled.
253
+ *
254
+ * The server backfills from `after` and then continues live on ONE stream, so
255
+ * there is no gap between catching up and keeping up and no second code path
256
+ * to write. `stream()` remains the right choice for a batch job that wants to
257
+ * drain and exit; this is for a process that stays open.
258
+ *
259
+ * The cursor advances as events arrive, so a dropped socket resumes from the
260
+ * last event delivered rather than replaying from where the subscription
261
+ * originally started.
262
+ *
263
+ * A cursor older than the retention window ends the subscription with a
264
+ * {@link FoirEventCursorExpiredError} carrying `resumeFrom`, exactly as the
265
+ * HTTP lane answers 410 — a gap is always something a consumer is told about,
266
+ * never something it has to notice.
267
+ */
268
+ subscribe(params: FoirEventsSubscribeParams): FoirSubscription;
269
+ }
270
+ export interface FoirEventsSubscribeParams extends FoirSubscriptionHandlers<FoirEvent> {
271
+ /**
272
+ * Exclusive cursor: begin with events having `seq > after`. Omit to start
273
+ * from the live edge rather than the oldest retained event — a long-running
274
+ * consumer that has no checkpoint usually wants what happens next, not the
275
+ * whole window.
276
+ */
277
+ after?: number;
278
+ /** Filter by event name. Omit for every event. */
279
+ events?: readonly string[];
230
280
  }
231
281
  /**
232
282
  * The cursor named a position older than the retention window, so events the
@@ -7,6 +7,7 @@ export type { OperationPayload, OperationTrigger, OperationRecordContext, Operat
7
7
  export { createCallbackClient } from './callback-client';
8
8
  export type { CallbackClient, CallbackResult } from './callback-client';
9
9
  export { createEventsClient, FoirEventCursorExpiredError, isFoirEventCursorExpired, FOIR_EVENTS_PATH, FOIR_EVENTS_SCOPE, FOIR_EVENTS_RETENTION_DAYS, } from './events-client';
10
- export type { FoirEvent, FoirEventActor, FoirEventPage, FoirEventRetention, FoirEventsClient, FoirEventsClientConfig, FoirEventsListParams, FoirEventsStreamParams, } from './events-client';
10
+ export type { FoirEvent, FoirEventActor, FoirEventPage, FoirEventRetention, FoirEventsClient, FoirEventsClientConfig, FoirEventsListParams, FoirEventsStreamParams, FoirEventsSubscribeParams, } from './events-client';
11
+ export type { FoirSubscription, FoirSubscriptionHandlers } from '../data/ws-transport';
11
12
  export { FoirRequestError, isFoirForbidden, isFoirNotFound, isFoirRateLimited, isFoirUnauthenticated, } from '../transport/foir-http';
12
- export type { FoirErrorCode, FoirErrorExtensions, FoirGraphQLErrorItem } from '../transport/foir-http';
13
+ export type { FoirErrorCode, FoirErrorExtensions, FoirGraphQLErrorItem, FoirAuthDowngrade, } from '../transport/foir-http';
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * requireScopedToken — the one-line ingress guard for BFF routes the
3
- * platform dispatches operations to (EID-498).
3
+ * platform dispatches operations to.
4
4
  *
5
5
  * Operation endpoints must be publicly reachable (the platform's dispatcher
6
6
  * refuses private networks), so "the BFF is never public-facing" is achieved