@foir/sdk 0.12.0 → 0.13.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 +34 -0
- package/dist/app/client.d.ts +74 -0
- package/dist/app/index.d.ts +1 -1
- package/dist/app.js +108 -0
- package/dist/server/operation-types.d.ts +3 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -108,6 +108,40 @@ nobody asking for `@foir/sdk` gets it early. The push that carries the commit to
|
|
|
108
108
|
|
|
109
109
|
Changes on `dev` that have not been published to npm.
|
|
110
110
|
|
|
111
|
+
## [0.13.0] — 2026-10-01
|
|
112
|
+
|
|
113
|
+
Published under npm's `next` tag from `dev`, because it calls parts of the
|
|
114
|
+
platform that production serves from the next promotion; `latest` moves to it
|
|
115
|
+
then.
|
|
116
|
+
|
|
117
|
+
### Added
|
|
118
|
+
|
|
119
|
+
- **`operations.start`** on the app client (`@foir/sdk/app`): a webhook
|
|
120
|
+
delivery starts a run of one of the app's own operations, with an input, and
|
|
121
|
+
is answered with the run's id without waiting for it. For work a delivery
|
|
122
|
+
cannot do in place: a delivery is not a run, so it holds none of the
|
|
123
|
+
authority the platform gives only inside one, and it has the few seconds its
|
|
124
|
+
vendor waits. Only the token a delivery arrived with may call it.
|
|
125
|
+
`idempotencyKey` names one delivery, so a redelivery does not start the work
|
|
126
|
+
twice. `concurrencyKey` names work of which one run should be in flight at a
|
|
127
|
+
time: a start under it joins the run in flight and starts nothing, and once
|
|
128
|
+
that run has ended the next start is a new run. A start under a key is
|
|
129
|
+
retried when it gets no answer; one under no key is asked once.
|
|
130
|
+
`AppOperationStart` and `AppStartedOperation` are its types.
|
|
131
|
+
- **`records.list`** on the app client: the records matching a filter, in an
|
|
132
|
+
order, at most `first` of them, with `hasMore` saying the filter matched more.
|
|
133
|
+
For work an app hands out in turn, such as the next ten people waiting: list
|
|
134
|
+
them, then update the ones listed by id. A model's list is named by its
|
|
135
|
+
plural, which its operator may set, so the first list a client makes reads
|
|
136
|
+
the data API's surface descriptor (`GET /v1/manifest`) as the same token,
|
|
137
|
+
once. `AppRecordList` is its query's type.
|
|
138
|
+
|
|
139
|
+
### Changed
|
|
140
|
+
|
|
141
|
+
- **Breaking: `OperationTrigger.type` gains `'webhook'`**, the trigger of a run
|
|
142
|
+
a delivery started with `operations.start`. A `switch` over the four earlier
|
|
143
|
+
values that TypeScript checked as exhaustive needs a case for it.
|
|
144
|
+
|
|
111
145
|
## [0.12.0] — 2026-10-01
|
|
112
146
|
|
|
113
147
|
Breaking changes under the pre-1.0 note, so this release is a MINOR. Published
|
package/dist/app/client.d.ts
CHANGED
|
@@ -260,6 +260,25 @@ export interface AppClient {
|
|
|
260
260
|
} | {
|
|
261
261
|
id: string;
|
|
262
262
|
}, fields: readonly string[]): Promise<AppRecord | null>;
|
|
263
|
+
/**
|
|
264
|
+
* The records matching a filter, in an order, at most `first` of them. For
|
|
265
|
+
* work handed out in turn, such as the next ten people waiting: list them,
|
|
266
|
+
* then update the ones listed by id.
|
|
267
|
+
*
|
|
268
|
+
* `where` is the model's typed filter and `orderBy` names model fields, as
|
|
269
|
+
* `updateMany` takes them. The platform orders by `createdAt`, `updatedAt`,
|
|
270
|
+
* `naturalKey` and `id`, and by a field the model indexes; ordering by any
|
|
271
|
+
* other is refused, naming it. `hasMore` says the filter matched more than
|
|
272
|
+
* were answered. Like `read`, it sees the PUBLISHED version.
|
|
273
|
+
*
|
|
274
|
+
* The name of a model's list is the operator's to set, so the first list a
|
|
275
|
+
* client makes asks the data API what this token is served (`GET
|
|
276
|
+
* /v1/manifest`), once.
|
|
277
|
+
*/
|
|
278
|
+
list(model: string, query: AppRecordList): Promise<{
|
|
279
|
+
records: AppRecord[];
|
|
280
|
+
hasMore: boolean;
|
|
281
|
+
}>;
|
|
263
282
|
/** Delete one record by its platform id, which only a read supplies. */
|
|
264
283
|
delete(model: string, id: string): Promise<{
|
|
265
284
|
deleted: boolean;
|
|
@@ -365,6 +384,61 @@ export interface AppClient {
|
|
|
365
384
|
stale: boolean;
|
|
366
385
|
}>;
|
|
367
386
|
};
|
|
387
|
+
operations: {
|
|
388
|
+
/**
|
|
389
|
+
* Start a run of one of this app's own operations, from a webhook
|
|
390
|
+
* delivery. The run is queued and dispatched as a hook's is, and this
|
|
391
|
+
* answers with its id without waiting for it.
|
|
392
|
+
*
|
|
393
|
+
* For work a delivery cannot do in place. A delivery is not a run, so it
|
|
394
|
+
* holds none of the authority the platform gives only inside one (setting
|
|
395
|
+
* a customer's consent is one), and it has the few seconds its vendor
|
|
396
|
+
* waits. Hand the work to an operation and answer the vendor.
|
|
397
|
+
*
|
|
398
|
+
* Only the token a delivery arrived with may call it, for an operation
|
|
399
|
+
* that is active at the install. An operation's own token is refused: it
|
|
400
|
+
* carries that operation's capabilities and no other's.
|
|
401
|
+
*/
|
|
402
|
+
start(operationKey: string, options?: AppOperationStart): Promise<AppStartedOperation>;
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
export interface AppRecordList {
|
|
406
|
+
/** The model's typed filter, by model field key. Absent matches every record. */
|
|
407
|
+
where?: JsonObject;
|
|
408
|
+
/** The order, most significant first. The record id breaks every tie. */
|
|
409
|
+
orderBy?: ReadonlyArray<{
|
|
410
|
+
field: string;
|
|
411
|
+
direction?: 'asc' | 'desc';
|
|
412
|
+
}>;
|
|
413
|
+
/** How many to answer, at most. */
|
|
414
|
+
first: number;
|
|
415
|
+
/** The model fields to read on each record. Its id, natural key and owner always come back. */
|
|
416
|
+
fields?: readonly string[];
|
|
417
|
+
}
|
|
418
|
+
export interface AppOperationStart {
|
|
419
|
+
/** The run's input. */
|
|
420
|
+
input?: JsonObject;
|
|
421
|
+
/**
|
|
422
|
+
* Names one delivery, so the vendor's redelivery of it does not start the
|
|
423
|
+
* work twice: pass the delivery's own id.
|
|
424
|
+
*/
|
|
425
|
+
idempotencyKey?: string;
|
|
426
|
+
/**
|
|
427
|
+
* Names work of which one run should be in flight at a time. While a run of
|
|
428
|
+
* the operation started under the key is in flight, a start under it joins
|
|
429
|
+
* that run and starts nothing; once that run has ended, the next start is a
|
|
430
|
+
* new run. For work that recurs and must not overlap, where an idempotency
|
|
431
|
+
* key would adopt the first run for ever.
|
|
432
|
+
*/
|
|
433
|
+
concurrencyKey?: string;
|
|
434
|
+
}
|
|
435
|
+
export interface AppStartedOperation {
|
|
436
|
+
/** The run: the one this call started, joined or adopted. */
|
|
437
|
+
executionId: string;
|
|
438
|
+
/** True when `concurrencyKey` matched a run in flight, so nothing was started. */
|
|
439
|
+
joined: boolean;
|
|
440
|
+
/** True when `idempotencyKey` matched a run in flight or completed, so nothing was started. */
|
|
441
|
+
deduplicated: boolean;
|
|
368
442
|
}
|
|
369
443
|
/** Build the client an installed app calls Foir with. See the module doc. */
|
|
370
444
|
export declare function createAppClient(config: AppClientConfig): AppClient;
|
package/dist/app/index.d.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* The manifest's own messages are on `@foir/sdk/app/contract`.
|
|
22
22
|
*/
|
|
23
23
|
export { createAppClient } from './client';
|
|
24
|
-
export type { AppBinding, AppClient, AppClientConfig, AppConsentChange, AppCustomerEraseResult, AppCustomerSaveResult, AppFieldMapping, AppInstallConfig, AppNotification, AppRecord, AppStatusReport, AppSyncDirection, AppUpsertResult, AppWhoami, } from './client';
|
|
24
|
+
export type { AppBinding, AppClient, AppClientConfig, AppConsentChange, AppCustomerEraseResult, AppCustomerSaveResult, AppFieldMapping, AppInstallConfig, AppNotification, AppOperationStart, AppRecord, AppRecordList, AppStartedOperation, AppStatusReport, AppSyncDirection, AppUpsertResult, AppWhoami, } from './client';
|
|
25
25
|
export { appCustomerUnlinkedOf, appLookupConflictOf, isAppFieldWithheld } from './errors';
|
|
26
26
|
export type { AppLookupConflict } from './errors';
|
|
27
27
|
export { appModelFieldName, appModelTypeName, appSelectorEnumValue } from './naming';
|
package/dist/app.js
CHANGED
|
@@ -337,6 +337,83 @@ function createAppClient(config) {
|
|
|
337
337
|
const refuse = (message) => {
|
|
338
338
|
throw new FoirRequestError(`[foir] ${message}`);
|
|
339
339
|
};
|
|
340
|
+
let surface;
|
|
341
|
+
function served() {
|
|
342
|
+
surface ??= withFoirRetry(async () => {
|
|
343
|
+
const token = (await config.dispatch.token())?.trim();
|
|
344
|
+
if (!token) {
|
|
345
|
+
throw new FoirRequestError(
|
|
346
|
+
"[foir] the dispatch token provider returned no token. An app client calls as the token it was built from."
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
let res;
|
|
350
|
+
try {
|
|
351
|
+
res = await doFetch(foirApiEndpoint(config.apiUrl, "/v1/manifest"), {
|
|
352
|
+
...config.requestInit,
|
|
353
|
+
method: "GET",
|
|
354
|
+
headers: buildFoirHeaders({ dispatchToken: token, dispatchContext: config.dispatch.context ?? void 0 })
|
|
355
|
+
});
|
|
356
|
+
} catch (err) {
|
|
357
|
+
throw new FoirRequestError("[foir] network error", { cause: err });
|
|
358
|
+
}
|
|
359
|
+
if (!res.ok) {
|
|
360
|
+
throw new FoirRequestError(`[foir] GET /v1/manifest failed: HTTP ${res.status} ${res.statusText}`, { status: res.status });
|
|
361
|
+
}
|
|
362
|
+
return await res.json();
|
|
363
|
+
}, config.retry).catch((error) => {
|
|
364
|
+
surface = void 0;
|
|
365
|
+
throw error;
|
|
366
|
+
});
|
|
367
|
+
return surface;
|
|
368
|
+
}
|
|
369
|
+
async function listMany(model, query) {
|
|
370
|
+
if (!Number.isInteger(query.first) || query.first < 1) refuse(`a list of ${model} asked for ${query.first} records; it takes a whole number of at least 1`);
|
|
371
|
+
const op = (await served()).models?.[model]?.ops?.["findMany"];
|
|
372
|
+
const name = op?.field;
|
|
373
|
+
if (!name) {
|
|
374
|
+
return refuse(
|
|
375
|
+
`this token is not served a list of ${model}: the operation it was minted for declares no read of that model`
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
const type = appModelTypeName(model);
|
|
379
|
+
const fields = query.fields ?? [];
|
|
380
|
+
const unselectable = fields.filter((key) => !isSelectableFieldKey(key));
|
|
381
|
+
if (unselectable.length > 0) refuse(`a list of ${model} asked for fields that cannot be selected: ${unselectable.join(", ")}`);
|
|
382
|
+
const orderBy = (query.orderBy ?? []).map((entry) => {
|
|
383
|
+
if (!isSelectableFieldKey(entry.field)) refuse(`a list of ${model} was ordered by "${entry.field}", which is not a field name`);
|
|
384
|
+
return { [entry.field]: entry.direction === "desc" ? "DESC" : "ASC" };
|
|
385
|
+
});
|
|
386
|
+
const answer = await field(
|
|
387
|
+
name,
|
|
388
|
+
`query AppListRecords($where: ${op?.argTypes?.["where"] ?? `${type}WhereInput`}, $orderBy: ${op?.argTypes?.["orderBy"] ?? `[${type}OrderByInput]`}, $first: Int) { ${name}(where: $where, orderBy: $orderBy, first: $first) { edges { node { ${[RECORD_ID_FIELD, RECORD_NATURAL_KEY_FIELD, RECORD_OWNER_FIELD, ...fields].join(" ")} } } pageInfo { hasNextPage } } }`,
|
|
389
|
+
{
|
|
390
|
+
first: query.first,
|
|
391
|
+
...query.where && Object.keys(query.where).length > 0 ? { where: query.where } : {},
|
|
392
|
+
...orderBy.length > 0 ? { orderBy } : {}
|
|
393
|
+
}
|
|
394
|
+
);
|
|
395
|
+
if (!answer || !Array.isArray(answer.edges)) return refuse(`${name} reported no error and returned no list`);
|
|
396
|
+
const records = answer.edges.map((edge) => {
|
|
397
|
+
const node = edge?.node;
|
|
398
|
+
const id = node?.[RECORD_ID_FIELD];
|
|
399
|
+
if (!node || typeof id !== "string" || id === "") return refuse(`${name} answered a record carrying no id`);
|
|
400
|
+
const values = {};
|
|
401
|
+
for (const key2 of fields) {
|
|
402
|
+
const got = node[key2];
|
|
403
|
+
if (got !== void 0) values[key2] = got;
|
|
404
|
+
}
|
|
405
|
+
const key = node[RECORD_NATURAL_KEY_FIELD];
|
|
406
|
+
const owner = node[RECORD_OWNER_FIELD];
|
|
407
|
+
return {
|
|
408
|
+
id,
|
|
409
|
+
naturalKey: typeof key === "string" && key !== "" ? key : null,
|
|
410
|
+
ownerId: typeof owner === "string" && owner !== "" ? owner : null,
|
|
411
|
+
fields: values,
|
|
412
|
+
unreadable: []
|
|
413
|
+
};
|
|
414
|
+
});
|
|
415
|
+
return { records, hasMore: answer.pageInfo?.hasNextPage === true };
|
|
416
|
+
}
|
|
340
417
|
async function readOne(model, by, fields) {
|
|
341
418
|
const byId = "id" in by;
|
|
342
419
|
const value = byId ? by.id : by.naturalKey;
|
|
@@ -512,6 +589,7 @@ function createAppClient(config) {
|
|
|
512
589
|
};
|
|
513
590
|
},
|
|
514
591
|
read: readOne,
|
|
592
|
+
list: listMany,
|
|
515
593
|
async delete(model, id) {
|
|
516
594
|
if (id === "") refuse(`a record delete on ${model} was given an empty id`);
|
|
517
595
|
const name = `delete${appModelTypeName(model)}`;
|
|
@@ -705,6 +783,36 @@ function createAppClient(config) {
|
|
|
705
783
|
if (!answer) return refuse("setCustomerConsent reported no error and answered nothing");
|
|
706
784
|
return { stale: answer.stale === true };
|
|
707
785
|
}
|
|
786
|
+
},
|
|
787
|
+
operations: {
|
|
788
|
+
async start(operationKey, options = {}) {
|
|
789
|
+
const key = operationKey.trim();
|
|
790
|
+
if (key === "") refuse("an operation was started with no key");
|
|
791
|
+
const params = ["$operationKey: String!"];
|
|
792
|
+
const args = ["operationKey: $operationKey"];
|
|
793
|
+
const variables = { operationKey: key };
|
|
794
|
+
if (options.input !== void 0) {
|
|
795
|
+
params.push("$input: JSON");
|
|
796
|
+
args.push("input: $input");
|
|
797
|
+
variables.input = options.input;
|
|
798
|
+
}
|
|
799
|
+
if (options.idempotencyKey !== void 0) {
|
|
800
|
+
params.push("$idempotencyKey: String");
|
|
801
|
+
args.push("idempotencyKey: $idempotencyKey");
|
|
802
|
+
variables.idempotencyKey = options.idempotencyKey;
|
|
803
|
+
}
|
|
804
|
+
if (options.concurrencyKey !== void 0) {
|
|
805
|
+
params.push("$concurrencyKey: String");
|
|
806
|
+
args.push("concurrencyKey: $concurrencyKey");
|
|
807
|
+
variables.concurrencyKey = options.concurrencyKey;
|
|
808
|
+
}
|
|
809
|
+
const document = `mutation AppStartOperation(${params.join(", ")}) { startOperation(${args.join(", ")}) { executionId joined deduplicated } }`;
|
|
810
|
+
const keyed = Boolean(options.idempotencyKey || options.concurrencyKey);
|
|
811
|
+
const data = keyed ? await run(document, variables) : await send(document, variables);
|
|
812
|
+
const answer = data["startOperation"];
|
|
813
|
+
if (!answer?.executionId) return refuse("startOperation reported no error and named no run");
|
|
814
|
+
return { executionId: answer.executionId, joined: answer.joined === true, deduplicated: answer.deduplicated === true };
|
|
815
|
+
}
|
|
708
816
|
}
|
|
709
817
|
};
|
|
710
818
|
}
|
|
@@ -127,9 +127,11 @@ export interface OperationCallback {
|
|
|
127
127
|
* - `lifecycle`: a hook on a platform event (a record published, an operation
|
|
128
128
|
* completed).
|
|
129
129
|
* - `schedule`: a cron schedule firing.
|
|
130
|
+
* - `webhook`: an app's webhook delivery handing its work to this operation
|
|
131
|
+
* (`operations.start` on the app client).
|
|
130
132
|
*/
|
|
131
133
|
export interface OperationTrigger {
|
|
132
|
-
type: 'ui' | 'api' | 'lifecycle' | 'schedule';
|
|
134
|
+
type: 'ui' | 'api' | 'lifecycle' | 'schedule' | 'webhook';
|
|
133
135
|
}
|
|
134
136
|
/**
|
|
135
137
|
* The record block a caller attached to a run, forwarded as the caller wrote
|