@foir/sdk 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +719 -27
  2. package/README.md +121 -2
  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 +18 -0
  8. package/dist/auth.js +209 -47
  9. package/dist/client.js +101 -13
  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/codegen/preset.d.ts +3 -2
  16. package/dist/content/content-renderer.d.ts +4 -5
  17. package/dist/content/index.d.ts +1 -1
  18. package/dist/content/richtext.d.ts +1 -1
  19. package/dist/data/builtins.d.ts +81 -13
  20. package/dist/data/client.d.ts +92 -9
  21. package/dist/data/decimal.d.ts +212 -0
  22. package/dist/data/document.d.ts +14 -1
  23. package/dist/data/index.d.ts +22 -10
  24. package/dist/data/manifest.d.ts +40 -4
  25. package/dist/data/schema.d.ts +18 -11
  26. package/dist/data/selection.d.ts +15 -0
  27. package/dist/data/subscriptions.d.ts +36 -10
  28. package/dist/data/transport.d.ts +363 -24
  29. package/dist/data/ws-transport.d.ts +8 -2
  30. package/dist/data.js +591 -64
  31. package/dist/graphql/cdn-route.d.ts +1 -1
  32. package/dist/graphql/foir-request.d.ts +1 -1
  33. package/dist/index.js +0 -9
  34. package/dist/live/coalesce.d.ts +57 -0
  35. package/dist/live/drift.d.ts +58 -0
  36. package/dist/live/events.d.ts +13 -0
  37. package/dist/live/headers.d.ts +53 -0
  38. package/dist/live/index.d.ts +54 -0
  39. package/dist/live/keys.d.ts +72 -0
  40. package/dist/live/manager.d.ts +77 -0
  41. package/dist/live/react/index.d.ts +143 -0
  42. package/dist/live/reconcile.d.ts +82 -0
  43. package/dist/live/resync.d.ts +59 -0
  44. package/dist/live/store.d.ts +134 -0
  45. package/dist/live/tagIndex.d.ts +29 -0
  46. package/dist/live/tanstack.d.ts +127 -0
  47. package/dist/live/writetoken.d.ts +86 -0
  48. package/dist/live-react.js +395 -0
  49. package/dist/live.js +845 -0
  50. package/dist/next/cacheTags.d.ts +245 -0
  51. package/dist/next/index.d.ts +7 -0
  52. package/dist/next.js +260 -34
  53. package/dist/push.js +163 -36
  54. package/dist/react.js +157 -33
  55. package/dist/realtime/index.d.ts +4 -5
  56. package/dist/server/events-client.d.ts +54 -4
  57. package/dist/server/index.d.ts +4 -2
  58. package/dist/server/operation-types.d.ts +86 -58
  59. package/dist/server/read-dispatch.d.ts +53 -0
  60. package/dist/server/require-scoped-token.d.ts +20 -1
  61. package/dist/server.js +602 -10
  62. package/dist/transport/foir-http.d.ts +168 -15
  63. package/package.json +10 -2
@@ -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
@@ -2,11 +2,13 @@ export { createServerClient, ServerClientError } from './server-client';
2
2
  export { verifyWebhookSignature } from './webhook-helpers';
3
3
  export { verifyScopedToken, parseContextHeader, parseSubject, recordsReadModels, recordsWriteModels, type ScopedTokenClaims, type ScopedTokenSubject, type VerifyScopedTokenOptions, } from './verify-scoped-token';
4
4
  export { requireScopedToken, ScopedTokenError, type RequireScopedTokenOptions, type ScopedTokenIngress, } from './require-scoped-token';
5
+ export { readDispatch, OperationPayloadError, type BodyCarrier, } from './read-dispatch';
5
6
  export type { ServerClient, ServerClientOptions, GraphQLResponse, EditorPlacement, } from './types';
6
7
  export type { OperationPayload, OperationTrigger, OperationRecordContext, OperationContext, OperationResult, OperationCallback, } from './operation-types';
7
8
  export { createCallbackClient } from './callback-client';
8
9
  export type { CallbackClient, CallbackResult } from './callback-client';
9
10
  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';
11
+ export type { FoirEvent, FoirEventActor, FoirEventPage, FoirEventRetention, FoirEventsClient, FoirEventsClientConfig, FoirEventsListParams, FoirEventsStreamParams, FoirEventsSubscribeParams, } from './events-client';
12
+ export type { FoirSubscription, FoirSubscriptionHandlers } from '../data/ws-transport';
11
13
  export { FoirRequestError, isFoirForbidden, isFoirNotFound, isFoirRateLimited, isFoirUnauthenticated, } from '../transport/foir-http';
12
- export type { FoirErrorCode, FoirErrorExtensions, FoirGraphQLErrorItem } from '../transport/foir-http';
14
+ export type { FoirErrorCode, FoirErrorExtensions, FoirGraphQLErrorItem, FoirAuthDowngrade, } from '../transport/foir-http';
@@ -1,41 +1,70 @@
1
1
  /**
2
2
  * Type definitions for platform operations.
3
3
  *
4
- * These types match the platform's operation execution payload structure.
4
+ * `OperationPayload` is the body the platform POSTs to an operation endpoint,
5
+ * field for field. It is written against the worker that builds it
6
+ * (`buildOperationPayload` in the platform's operation processor), not against
7
+ * what an endpoint might like to receive: every field here is one the platform
8
+ * sends, present exactly when the platform sends it. A field the platform does
9
+ * not send is not here, because a type that promises one is how an endpoint
10
+ * comes to read `undefined` and refuse every call.
11
+ *
12
+ * Read it with `readDispatch(request)`, which parses and checks the body into
13
+ * this type.
5
14
  */
6
15
  /**
7
- * Operation payload sent by the platform to extension endpoints.
16
+ * The body the platform sends an operation endpoint.
17
+ *
18
+ * The same shape however the run was started (a person in the console, an
19
+ * `execute<Op>` call, a lifecycle hook, a schedule) and whichever way it is
20
+ * delivered (to the registered endpoint, or down `foir operations dev` to one
21
+ * on your machine).
8
22
  *
9
- * All operations receive this standardized payload structure regardless
10
- * of how they're triggered (UI, API, lifecycle hook, schedule).
23
+ * `TInput` types `input`. It is an assertion, not a check: the platform
24
+ * validates `input` against the operation's input schema before dispatching,
25
+ * and this parameter lets the endpoint say what that schema is.
11
26
  */
12
- export interface OperationPayload {
13
- /** Execution id — present so the extension can include it in callbacks. */
14
- executionId?: string;
15
- /** Unique key identifying this operation (e.g., 'tilly-process-note') */
27
+ export interface OperationPayload<TInput extends object = Record<string, unknown>> {
28
+ /** The run. Always present; quote it in a callback and in your own logs. */
29
+ executionId: string;
30
+ /** The key of the operation being run, e.g. `send_quote`. */
16
31
  operationKey: string;
17
- /** How this operation was triggered */
32
+ /** How the run was started. */
18
33
  trigger: OperationTrigger;
19
- /** Input data provided by the caller */
20
- input: Record<string, unknown>;
21
- /** Content/field value (for field-triggered operations) */
22
- content: unknown;
23
- /** Record context (if operation was triggered by a record event) */
24
- record: OperationRecordContext | null;
25
- /** Platform context (tenant, project, user, locale) */
34
+ /**
35
+ * What the caller passed as the operation's input. Always an object:
36
+ * `readDispatch` reads a run started with no input (a schedule that declares
37
+ * none, which the platform serialises as `null`) as `{}`.
38
+ */
39
+ input: TInput;
40
+ /** The tenant, project and principal the run belongs to. */
26
41
  context: OperationContext;
27
- /** Extension credentials (auto-injected by platform) */
28
- credentials?: Record<string, unknown>;
29
- /** Extension config (auto-injected by platform) */
30
- config?: Record<string, unknown>;
31
42
  /**
32
- * Async-callback dispatch metadata. Present iff the operation was
33
- * dispatched in `async-callback` mode. Contains the scoped token, the
34
- * api-public GraphQL endpoint, and the per-op typed mutation names the
35
- * extension should call back to.
43
+ * The content block the caller attached when it started the run, forwarded
44
+ * as the caller wrote it. Absent when there was none, which is every run
45
+ * started by a hook, a schedule or `execute<Op>`.
46
+ */
47
+ content?: Record<string, unknown>;
48
+ /**
49
+ * The record block the caller attached when it started the run, forwarded
50
+ * as the caller wrote it. Absent when there was none. For a record event,
51
+ * the record's id arrives in `input` (for example `input.recordId`), not
52
+ * here: read the record back as the operation when you need its content.
53
+ */
54
+ record?: OperationRecordContext;
55
+ /**
56
+ * Caller-supplied context about where the run was started from, forwarded as
57
+ * written. Absent when there was none.
58
+ */
59
+ triggerContext?: Record<string, unknown>;
60
+ /**
61
+ * Where and how to report back. Present only when the run was dispatched in
62
+ * ASYNC mode: the endpoint answers `202 Accepted` and reports the outcome
63
+ * later through this block (see `createCallbackClient`).
36
64
  *
37
- * Absent for SYNC operations — the extension returns its result in the
38
- * HTTP response body and there's nothing to call back to.
65
+ * Absent in the blocking (SYNC) mode. A blocking run has no callback path:
66
+ * the endpoint's response body is the result. Branch on this field being
67
+ * present to tell the two modes apart.
39
68
  */
40
69
  callback?: OperationCallback;
41
70
  }
@@ -61,53 +90,52 @@ export interface OperationCallback {
61
90
  progress: string;
62
91
  cancel: string;
63
92
  };
93
+ /**
94
+ * The operation keys that led to this run, its own last. Present when the
95
+ * run was caused by another operation's work.
96
+ */
97
+ causationChain?: string[];
64
98
  }
65
99
  /**
66
- * Trigger information describing how the operation was invoked.
100
+ * How a run was started. The four values are the platform's own; a caller that
101
+ * names none is recorded as `api`.
102
+ *
103
+ * - `ui`: a console surface that names itself as the trigger, such as an app's
104
+ * configure page.
105
+ * - `api`: an `execute<Op>` call, or any caller that named no trigger.
106
+ * - `lifecycle`: a hook on a platform event (a record published, an operation
107
+ * completed).
108
+ * - `schedule`: a cron schedule firing.
67
109
  */
68
110
  export interface OperationTrigger {
69
- /** Trigger type */
70
- type: 'ui' | 'api' | 'lifecycle' | 'field';
71
- /** Field key (for field-triggered operations) */
72
- fieldKey?: string;
73
- /** Field type (for field-triggered operations) */
74
- fieldType?: string;
111
+ type: 'ui' | 'api' | 'lifecycle' | 'schedule';
75
112
  }
76
113
  /**
77
- * Record context provided when operation is triggered by a record event.
114
+ * The record block a caller attached to a run, forwarded as the caller wrote
115
+ * it. The platform does not shape it, so neither does this type.
78
116
  */
79
- export interface OperationRecordContext {
80
- /** Record ID */
81
- id: string;
82
- /** Model key (e.g., 'tilly_note') */
83
- modelKey: string;
84
- /** Version ID (if versioned record) */
85
- versionId?: string;
86
- /** Record content/data */
87
- data: Record<string, unknown>;
88
- /** Record metadata */
89
- metadata: {
90
- /** Natural key (slug, handle, etc.) */
91
- naturalKey?: string;
92
- /** Additional metadata fields */
93
- [key: string]: unknown;
94
- };
95
- }
117
+ export type OperationRecordContext = Record<string, unknown>;
96
118
  /**
97
- * Platform context provided to all operations.
119
+ * The tenant, project and principal a run belongs to.
98
120
  */
99
121
  export interface OperationContext {
100
122
  /** Tenant ID */
101
123
  tenantId: string;
102
124
  /** Project ID */
103
125
  projectId: string;
104
- /** User ID (if user-triggered) */
105
- userId?: string;
106
- /** Customer ID (if customer-scoped operation) */
126
+ /**
127
+ * The principal that started the run. Always present, and the empty string
128
+ * when the run has none, as a hook's run does.
129
+ */
130
+ userId: string;
131
+ /**
132
+ * The customer the run acts for, when it acts for one: the signed-in
133
+ * customer who ran it, or the owner of the record whose event fired the
134
+ * hook. The dispatch token carries the same customer, so a client built from
135
+ * it acts for them too.
136
+ */
107
137
  customerId?: string;
108
- /** Locale code (if locale-specific) */
109
- locale?: string;
110
- /** Timestamp when operation was triggered */
138
+ /** When the platform built this body, RFC 3339 in UTC to the second. */
111
139
  timestamp: string;
112
140
  }
113
141
  /**
@@ -0,0 +1,53 @@
1
+ /**
2
+ * readDispatch: the body of an operation dispatch, parsed and checked.
3
+ *
4
+ * An operation endpoint is handed two things. The headers carry the dispatch
5
+ * token, which `requireScopedToken` verifies. The body carries the run: its id,
6
+ * the operation's key, how it was started, the context it runs in, and the
7
+ * caller's input under `input`. Reading the body as though it WERE the input is
8
+ * the mistake this exists to prevent, because it fails quietly: every field the
9
+ * endpoint looks for is absent, and the endpoint refuses every call on a
10
+ * missing field it was in fact sent.
11
+ *
12
+ * ```ts
13
+ * const { dispatch } = await requireScopedToken(request, { expectedProjectId });
14
+ * const { input, context } = await readDispatch<{ email: string }>(request);
15
+ * const foir = createFoirClient({ apiUrl, dispatch, generated });
16
+ * ```
17
+ *
18
+ * The shape is the platform's `buildOperationPayload`, field for field; see
19
+ * `OperationPayload`. A body that is not that shape is refused with
20
+ * {@link OperationPayloadError}, naming what is wrong, rather than handed on
21
+ * half-read.
22
+ *
23
+ * What it does not do is drop fields. A key the platform adds later is kept on
24
+ * the returned object, untyped, because a reader that re-built the body from
25
+ * the fields it knows would delete whatever it did not, which is the exact way
26
+ * blocks of this payload have gone missing on their way to an endpoint before.
27
+ */
28
+ import type { OperationPayload } from './operation-types';
29
+ /**
30
+ * The dispatch body was not an operation payload. `status` is the HTTP status
31
+ * the route should answer with: 400, since the request is malformed rather than
32
+ * unauthorised (the token has already been verified by the time the body is
33
+ * read).
34
+ */
35
+ export declare class OperationPayloadError extends Error {
36
+ readonly status: 400;
37
+ constructor(message: string);
38
+ }
39
+ /** The minimal request shape needed: satisfied by any fetch-API Request. */
40
+ export interface BodyCarrier {
41
+ text(): Promise<string>;
42
+ }
43
+ /**
44
+ * Read the dispatch body off an operation request.
45
+ *
46
+ * `TInput` types `input`, as an assertion: the platform has already checked the
47
+ * input against the operation's input schema, and this is where the endpoint
48
+ * says what that schema is.
49
+ *
50
+ * Reads the body, so call it once per request and after nothing else has
51
+ * consumed it.
52
+ */
53
+ export declare function readDispatch<TInput extends object = Record<string, unknown>>(request: BodyCarrier): Promise<OperationPayload<TInput>>;
@@ -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
@@ -16,6 +16,17 @@
16
16
  * });
17
17
  * ```
18
18
  *
19
+ * The verified token is also the endpoint's credential for calling Foir back.
20
+ * `dispatch` on the result is the token as a data-client credential, so the
21
+ * endpoint acts as the operation, with the capabilities the operation
22
+ * declared, and holds no key of its own. `generated` is the module
23
+ * `foir types --operation <key>` wrote for the operation:
24
+ *
25
+ * ```ts
26
+ * const { dispatch } = await requireScopedToken(request, { expectedProjectId });
27
+ * const foir = createFoirClient({ apiUrl: env.PLATFORM_API_URL, dispatch, generated });
28
+ * ```
29
+ *
19
30
  * Throws {@link ScopedTokenError} with `status` 401 (missing/invalid token)
20
31
  * or 403 (valid token for the wrong tenant/project) — map it to a response
21
32
  * in one place:
@@ -30,6 +41,7 @@
30
41
  * ```
31
42
  */
32
43
  import type { ScopedTokenClaims, ScopedTokenSubject, VerifyScopedTokenOptions } from './verify-scoped-token';
44
+ import type { FoirDispatchCredential } from '../data/transport';
33
45
  /**
34
46
  * Verification failure with the HTTP status the route should answer with:
35
47
  * 401 for a missing or invalid token, 403 for a valid token bound to the
@@ -66,6 +78,13 @@ export interface ScopedTokenIngress {
66
78
  subject: ScopedTokenSubject;
67
79
  /** Parsed `X-Foir-Context` correlation metadata (empty when absent). */
68
80
  context: Record<string, string>;
81
+ /**
82
+ * The token this call verified, as the credential for a data client that
83
+ * calls Foir AS the operation:
84
+ * `createFoirClient({ apiUrl, dispatch, generated })`. It sends the token as
85
+ * `X-Foir-Token` with the dispatch's `X-Foir-Context`, and never an api key.
86
+ */
87
+ dispatch: FoirDispatchCredential;
69
88
  }
70
89
  /**
71
90
  * Verify the platform-minted scoped token on an inbound dispatch request.