@plitzi/sdk-server 0.35.5 → 0.35.7

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
@@ -1,5 +1,43 @@
1
1
  # @plitzi/sdk-server
2
2
 
3
+ ## 0.35.7
4
+
5
+ ### Patch Changes
6
+
7
+ - An SMTP failure says which credential it was, and a credential's values can be replaced from the builder.
8
+
9
+ - **`email.send` names the credential it could not send through:** `Could not send through the SMTP credential
10
+ "ceniza-smtp": its SMTP host is on a private network…`. The host used to be in the sentence, and a run's trace
11
+ redacts every value of a credential it resolved, so the message read `The SMTP host "«redacted»"…`.
12
+ - **Credentials can be edited in the builder.** A pencil on each row opens the form with the name and the provider
13
+ fixed and nothing it holds shown: every value is entered again, and saving replaces all of them. SMTP credentials
14
+ are also labelled in the list.
15
+
16
+ - Updated dependencies
17
+ - @plitzi/plitzi-sdk@0.35.7
18
+ - @plitzi/sdk-shared@0.35.7
19
+
20
+ ## 0.35.6
21
+
22
+ ### Patch Changes
23
+
24
+ - A failed server action can give back what it already did.
25
+
26
+ - **`flow.onFailure` ("On Failure") marks where the undo begins.** A run that reaches it has succeeded and ends there.
27
+ A run whose step failed jumps to it and runs the steps after it, in order, each still asking its own `when` — the
28
+ failure may have come before the thing to undo was ever done — with `{{ failure.step }}` and `{{ failure.message }}`
29
+ in scope. The run still ends failed, with the failure it had; an undo step that fails too is added to it.
30
+ - **The undo runs on its own clock and budget:** 5 seconds (or the run's own timeout if shorter) and its own outbound
31
+ request budget, because the run's may be exactly what ran out, and a caller closing the connection does not stop it.
32
+ A run that hit its deadline is undone too.
33
+ - **`defineAction` writes it from `onFailure: [...]`**, after the answer. `validateActionDocument` refuses an output
34
+ step or a second handler after one, warns about a handler with nothing to undo or nothing that undoes, and reserves
35
+ `failure` as a step id. `FAILURE_HANDLER_TASK` is exported from `@plitzi/sdk-shared/actions`.
36
+
37
+ - Updated dependencies
38
+ - @plitzi/plitzi-sdk@0.35.6
39
+ - @plitzi/sdk-shared@0.35.6
40
+
3
41
  ## 0.35.5
4
42
 
5
43
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  import { triggerParams } from "./triggers.js";
2
- import { hasValidToken } from "@plitzi/sdk-shared/helpers/twigWrapper";
3
2
  import { parseCron, readSmtpCredential, triggerHasStaleVerify, triggerVerify } from "@plitzi/sdk-shared/actions";
3
+ import { hasValidToken } from "@plitzi/sdk-shared/helpers/twigWrapper";
4
4
  //#region src/modules/actions/runtime/check.ts
5
5
  /** A value the check can reason about: a literal the author typed, not a template resolved at run time. */
6
6
  var literal = (value) => {
@@ -63,11 +63,11 @@ var createEmailSender = ({ kv, dailyLimitPerSpace = 200, allowPrivateHosts = fal
63
63
  try {
64
64
  addresses = await lookup(smtp.host);
65
65
  } catch {
66
- throw new Error(`The SMTP host "${smtp.host}" does not resolve`);
66
+ throw new Error("its SMTP host does not resolve");
67
67
  }
68
68
  const [first] = addresses;
69
- if (addresses.length === 0) throw new Error(`The SMTP host "${smtp.host}" does not resolve`);
70
- if (!allowPrivateHosts && (isBlockedHost(smtp.host) || addresses.some(({ address }) => isBlockedHost(address)))) throw new Error(`The SMTP host "${smtp.host}" is on a private network, which this server does not connect to`);
69
+ if (addresses.length === 0) throw new Error("its SMTP host does not resolve");
70
+ if (!allowPrivateHosts && (isBlockedHost(smtp.host) || addresses.some(({ address }) => isBlockedHost(address)))) throw new Error("its SMTP host is on a private network, which this server does not connect to");
71
71
  return (delivery) => deliver({
72
72
  address: first.address,
73
73
  smtp: delivery.smtp,
@@ -8,6 +8,7 @@ import { createEmailSender } from "./email.js";
8
8
  import { createRedactor, projectUser } from "./scope.js";
9
9
  import { precheckRun } from "./precheck.js";
10
10
  import onAbort from "../../../helpers/onAbort.js";
11
+ import { FAILURE_HANDLER_TASK } from "@plitzi/sdk-shared/actions";
11
12
  import { evaluateRuleGroup } from "@plitzi/sdk-shared/helpers/ruleEvaluator";
12
13
  import { hasValidToken, processTwig } from "@plitzi/sdk-shared/helpers/twigWrapper";
13
14
  //#region src/modules/actions/runtime/runAction.ts
@@ -16,6 +17,14 @@ var MAX_TWIG_RESOLUTION_PASSES = 5;
16
17
  * boundaries a flow actually has, and it keeps a long run to sixty reads a minute. */
17
18
  var CANCEL_POLL_MS = 1e3;
18
19
  /**
20
+ * The most a failed run's undo may take, on a clock of its own.
21
+ *
22
+ * Its own because the run's clock may be exactly what ran out: a run that hit its deadline has seats to give back
23
+ * too. Short because the caller is still waiting for the failure, and giving back what a flow took is a handful of
24
+ * writes, not another run. Never longer than the run itself was allowed.
25
+ */
26
+ var UNDO_TIMEOUT_MS = 5e3;
27
+ /**
19
28
  * The run's deadline, as a promise that loses patience.
20
29
  *
21
30
  * Aborting the controller is the polite half: a task that watches its signal stops, and the loop refuses to start
@@ -167,6 +176,21 @@ var errorNode = (action) => ({
167
176
  flowId: "",
168
177
  enabled: true
169
178
  });
179
+ /**
180
+ * Where a chain's undo begins, if it has one: the first `flow.onFailure` its steps reach.
181
+ *
182
+ * Per trigger, because each way in walks its own chain. Walked with a visited set: the validator refuses a chain that
183
+ * comes back on itself, but a stored document is still customer input, and a lookup must not be what spins.
184
+ */
185
+ var failureHandlerOf = (nodes, trigger) => {
186
+ const visited = /* @__PURE__ */ new Set();
187
+ let next = nodes[trigger.afterNode];
188
+ while (next && !visited.has(next.id)) {
189
+ if (next.action === FAILURE_HANDLER_TASK) return next;
190
+ visited.add(next.id);
191
+ next = nodes[next.afterNode];
192
+ }
193
+ };
170
194
  var withDefaults = (task, params) => Object.entries(task.params).reduce((acum, [key, param]) => {
171
195
  if (acum[key] === void 0 || acum[key] === "") acum[key] = param.defaultValue ?? "";
172
196
  return acum;
@@ -229,15 +253,21 @@ var createActionRunner = (config, registry, baseFetch = fetch) => {
229
253
  const deadline = createDeadline(request.emit ? limits.streamTimeoutMs : limits.timeoutMs, controller);
230
254
  const releaseOuter = onAbort(request.signal, () => controller.abort());
231
255
  const scopedKv = namespaceKv(kv, request.spaceId);
232
- const runFetch = createRunFetch(baseFetch, controller.signal, limits, [...request.lineage ?? [], entry.id]);
233
- const buildContext = (scope) => ({
256
+ const lineage = [...request.lineage ?? [], entry.id];
257
+ /**
258
+ * What a step runs with, for one abort signal and one outbound budget.
259
+ *
260
+ * Two of them per run at most: the flow's, and — only when it failed — the undo's, which must still be able to
261
+ * reach the outside world after the flow's signal was aborted or its request budget spent.
262
+ */
263
+ const contextFor = (signal, runFetch) => (scope) => ({
234
264
  runId,
235
265
  spaceId: request.spaceId,
236
266
  environment: request.environment,
237
267
  trigger: request.trigger,
238
268
  user: request.user,
239
269
  callerId: request.callerId,
240
- signal: controller.signal,
270
+ signal,
241
271
  scope,
242
272
  /**
243
273
  * The secret a STEP asked for, resolved inside that step and never in the flow scope.
@@ -268,6 +298,7 @@ var createActionRunner = (config, registry, baseFetch = fetch) => {
268
298
  email: emailSender,
269
299
  emit: (chunk) => request.emit?.(redact(chunk))
270
300
  });
301
+ const buildContext = contextFor(controller.signal, createRunFetch(baseFetch, controller.signal, limits, lineage));
271
302
  const trace = [];
272
303
  const startedAt = Date.now();
273
304
  let failure;
@@ -304,11 +335,15 @@ var createActionRunner = (config, registry, baseFetch = fetch) => {
304
335
  * hits its deadline answers, releases its slot and closes its connection while whatever step ignored the abort
305
336
  * carries on in the background — which is the difference between a stuck STEP and a stuck server.
306
337
  */
338
+ const failureHandler = failureHandlerOf(document.nodes, triggerNode);
339
+ /** The step the flow was running when it stopped — what `{{ failure.step }}` names to the undo. */
340
+ let stepInFlight = "";
307
341
  const runFlow = async () => {
308
342
  let current = triggerNode;
309
343
  let executed = 0;
310
344
  let next = document.nodes[current.afterNode];
311
345
  while (next) {
346
+ if (next.action === FAILURE_HANDLER_TASK) break;
312
347
  if (controller.signal.aborted) {
313
348
  status = "aborted";
314
349
  break;
@@ -321,6 +356,7 @@ var createActionRunner = (config, registry, baseFetch = fetch) => {
321
356
  executed += 1;
322
357
  if (executed > limits.maxNodes) throw new ActionRunError("over_capacity", `Action exceeded its ${limits.maxNodes} step budget`);
323
358
  const startTime = Date.now();
359
+ stepInFlight = next.id;
324
360
  const outcome = await runNode(next, scope, registry, buildContext);
325
361
  trace.push({
326
362
  node: next,
@@ -337,6 +373,68 @@ var createActionRunner = (config, registry, baseFetch = fetch) => {
337
373
  next = document.nodes[current.afterNode];
338
374
  }
339
375
  };
376
+ /**
377
+ * Gives back what a run that did not finish already did: the steps after `flow.onFailure`.
378
+ *
379
+ * On a clock and an outbound budget of its own, because the run's may be exactly what ran out, and deliberately
380
+ * NOT tied to the caller's connection — somebody closing the tab is the last reason to leave seats taken. Each step
381
+ * still asks its own `when`: the failure may have come before the thing to undo was ever done. Nothing it does
382
+ * changes the run's status or its answer; an undo that fails too is added to the failure, never put in its place.
383
+ */
384
+ const undo = async (handler) => {
385
+ const undoController = new AbortController();
386
+ const undoDeadline = createDeadline(Math.min(limits.timeoutMs, UNDO_TIMEOUT_MS), undoController);
387
+ const buildUndoContext = contextFor(undoController.signal, createRunFetch(baseFetch, undoController.signal, limits, lineage));
388
+ scope.failure = {
389
+ step: stepInFlight,
390
+ message: failure ?? "",
391
+ status
392
+ };
393
+ trace.push({
394
+ node: handler,
395
+ status: "success",
396
+ result: redact(scope.failure),
397
+ postCallbacks: [],
398
+ startTime: Date.now(),
399
+ endTime: Date.now()
400
+ });
401
+ const runUndo = async () => {
402
+ let executed = 0;
403
+ let next = document.nodes[handler.afterNode];
404
+ while (next) {
405
+ executed += 1;
406
+ if (executed > limits.maxNodes) throw new ActionRunError("over_capacity", `Undoing the action exceeded its ${limits.maxNodes} step budget`);
407
+ const startTime = Date.now();
408
+ const outcome = await runNode(next, scope, registry, buildUndoContext);
409
+ trace.push({
410
+ node: next,
411
+ status: outcome.status,
412
+ result: redact(outcome.result),
413
+ postCallbacks: [],
414
+ startTime,
415
+ endTime: Date.now()
416
+ });
417
+ scope[next.id] = outcome.result;
418
+ next = document.nodes[next.afterNode];
419
+ }
420
+ };
421
+ try {
422
+ await Promise.race([runUndo(), undoDeadline.promise]);
423
+ } catch (error) {
424
+ const message = error instanceof Error ? error.message : String(error);
425
+ trace.push({
426
+ node: errorNode("undo"),
427
+ status: "failed",
428
+ result: redact({ error: message }),
429
+ postCallbacks: [],
430
+ startTime: Date.now(),
431
+ endTime: Date.now()
432
+ });
433
+ failure = `${failure ?? "Action failed"} — and undoing it failed too: ${message}`;
434
+ } finally {
435
+ undoDeadline.clear();
436
+ }
437
+ };
340
438
  /** Set when the run ends in a way the CALLER has to hear as a status code rather than as a result. */
341
439
  let fatal;
342
440
  try {
@@ -380,6 +478,7 @@ var createActionRunner = (config, registry, baseFetch = fetch) => {
380
478
  deadline.clear();
381
479
  releaseOuter();
382
480
  }
481
+ if (status !== "completed" && failureHandler?.enabled) await undo(failureHandler);
383
482
  const output = returned ?? {};
384
483
  await record({
385
484
  runId,
@@ -54,12 +54,17 @@ var emailTasks = [{
54
54
  if (!stored) throw new Error(`This space has no SMTP credential called "${identifier}" — add it in Credentials`);
55
55
  const reading = readSmtpCredential(stored);
56
56
  if (!("settings" in reading)) throw new Error(`Credential "${identifier}" is not a usable SMTP server: ${reading.problems.map((problem) => `${problem.key} — ${problem.message}`).join("; ")}`);
57
- await ctx.email.send(ctx.spaceId, reading.settings, {
58
- to: recipient,
59
- subject: title,
60
- text,
61
- ...answerTo === "" ? {} : { replyTo: answerTo }
62
- }, ctx.signal);
57
+ try {
58
+ await ctx.email.send(ctx.spaceId, reading.settings, {
59
+ to: recipient,
60
+ subject: title,
61
+ text,
62
+ ...answerTo === "" ? {} : { replyTo: answerTo }
63
+ }, ctx.signal);
64
+ } catch (error) {
65
+ const reason = error instanceof Error ? error.message : String(error);
66
+ throw new Error(`Could not send through the SMTP credential "${identifier}": ${reason}`, { cause: error });
67
+ }
63
68
  return { sent: true };
64
69
  }
65
70
  }];
@@ -46,6 +46,24 @@ var fail = {
46
46
  }
47
47
  };
48
48
  /**
49
+ * Where a flow's undo begins — read by the runner, never run as a step.
50
+ *
51
+ * A run that reaches it has succeeded and ends there. A run whose step failed before it jumps here instead and runs
52
+ * the steps after it, each still asking its own `when` — the failure may have come before the thing to undo was ever
53
+ * done — and still ends as failed, with the failure it had. What failed is in their scope as `{{ failure.step }}` and
54
+ * `{{ failure.message }}`.
55
+ */
56
+ var onFailure = {
57
+ namespace: "flow",
58
+ action: "onFailure",
59
+ title: "On Failure",
60
+ description: "The steps after this one run only when a step before it fails, to undo what the flow already did",
61
+ params: {},
62
+ run: () => {
63
+ throw new Error("On Failure marks where a failed run’s undo begins; the runner never runs it as a step");
64
+ }
65
+ };
66
+ /**
49
67
  * Declares what the caller gets back — and IS the declaration.
50
68
  *
51
69
  * The flow scope is not the answer: it holds every node's raw result, including whatever a fetch happened to
@@ -102,7 +120,8 @@ var emit = {
102
120
  var flowTasks = [
103
121
  delay,
104
122
  fail,
105
- output
123
+ output,
124
+ onFailure
106
125
  ];
107
126
  var streamTasks = [emit];
108
127
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plitzi/sdk-server",
3
- "version": "0.35.5",
3
+ "version": "0.35.7",
4
4
  "license": "AGPL-3.0",
5
5
  "files": [
6
6
  "dist",
@@ -58,8 +58,8 @@
58
58
  "type": "module",
59
59
  "sideEffects": false,
60
60
  "dependencies": {
61
- "@plitzi/plitzi-sdk": "0.35.5",
62
- "@plitzi/sdk-shared": "0.35.5",
61
+ "@plitzi/plitzi-sdk": "0.35.7",
62
+ "@plitzi/sdk-shared": "0.35.7",
63
63
  "ejs": "^6.0.1",
64
64
  "esbuild": "^0.28.2",
65
65
  "jsonwebtoken": "^9.0.3",