@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/README.md +30 -28
- package/dist/define.d.ts +4 -1
- package/dist/define.js.map +1 -1
- package/dist/describe.d.ts +2 -0
- package/dist/describe.js +2 -1
- package/dist/describe.js.map +1 -1
- package/dist/fill.d.ts +4 -0
- package/dist/fill.js +13 -0
- package/dist/fill.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/op.d.ts +2 -0
- package/dist/op.js +4 -2
- package/dist/op.js.map +1 -1
- package/dist/plugin.d.ts +28 -0
- package/dist/plugin.js +51 -0
- package/dist/plugin.js.map +1 -1
- package/dist/scenario.js +56 -24
- package/dist/scenario.js.map +1 -1
- package/dist/vendor-info.d.ts +19 -0
- package/dist/vendor-info.js +49 -0
- package/dist/vendor-info.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oxlint.json +2 -4
- package/package.json +1 -1
- package/src/define.ts +4 -1
- package/src/describe.ts +2 -1
- package/src/fill.ts +15 -0
- package/src/index.ts +2 -0
- package/src/op.ts +5 -2
- package/src/plugin.ts +73 -4
- package/src/scenario.ts +58 -24
- package/src/vendor-info.ts +53 -0
- package/src/version.ts +1 -1
package/src/plugin.ts
CHANGED
|
@@ -1,15 +1,67 @@
|
|
|
1
1
|
import { readDataFile } from "./datafile.ts";
|
|
2
2
|
|
|
3
|
-
// An oxlint JS plugin with
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
/**
|
|
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 (
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
257
|
-
if (
|
|
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 (
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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({
|
|
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) => [
|
|
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.
|
|
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
|