@sanoma-ai/workflows 0.4.0 → 0.4.2

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/src/plugin.ts CHANGED
@@ -1,15 +1,67 @@
1
1
  import { readDataFile } from "./datafile.ts";
2
2
 
3
- // An oxlint JS plugin with one rule, `sanoma/data-file`: the data-file subset `readResources`
4
- // reads, the same check as `lintResources`, on the tree oxlint has already parsed. The
5
- // package's `oxlint.json` turns it on for `resources/**`. Typed here by the little it uses, so
6
- // the package needs no oxlint types.
3
+ // An oxlint JS plugin with two rules, on the trees oxlint has already parsed, which the package's
4
+ // `oxlint.json` turns on: `sanoma/data-file` for `resources/**`, the data-file subset
5
+ // `readResources` reads, the same check as `lintResources`; and `sanoma/date` for `workflows/**`,
6
+ // `Date` reading the clock. Typed here by the little they use, so the package needs no oxlint types.
7
7
 
8
8
  interface Context {
9
9
  filename: string;
10
+ sourceCode: { isGlobalReference(node: Node): boolean };
10
11
  report(diagnostic: { message: string; node: { range: [number, number] } }): void;
11
12
  }
12
13
 
14
+ /** A node of oxlint's ESTree, by the little the `date` rule reads. */
15
+ interface Node {
16
+ type: string;
17
+ range: [number, number];
18
+ parent?: Node;
19
+ name?: string;
20
+ callee?: Node;
21
+ object?: Node;
22
+ property?: Node;
23
+ computed?: boolean;
24
+ arguments?: Node[];
25
+ operator?: string;
26
+ right?: Node;
27
+ }
28
+
29
+ const DATE_MESSAGE =
30
+ "Workflows are replayed, so a workflow reads the clock with `await ctx.now()`, never `Date`: `new Date(await ctx.now())` for a date, `new Date(value)`, `Date.parse` or `Date.UTC` for one it has, and `ctx.sleep({ until })` to wait for a date.";
31
+
32
+ /** Where `Date` names the type, which the build erases: `d: Date`, `typeof Date`, `implements Date`. */
33
+ const TYPE_PARENTS = new Set([
34
+ "TSTypeReference",
35
+ "TSTypeQuery",
36
+ "TSQualifiedName",
37
+ "TSClassImplements",
38
+ "TSInterfaceHeritage",
39
+ ]);
40
+
41
+ /** `Date`'s functions that read no clock. */
42
+ const PURE = new Set(["parse", "UTC"]);
43
+
44
+ /**
45
+ * Whether `Date` (`node`) reads no clock where it is: `new Date(value)`, `Date.parse(…)`,
46
+ * `Date.UTC(…)`, `x instanceof Date`, or a type. A node without its parent is reported.
47
+ */
48
+ function readsNoClock(node: Node): boolean {
49
+ const { parent } = node;
50
+ if (!parent) return false;
51
+ if (TYPE_PARENTS.has(parent.type)) return true;
52
+ if (parent.type === "BinaryExpression") return parent.operator === "instanceof" && parent.right === node;
53
+ if (parent.type === "NewExpression" && parent.callee === node) {
54
+ const [first] = parent.arguments ?? [];
55
+ return first !== undefined && first.type !== "SpreadElement";
56
+ }
57
+ return (
58
+ parent.type === "MemberExpression" &&
59
+ parent.object === node &&
60
+ !parent.computed &&
61
+ PURE.has(parent.property?.name ?? "")
62
+ );
63
+ }
64
+
13
65
  const plugin = {
14
66
  meta: { name: "sanoma" },
15
67
  rules: {
@@ -31,6 +83,23 @@ const plugin = {
31
83
  };
32
84
  },
33
85
  },
86
+ // `Date` reads the clock in `Date.now()`, `new Date()` and `Date()`, which a replay would read
87
+ // again; a workflow reads it with `ctx.now()`, recorded. Every other use of `Date` is pure:
88
+ // `new Date(value)` (of `await ctx.now()`, or of a date the workflow has), `Date.parse`, `Date.UTC`.
89
+ date: {
90
+ meta: {
91
+ type: "problem",
92
+ docs: { description: "A workflow reads the clock with `await ctx.now()`, never `Date`" },
93
+ },
94
+ create(context: Context) {
95
+ return {
96
+ Identifier(node: Node) {
97
+ if (node.name !== "Date" || readsNoClock(node)) return;
98
+ if (context.sourceCode.isGlobalReference(node)) context.report({ message: DATE_MESSAGE, node });
99
+ },
100
+ };
101
+ },
102
+ },
34
103
  },
35
104
  };
36
105
 
package/src/scenario.ts CHANGED
@@ -29,10 +29,11 @@ import {
29
29
  import { errorCode, errorInfo, SanomaError } from "./errors.ts";
30
30
  import { type CallRecord, contains, type CoreExpectation, detailOf, isCall } from "./expectations.ts";
31
31
  import type { Fake } from "./fake.ts";
32
- import { fill, isBug, seedFrom, shapeOf } from "./fill.ts";
32
+ import { fill, isBug, objectUnder, restOf, seedFrom, shapeOf } from "./fill.ts";
33
33
  import type { LedgerRecord } from "./ledger.ts";
34
34
  import type { Driver, Op } from "./op.ts";
35
35
  import { type OutlineNode, outlineWorkflow } from "./outline.ts";
36
+ import { isObject } from "./resource.ts";
36
37
  import { admit, type DeclaredPeople, readPeople } from "./resources.ts";
37
38
  import { memorySecrets } from "./secret.ts";
38
39
  import {
@@ -229,22 +230,50 @@ const FAULTS = {
229
230
  "loses its reply once": "loseReply",
230
231
  } as const;
231
232
 
232
- /** The schema of the field `name` of an object schema (`owner`, for the error); throws naming the fields it has. */
233
+ /**
234
+ * The schema of the field `name` of an object schema (`owner`, for the error), or of the fields it
235
+ * takes besides its own (`restOf`); throws naming the fields it has.
236
+ */
233
237
  function fieldOf(schema: z.ZodType, name: string, owner: string): z.ZodType {
234
238
  const shape = shapeOf(schema);
235
- if (!Object.hasOwn(shape, name)) {
236
- throw new Error(`no field "${name}" in ${owner}; it has ${Object.keys(shape).join(", ") || "none"}`);
237
- }
238
- return shape[name]!;
239
+ if (Object.hasOwn(shape, name)) return shape[name]!;
240
+ const rest = restOf(schema);
241
+ if (rest) return rest;
242
+ throw new Error(`no field "${name}" in ${owner}; it has ${Object.keys(shape).join(", ") || "none"}`);
239
243
  }
240
244
 
241
245
  const issues = (error: z.ZodError) => error.issues.map((i) => i.message).join("; ");
242
246
 
247
+ /**
248
+ * Why `field` refuses `value`, or undefined when it takes it. `partial`, for what a `Then` expects
249
+ * at least: an object may leave fields out at any depth, and only the fields it has are checked.
250
+ */
251
+ function refusal(field: z.ZodType, value: unknown, what: string, partial: boolean): string | undefined {
252
+ const object = partial && isObject(value) ? objectUnder(field) : undefined;
253
+ if (object) return fieldsRefusal(object, value as Record<string, unknown>, what, true);
254
+ const parsed = field.safeParse(value);
255
+ return parsed.success ? undefined : `${what} cannot be ${JSON.stringify(value)}: ${issues(parsed.error)}`;
256
+ }
257
+
258
+ /** Why the object schema refuses the first of `fields` it refuses (`refusal`), `owner` its owner in errors. */
259
+ function fieldsRefusal(
260
+ schema: z.ZodType,
261
+ fields: Record<string, unknown>,
262
+ owner: string,
263
+ partial: boolean,
264
+ ): string | undefined {
265
+ for (const [name, v] of Object.entries(fields)) {
266
+ const why = refusal(fieldOf(schema, name, owner), v, `${name} in ${owner}`, partial);
267
+ if (why) return why;
268
+ }
269
+ return undefined;
270
+ }
271
+
243
272
  /**
244
273
  * The value text from a table cell or a phrase gives a field: the text itself when the field
245
274
  * takes it, else the text read as JSON (a number, `true`, a list) when the field takes that.
246
275
  */
247
- function valueFor(field: z.ZodType, text: string, what: string): unknown {
276
+ function valueFor(field: z.ZodType, text: string, what: string, partial = false): unknown {
248
277
  const asText = field.safeParse(text);
249
278
  if (asText.success) return text;
250
279
  let json: unknown;
@@ -253,17 +282,18 @@ function valueFor(field: z.ZodType, text: string, what: string): unknown {
253
282
  } catch {
254
283
  throw new Error(`${what} cannot be ${JSON.stringify(text)}: ${issues(asText.error)}`);
255
284
  }
256
- const asJson = field.safeParse(json);
257
- if (!asJson.success) throw new Error(`${what} cannot be ${text}: ${issues(asJson.error)}`);
285
+ const why = refusal(field, json, what, partial);
286
+ if (why) throw new Error(why);
258
287
  return json;
259
288
  }
260
289
 
261
290
  /**
262
291
  * The fields the step's doc string (a JSON object) or two-column table gives `owner` (an
263
292
  * operation's input, or a resource), each checked against its field in `schema`: a field it
264
- * lacks, or a value it refuses, is an error.
293
+ * lacks, or a value it refuses, is an error. `partial` (a `Then`'s "at least these fields"):
294
+ * an object value may leave fields out, at any depth.
265
295
  */
266
- function fieldsFrom(pickle: PickleStep, schema: z.ZodType, owner: string): Record<string, unknown> {
296
+ function fieldsFrom(pickle: PickleStep, schema: z.ZodType, owner: string, partial = false): Record<string, unknown> {
267
297
  const { docString, dataTable } = pickle.argument ?? {};
268
298
  const what = (name: string) => `${name} in ${owner}`;
269
299
  if (dataTable) {
@@ -271,7 +301,7 @@ function fieldsFrom(pickle: PickleStep, schema: z.ZodType, owner: string): Recor
271
301
  dataTable.rows.map(({ cells }) => {
272
302
  if (cells.length !== 2) throw new Error(`the table after "${pickle.text}" needs two columns: name | value`);
273
303
  const [name, text] = cells.map((c) => c.value) as [string, string];
274
- return [name, valueFor(fieldOf(schema, name, owner), text, what(name))];
304
+ return [name, valueFor(fieldOf(schema, name, owner), text, what(name), partial)];
275
305
  }),
276
306
  );
277
307
  }
@@ -282,14 +312,10 @@ function fieldsFrom(pickle: PickleStep, schema: z.ZodType, owner: string): Recor
282
312
  } catch (err) {
283
313
  throw new Error(`the doc string after "${pickle.text}" is not JSON: ${errorMessage(err)}`, { cause: err });
284
314
  }
285
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
286
- throw new Error(`"${pickle.text}" needs a JSON object`);
287
- }
288
- for (const [name, v] of Object.entries(value)) {
289
- const parsed = fieldOf(schema, name, owner).safeParse(v);
290
- if (!parsed.success) throw new Error(`${what(name)} cannot be ${JSON.stringify(v)}: ${issues(parsed.error)}`);
291
- }
292
- return value as Record<string, unknown>;
315
+ if (!isObject(value)) throw new Error(`"${pickle.text}" needs a JSON object`);
316
+ const why = fieldsRefusal(schema, value, owner, partial);
317
+ if (why) throw new Error(why);
318
+ return value;
293
319
  }
294
320
 
295
321
  /** A parameter's transform: the name, when `ids` has it. */
@@ -504,7 +530,7 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
504
530
  source: "{op} was called with",
505
531
  takes: TAKES_OBJECT,
506
532
  read: ([op], pickle) =>
507
- expecting({ op: op as string, input: fieldsFrom(pickle, opOf(op).input, inputOf(op)), called: true }),
533
+ expecting({ op: op as string, input: fieldsFrom(pickle, opOf(op).input, inputOf(op), true), called: true }),
508
534
  },
509
535
  ...[true, false].map((called): Rule => ({
510
536
  kind: "then",
@@ -545,7 +571,11 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
545
571
  // An unnamed call with a computed segment is named by the operation it calls.
546
572
  const op = scope.ops.get(node.id) ?? (node.name === undefined ? scope.ops.get(name as string) : undefined);
547
573
  if (!op) throw new Error(`${step} calls ${node.id}, whose input cannot be read: name its operation in full`);
548
- return expecting({ name: name as string, input: fieldsFrom(pickle, op.input, inputOf(op.id)), called: true });
574
+ return expecting({
575
+ name: name as string,
576
+ input: fieldsFrom(pickle, op.input, inputOf(op.id), true),
577
+ called: true,
578
+ });
549
579
  },
550
580
  },
551
581
  ...[true, false].map((called): Rule => ({
@@ -603,7 +633,7 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
603
633
  const last = lastCall(records);
604
634
  if (typeof last === "string") return last;
605
635
  if (last.error) return `it failed: ${last.error.code ?? last.error.name}: ${last.error.message}`;
606
- const expected = fieldsFrom(pickle, opOf(last.op).output, `${last.op}'s output`);
636
+ const expected = fieldsFrom(pickle, opOf(last.op).output, `${last.op}'s output`, true);
607
637
  return contains(last.output, expected) ? undefined : `it returned ${JSON.stringify(last.output)}`;
608
638
  },
609
639
  }),
@@ -707,9 +737,13 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
707
737
  transformer: (...g) => field(g),
708
738
  });
709
739
  }
740
+ // An `expect` phrase, like `was called with`, may give some of an object's fields.
710
741
  const given = (values: unknown[]) =>
711
742
  Object.fromEntries(
712
- fields.map((name, i) => [name, valueFor(schemas[i]!, values[i] as string, `${name} in ${inputOf(op.id)}`)]),
743
+ fields.map((name, i) => [
744
+ name,
745
+ valueFor(schemas[i]!, values[i] as string, `${name} in ${inputOf(op.id)}`, kind === "then"),
746
+ ]),
713
747
  );
714
748
  const common = { source: template, group: op.id, expression: template.replaceAll(/\{([^}]*)\}/g, "{field_$1}") };
715
749
  rules.push(
@@ -0,0 +1,53 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { z } from "zod";
5
+ import type { VendorInfo } from "./op.ts";
6
+ import { upFrom } from "./paths.ts";
7
+
8
+ /** A logo file's path in the package: relative, never out of it (`..`), an SVG. */
9
+ const LogoFile = z.string().regex(/^(?!\/)(?!.*\.\.)[\w./-]+\.svg$/, "a relative path to an .svg file in the package");
10
+
11
+ /**
12
+ * The `sanoma` field of a connector's package.json: the vendor's name and its logo files, beside
13
+ * package.json. The app reads the same field from npm to list connectors not installed.
14
+ */
15
+ export const SanomaPackageField = z.object({
16
+ title: z.string().min(1),
17
+ logo: LogoFile.optional(),
18
+ logoDark: LogoFile.optional(),
19
+ });
20
+
21
+ /**
22
+ * A connector's `VendorInfo`, read from its own package.json, the one place it is written: the
23
+ * package's `name` and `homepage`, and the `sanoma` field's `title` and logo files, such as
24
+ * `"sanoma": { "title": "Ghost", "logo": "logo.svg", "logoDark": "logo-dark.svg" }`. The app
25
+ * lists connectors not installed from the same field on npm. `moduleUrl` is any module of the
26
+ * package, whose nearest package.json is read: `vendorInfo(import.meta.url)`.
27
+ */
28
+ export function vendorInfo(moduleUrl: string | URL): VendorInfo {
29
+ const from = dirname(fileURLToPath(moduleUrl));
30
+ const root = upFrom(from, (dir) => (existsSync(join(dir, "package.json")) ? dir : undefined)) ?? from;
31
+ const read = (path: string) => readFileSync(join(root, path), "utf8").trim();
32
+ const pkg = JSON.parse(read("package.json")) as { name: string; homepage?: string; sanoma?: unknown };
33
+ const field = SanomaPackageField.safeParse(pkg.sanoma);
34
+ if (!field.success) {
35
+ throw new Error(
36
+ `${pkg.name}: package.json needs a "sanoma" field with a title, and logos as .svg paths in the package: ${z.prettifyError(field.error)}`,
37
+ );
38
+ }
39
+ const { title, logo, logoDark } = field.data;
40
+ const svg = (path: string) => {
41
+ try {
42
+ return read(path);
43
+ } catch {
44
+ throw new Error(`${pkg.name}: the "sanoma" field's logo ${path} is not in the package`);
45
+ }
46
+ };
47
+ return {
48
+ title,
49
+ ...(logo && { logo: { svg: svg(logo), ...(logoDark && { dark: svg(logoDark) }) } }),
50
+ package: pkg.name,
51
+ ...(pkg.homepage && { homepage: pkg.homepage }),
52
+ };
53
+ }
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { jsonSchemaOf, opIdsOf, type WorkflowDefinition } from "./define.ts";
6
6
  import { upFrom } from "./paths.ts";
7
7
 
8
8
  /** This package's version. A unit test keeps it equal to package.json. */
9
- export const RUNTIME_VERSION = "0.4.0";
9
+ export const RUNTIME_VERSION = "0.4.2";
10
10
 
11
11
  /**
12
12
  * The sequence of DBOS calls the runtime makes around each `ctx` call: policy step, approval