@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
package/dist/server.js CHANGED
@@ -351,6 +351,20 @@ var FoirRequestError = class extends Error {
351
351
  status;
352
352
  /** GraphQL errors, when the request failed with a no-data error envelope. */
353
353
  graphQLErrors;
354
+ /**
355
+ * The `data` the server did return, when a STRICT read refused a partial
356
+ * response. Present only on that path: without it, opting into strictness
357
+ * would trade a silent wrong answer for a thrown one that discards the answer,
358
+ * and a caller that wants to log what it nearly rendered could not.
359
+ */
360
+ partialData;
361
+ /**
362
+ * The selections the server stripped, when the refusal was a pruned document
363
+ * rather than a partial error. Same purpose as `partialData`: a caller that
364
+ * wants to log what it nearly rendered, or name the offending path in a
365
+ * developer-facing message, has it without re-parsing anything.
366
+ */
367
+ prunedSelections;
354
368
  cause;
355
369
  constructor(message, options) {
356
370
  super(message);
@@ -358,6 +372,8 @@ var FoirRequestError = class extends Error {
358
372
  this.cause = options?.cause;
359
373
  this.status = options?.status;
360
374
  this.graphQLErrors = options?.graphQLErrors;
375
+ this.partialData = options?.partialData;
376
+ this.prunedSelections = options?.prunedSelections;
361
377
  }
362
378
  /**
363
379
  * The code of the first error, if the response carried one.
@@ -423,6 +439,9 @@ function buildFoirHeaders(input) {
423
439
  if (input.bearerToken) {
424
440
  headers["Authorization"] = `Bearer ${input.bearerToken}`;
425
441
  }
442
+ if (input.orgId) {
443
+ headers["X-Org-Id"] = input.orgId;
444
+ }
426
445
  if (input.extra) {
427
446
  for (const [key, value] of Object.entries(input.extra)) {
428
447
  headers[key] = value;
@@ -438,6 +457,372 @@ function foirApiEndpoint(apiUrl, path) {
438
457
  return base + path;
439
458
  }
440
459
 
460
+ // src/data/transport.ts
461
+ async function resolveBearerToken(config) {
462
+ const source = config.bearerToken;
463
+ if (typeof source === "function") return await source() ?? void 0;
464
+ return source ?? void 0;
465
+ }
466
+
467
+ // src/data/ws-transport.ts
468
+ var GQL = {
469
+ connectionInit: "connection_init",
470
+ connectionAck: "connection_ack",
471
+ ping: "ping",
472
+ pong: "pong",
473
+ subscribe: "subscribe",
474
+ next: "next",
475
+ error: "error",
476
+ complete: "complete"
477
+ };
478
+ var SUBPROTOCOL = "graphql-transport-ws";
479
+ var OPEN_READY_STATE = 1;
480
+ var DEFAULT_RECONNECT = {
481
+ maxAttempts: Infinity,
482
+ initialDelayMs: 500,
483
+ maxDelayMs: 15e3,
484
+ jitter: 0.25
485
+ };
486
+ function foirSocketEndpoint(apiUrl) {
487
+ const trimmed = apiUrl.replace(/\/+$/, "").replace(/\/graphql$/, "");
488
+ return `${trimmed.replace(/^http/, "ws")}/graphql/ws`;
489
+ }
490
+ var FoirSocketTransport = class {
491
+ config;
492
+ policy;
493
+ registrations = /* @__PURE__ */ new Map();
494
+ socket = null;
495
+ acknowledged = false;
496
+ connecting = false;
497
+ attempt = 0;
498
+ nextId = 0;
499
+ disposed = false;
500
+ keepAlive = null;
501
+ retryTimer = null;
502
+ constructor(config) {
503
+ this.config = config;
504
+ this.policy = { ...DEFAULT_RECONNECT, ...config.reconnect ?? {} };
505
+ }
506
+ /**
507
+ * Start a subscription.
508
+ *
509
+ * Returns synchronously so a caller can hold the handle immediately, while
510
+ * the connect and the handshake proceed in the background. A subscription
511
+ * registered before the socket is acknowledged is sent the moment it is —
512
+ * which is the same path a reconnect takes, so the ordinary case and the
513
+ * recovery case exercise one piece of code.
514
+ */
515
+ subscribe(request, handlers) {
516
+ if (this.disposed) {
517
+ throw new FoirRequestError("[foir] this client has been disposed; create a new one");
518
+ }
519
+ this.nextId += 1;
520
+ const id = String(this.nextId);
521
+ this.registrations.set(id, {
522
+ id,
523
+ request,
524
+ handlers,
525
+ sent: false,
526
+ established: false
527
+ });
528
+ this.ensureConnected();
529
+ this.flush();
530
+ return {
531
+ unsubscribe: () => this.cancel(id)
532
+ };
533
+ }
534
+ /** Close the socket and drop every subscription. */
535
+ dispose() {
536
+ this.disposed = true;
537
+ this.registrations.clear();
538
+ this.teardownSocket(true);
539
+ }
540
+ /** Live subscription count. Exposed for tests and diagnostics. */
541
+ get size() {
542
+ return this.registrations.size;
543
+ }
544
+ // -------------------------------------------------------------------------
545
+ // Connection
546
+ // -------------------------------------------------------------------------
547
+ ensureConnected() {
548
+ if (this.disposed || this.socket || this.connecting) return;
549
+ this.connecting = true;
550
+ const factory = this.config.webSocketImpl ?? defaultWebSocketFactory();
551
+ const url = this.config.wsUrl ?? foirSocketEndpoint(this.config.apiUrl);
552
+ let socket;
553
+ try {
554
+ socket = factory(url, SUBPROTOCOL);
555
+ } catch (cause) {
556
+ this.connecting = false;
557
+ this.scheduleReconnect(
558
+ new FoirRequestError(`[foir] could not open a WebSocket to ${url}: ${String(cause)}`)
559
+ );
560
+ return;
561
+ }
562
+ this.socket = socket;
563
+ socket.onopen = () => {
564
+ void this.sendConnectionInit(socket);
565
+ };
566
+ socket.onmessage = (event) => this.receive(event.data);
567
+ socket.onerror = () => {
568
+ };
569
+ socket.onclose = () => this.handleClose();
570
+ }
571
+ async sendConnectionInit(socket) {
572
+ this.connecting = false;
573
+ try {
574
+ const headers = buildFoirHeaders({
575
+ apiKey: this.config.apiKey,
576
+ draftChannel: this.config.channel === "draft",
577
+ bearerToken: await resolveBearerToken(this.config),
578
+ orgId: this.config.orgId,
579
+ extra: this.config.headers
580
+ });
581
+ const { "Content-Type": _contentType, ...params } = headers;
582
+ const extra = await this.config.connectionParams?.() ?? {};
583
+ this.send(socket, {
584
+ type: GQL.connectionInit,
585
+ payload: { ...params, ...extra }
586
+ });
587
+ } catch (cause) {
588
+ this.failAll(
589
+ new FoirRequestError(`[foir] could not resolve the subscription credential: ${String(cause)}`)
590
+ );
591
+ this.teardownSocket(true);
592
+ }
593
+ }
594
+ handleClose() {
595
+ const wasAcknowledged = this.acknowledged;
596
+ this.teardownSocket(false);
597
+ if (this.disposed || this.registrations.size === 0) return;
598
+ for (const registration of this.registrations.values()) {
599
+ registration.sent = false;
600
+ }
601
+ this.scheduleReconnect(
602
+ new FoirRequestError(
603
+ wasAcknowledged ? "[foir] the subscription socket closed and could not be re-established" : "[foir] the subscription socket closed before the credential was accepted"
604
+ )
605
+ );
606
+ }
607
+ scheduleReconnect(error) {
608
+ if (this.disposed || this.registrations.size === 0) return;
609
+ if (this.attempt >= this.policy.maxAttempts) {
610
+ this.failAll(error);
611
+ return;
612
+ }
613
+ const exponent = Math.min(this.attempt, 20);
614
+ const base = Math.min(
615
+ this.policy.initialDelayMs * 2 ** exponent,
616
+ this.policy.maxDelayMs
617
+ );
618
+ const delay = base + base * this.policy.jitter * Math.random();
619
+ this.attempt += 1;
620
+ this.retryTimer = setTimeout(() => {
621
+ this.retryTimer = null;
622
+ this.ensureConnected();
623
+ }, delay);
624
+ const timer = this.retryTimer;
625
+ if (typeof timer?.unref === "function") timer.unref();
626
+ }
627
+ teardownSocket(closeIt) {
628
+ if (this.keepAlive !== null) {
629
+ clearInterval(this.keepAlive);
630
+ this.keepAlive = null;
631
+ }
632
+ if (this.retryTimer !== null) {
633
+ clearTimeout(this.retryTimer);
634
+ this.retryTimer = null;
635
+ }
636
+ const socket = this.socket;
637
+ this.socket = null;
638
+ this.acknowledged = false;
639
+ this.connecting = false;
640
+ if (!socket) return;
641
+ socket.onopen = null;
642
+ socket.onmessage = null;
643
+ socket.onerror = null;
644
+ socket.onclose = null;
645
+ if (closeIt) {
646
+ try {
647
+ socket.close(1e3, "client disposed");
648
+ } catch {
649
+ }
650
+ }
651
+ }
652
+ // -------------------------------------------------------------------------
653
+ // Frames
654
+ // -------------------------------------------------------------------------
655
+ receive(data) {
656
+ if (typeof data !== "string") return;
657
+ let message;
658
+ try {
659
+ message = JSON.parse(data);
660
+ } catch {
661
+ return;
662
+ }
663
+ switch (message.type) {
664
+ case GQL.connectionAck:
665
+ this.acknowledged = true;
666
+ this.attempt = 0;
667
+ this.startKeepAlive();
668
+ this.flush();
669
+ return;
670
+ case GQL.ping:
671
+ if (this.socket) this.send(this.socket, { type: GQL.pong });
672
+ return;
673
+ case GQL.pong:
674
+ return;
675
+ case GQL.next: {
676
+ const registration = message.id ? this.registrations.get(message.id) : void 0;
677
+ if (!registration) return;
678
+ const payload = message.payload;
679
+ const root = payload?.data;
680
+ if (!root) return;
681
+ const keys = Object.keys(root);
682
+ if (keys.length !== 1) return;
683
+ registration.handlers.onData(root[keys[0]]);
684
+ return;
685
+ }
686
+ case GQL.error: {
687
+ const error = new FoirRequestError(`[foir] subscription refused: ${describe(message.payload)}`, {
688
+ graphQLErrors: graphQLErrorItems(message.payload)
689
+ });
690
+ if (!message.id) {
691
+ this.failAll(error);
692
+ this.teardownSocket(true);
693
+ return;
694
+ }
695
+ const registration = this.registrations.get(message.id);
696
+ this.registrations.delete(message.id);
697
+ registration?.handlers.onError?.(error);
698
+ this.closeIfIdle();
699
+ return;
700
+ }
701
+ case GQL.complete: {
702
+ if (!message.id) return;
703
+ const registration = this.registrations.get(message.id);
704
+ this.registrations.delete(message.id);
705
+ registration?.handlers.onComplete?.();
706
+ this.closeIfIdle();
707
+ return;
708
+ }
709
+ default:
710
+ return;
711
+ }
712
+ }
713
+ /**
714
+ * Send every registration the current socket has not yet been told about.
715
+ *
716
+ * This IS the resubscribe path. A reconnect clears `sent` on every
717
+ * registration and lands here, so a subscription re-established after a drop
718
+ * takes the same code as one opened for the first time — there is no second
719
+ * implementation that can be correct in one case and wrong in the other.
720
+ */
721
+ flush() {
722
+ const socket = this.socket;
723
+ if (!socket || !this.acknowledged || socket.readyState !== OPEN_READY_STATE) return;
724
+ for (const registration of this.registrations.values()) {
725
+ if (registration.sent) continue;
726
+ this.send(socket, {
727
+ id: registration.id,
728
+ type: GQL.subscribe,
729
+ payload: registration.request
730
+ });
731
+ registration.sent = true;
732
+ if (registration.established) {
733
+ registration.handlers.onReconnect?.();
734
+ } else {
735
+ registration.established = true;
736
+ }
737
+ }
738
+ }
739
+ cancel(id) {
740
+ const registration = this.registrations.get(id);
741
+ if (!registration) return;
742
+ this.registrations.delete(id);
743
+ if (this.socket && registration.sent && this.socket.readyState === OPEN_READY_STATE) {
744
+ this.send(this.socket, { id, type: GQL.complete });
745
+ }
746
+ this.closeIfIdle();
747
+ }
748
+ /**
749
+ * Close the socket once nothing is using it.
750
+ *
751
+ * A socket held open with no subscriptions is a connection the server has to
752
+ * keep, a keepalive timer that keeps a serverless invocation alive, and
753
+ * nothing in return.
754
+ */
755
+ closeIfIdle() {
756
+ if (this.registrations.size === 0) this.teardownSocket(true);
757
+ }
758
+ failAll(error) {
759
+ const registrations = [...this.registrations.values()];
760
+ this.registrations.clear();
761
+ for (const registration of registrations) {
762
+ registration.handlers.onError?.(error);
763
+ }
764
+ }
765
+ /**
766
+ * Client-side keepalive.
767
+ *
768
+ * The server pings on its own timer and this client answers, which is enough
769
+ * for the server to know the client is alive. This is the other direction:
770
+ * an idle subscription can sit for hours with no events, and a proxy that
771
+ * times out idle sockets will drop it. A periodic `ping` keeps it warm, and
772
+ * the pong that comes back proves the path is still there.
773
+ */
774
+ startKeepAlive() {
775
+ if (this.keepAlive !== null) clearInterval(this.keepAlive);
776
+ this.keepAlive = setInterval(() => {
777
+ const socket = this.socket;
778
+ if (!socket || socket.readyState !== OPEN_READY_STATE) return;
779
+ this.send(socket, { type: GQL.ping });
780
+ }, 2e4);
781
+ const timer = this.keepAlive;
782
+ if (typeof timer?.unref === "function") timer.unref();
783
+ }
784
+ send(socket, message) {
785
+ try {
786
+ socket.send(JSON.stringify(message));
787
+ } catch {
788
+ }
789
+ }
790
+ };
791
+ function defaultWebSocketFactory() {
792
+ const Impl = globalThis.WebSocket;
793
+ if (typeof Impl !== "function") {
794
+ throw new FoirRequestError(
795
+ "[foir] no WebSocket implementation is available in this runtime. Pass one as `webSocketImpl` \u2014 e.g. `webSocketImpl: (url, protocols) => new WebSocket(url, protocols)` from the `ws` package on Node < 22."
796
+ );
797
+ }
798
+ return (url, protocols) => new Impl(url, protocols);
799
+ }
800
+ function graphQLErrorItems(payload) {
801
+ const list = Array.isArray(payload) ? payload : payload == null ? [] : [payload];
802
+ const items = [];
803
+ for (const entry of list) {
804
+ if (typeof entry !== "object" || entry === null) continue;
805
+ const { message, path, extensions } = entry;
806
+ if (extensions === void 0 && path === void 0) continue;
807
+ items.push({
808
+ message: typeof message === "string" ? message : describe(entry),
809
+ ...Array.isArray(path) ? { path } : {},
810
+ ...typeof extensions === "object" && extensions !== null ? { extensions } : {}
811
+ });
812
+ }
813
+ return items.length > 0 ? items : void 0;
814
+ }
815
+ function describe(payload) {
816
+ if (payload == null) return "no reason given";
817
+ if (typeof payload === "string") return payload;
818
+ if (Array.isArray(payload)) return payload.map(describe).join("; ");
819
+ if (typeof payload === "object") {
820
+ const message = payload.message;
821
+ if (typeof message === "string") return message;
822
+ }
823
+ return JSON.stringify(payload);
824
+ }
825
+
441
826
  // src/server/events-client.ts
442
827
  var FOIR_EVENTS_PATH = "/v1/events";
443
828
  var FOIR_EVENTS_SCOPE = "events:list.project";
@@ -486,7 +871,7 @@ function assertOperatorKey(apiKey) {
486
871
  }
487
872
  if (!key.startsWith("sk_")) {
488
873
  throw new FoirRequestError(
489
- `[foir] createEventsClient: apiKey must be an sk_ secret key (sk_ or sk_t_), got a key starting "${key.slice(0, 4)}". The event log is reachable by an sk_ api key or an admin session only.`
874
+ `[foir] createEventsClient: apiKey must be an sk_ secret key, got a key starting "${key.slice(0, 4)}". The event log is reachable by an sk_ api key or an admin session only.`
490
875
  );
491
876
  }
492
877
  return key;
@@ -629,7 +1014,74 @@ function createEventsClient(config) {
629
1014
  after = page.nextCursor;
630
1015
  }
631
1016
  }
632
- return { list, stream };
1017
+ let socket = null;
1018
+ let live = 0;
1019
+ function subscribe(params) {
1020
+ const { after, events, onData, onError, onComplete, onReconnect } = params;
1021
+ socket ??= new FoirSocketTransport({
1022
+ apiUrl: config.apiUrl,
1023
+ apiKey,
1024
+ ...config.headers ? { headers: config.headers } : {},
1025
+ ...config.wsUrl ? { wsUrl: config.wsUrl } : {},
1026
+ ...config.webSocketImpl ? { webSocketImpl: config.webSocketImpl } : {},
1027
+ ...config.reconnect ? { reconnect: config.reconnect } : {}
1028
+ });
1029
+ const variables = {
1030
+ ...after !== void 0 ? { after: readSeq(after, "after") } : {},
1031
+ ...events && events.length > 0 ? { events } : {}
1032
+ };
1033
+ live += 1;
1034
+ let released = false;
1035
+ const release = () => {
1036
+ if (released) return;
1037
+ released = true;
1038
+ live -= 1;
1039
+ if (live === 0 && socket) {
1040
+ socket.dispose();
1041
+ socket = null;
1042
+ }
1043
+ };
1044
+ const handle = socket.subscribe(
1045
+ { query: EVENTS_SUBSCRIPTION, variables },
1046
+ {
1047
+ onData: (event) => {
1048
+ if (typeof event?.seq === "number" && event.seq > (variables.after ?? 0)) {
1049
+ variables.after = event.seq;
1050
+ }
1051
+ onData(event);
1052
+ },
1053
+ onError: (error) => {
1054
+ release();
1055
+ onError?.(asEventsSocketError(error, variables.after));
1056
+ },
1057
+ onComplete: () => {
1058
+ release();
1059
+ onComplete?.();
1060
+ },
1061
+ ...onReconnect ? { onReconnect } : {}
1062
+ }
1063
+ );
1064
+ return {
1065
+ unsubscribe: () => {
1066
+ handle.unsubscribe();
1067
+ release();
1068
+ }
1069
+ };
1070
+ }
1071
+ return { list, stream, subscribe };
1072
+ }
1073
+ var EVENTS_SUBSCRIPTION = "subscription FoirEvents($after: EventSeq, $events: [String!]) {\n events(after: $after, events: $events) {\n id\n seq\n event\n payload\n actor { kind ref }\n causationChain\n createdAt\n }\n}";
1074
+ function asEventsSocketError(error, requestedCursor) {
1075
+ for (const item of error.graphQLErrors ?? []) {
1076
+ if (item.extensions?.code !== "cursor_expired") continue;
1077
+ const resumeFrom = item.extensions.resumeFrom;
1078
+ return new FoirEventCursorExpiredError(`[foir] events: cursor expired: ${item.message}`, {
1079
+ status: 410,
1080
+ ...typeof resumeFrom === "number" ? { resumeFrom } : {},
1081
+ ...requestedCursor !== void 0 ? { requestedCursor } : {}
1082
+ });
1083
+ }
1084
+ return error;
633
1085
  }
634
1086
  export {
635
1087
  FOIR_EVENTS_PATH,
@@ -69,6 +69,42 @@ export interface FoirGraphQLErrorItem {
69
69
  path?: Array<string | number>;
70
70
  extensions?: FoirErrorExtensions;
71
71
  }
72
+ /**
73
+ * One selection the server stripped from the document before executing it.
74
+ *
75
+ * The data plane PRUNES rather than refuses: a selection that does not match
76
+ * the project's current schema is removed and the rest of the query runs, so an
77
+ * editor-driven model change cannot fail a deployed client's query mid-flight.
78
+ * The report arrives in `extensions.pruned` and deliberately NOT in `errors`,
79
+ * because clients treat any `errors` entry as a failed response.
80
+ *
81
+ * Which is why this type exists here. Pruning is the ONLY signal for the
82
+ * commonest way to get a quietly incomplete object — asking for a field the
83
+ * type does not have — and it produces no error for `strict` to catch.
84
+ */
85
+ export interface FoirPrunedSelection {
86
+ /** Dotted path to the stripped selection, e.g. `pageBySlug.logo.id`. */
87
+ path: string;
88
+ /** Why it was stripped. See `MALFORMED_PRUNE_REASONS`. */
89
+ reason: string;
90
+ /** The parent type the selection was made on, when the server named one. */
91
+ type?: string;
92
+ }
93
+ /**
94
+ * The prune reasons that mean THE DOCUMENT IS WRONG, as opposed to the schema
95
+ * having moved underneath a document that was right when it shipped.
96
+ *
97
+ * A field the type does not have, a subselection on a scalar, or a composite
98
+ * asked for without one: each is a query that could never have been served as
99
+ * written. They are the shape that silently returns a plausible object with a
100
+ * key missing, which is how an empty file id became a `setNull` and would have
101
+ * deleted a logo on the next save.
102
+ *
103
+ * The rest — dead type conditions, dead fragment spreads, unreferenced
104
+ * fragments, unused variables — are what the pruner exists to absorb, and they
105
+ * stay a warning.
106
+ */
107
+ export declare const MALFORMED_PRUNE_REASONS: ReadonlySet<string>;
72
108
  /** What the `X-RateLimit-*` headers said on the response that carried them. */
73
109
  export interface FoirRateLimitSnapshot {
74
110
  /** Requests allowed per window for this credential. */
@@ -83,11 +119,27 @@ export declare class FoirRequestError extends Error {
83
119
  readonly status?: number;
84
120
  /** GraphQL errors, when the request failed with a no-data error envelope. */
85
121
  readonly graphQLErrors?: ReadonlyArray<FoirGraphQLErrorItem>;
122
+ /**
123
+ * The `data` the server did return, when a STRICT read refused a partial
124
+ * response. Present only on that path: without it, opting into strictness
125
+ * would trade a silent wrong answer for a thrown one that discards the answer,
126
+ * and a caller that wants to log what it nearly rendered could not.
127
+ */
128
+ readonly partialData?: unknown;
129
+ /**
130
+ * The selections the server stripped, when the refusal was a pruned document
131
+ * rather than a partial error. Same purpose as `partialData`: a caller that
132
+ * wants to log what it nearly rendered, or name the offending path in a
133
+ * developer-facing message, has it without re-parsing anything.
134
+ */
135
+ readonly prunedSelections?: ReadonlyArray<FoirPrunedSelection>;
86
136
  readonly cause?: unknown;
87
137
  constructor(message: string, options?: {
88
138
  cause?: unknown;
89
139
  status?: number;
90
140
  graphQLErrors?: ReadonlyArray<FoirGraphQLErrorItem>;
141
+ partialData?: unknown;
142
+ prunedSelections?: ReadonlyArray<FoirPrunedSelection>;
91
143
  });
92
144
  /**
93
145
  * The code of the first error, if the response carried one.
@@ -158,6 +210,26 @@ export interface FoirRequestOptions {
158
210
  * (e.g. `myProfile`). Used by `@foir/sdk/auth`'s post-login profile fetch.
159
211
  */
160
212
  bearerToken?: string;
213
+ /**
214
+ * The organization this request acts in, sent as `X-Org-Id`. For a
215
+ * project-scoped key: the server resolves it to an active organization of
216
+ * the key's own project (else `NOT_FOUND`) and binds the request to it — a
217
+ * key already confined to an organization may only name that one (else
218
+ * `PERMISSION_DENIED`). See `FoirClientConfig.orgId` for the full contract.
219
+ *
220
+ * PER CALL, which is the narrower and riskier of the two spellings. The
221
+ * client-level option is the one to reach for: set it once where the
222
+ * organization is resolved and every request from that client carries it,
223
+ * so there is no call left to forget. This one is for the odd request that
224
+ * has to act somewhere else — and a request that omits it is not refused,
225
+ * it reads the project-shared tier, which is a smaller answer rather than an
226
+ * error.
227
+ *
228
+ * ⚠️ It names a SUBTREE. Organizations nest and reach runs downward, so a
229
+ * reseller's id covers every client beneath it. Send the narrowest id that
230
+ * covers what the request is about.
231
+ */
232
+ orgId?: string;
161
233
  /**
162
234
  * Called with the `X-RateLimit-*` headers of every response, successful or
163
235
  * not, and with `undefined` when a response carried none.
@@ -192,6 +264,12 @@ export interface FoirHeaderInput {
192
264
  */
193
265
  draftChannel?: boolean;
194
266
  bearerToken?: string;
267
+ /**
268
+ * The organization the request acts in (`X-Org-Id`). Set from
269
+ * `FoirClientConfig.orgId` / `FoirRequestOptions.orgId`; a caller who
270
+ * spells the header in `extra` still wins, because `extra` is applied last.
271
+ */
272
+ orgId?: string;
195
273
  /** Caller-supplied extras, applied last so a caller can override. */
196
274
  extra?: Readonly<Record<string, string>>;
197
275
  }
@@ -218,17 +296,42 @@ export declare function buildFoirHeaders(input: FoirHeaderInput): Record<string,
218
296
  * accepted. `data/transport.ts` re-exports it under its published name.
219
297
  */
220
298
  export declare function foirApiEndpoint(apiUrl: string, path: `/${string}`): string;
299
+ /**
300
+ * Foir DISCARDED a credential you sent, and served the request without it.
301
+ *
302
+ * A customer JWT is optional: if it fails to verify, the read is served as an
303
+ * ordinary anonymous one rather than refused, because plenty of reads
304
+ * legitimately have nobody signed in. That is the right behaviour and it has a
305
+ * sharp edge — a lapsed token and a genuinely anonymous request produce the
306
+ * same 200, so the failure surfaces as WRONG CONTENT rather than as an error.
307
+ * This block is how you find out.
308
+ *
309
+ * Present only when a token was actually sent and rejected. Its presence is
310
+ * always a signal.
311
+ */
312
+ export interface FoirAuthDowngrade {
313
+ /** Always `"rejected"`. */
314
+ customerToken: string;
315
+ /**
316
+ * `expired` — re-exchange and retry. `invalid` — not a customer token for
317
+ * this environment. `wrong_project` — the token and the api-key name
318
+ * different projects.
319
+ */
320
+ reason: 'expired' | 'invalid' | 'wrong_project';
321
+ /** The authority the request actually ran under. */
322
+ servedAs: string;
323
+ }
221
324
  export interface FoirResponseEnvelope<T> {
222
325
  data?: T;
223
326
  errors?: Array<FoirGraphQLErrorItem>;
327
+ extensions?: {
328
+ auth?: FoirAuthDowngrade;
329
+ pruned?: {
330
+ selections?: Array<FoirPrunedSelection>;
331
+ };
332
+ [key: string]: unknown;
333
+ };
224
334
  }
225
- /**
226
- * Unwrap a Foir `{ data, errors }` envelope.
227
- *
228
- * GraphQL can return partial data alongside errors; when data is present the
229
- * errors are logged and the data returned (callers handle nulls), and we only
230
- * throw when there is no data at all. Both Foir data-plane routes — `/graphql`
231
- * and `/cdn/graphql` — hand their document to the same executor, so the two
232
- * return this envelope and get the same treatment.
233
- */
234
- export declare function readFoirEnvelope<T>(json: FoirResponseEnvelope<T>): T;
335
+ export declare function readFoirEnvelope<T>(json: FoirResponseEnvelope<T>, options?: {
336
+ strict?: boolean;
337
+ }): T;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foir/sdk",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Official Foir SDK — editor iframe client + server-side platform helpers",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -25,6 +25,14 @@
25
25
  "types": "./dist/data/index.d.ts",
26
26
  "import": "./dist/data.js"
27
27
  },
28
+ "./live": {
29
+ "types": "./dist/live/index.d.ts",
30
+ "import": "./dist/live.js"
31
+ },
32
+ "./live/react": {
33
+ "types": "./dist/live/react/index.d.ts",
34
+ "import": "./dist/live-react.js"
35
+ },
28
36
  "./media": {
29
37
  "types": "./dist/media/index.d.ts",
30
38
  "import": "./dist/media.js"
@@ -157,7 +165,7 @@
157
165
  "node": ">=18.0.0"
158
166
  },
159
167
  "scripts": {
160
- "build": "tsup && tsc -p tsconfig.build.json && node scripts/assert-export-types.mjs && node scripts/assert-public-exports.mjs",
168
+ "build": "node scripts/clean-dist.mjs && tsup && tsc -p tsconfig.build.json && node scripts/assert-export-types.mjs && node scripts/assert-public-exports.mjs",
161
169
  "snapshot:exports": "node scripts/assert-public-exports.mjs --update",
162
170
  "dev": "node scripts/dev-watch.mjs",
163
171
  "check-types": "tsc --noEmit",