initiative-plugin-sdk 7.2.0 → 8.0.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/dist/testing.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * `initiative-plugin-sdk/testing`: a declarative plug-in's requests and mappings,
3
- * run against recorded vendor answers, with no server and no network.
2
+ * `initiative-plugin-sdk/testing`: a declarative plug-in's requests, mappings and
3
+ * writes, run against recorded vendor answers, with no server and no network.
4
4
  *
5
5
  * Each runner renders the requests the way Initiative makes them (method,
6
6
  * address with its query, headers, body), answers them from `responses` in
@@ -16,15 +16,28 @@
16
16
  * to retry;
17
17
  * - `{ refused }`: an `after_connect` that refused the connection.
18
18
  *
19
+ * Every runner but {@link runExchangeBody}, whose expression reads only the
20
+ * connection's fields, takes the install's `settings`, each setting's value by
21
+ * key, and `aliases`, each unique setting's earlier values by key, which every
22
+ * other expression reads; both default to none.
23
+ *
24
+ * An action's run answers the `change` its `write` makes to the plug-in's values
25
+ * on the item, and a schedule's or a delivery's the targeted `writes`, each held
26
+ * to the contract's `change` or `targetedChange`, to one operation on each key,
27
+ * and to the plug-in's fields: a list field's whole value is a list of
28
+ * elements, and only a list's elements are upserted or removed. A `write`
29
+ * answering nothing changes nothing.
30
+ *
19
31
  * A rendered request carries no credential: Initiative adds the one its
20
32
  * `connection` names, as the plug-in's `auth` says. A predicate (`when`, `more`,
21
33
  * `refuse_when`) holds when JSONata's `$boolean` of its answer is true.
22
34
  *
23
35
  * A run fails, rather than answering, on a mistake in the test or the plug-in: a
24
36
  * request with no recorded response left for it, a recorded response nothing
25
- * asked for, or a mapped answer that does not fit the declared returns.
37
+ * asked for, a mapped answer that does not fit the declared returns, or a
38
+ * `write` answering what is not a change.
26
39
  */
27
- import type { ConnectionState } from "./contract.js";
40
+ import type { ActorKind, Change, ConnectionState, ItemKind, TargetedChange } from "./contract.js";
28
41
  import type { AnyPlugin } from "./define.js";
29
42
  export { evaluate, ExpressionError, parseExpression } from "./expression.js";
30
43
  /** One answer the vendor gave, as a test records it. Status 200 and no headers when left out. */
@@ -39,7 +52,7 @@ export interface RenderedRequest {
39
52
  url: string;
40
53
  headers: Record<string, string>;
41
54
  body?: unknown;
42
- /** The connection whose credential Initiative would add. */
55
+ /** The connection whose credential Initiative would add, for the call's actor when the request names one per actor. */
43
56
  connection?: string;
44
57
  }
45
58
  /** What a run made and what it answered. */
@@ -55,59 +68,157 @@ export type Run<T> = {
55
68
  } | {
56
69
  refused: string;
57
70
  });
71
+ /** A declarative action's run: what it sent, and what its endpoint and its `write` answered. */
72
+ export type ActionRun = {
73
+ requests: RenderedRequest[];
74
+ } & ({
75
+ /** The endpoint's mapped answer, when the action names one. */
76
+ result?: Record<string, unknown>;
77
+ /** The change its `write` answered, when it has one that answered something. */
78
+ change?: Change;
79
+ } | {
80
+ unavailable: string;
81
+ detail?: string;
82
+ } | {
83
+ transient: true;
84
+ });
85
+ /** A declarative schedule's run: what it sent, the endpoint's mapped answer, and the changes its `write` answered. */
86
+ export type ScheduleRun = {
87
+ requests: RenderedRequest[];
88
+ } & ({
89
+ result: Record<string, unknown>;
90
+ writes: TargetedChange[];
91
+ } | {
92
+ unavailable: string;
93
+ detail?: string;
94
+ } | {
95
+ transient: true;
96
+ });
58
97
  export interface Recorded {
59
98
  /** The vendor's answers, in the order the requests are made. */
60
99
  responses: RecordedResponse[];
61
100
  /** The time of the call: `now`, and what `$now()` and `$millis()` answer. Default: the present. */
62
101
  now?: Date | string | number;
63
102
  }
103
+ /** The install's settings, as every expression reads them. */
104
+ export interface InstallSettings {
105
+ /** Each setting's value, by key. */
106
+ settings?: Record<string, string>;
107
+ /** Each unique setting's earlier values, by key. */
108
+ aliases?: Record<string, string[]>;
109
+ }
64
110
  /**
65
111
  * One declarative endpoint: its request or steps rendered from `params`, the
66
- * `connections`' non-secret fields (keyed by connection id) and `now`, answered
67
- * from `responses`, and its map held to the declared returns. An expression
68
- * reads `connection`, the fields of the connection its request names, and
69
- * `connections`, the fields of each connection the endpoint's `requires` names
70
- * that `connections` holds, by id. The call has no actor: who may run it, and
71
- * on whose credential, is Initiative's to decide.
112
+ * `connections`' non-secret fields (keyed by connection id), the install's
113
+ * `settings` and `aliases`, and `now`, answered from `responses`, and its map
114
+ * held to the declared returns. The call is made as `actor`, by default the
115
+ * endpoint's first: a request naming a connection per actor carries the one it
116
+ * names for that actor, and the run throws a TypeError when it names none. An
117
+ * expression reads `connection`, the fields of the connection its request
118
+ * carries, and `connections`, the fields of each connection the endpoint's
119
+ * `requires` names that `connections` holds, by id. Who may make the call is
120
+ * Initiative's to decide.
72
121
  */
73
- export declare function runEndpoint(plugin: AnyPlugin, name: string, call: Recorded & {
122
+ export declare function runEndpoint(plugin: AnyPlugin, name: string, call: Recorded & InstallSettings & {
74
123
  params?: Record<string, unknown>;
75
124
  connections?: Record<string, Record<string, unknown>>;
125
+ /** Who the call is made as. Default: the endpoint's first actor. */
126
+ actor?: ActorKind;
76
127
  }): Promise<Run<Record<string, unknown>>>;
128
+ /**
129
+ * A declarative action, run on one item with the `params` the reader answered,
130
+ * from one of a list field's elements when `element` names its `ref`. Its
131
+ * endpoint, when it names one, is sent those params, made as `actor` (by
132
+ * default the endpoint's first), and rendered and answered as
133
+ * {@link runEndpoint} does it, its expressions also reading `item` and
134
+ * `element`; an answer of unavailable or transient is the run's, and nothing is
135
+ * written. Its `write`, when it has one, then answers the change Initiative
136
+ * makes to the plug-in's values on the item, reading `item`, `held` (those
137
+ * values as they stand, by key), `result` (the endpoint's mapped answer, absent
138
+ * with no endpoint), `params`, `element`, `settings`, `aliases` and `now`. Like
139
+ * the endpoint's, an expression that fails answers `mapping-failed`.
140
+ */
141
+ export declare function runAction(plugin: AnyPlugin, name: string, call: Omit<Recorded, "responses"> & InstallSettings & {
142
+ /** The vendor's answers to the endpoint's calls; none when the action names no endpoint. */
143
+ responses?: RecordedResponse[];
144
+ item: {
145
+ kind: ItemKind;
146
+ id: number;
147
+ title: string;
148
+ };
149
+ held?: Record<string, unknown>;
150
+ /** What the reader answered, for the params the action asks for. */
151
+ params?: Record<string, unknown>;
152
+ /** The `ref` of the list element it was run from. */
153
+ element?: string;
154
+ connections?: Record<string, Record<string, unknown>>;
155
+ /** Who its endpoint's call is made as. Default: the endpoint's first actor. */
156
+ actor?: ActorKind;
157
+ }): Promise<ActionRun>;
158
+ /**
159
+ * A declarative schedule's run in one community, made as the installation. Its
160
+ * `params` answer the endpoint's params from `since`, `now`, `connection` (the
161
+ * fields of the connection the endpoint's first request names), `settings` and
162
+ * `aliases`; the endpoint is rendered and answered with them as
163
+ * {@link runEndpoint} does it; and its `write` answers, from the endpoint's
164
+ * mapped answer as `result` beside `since`, `now`, `settings` and `aliases`,
165
+ * the changes Initiative makes as the install. `since` is when the schedule
166
+ * last succeeded there, as ISO 8601: null, the default, the first time.
167
+ */
168
+ export declare function runSchedule(plugin: AnyPlugin, name: string, call: Recorded & InstallSettings & {
169
+ since?: string | null;
170
+ connections?: Record<string, Record<string, unknown>>;
171
+ }): Promise<ScheduleRun>;
77
172
  /**
78
173
  * A declarative `after_connect`: its request or steps made with the flow's
79
174
  * `params`, mapped, and refused when it says. The map and `refuse_when` read
80
175
  * the last answer as `response` and, with steps, each one's as `steps.<name>`.
81
176
  */
82
- export declare function runAfterConnect(plugin: AnyPlugin, connection: string, call: Recorded & {
177
+ export declare function runAfterConnect(plugin: AnyPlugin, connection: string, call: Recorded & InstallSettings & {
83
178
  params?: Record<string, string>;
84
179
  }): Promise<Run<{
85
180
  values?: Record<string, unknown>;
86
181
  account_label?: string;
87
182
  }>>;
88
183
  /** A connection's health check: its request made with the connection's non-secret `fields`, and the state it reads. */
89
- export declare function runHealth(plugin: AnyPlugin, connection: string, call: Recorded & {
184
+ export declare function runHealth(plugin: AnyPlugin, connection: string, call: Recorded & InstallSettings & {
90
185
  fields?: Record<string, unknown>;
91
186
  }): Promise<Run<ConnectionState>>;
92
- /** What one delivery emits, and the connection state it sets. */
187
+ /**
188
+ * The body Initiative posts with a connection's signed JWT, from its token's
189
+ * `exchange_body`: the object with each `{vendor.<key>}` in its strings filled
190
+ * from `vendor` and each `{<key>}` from the connection's non-secret `fields`,
191
+ * or the expression's answer, which reads those fields as `connection` and
192
+ * nothing else. It throws a TypeError where Initiative fails the token request,
193
+ * on a name with no value or an answer that is not an object, and for a
194
+ * connection with no `exchange_body`; an expression that fails throws
195
+ * {@link ExpressionError}.
196
+ */
197
+ export declare function runExchangeBody(plugin: AnyPlugin, connection: string, call?: {
198
+ fields?: Record<string, unknown>;
199
+ vendor?: Record<string, string>;
200
+ }): Promise<Record<string, unknown>>;
201
+ /** What one delivery emits, the changes it makes, and the connection state it sets. */
93
202
  export interface WebhookRun {
94
203
  event?: {
95
204
  emit: string;
96
205
  payload: Record<string, unknown>;
97
206
  };
207
+ /** The targeted changes the matching `events` row's `write` answered, when it has one. */
208
+ writes?: TargetedChange[];
98
209
  status?: {
99
210
  connection: string;
100
211
  state: ConnectionState;
101
212
  };
102
213
  }
103
214
  /**
104
- * One webhook delivery, as a declarative plug-in maps it: the event the first
105
- * matching `events` row emits (by the emit endpoint's key, its payload held to
106
- * that endpoint's returns), and the state the first matching `status` row
107
- * sets. A delivery nothing matches answers neither. An expression that fails
108
- * throws {@link ExpressionError}.
215
+ * One webhook delivery, as a declarative plug-in maps it: the first matching
216
+ * `events` row's event (by the emit endpoint's key, its payload held to that
217
+ * endpoint's returns) and the changes its `write` answers, and the state the
218
+ * first matching `status` row sets. A delivery nothing matches answers neither.
219
+ * An expression that fails throws {@link ExpressionError}.
109
220
  */
110
- export declare function runWebhook(plugin: AnyPlugin, delivery: {
221
+ export declare function runWebhook(plugin: AnyPlugin, delivery: InstallSettings & {
111
222
  headers?: Record<string, string>;
112
223
  payload: unknown;
113
224
  /** The routed connection's non-secret fields. */
package/dist/testing.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * `initiative-plugin-sdk/testing`: a declarative plug-in's requests and mappings,
3
- * run against recorded vendor answers, with no server and no network.
2
+ * `initiative-plugin-sdk/testing`: a declarative plug-in's requests, mappings and
3
+ * writes, run against recorded vendor answers, with no server and no network.
4
4
  *
5
5
  * Each runner renders the requests the way Initiative makes them (method,
6
6
  * address with its query, headers, body), answers them from `responses` in
@@ -16,16 +16,30 @@
16
16
  * to retry;
17
17
  * - `{ refused }`: an `after_connect` that refused the connection.
18
18
  *
19
+ * Every runner but {@link runExchangeBody}, whose expression reads only the
20
+ * connection's fields, takes the install's `settings`, each setting's value by
21
+ * key, and `aliases`, each unique setting's earlier values by key, which every
22
+ * other expression reads; both default to none.
23
+ *
24
+ * An action's run answers the `change` its `write` makes to the plug-in's values
25
+ * on the item, and a schedule's or a delivery's the targeted `writes`, each held
26
+ * to the contract's `change` or `targetedChange`, to one operation on each key,
27
+ * and to the plug-in's fields: a list field's whole value is a list of
28
+ * elements, and only a list's elements are upserted or removed. A `write`
29
+ * answering nothing changes nothing.
30
+ *
19
31
  * A rendered request carries no credential: Initiative adds the one its
20
32
  * `connection` names, as the plug-in's `auth` says. A predicate (`when`, `more`,
21
33
  * `refuse_when`) holds when JSONata's `$boolean` of its answer is true.
22
34
  *
23
35
  * A run fails, rather than answering, on a mistake in the test or the plug-in: a
24
36
  * request with no recorded response left for it, a recorded response nothing
25
- * asked for, or a mapped answer that does not fit the declared returns.
37
+ * asked for, a mapped answer that does not fit the declared returns, or a
38
+ * `write` answering what is not a change.
26
39
  */
27
- import { PLATFORM_CODES } from "./contract.js";
40
+ import { CAPS, PLATFORM_CODES } from "./contract.js";
28
41
  import { evaluate, ExpressionError } from "./expression.js";
42
+ import { definitionValidator } from "./validate.js";
29
43
  export { evaluate, ExpressionError, parseExpression } from "./expression.js";
30
44
  /** A run's answer, thrown from wherever it is decided. */
31
45
  class Outcome {
@@ -37,17 +51,24 @@ class Outcome {
37
51
  class Context {
38
52
  hosts;
39
53
  rules;
54
+ actor;
40
55
  requests = [];
41
56
  now;
57
+ /** What every expression reads of the install. */
58
+ installed;
42
59
  clock;
43
60
  responses;
44
61
  constructor(hosts, recorded,
45
62
  /** The endpoint's own rules, and whether an answer outside 2xx fails the run. */
46
- rules) {
63
+ rules,
64
+ /** Who the call is made as, when it is made as one of an endpoint's actors. */
65
+ actor) {
47
66
  this.hosts = hosts;
48
67
  this.rules = rules;
68
+ this.actor = actor;
49
69
  this.clock = recorded.now ?? new Date();
50
70
  this.now = new Date(this.clock).toISOString();
71
+ this.installed = { settings: recorded.settings ?? {}, aliases: recorded.aliases ?? {} };
51
72
  this.responses = [...recorded.responses];
52
73
  }
53
74
  async value(text, document, where) {
@@ -60,6 +81,17 @@ class Context {
60
81
  throw new Outcome({ unavailable: "mapping-failed", detail: `${where}: ${error.message}` });
61
82
  }
62
83
  }
84
+ /** The connection a request names, for the call's actor when it names one per actor. */
85
+ connection(request, where) {
86
+ const named = request.connection;
87
+ if (typeof named !== "object")
88
+ return named;
89
+ const id = this.actor === undefined ? undefined : named[this.actor];
90
+ if (id === undefined) {
91
+ throw new TypeError(`${where} names no connection for ${this.actor === undefined ? "a call with no actor" : `the ${this.actor}`}`);
92
+ }
93
+ return id;
94
+ }
63
95
  async holds(text, document, where) {
64
96
  return (await evaluate("$boolean($)", await this.value(text, document, where))) === true;
65
97
  }
@@ -169,12 +201,13 @@ async function render(request, document, context, where, page) {
169
201
  }
170
202
  body = { query: request.graphql.query, ...(variables === undefined ? {} : { variables }) };
171
203
  }
204
+ const connection = context.connection(request, where);
172
205
  return {
173
206
  method: request.method,
174
207
  url: url.toString(),
175
208
  headers,
176
209
  ...(body === undefined ? {} : { body }),
177
- ...(request.connection ? { connection: request.connection } : {}),
210
+ ...(connection ? { connection } : {}),
178
211
  };
179
212
  }
180
213
  /**
@@ -255,48 +288,190 @@ function fits(answer, returns, where) {
255
288
  return answer;
256
289
  }
257
290
  /**
258
- * One declarative endpoint: its request or steps rendered from `params`, the
259
- * `connections`' non-secret fields (keyed by connection id) and `now`, answered
260
- * from `responses`, and its map held to the declared returns. An expression
261
- * reads `connection`, the fields of the connection its request names, and
262
- * `connections`, the fields of each connection the endpoint's `requires` names
263
- * that `connections` holds, by id. The call has no actor: who may run it, and
264
- * on whose credential, is Initiative's to decide.
291
+ * Holds what a `write` answered to the contract's definition `name`, `change`
292
+ * or `targetedChange`, to one operation on each key, and to the plug-in's
293
+ * fields: a list field's whole value is a list of elements, and only a list's
294
+ * elements are upserted or removed. Each value, and each element, is held to
295
+ * the cap on one value.
265
296
  */
266
- export async function runEndpoint(plugin, name, call) {
297
+ function fitsChange(plugin, answer, name, where) {
298
+ const check = definitionValidator(name);
299
+ if (!check(answer)) {
300
+ const why = (check.errors ?? []).map((error) => `${error.instancePath || "/"} ${error.message}`).join("; ");
301
+ throw new Error(`${where} answered ${text(answer)}, which is not a ${name === "change" ? "change" : "targeted change"}: ${why}`);
302
+ }
303
+ const { set = {}, upsert = {}, remove = {} } = answer;
304
+ const keys = [...Object.keys(set), ...Object.keys(upsert), ...Object.keys(remove)];
305
+ const twice = keys.find((key, index) => keys.indexOf(key) !== index);
306
+ if (twice !== undefined)
307
+ throw new Error(`${where} changes '${twice}' twice: a change sets, upserts or removes a key`);
308
+ const list = (key) => plugin.fields?.[key]?.list === true;
309
+ for (const key of [...Object.keys(upsert), ...Object.keys(remove)]) {
310
+ if (!list(key))
311
+ throw new Error(`${where} changes elements of '${key}', which is not a list field`);
312
+ }
313
+ const values = Object.entries(upsert).flatMap(([key, elements]) => elements.map((element) => [key, element]));
314
+ const element = definitionValidator("listElement");
315
+ for (const [key, value] of Object.entries(set)) {
316
+ if (value === null)
317
+ continue;
318
+ if (!list(key))
319
+ values.push([key, value]);
320
+ else if (Array.isArray(value) && value.every((one) => element(one)))
321
+ values.push(...value.map((one) => [key, one]));
322
+ else
323
+ throw new Error(`${where} sets '${key}', a list field, to ${text(value)}, which is not a list of elements`);
324
+ }
325
+ for (const [key, value] of values) {
326
+ if (Buffer.byteLength(JSON.stringify(value), "utf-8") > CAPS.metadataValueBytes) {
327
+ throw new Error(`${where} gives '${key}' a value of more than ${CAPS.metadataValueBytes} bytes as JSON`);
328
+ }
329
+ }
330
+ }
331
+ /** What a `write` answered, as targeted changes: a list of at most `writeTargets`. Nothing is no change. */
332
+ function targeted(plugin, answer, where) {
333
+ if (answer === undefined)
334
+ return [];
335
+ if (!Array.isArray(answer)) {
336
+ throw new Error(`${where} answered ${text(answer)}, which is not a list of targeted changes ` +
337
+ "(a JSONata path matching one item answers it alone: wrap the answer in [ ] to keep a list)");
338
+ }
339
+ if (answer.length > CAPS.writeTargets)
340
+ throw new Error(`${where} answered ${answer.length} changes, more than ${CAPS.writeTargets}`);
341
+ answer.forEach((change, index) => fitsChange(plugin, change, "targetedChange", `${where}/${index}`));
342
+ return answer;
343
+ }
344
+ /** A declarative endpoint of the plug-in, by its key. */
345
+ function declarative(plugin, name) {
267
346
  const endpoint = plugin.endpoints?.[name];
268
347
  if (!endpoint || !("map" in endpoint))
269
348
  throw new TypeError(`'${name}' is not a declarative endpoint of this plug-in`);
270
- const context = new Context(plugin.hosts ?? [], call, endpoint.errors ?? []);
271
- return context.run(async () => {
272
- const given = call.connections ?? {};
273
- const required = [...(endpoint.requires?.all_of ?? []), ...(endpoint.requires?.any_of ?? [])];
274
- const connections = Object.fromEntries(required.filter((id) => Object.hasOwn(given, id)).map((id) => [id, given[id]]));
275
- const base = { params: call.params ?? {}, connections, now: context.now };
276
- const steps = endpoint.steps ?? [{ name: "", request: endpoint.request }];
277
- const answers = {};
278
- let read = base;
279
- for (const [index, step] of steps.entries()) {
280
- read = {
281
- ...base,
282
- connection: call.connections?.[step.request.connection ?? ""] ?? {},
283
- ...(endpoint.steps ? { steps: { ...answers } } : {}),
284
- };
285
- const where = endpoint.steps ? `steps/${index}/request` : "request";
286
- const response = await perform(step.request, read, context, where);
287
- if (endpoint.steps)
288
- answers[step.name] = response;
289
- read = { ...read, ...(endpoint.steps ? { steps: { ...answers } } : {}), response };
349
+ return endpoint;
350
+ }
351
+ /**
352
+ * One declarative endpoint's request or steps, answered in `context`, and its
353
+ * map held to the declared returns: what {@link runEndpoint} runs, and what an
354
+ * action or a schedule runs for the endpoint it names. An action's call also
355
+ * reads its `subject`: the `item`, and the `element` it was run from.
356
+ */
357
+ async function answerEndpoint(endpoint, call, context) {
358
+ const given = call.connections ?? {};
359
+ const required = [...(endpoint.requires?.all_of ?? []), ...(endpoint.requires?.any_of ?? [])];
360
+ const connections = Object.fromEntries(required.filter((id) => Object.hasOwn(given, id)).map((id) => [id, given[id]]));
361
+ const base = { params: call.params ?? {}, connections, now: context.now, ...context.installed, ...call.subject };
362
+ const steps = endpoint.steps ?? [{ name: "", request: endpoint.request }];
363
+ const answers = {};
364
+ let read = base;
365
+ for (const [index, step] of steps.entries()) {
366
+ const where = endpoint.steps ? `steps/${index}/request` : "request";
367
+ read = {
368
+ ...base,
369
+ connection: given[context.connection(step.request, where) ?? ""] ?? {},
370
+ ...(endpoint.steps ? { steps: { ...answers } } : {}),
371
+ };
372
+ const response = await perform(step.request, read, context, where);
373
+ if (endpoint.steps)
374
+ answers[step.name] = response;
375
+ read = { ...read, ...(endpoint.steps ? { steps: { ...answers } } : {}), response };
376
+ }
377
+ const answer = await context.value(endpoint.map, read, "map");
378
+ if (answer && typeof answer === "object" && "unavailable" in answer) {
379
+ const code = answer.unavailable;
380
+ if (typeof code !== "string" || ![...(endpoint.unavailable ?? []), ...PLATFORM_CODES].includes(code)) {
381
+ throw new Error(`map answered unavailable ${text(code)}, which is not one of this endpoint's codes`);
290
382
  }
291
- const answer = await context.value(endpoint.map, read, "map");
292
- if (answer && typeof answer === "object" && "unavailable" in answer) {
293
- const code = answer.unavailable;
294
- if (typeof code !== "string" || ![...(endpoint.unavailable ?? []), ...PLATFORM_CODES].includes(code)) {
295
- throw new Error(`map answered unavailable ${text(code)}, which is not one of this endpoint's codes`);
296
- }
297
- return { unavailable: code };
383
+ return { unavailable: code };
384
+ }
385
+ return { result: fits(answer, endpoint.returns, "map") };
386
+ }
387
+ /**
388
+ * One declarative endpoint: its request or steps rendered from `params`, the
389
+ * `connections`' non-secret fields (keyed by connection id), the install's
390
+ * `settings` and `aliases`, and `now`, answered from `responses`, and its map
391
+ * held to the declared returns. The call is made as `actor`, by default the
392
+ * endpoint's first: a request naming a connection per actor carries the one it
393
+ * names for that actor, and the run throws a TypeError when it names none. An
394
+ * expression reads `connection`, the fields of the connection its request
395
+ * carries, and `connections`, the fields of each connection the endpoint's
396
+ * `requires` names that `connections` holds, by id. Who may make the call is
397
+ * Initiative's to decide.
398
+ */
399
+ export async function runEndpoint(plugin, name, call) {
400
+ const endpoint = declarative(plugin, name);
401
+ const context = new Context(plugin.hosts ?? [], call, endpoint.errors ?? [], call.actor ?? endpoint.actors?.[0]);
402
+ return context.run(() => answerEndpoint(endpoint, call, context));
403
+ }
404
+ /**
405
+ * A declarative action, run on one item with the `params` the reader answered,
406
+ * from one of a list field's elements when `element` names its `ref`. Its
407
+ * endpoint, when it names one, is sent those params, made as `actor` (by
408
+ * default the endpoint's first), and rendered and answered as
409
+ * {@link runEndpoint} does it, its expressions also reading `item` and
410
+ * `element`; an answer of unavailable or transient is the run's, and nothing is
411
+ * written. Its `write`, when it has one, then answers the change Initiative
412
+ * makes to the plug-in's values on the item, reading `item`, `held` (those
413
+ * values as they stand, by key), `result` (the endpoint's mapped answer, absent
414
+ * with no endpoint), `params`, `element`, `settings`, `aliases` and `now`. Like
415
+ * the endpoint's, an expression that fails answers `mapping-failed`.
416
+ */
417
+ export async function runAction(plugin, name, call) {
418
+ const action = plugin.actions?.[name];
419
+ if (!action)
420
+ throw new TypeError(`'${name}' is not an action of this plug-in`);
421
+ if (!action.on.includes(call.item.kind))
422
+ throw new TypeError(`'${name}' is not offered on ${call.item.kind}`);
423
+ const unasked = Object.keys(call.params ?? {}).find((key) => !action.params?.includes(key));
424
+ if (unasked !== undefined)
425
+ throw new TypeError(`'${name}' does not ask for '${unasked}'`);
426
+ if (call.element !== undefined && !Object.values(plugin.fields ?? {}).some((field) => field.actions?.includes(name))) {
427
+ throw new TypeError(`'${name}' is offered on no list's elements`);
428
+ }
429
+ const endpoint = action.endpoint === undefined ? undefined : declarative(plugin, action.endpoint);
430
+ const context = new Context(plugin.hosts ?? [], { ...call, responses: call.responses ?? [] }, endpoint?.errors ?? [], call.actor ?? endpoint?.actors?.[0]);
431
+ const subject = { item: call.item, ...(call.element === undefined ? {} : { element: call.element }) };
432
+ return context.run(async () => {
433
+ const sent = { params: call.params, connections: call.connections, subject };
434
+ const called = endpoint && (await answerEndpoint(endpoint, sent, context));
435
+ if (called && !("result" in called))
436
+ return called;
437
+ const read = { ...subject, held: call.held ?? {}, ...called, params: call.params ?? {}, ...context.installed, now: context.now };
438
+ const change = action.write === undefined ? undefined : await context.value(action.write, read, "write");
439
+ if (change !== undefined)
440
+ fitsChange(plugin, change, "change", "write");
441
+ return { ...called, ...(change === undefined ? {} : { change: change }) };
442
+ });
443
+ }
444
+ /**
445
+ * A declarative schedule's run in one community, made as the installation. Its
446
+ * `params` answer the endpoint's params from `since`, `now`, `connection` (the
447
+ * fields of the connection the endpoint's first request names), `settings` and
448
+ * `aliases`; the endpoint is rendered and answered with them as
449
+ * {@link runEndpoint} does it; and its `write` answers, from the endpoint's
450
+ * mapped answer as `result` beside `since`, `now`, `settings` and `aliases`,
451
+ * the changes Initiative makes as the install. `since` is when the schedule
452
+ * last succeeded there, as ISO 8601: null, the default, the first time.
453
+ */
454
+ export async function runSchedule(plugin, name, call) {
455
+ const schedule = plugin.schedules?.[name];
456
+ if (!schedule || "run" in schedule)
457
+ throw new TypeError(`'${name}' is not a declarative schedule of this plug-in`);
458
+ const endpoint = declarative(plugin, schedule.endpoint);
459
+ const context = new Context(plugin.hosts ?? [], call, endpoint.errors ?? [], "installation");
460
+ return context.run(async () => {
461
+ const since = call.since ?? null;
462
+ const first = endpoint.steps?.[0].request ?? endpoint.request;
463
+ const connection = call.connections?.[context.connection(first, endpoint.steps ? "steps/0/request" : "request") ?? ""] ?? {};
464
+ const read = { since, now: context.now, ...context.installed };
465
+ const params = schedule.params === undefined ? undefined : await context.value(schedule.params, { ...read, connection }, "params");
466
+ if (params !== undefined && (!params || typeof params !== "object" || Array.isArray(params))) {
467
+ throw new Error(`params answered ${text(params)}, which is not an object of the endpoint's params`);
298
468
  }
299
- return { result: fits(answer, endpoint.returns, "map") };
469
+ const sent = { params: params, connections: call.connections };
470
+ const called = await answerEndpoint(endpoint, sent, context);
471
+ if (!("result" in called))
472
+ return called;
473
+ const writes = targeted(plugin, await context.value(schedule.write, { result: called.result, ...read }, "write"), "write");
474
+ return { result: called.result, writes };
300
475
  });
301
476
  }
302
477
  /**
@@ -310,7 +485,7 @@ export async function runAfterConnect(plugin, connection, call) {
310
485
  throw new TypeError(`'${connection}' has no declarative after_connect`);
311
486
  const context = new Context(plugin.hosts ?? [], call, []);
312
487
  return context.run(async () => {
313
- const base = { params: call.params ?? {}, now: context.now };
488
+ const base = { params: call.params ?? {}, now: context.now, ...context.installed };
314
489
  const steps = after.steps ?? [{ name: "", request: after.request }];
315
490
  const answers = {};
316
491
  let read = base;
@@ -339,7 +514,7 @@ export async function runHealth(plugin, connection, call) {
339
514
  throw new TypeError(`'${connection}' declares no health check`);
340
515
  const context = new Context(plugin.hosts ?? [], call, null);
341
516
  return context.run(async () => {
342
- const document = { params: {}, connection: call.fields ?? {}, now: context.now };
517
+ const document = { params: {}, connection: call.fields ?? {}, now: context.now, ...context.installed };
343
518
  const response = await perform(health.request, document, context, "health/request");
344
519
  for (const [index, row] of health.states.entries()) {
345
520
  if (row.status !== undefined && !matches(row.status, response.status))
@@ -352,11 +527,52 @@ export async function runHealth(plugin, connection, call) {
352
527
  });
353
528
  }
354
529
  /**
355
- * One webhook delivery, as a declarative plug-in maps it: the event the first
356
- * matching `events` row emits (by the emit endpoint's key, its payload held to
357
- * that endpoint's returns), and the state the first matching `status` row
358
- * sets. A delivery nothing matches answers neither. An expression that fails
359
- * throws {@link ExpressionError}.
530
+ * The body Initiative posts with a connection's signed JWT, from its token's
531
+ * `exchange_body`: the object with each `{vendor.<key>}` in its strings filled
532
+ * from `vendor` and each `{<key>}` from the connection's non-secret `fields`,
533
+ * or the expression's answer, which reads those fields as `connection` and
534
+ * nothing else. It throws a TypeError where Initiative fails the token request,
535
+ * on a name with no value or an answer that is not an object, and for a
536
+ * connection with no `exchange_body`; an expression that fails throws
537
+ * {@link ExpressionError}.
538
+ */
539
+ export async function runExchangeBody(plugin, connection, call = {}) {
540
+ const body = plugin.connections?.[connection]?.token?.exchange_body;
541
+ if (body === undefined)
542
+ throw new TypeError(`'${connection}' declares no exchange_body`);
543
+ const fields = call.fields ?? {};
544
+ if (typeof body === "string") {
545
+ const answer = await evaluate(body, { connection: fields });
546
+ if (!answer || typeof answer !== "object" || Array.isArray(answer)) {
547
+ throw new TypeError(`exchange_body answered ${text(answer)}, which is not an object`);
548
+ }
549
+ return answer;
550
+ }
551
+ const filled = (value, where) => {
552
+ if (typeof value === "string") {
553
+ return value.replace(/\{([^}]*)\}/g, (_, name) => {
554
+ const [values, key] = name.startsWith("vendor.") ? [call.vendor ?? {}, name.slice("vendor.".length)] : [fields, name];
555
+ const given = Object.hasOwn(values, key) ? values[key] : undefined;
556
+ if (given === undefined || given === null || given === "")
557
+ throw new TypeError(`${where} names '{${name}}', which has no value`);
558
+ return text(given);
559
+ });
560
+ }
561
+ if (Array.isArray(value))
562
+ return value.map((child, index) => filled(child, `${where}/${index}`));
563
+ if (value && typeof value === "object") {
564
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, filled(child, `${where}/${key}`)]));
565
+ }
566
+ return value;
567
+ };
568
+ return filled(body, "exchange_body");
569
+ }
570
+ /**
571
+ * One webhook delivery, as a declarative plug-in maps it: the first matching
572
+ * `events` row's event (by the emit endpoint's key, its payload held to that
573
+ * endpoint's returns) and the changes its `write` answers, and the state the
574
+ * first matching `status` row sets. A delivery nothing matches answers neither.
575
+ * An expression that fails throws {@link ExpressionError}.
360
576
  */
361
577
  export async function runWebhook(plugin, delivery) {
362
578
  const clock = delivery.now ?? new Date();
@@ -365,14 +581,20 @@ export async function runWebhook(plugin, delivery) {
365
581
  payload: delivery.payload,
366
582
  connection: delivery.connection ?? {},
367
583
  now: new Date(clock).toISOString(),
584
+ settings: delivery.settings ?? {},
585
+ aliases: delivery.aliases ?? {},
368
586
  };
369
587
  const holds = async (expression) => (await evaluate("$boolean($)", await evaluate(expression, document, { now: clock }))) === true;
370
588
  const out = {};
371
589
  for (const row of plugin.webhooks?.events ?? []) {
372
590
  if (!(await holds(row.when)))
373
591
  continue;
374
- const payload = await evaluate(row.map, document, { now: clock });
375
- out.event = { emit: row.emit, payload: fits(payload, plugin.endpoints?.[row.emit]?.returns, `the ${row.emit} event`) };
592
+ if (row.emit !== undefined) {
593
+ const payload = await evaluate(row.map, document, { now: clock });
594
+ out.event = { emit: row.emit, payload: fits(payload, plugin.endpoints?.[row.emit]?.returns, `the ${row.emit} event`) };
595
+ }
596
+ if (row.write !== undefined)
597
+ out.writes = targeted(plugin, await evaluate(row.write, document, { now: clock }), "write");
376
598
  break;
377
599
  }
378
600
  for (const row of plugin.webhooks?.status ?? []) {