@foir/sdk 0.30.1 → 0.31.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 CHANGED
@@ -180,6 +180,20 @@ Changes on `dev` that have not been published to npm.
180
180
  stream nobody read, which threw. A page loaded again still picks the answer
181
181
  up from the stored run with `resume`.
182
182
 
183
+ ### Changed
184
+
185
+ - **Breaking: a prose entry's identity is `_id`.** `Passage._key` is now
186
+ `Passage._id`, a markDef is named by its `_id`, and the renderers key
187
+ entries by `_id` alone. Ask for `_id` where a query asked for `_key`. The id
188
+ is minted once and never changes, and is unique within its record; it is not
189
+ a record id.
190
+ - **Breaking: a reference is followed by `_targetId` alone.**
191
+ `Reference._targetNaturalKey` is gone: a reference names its target by id.
192
+ `Reference._id` is the entry's own id, never the target's.
193
+ - **`Image`, `Video`, `FileValue` and `GeoPoint` carry `_id`**: the value's own
194
+ id when it is an item of a list or an entry of a prose field, and null on a
195
+ plain field's value.
196
+
183
197
  ## [0.29.0] — 2026-10-21
184
198
 
185
199
  ### Added
package/README.md CHANGED
@@ -27,7 +27,7 @@ pnpm add yjs y-websocket # for @foir/sdk/realtime
27
27
  | `@foir/sdk/data` | Typed data client over `/graphql` — `createFoirClient`, per-key-shaped ops, `include`/`omit`, `fetchFoirManifest`; prints the document for you, so still no `graphql` | **none** |
28
28
  | `@foir/sdk/auth` | Framework-agnostic sign-in for your app's users: login/register/OTP/provider flows, token lifecycle (`SessionAdapter`), `createFoirSessionClient` (Foir-hosted cookie sessions), `authedRequest`, guards | `graphql` |
29
29
  | `@foir/sdk/react` | Drop-in React auth — `<FoirProvider>` (token or `hostedSession` mode), `useFoirSession`/`useFoirUser`/`useFoirAuth`, `<SignIn>`, `<OAuthButtons>`, `<UserButton>` | `react`, `graphql` |
30
- | `@foir/sdk/next` | Next.js App Router BFF auth — `createFoirCookieAdapter`, `getFoirSession`, `foirSignIn*`, `completeFoirProviderLogin`, `createFoirMiddleware` | `graphql` |
30
+ | `@foir/sdk/next` | Next.js App Router BFF auth: `createFoirCookieAdapter`, `getFoirSession`, `foirSignIn*`, `startFoirProviderSignIn`, `finishFoirProviderSignIn`, `createFoirMiddleware` | `graphql` |
31
31
  | `@foir/sdk/server` | Server-side helpers for extension/config API backends — `createServerClient`, `verifyWebhookSignature`, `verifyScopedToken`, callback client | — |
32
32
  | `@foir/sdk/live` | Live reads — the client half of Foir's invalidation graph: tag index, raw-payload store, `seq` ordering, record-change → tag mapping | **none** |
33
33
  | `@foir/sdk/live/react` | `<FoirLive>` — one socket, refcounted per model, wired to a query client you already have | `react` |
@@ -55,9 +55,10 @@
55
55
  * `foirChatMessages` reads a thread back as `useChat`'s messages.
56
56
  */
57
57
  import type { ChatTransport, FlexibleSchema, UIMessage, UIMessageChunk } from 'ai';
58
- import type { FoirAgentAttachmentUpload, FoirAgentRun, FoirAgentRunNode, FoirAgentThread, FoirCreateAgentAttachmentArgs, FoirStartAgentRunArgs } from '../data/builtins';
58
+ import type { FoirAgentAttachmentUpload, FoirAgentRun, FoirAgentHeldAct, FoirAgentRunNode, FoirAgentThread, FoirCreateAgentAttachmentArgs, FoirStartAgentRunArgs } from '../data/builtins';
59
59
  import type { FoirAgentRunEvent, FoirAgentRunSubscribeOptions } from '../data/subscriptions';
60
60
  import type { FoirSubscription } from '../data/ws-transport';
61
+ import { type StepReply } from '../runs/step-reply';
61
62
  /** What the transport needs of a client: the agents namespace a key that runs an agent is given. */
62
63
  export interface FoirChatClient {
63
64
  ns: {
@@ -68,12 +69,7 @@ export interface FoirChatClient {
68
69
  id: string;
69
70
  approve: boolean;
70
71
  reason?: string;
71
- }): Promise<FoirAgentRunNode | null>;
72
- answerPageTool(args: {
73
- id: string;
74
- output: unknown;
75
- visitorToken?: string;
76
- }): Promise<FoirAgentRunNode | null>;
72
+ }): Promise<FoirAgentHeldAct | null>;
77
73
  agentThread(args: {
78
74
  id: string;
79
75
  visitorToken?: string;
@@ -144,8 +140,11 @@ export declare class FoirRunChunks {
144
140
  private opened;
145
141
  /** The run stopped for this stream's followers: it ended, or holds an act. */
146
142
  stopped: boolean;
147
- /** Calls to the page's tools waiting for the app's `addToolOutput`. */
148
- readonly pageCalls: Set<string>;
143
+ /**
144
+ * Calls to the page's tools waiting for the app's `addToolOutput`, each with
145
+ * the reply the page answers it by.
146
+ */
147
+ readonly pageCalls: Map<string, StepReply | null>;
149
148
  /** Calls to the page's tools the transport runs and answers itself, until the run hears them. */
150
149
  readonly answering: Set<string>;
151
150
  /** Calls the transport is to run: taken with `takeCalls`. */
package/dist/ai.js CHANGED
@@ -57,6 +57,153 @@ function randomSecret() {
57
57
  return out;
58
58
  }
59
59
 
60
+ // src/transport/foir-http.ts
61
+ var FoirRequestError = class extends Error {
62
+ /** HTTP status code, when the request failed at the HTTP layer. */
63
+ status;
64
+ /** GraphQL errors, when the request failed with a no-data error envelope. */
65
+ graphQLErrors;
66
+ /**
67
+ * The `data` the server did return, when a STRICT read refused a partial
68
+ * response. Present only on that path: without it, opting into strictness
69
+ * would trade a silent wrong answer for a thrown one that discards the answer,
70
+ * and a caller that wants to log what it nearly rendered could not.
71
+ */
72
+ partialData;
73
+ /**
74
+ * The selections the server stripped, when the refusal was a pruned document
75
+ * rather than a partial error. Same purpose as `partialData`: a caller that
76
+ * wants to log what it nearly rendered, or name the offending path in a
77
+ * developer-facing message, has it without re-parsing anything.
78
+ */
79
+ prunedSelections;
80
+ cause;
81
+ constructor(message, options) {
82
+ super(message);
83
+ this.name = "FoirRequestError";
84
+ this.cause = options?.cause;
85
+ this.status = options?.status;
86
+ this.graphQLErrors = options?.graphQLErrors;
87
+ this.partialData = options?.partialData;
88
+ this.prunedSelections = options?.prunedSelections;
89
+ }
90
+ /**
91
+ * The code of the first error, if the response carried one.
92
+ *
93
+ * A getter rather than a stored field so it stays correct for an error
94
+ * constructed anywhere, including by a caller in a test.
95
+ */
96
+ get code() {
97
+ return this.graphQLErrors?.[0]?.extensions?.code;
98
+ }
99
+ /** The correlation id, if the response carried one. */
100
+ get requestId() {
101
+ return this.graphQLErrors?.[0]?.extensions?.requestId;
102
+ }
103
+ /**
104
+ * Whether retrying this request could succeed.
105
+ *
106
+ * The 429 is the one that has to be in here and is easy to leave out: it sits
107
+ * among the 4xx statuses that are otherwise permanent, so a policy written as
108
+ * "retry 5xx, never retry 4xx" excludes exactly the failure that clears on its
109
+ * own. A storefront wrote that policy, its key saturated, and every read
110
+ * failed on the first attempt for an hour.
111
+ */
112
+ get retryable() {
113
+ if (this.status === void 0) {
114
+ if (this.graphQLErrors?.length || this.prunedSelections?.length) {
115
+ return this.code === "RATE_LIMITED" || this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
116
+ }
117
+ return true;
118
+ }
119
+ if (this.status === 429 || this.code === "RATE_LIMITED") return true;
120
+ if (this.status >= 500) return true;
121
+ return this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
122
+ }
123
+ /**
124
+ * How long to wait before retrying, in milliseconds, when the server said.
125
+ * Undefined when it did not, which means the caller picks its own backoff.
126
+ */
127
+ get retryAfterMs() {
128
+ const seconds = this.graphQLErrors?.[0]?.extensions?.retryAfter;
129
+ return typeof seconds === "number" && seconds > 0 ? seconds * 1e3 : void 0;
130
+ }
131
+ };
132
+
133
+ // src/transport/retry.ts
134
+ var DEFAULT_ATTEMPTS = 3;
135
+ var DEFAULT_BASE_DELAY_MS = 200;
136
+ var DEFAULT_MAX_DELAY_MS = 2e4;
137
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
138
+ function foirRetryDelayMs(error, attempt, policy = {}) {
139
+ if (!(error instanceof FoirRequestError) || !error.retryable) return null;
140
+ const max = policy.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
141
+ const stated = error.retryAfterMs;
142
+ if (stated !== void 0) return Math.min(stated, max);
143
+ const base = policy.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
144
+ return Math.min(base * 2 ** (attempt - 1), max);
145
+ }
146
+ async function withFoirRetry(fn, policy = {}) {
147
+ const attempts = Math.max(1, policy.attempts ?? DEFAULT_ATTEMPTS);
148
+ const sleep = policy.sleep ?? defaultSleep;
149
+ for (let attempt = 1; ; attempt += 1) {
150
+ try {
151
+ return await fn();
152
+ } catch (error) {
153
+ const delayMs = attempt >= attempts ? null : foirRetryDelayMs(error, attempt, policy);
154
+ if (delayMs === null) throw error;
155
+ policy.onRetry?.({ attempt, delayMs, error });
156
+ await sleep(delayMs);
157
+ }
158
+ }
159
+ }
160
+
161
+ // src/runs/step-reply.ts
162
+ async function replyToStep(reply, body, opts = {}) {
163
+ if (!reply?.token || !reply.signingKey || !reply.endpoint) {
164
+ throw new Error("replyToStep: the step was handed no reply, so it cannot answer");
165
+ }
166
+ return sendReply(reply.endpoint, reply.token, reply.signingKey, body, opts);
167
+ }
168
+ async function sendReply(endpoint, token, signingKey, body, opts = {}) {
169
+ const fetchImpl = opts.fetch ?? globalThis.fetch;
170
+ if (!fetchImpl) throw new Error("replyToStep: no fetch available; pass opts.fetch on runtimes without a global fetch");
171
+ const payload = JSON.stringify(body);
172
+ const signature = await hmacSha256Hex(signingKey, payload);
173
+ return withFoirRetry(async () => {
174
+ let resp;
175
+ try {
176
+ resp = await fetchImpl(endpoint, {
177
+ method: "POST",
178
+ headers: {
179
+ "Content-Type": "application/json",
180
+ Authorization: `Bearer ${token}`,
181
+ "X-Foir-Signature": `sha256=${signature}`
182
+ },
183
+ body: payload,
184
+ ...opts.signal ? { signal: opts.signal } : {}
185
+ });
186
+ } catch (err) {
187
+ if (opts.signal?.aborted) throw err;
188
+ throw new FoirRequestError("the reply got no answer", { cause: err });
189
+ }
190
+ if (!resp.ok) {
191
+ throw new FoirRequestError(`the reply was refused with HTTP ${resp.status}: ${await resp.text()}`, { status: resp.status });
192
+ }
193
+ const out = await resp.json();
194
+ if (!out || typeof out.status !== "string") {
195
+ throw new FoirRequestError("the reply was answered with nothing", { status: resp.status });
196
+ }
197
+ return { status: out.status, cancelled: out.cancelled === true, applied: out.applied === true };
198
+ }, opts.retry);
199
+ }
200
+ async function hmacSha256Hex(secret, body) {
201
+ const enc = new TextEncoder();
202
+ const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
203
+ const sig = await crypto.subtle.sign("HMAC", key, enc.encode(body));
204
+ return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
205
+ }
206
+
60
207
  // src/ai/index.ts
61
208
  var TOOL_KINDS = /* @__PURE__ */ new Set(["VERB", "OPERATION", "MCP_TOOL", "AGENT", "PAGE_TOOL"]);
62
209
  var STOPS = /* @__PURE__ */ new Set(["COMPLETED", "FAILED", "SKIPPED", "CANCELLED", "HELD"]);
@@ -82,8 +229,11 @@ var FoirRunChunks = class {
82
229
  opened = false;
83
230
  /** The run stopped for this stream's followers: it ended, or holds an act. */
84
231
  stopped = false;
85
- /** Calls to the page's tools waiting for the app's `addToolOutput`. */
86
- pageCalls = /* @__PURE__ */ new Set();
232
+ /**
233
+ * Calls to the page's tools waiting for the app's `addToolOutput`, each with
234
+ * the reply the page answers it by.
235
+ */
236
+ pageCalls = /* @__PURE__ */ new Map();
87
237
  /** Calls to the page's tools the transport runs and answers itself, until the run hears them. */
88
238
  answering = /* @__PURE__ */ new Set();
89
239
  /** Calls the transport is to run: taken with `takeCalls`. */
@@ -250,7 +400,7 @@ var FoirRunChunks = class {
250
400
  this.answering.add(node.id);
251
401
  this.toRun.push(node);
252
402
  } else {
253
- this.pageCalls.add(node.id);
403
+ this.pageCalls.set(node.id, node.reply ?? null);
254
404
  }
255
405
  return out;
256
406
  }
@@ -370,7 +520,6 @@ function agentsOf(client, caller) {
370
520
  startAgentRun: refuse,
371
521
  onRun: refuse,
372
522
  decideAgentApproval: refuse,
373
- answerPageTool: refuse,
374
523
  agentThread: refuse,
375
524
  createAgentAttachment: refuse
376
525
  };
@@ -416,6 +565,10 @@ function foirChatTransport(options) {
416
565
  }
417
566
  return c;
418
567
  };
568
+ const answerPage = async (id, reply, output) => {
569
+ if (!reply) throw new Error(`The call ${id} came without its reply, so the page cannot answer it.`);
570
+ await replyToStep(reply, { result: output ?? null });
571
+ };
419
572
  const answerCall = async (node, signal) => {
420
573
  const tool = pageTools[node.key];
421
574
  let output;
@@ -426,7 +579,7 @@ function foirChatTransport(options) {
426
579
  } catch (err) {
427
580
  output = { error: err instanceof Error ? err.message : String(err) };
428
581
  }
429
- await agents.answerPageTool({ id: node.id, output, visitorToken: visitor });
582
+ await answerPage(node.id, node.reply, output);
430
583
  };
431
584
  const follow = (runId, fresh, signal) => {
432
585
  const chunks = chunksOf(runId);
@@ -478,7 +631,7 @@ function foirChatTransport(options) {
478
631
  async sendMessages({ chatId, messages, abortSignal }) {
479
632
  const decisions = decisionsIn(messages, decided);
480
633
  const waiting = /* @__PURE__ */ new Set();
481
- for (const c of runs.values()) for (const id2 of c.pageCalls) waiting.add(id2);
634
+ for (const c of runs.values()) for (const id2 of c.pageCalls.keys()) waiting.add(id2);
482
635
  const answers = pageAnswersIn(messages, waiting);
483
636
  if (decisions.length > 0 || answers.length > 0) {
484
637
  for (const d of decisions) {
@@ -486,7 +639,9 @@ function foirChatTransport(options) {
486
639
  decided.add(d.id);
487
640
  }
488
641
  for (const a of answers) {
489
- await agents.answerPageTool({ id: a.id, output: a.output, visitorToken: visitor });
642
+ let reply;
643
+ for (const c of runs.values()) reply ??= c.pageCalls.get(a.id);
644
+ await answerPage(a.id, reply, a.output);
490
645
  for (const c of runs.values()) c.pageCalls.delete(a.id);
491
646
  }
492
647
  let runId = lastRun.get(chatId);
@@ -462,11 +462,15 @@ export interface AppClient {
462
462
  deleted: boolean;
463
463
  }>;
464
464
  /**
465
- * Drop what the data API has cached for one record, by natural key or id,
466
- * so its next read resolves it again. False when Foir holds no such
467
- * record. For a source read live from its host.
465
+ * Drop what the data API has cached for one record, named by its id or
466
+ * by its natural key, so its next read resolves it again. False when Foir
467
+ * holds no such record. For a source read live from its host.
468
468
  */
469
- refresh(model: string, idOrKey: string): Promise<{
469
+ refresh(model: string, record: {
470
+ id: string;
471
+ } | {
472
+ naturalKey: string;
473
+ }): Promise<{
470
474
  refreshed: boolean;
471
475
  }>;
472
476
  /**
@@ -766,7 +770,7 @@ export interface AppStartedOperation {
766
770
  /** True when `concurrencyKey` matched a run in flight, so nothing was started. */
767
771
  joined: boolean;
768
772
  /** True when `idempotencyKey` matched a run in flight or completed, so nothing was started. */
769
- deduplicated: boolean;
773
+ replayed: boolean;
770
774
  }
771
775
  /** Build the client an installed app calls Foir with. See the module doc. */
772
776
  export declare function createAppClient(config: AppClientConfig): AppClient;
package/dist/app.js CHANGED
@@ -632,10 +632,16 @@ function createAppClient(config) {
632
632
  const deleted = await field(name, `mutation AppDeleteRecord($id: String!) { ${name}(id: $id) }`, { id });
633
633
  return { deleted: deleted === true };
634
634
  },
635
- async refresh(model, idOrKey) {
636
- if (idOrKey === "") refuse(`a record refresh on ${model} was given an empty name`);
635
+ async refresh(model, record) {
636
+ const byId = "id" in record;
637
+ const value = byId ? record.id : record.naturalKey;
638
+ if (value === "") refuse(`a record refresh on ${model} was given an empty name`);
637
639
  const name = `refresh${appModelTypeName(model)}`;
638
- const refreshed = await field(name, `mutation AppRefreshRecord($idOrKey: String!) { ${name}(idOrKey: $idOrKey) }`, { idOrKey });
640
+ const refreshed = await field(
641
+ name,
642
+ byId ? `mutation AppRefreshRecord($id: ID!) { ${name}(id: $id) }` : `mutation AppRefreshRecord($naturalKey: String!) { ${name}(naturalKey: $naturalKey) }`,
643
+ byId ? { id: value } : { naturalKey: value }
644
+ );
639
645
  if (typeof refreshed !== "boolean") return refuse(`${name} reported no error and did not say whether it refreshed the record`);
640
646
  return { refreshed };
641
647
  },
@@ -972,12 +978,12 @@ function createAppClient(config) {
972
978
  args.push("concurrencyKey: $concurrencyKey");
973
979
  variables.concurrencyKey = options.concurrencyKey;
974
980
  }
975
- const document = `mutation AppStartOperation(${params.join(", ")}) { startOperation(${args.join(", ")}) { executionId joined deduplicated } }`;
981
+ const document = `mutation AppStartOperation(${params.join(", ")}) { startOperation(${args.join(", ")}) { executionId joined replayed } }`;
976
982
  const keyed = Boolean(options.idempotencyKey || options.concurrencyKey);
977
983
  const data = keyed ? await run(document, variables) : await send(document, variables);
978
984
  const answer = data["startOperation"];
979
985
  if (!answer?.executionId) return refuse("startOperation reported no error and named no run");
980
- return { executionId: answer.executionId, joined: answer.joined === true, deduplicated: answer.deduplicated === true };
986
+ return { executionId: answer.executionId, joined: answer.joined === true, replayed: answer.replayed === true };
981
987
  }
982
988
  }
983
989
  };
@@ -1067,18 +1073,11 @@ function readCallback(value) {
1067
1073
  if (!isObject(value)) return void 0;
1068
1074
  const token = text(value.token);
1069
1075
  if (!token) return void 0;
1070
- const mutations = isObject(value.mutations) ? value.mutations : {};
1071
1076
  const signingKey = text(value.signingKey);
1072
1077
  return {
1073
- gqlEndpoint: text(value.gqlEndpoint) ?? "",
1078
+ endpoint: text(value.endpoint) ?? "",
1074
1079
  token,
1075
1080
  expiresAt: text(value.expiresAt) ?? "",
1076
- mutations: {
1077
- complete: text(mutations.complete) ?? "",
1078
- fail: text(mutations.fail) ?? "",
1079
- progress: text(mutations.progress) ?? "",
1080
- cancel: text(mutations.cancel) ?? ""
1081
- },
1082
1081
  ...Array.isArray(value.causationChain) ? { causationChain: value.causationChain.map(String) } : {},
1083
1082
  ...signingKey ? { signingKey } : {}
1084
1083
  };