@sanoma-ai/workflows 0.0.0-stage → 0.2.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/LICENSE +105 -0
- package/README.md +544 -2
- package/dist/apply-steps.d.ts +6 -0
- package/dist/apply-steps.js +93 -0
- package/dist/apply-steps.js.map +1 -0
- package/dist/apply.d.ts +197 -0
- package/dist/apply.js +470 -0
- package/dist/apply.js.map +1 -0
- package/dist/approvals.d.ts +46 -0
- package/dist/approvals.js +111 -0
- package/dist/approvals.js.map +1 -0
- package/dist/ast.d.ts +57 -0
- package/dist/ast.js +81 -0
- package/dist/ast.js.map +1 -0
- package/dist/call.d.ts +37 -0
- package/dist/call.js +417 -0
- package/dist/call.js.map +1 -0
- package/dist/client.d.ts +203 -0
- package/dist/client.js +457 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +126 -0
- package/dist/config.js +264 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +55 -0
- package/dist/credentials.js +261 -0
- package/dist/credentials.js.map +1 -0
- package/dist/cucumber.d.ts +64 -0
- package/dist/cucumber.js +158 -0
- package/dist/cucumber.js.map +1 -0
- package/dist/datafile.d.ts +71 -0
- package/dist/datafile.js +380 -0
- package/dist/datafile.js.map +1 -0
- package/dist/db.d.ts +58 -0
- package/dist/db.js +179 -0
- package/dist/db.js.map +1 -0
- package/dist/define.d.ts +205 -0
- package/dist/define.js +103 -0
- package/dist/define.js.map +1 -0
- package/dist/describe.d.ts +141 -0
- package/dist/describe.js +135 -0
- package/dist/describe.js.map +1 -0
- package/dist/drift.d.ts +127 -0
- package/dist/drift.js +167 -0
- package/dist/drift.js.map +1 -0
- package/dist/env.d.ts +49 -0
- package/dist/env.js +83 -0
- package/dist/env.js.map +1 -0
- package/dist/errors.d.ts +88 -0
- package/dist/errors.js +148 -0
- package/dist/errors.js.map +1 -0
- package/dist/expectations.d.ts +26 -0
- package/dist/expectations.js +162 -0
- package/dist/expectations.js.map +1 -0
- package/dist/fake.d.ts +55 -0
- package/dist/fake.js +87 -0
- package/dist/fake.js.map +1 -0
- package/dist/fill.d.ts +9 -0
- package/dist/fill.js +17 -0
- package/dist/fill.js.map +1 -0
- package/dist/host.d.ts +56 -0
- package/dist/host.js +45 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger.d.ts +217 -0
- package/dist/ledger.js +251 -0
- package/dist/ledger.js.map +1 -0
- package/dist/lint.d.ts +32 -0
- package/dist/lint.js +168 -0
- package/dist/lint.js.map +1 -0
- package/dist/lockfile.d.ts +36 -0
- package/dist/lockfile.js +69 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/log.d.ts +24 -0
- package/dist/log.js +83 -0
- package/dist/log.js.map +1 -0
- package/dist/op.d.ts +260 -0
- package/dist/op.js +188 -0
- package/dist/op.js.map +1 -0
- package/dist/outline.d.ts +179 -0
- package/dist/outline.js +506 -0
- package/dist/outline.js.map +1 -0
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +30 -0
- package/dist/paths.js.map +1 -0
- package/dist/plugin.d.ts +28 -0
- package/dist/plugin.js +25 -0
- package/dist/plugin.js.map +1 -0
- package/dist/policy.d.ts +114 -0
- package/dist/policy.js +60 -0
- package/dist/policy.js.map +1 -0
- package/dist/resource.d.ts +378 -0
- package/dist/resource.js +430 -0
- package/dist/resource.js.map +1 -0
- package/dist/resources.d.ts +77 -0
- package/dist/resources.js +244 -0
- package/dist/resources.js.map +1 -0
- package/dist/run.d.ts +101 -0
- package/dist/run.js +7 -0
- package/dist/run.js.map +1 -0
- package/dist/sandbox.d.ts +33 -0
- package/dist/sandbox.js +127 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/scenario.d.ts +221 -0
- package/dist/scenario.js +1104 -0
- package/dist/scenario.js.map +1 -0
- package/dist/scenarios.d.ts +55 -0
- package/dist/scenarios.js +71 -0
- package/dist/scenarios.js.map +1 -0
- package/dist/secret.d.ts +42 -0
- package/dist/secret.js +58 -0
- package/dist/secret.js.map +1 -0
- package/dist/shared.d.ts +69 -0
- package/dist/shared.js +84 -0
- package/dist/shared.js.map +1 -0
- package/dist/source.d.ts +58 -0
- package/dist/source.js +54 -0
- package/dist/source.js.map +1 -0
- package/dist/steps.d.ts +76 -0
- package/dist/steps.js +23 -0
- package/dist/steps.js.map +1 -0
- package/dist/version.d.ts +29 -0
- package/dist/version.js +76 -0
- package/dist/version.js.map +1 -0
- package/dist/worker.d.ts +37 -0
- package/dist/worker.js +228 -0
- package/dist/worker.js.map +1 -0
- package/dist/writer.d.ts +35 -0
- package/dist/writer.js +201 -0
- package/dist/writer.js.map +1 -0
- package/oxlint.json +128 -0
- package/package.json +96 -3
- package/src/apply-steps.ts +110 -0
- package/src/apply.ts +626 -0
- package/src/approvals.ts +157 -0
- package/src/ast.ts +115 -0
- package/src/call.ts +483 -0
- package/src/client.ts +604 -0
- package/src/config.ts +404 -0
- package/src/credentials.ts +296 -0
- package/src/cucumber.ts +230 -0
- package/src/datafile.ts +481 -0
- package/src/db.ts +197 -0
- package/src/define.ts +285 -0
- package/src/describe.ts +282 -0
- package/src/drift.ts +259 -0
- package/src/env.ts +122 -0
- package/src/errors.ts +202 -0
- package/src/expectations.ts +191 -0
- package/src/fake.ts +156 -0
- package/src/fill.ts +24 -0
- package/src/host.ts +96 -0
- package/src/index.ts +143 -0
- package/src/ledger.ts +419 -0
- package/src/lint.ts +208 -0
- package/src/lockfile.ts +95 -0
- package/src/log.ts +82 -0
- package/src/op.ts +402 -0
- package/src/outline.ts +620 -0
- package/src/paths.ts +32 -0
- package/src/plugin.ts +37 -0
- package/src/policy.ts +139 -0
- package/src/resource.ts +670 -0
- package/src/resources.ts +311 -0
- package/src/run.ts +106 -0
- package/src/sandbox.ts +161 -0
- package/src/scenario.ts +1365 -0
- package/src/scenarios.ts +111 -0
- package/src/secret.ts +92 -0
- package/src/shared.ts +126 -0
- package/src/source.ts +82 -0
- package/src/steps.ts +90 -0
- package/src/version.ts +86 -0
- package/src/worker.ts +264 -0
- package/src/writer.ts +225 -0
package/src/datafile.ts
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { basename, dirname, join } from "node:path";
|
|
2
|
+
import type { Node } from "./ast.ts";
|
|
3
|
+
import { inside, nearestDir, RELATIVE } from "./paths.ts";
|
|
4
|
+
import { type Secret, SECRET_NAME } from "./secret.ts";
|
|
5
|
+
|
|
6
|
+
// The one definition of what a data file may hold, over an ESTree program from oxc: the reader
|
|
7
|
+
// (`resources.ts`), `lintResources` (`lint.ts`) and the oxlint plugin (`plugin.ts`) all
|
|
8
|
+
// read a file through `readDataFile`. Type imports only, so the plugin, which oxlint hands its
|
|
9
|
+
// own tree, never loads oxc-parser.
|
|
10
|
+
|
|
11
|
+
/** Something a data file may not hold, at UTF-16 offsets into its text. */
|
|
12
|
+
export interface DataFileProblem {
|
|
13
|
+
start: number;
|
|
14
|
+
end: number;
|
|
15
|
+
message: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A name in a data file that refers to a declared resource: one imported from a data file, or exported by this one. */
|
|
19
|
+
export class Reference {
|
|
20
|
+
readonly name: string;
|
|
21
|
+
constructor(name: string) {
|
|
22
|
+
this.name = name;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const REFERENCE = Symbol.for("sanoma.reference");
|
|
27
|
+
Object.defineProperty(Reference.prototype, REFERENCE, { value: true });
|
|
28
|
+
|
|
29
|
+
/** True for a `Reference`, made by any copy of this package: read by a registered symbol, not `instanceof`. */
|
|
30
|
+
export const isReference = (value: unknown): value is Reference =>
|
|
31
|
+
typeof value === "object" && value !== null && REFERENCE in value;
|
|
32
|
+
|
|
33
|
+
/** What `secret("NAME")` stands for in a data file: its marker, `{ $secret: "NAME", generate?: true }`. */
|
|
34
|
+
export type SecretRef = Secret;
|
|
35
|
+
|
|
36
|
+
/** A value in a data file: a literal, a reference to a declared resource, or a secret's marker. */
|
|
37
|
+
export type DataValue =
|
|
38
|
+
| null
|
|
39
|
+
| boolean
|
|
40
|
+
| number
|
|
41
|
+
| string
|
|
42
|
+
| Reference
|
|
43
|
+
| SecretRef
|
|
44
|
+
| DataValue[]
|
|
45
|
+
| { [field: string]: DataValue };
|
|
46
|
+
|
|
47
|
+
/** An `import { name } from "…"`: of a connector's constructors (`from "…/resources"`), or of another data file's resource. */
|
|
48
|
+
export interface DataFileImport {
|
|
49
|
+
kind: "constructors" | "file";
|
|
50
|
+
/** The specifier as written. */
|
|
51
|
+
source: string;
|
|
52
|
+
/** The name the other module exports. */
|
|
53
|
+
imported: string;
|
|
54
|
+
/** The name it has in this file. */
|
|
55
|
+
local: string;
|
|
56
|
+
start: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** An `export const name = vendor.type({ … })`. */
|
|
60
|
+
export interface DataFileExport {
|
|
61
|
+
name: string;
|
|
62
|
+
/** The local name of the constructors it calls (`github`). */
|
|
63
|
+
vendor: string;
|
|
64
|
+
type: string;
|
|
65
|
+
desired: { [field: string]: DataValue };
|
|
66
|
+
/** The `export const` statement. */
|
|
67
|
+
start: number;
|
|
68
|
+
end: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface DataFile {
|
|
72
|
+
imports: DataFileImport[];
|
|
73
|
+
/** Each `export const` whose value is in the subset. */
|
|
74
|
+
exports: DataFileExport[];
|
|
75
|
+
/** Every name an `export const` declares, its value in the subset or not. */
|
|
76
|
+
declared: string[];
|
|
77
|
+
problems: DataFileProblem[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A connector's constructors entry: a package specifier that ends in `/resources`. */
|
|
81
|
+
const CONSTRUCTORS = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*\/resources$/;
|
|
82
|
+
|
|
83
|
+
/** Where a data file imports `secret` from; checked before `CONSTRUCTORS`, which they match too. */
|
|
84
|
+
export const SECRET_ENTRIES: ReadonlySet<string> = new Set(["sanoma/resources", "@sanoma-ai/workflows/resources"]);
|
|
85
|
+
const SECRET_CALL = '`secret("NAME")` or `secret("NAME", { generate: true })`, NAME upper case (A-Z, 0-9 and _)';
|
|
86
|
+
|
|
87
|
+
const SHAPE =
|
|
88
|
+
"a data file holds only imports, `export const <name> = <vendor>.<type>({ … })` and `export default [ … ]`";
|
|
89
|
+
const IMPORTS =
|
|
90
|
+
"import resource constructors from a connector's resources entry " +
|
|
91
|
+
'(`import { github } from "@sanoma-ai/connector-github/resources"`) ' +
|
|
92
|
+
'and resources from other data files (`import { site } from "./github.ts"`), and `secret` from "sanoma/resources"';
|
|
93
|
+
const VALUES = "write a string, number, boolean, null, object or array, or name a declared resource";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Reads a data file's program without running it: its imports, each `export const` resource
|
|
97
|
+
* with its fields evaluated, and everything outside the data-file subset as a problem that says
|
|
98
|
+
* what to write instead.
|
|
99
|
+
*
|
|
100
|
+
* The subset: named imports, from a connector's `…/resources` entry or a `.ts` data file;
|
|
101
|
+
* `export const <name> = <vendor>.<type>({ … })`, one per statement, its argument built of
|
|
102
|
+
* strings (template literals without `${}` too), numbers, booleans, null, objects and arrays,
|
|
103
|
+
* and names of resources this file declares or imports; and `export default [a, b]`.
|
|
104
|
+
*
|
|
105
|
+
* `root` is the directory relative imports must stay in: by default the nearest `resources/`
|
|
106
|
+
* directory above `filename`, else the file's own.
|
|
107
|
+
*/
|
|
108
|
+
export function readDataFile(
|
|
109
|
+
program: Node,
|
|
110
|
+
filename: string,
|
|
111
|
+
root: string = nearestDir(filename, ["resources"]),
|
|
112
|
+
): DataFile {
|
|
113
|
+
const problems: DataFileProblem[] = [];
|
|
114
|
+
const fail = (node: { start: number; end: number }, message: string) =>
|
|
115
|
+
problems.push({ start: node.start, end: node.end, message });
|
|
116
|
+
const imports: DataFileImport[] = [];
|
|
117
|
+
const exports: DataFileExport[] = [];
|
|
118
|
+
// Names a refused import or declaration binds: reported once, where they are bound.
|
|
119
|
+
const refused = new Set<string>();
|
|
120
|
+
// The local names of `secret`.
|
|
121
|
+
const secrets = new Set<string>();
|
|
122
|
+
|
|
123
|
+
// Imports and top-level names first, each once: imports are hoisted, and a reference may name
|
|
124
|
+
// a resource declared further down. An unexported const is refused where it is declared, not
|
|
125
|
+
// again where it is used.
|
|
126
|
+
const bound = new Set<string>();
|
|
127
|
+
const bind = (id: Node) => {
|
|
128
|
+
if (bound.has(id.name)) fail(id, `${id.name} is declared twice in this file: give each its own name`);
|
|
129
|
+
bound.add(id.name);
|
|
130
|
+
};
|
|
131
|
+
const declared = new Set<string>();
|
|
132
|
+
for (const s of program.body) {
|
|
133
|
+
if (s.type === "ImportDeclaration") {
|
|
134
|
+
for (const sp of s.specifiers) bind(sp.local);
|
|
135
|
+
readImport(s);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const decl = s.type === "ExportNamedDeclaration" ? s.declaration : s;
|
|
139
|
+
if (decl?.type !== "VariableDeclaration") continue;
|
|
140
|
+
for (const d of decl.declarations) {
|
|
141
|
+
if (d.id.type !== "Identifier") continue;
|
|
142
|
+
bind(d.id);
|
|
143
|
+
(decl === s ? refused : declared).add(d.id.name);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const s of program.body) {
|
|
148
|
+
switch (s.type) {
|
|
149
|
+
case "ImportDeclaration":
|
|
150
|
+
break;
|
|
151
|
+
case "ExportNamedDeclaration":
|
|
152
|
+
readExport(s);
|
|
153
|
+
break;
|
|
154
|
+
case "ExportDefaultDeclaration":
|
|
155
|
+
readDefault(s);
|
|
156
|
+
break;
|
|
157
|
+
case "ExportAllDeclaration":
|
|
158
|
+
fail(s, `export * is not allowed in a data file: ${SHAPE}`);
|
|
159
|
+
break;
|
|
160
|
+
case "VariableDeclaration": {
|
|
161
|
+
const name = s.declarations[0]?.id?.name;
|
|
162
|
+
fail(
|
|
163
|
+
s,
|
|
164
|
+
name === undefined
|
|
165
|
+
? `an unexported declaration is not allowed in a data file: ${SHAPE}`
|
|
166
|
+
: `\`${s.kind} ${name}\` is not allowed in a data file: export it as a resource, \`export const ${name} = <vendor>.<type>({ … })\`, or write its value where it is used`,
|
|
167
|
+
);
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
default:
|
|
171
|
+
fail(s, `${describe(s)} is not allowed in a data file: ${SHAPE}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function readImport(s: Node) {
|
|
176
|
+
const spec: string = s.source.value;
|
|
177
|
+
if (s.importKind === "type") {
|
|
178
|
+
fail(s, "`import type` is not allowed in a data file: it holds values only, and the constructors type them");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const refusal = whyRefused(spec);
|
|
182
|
+
if (refusal) {
|
|
183
|
+
fail(s.source, `import "${spec}" is not allowed in a data file: ${refusal}`);
|
|
184
|
+
for (const sp of s.specifiers) refused.add(sp.local.name);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (!s.specifiers.length) {
|
|
188
|
+
fail(s, `import "${spec}" is not allowed in a data file: it imports nothing, and a data file runs no code`);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (SECRET_ENTRIES.has(spec)) {
|
|
192
|
+
for (const sp of s.specifiers) {
|
|
193
|
+
if (
|
|
194
|
+
sp.type === "ImportSpecifier" &&
|
|
195
|
+
sp.importKind !== "type" &&
|
|
196
|
+
(sp.imported.name ?? sp.imported.value) === "secret"
|
|
197
|
+
) {
|
|
198
|
+
secrets.add(sp.local.name);
|
|
199
|
+
} else {
|
|
200
|
+
fail(sp, `a data file imports only \`secret\` from "${spec}": \`import { secret } from "${spec}"\``);
|
|
201
|
+
refused.add(sp.local.name);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
for (const sp of s.specifiers) {
|
|
207
|
+
if (sp.type !== "ImportSpecifier") {
|
|
208
|
+
const how = sp.type === "ImportNamespaceSpecifier" ? `import * as ${sp.local.name}` : `import ${sp.local.name}`;
|
|
209
|
+
fail(sp, `${how} is not allowed in a data file: import the names you use, \`import { name } from "${spec}"\``);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (sp.importKind === "type") {
|
|
213
|
+
fail(sp, "`type` imports are not allowed in a data file: it holds values only, and the constructors type them");
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const imported: string = sp.imported.name ?? sp.imported.value;
|
|
217
|
+
const kind = RELATIVE.test(spec) ? "file" : "constructors";
|
|
218
|
+
imports.push({ kind, source: spec, imported, local: sp.local.name, start: sp.start });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Why an import's specifier is not allowed in a data file, or undefined when it is. */
|
|
223
|
+
function whyRefused(spec: string): string | undefined {
|
|
224
|
+
if (!RELATIVE.test(spec)) return CONSTRUCTORS.test(spec) ? undefined : IMPORTS;
|
|
225
|
+
if (!spec.endsWith(".ts")) return "name the data file with its `.ts` extension";
|
|
226
|
+
if (!inside(root, join(dirname(filename), spec))) {
|
|
227
|
+
return `it reaches outside ${basename(root)}/; import only other data files`;
|
|
228
|
+
}
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function readExport(s: Node) {
|
|
233
|
+
const decl = s.declaration;
|
|
234
|
+
if (!decl) {
|
|
235
|
+
fail(
|
|
236
|
+
s,
|
|
237
|
+
s.source
|
|
238
|
+
? `export … from "${s.source.value}" is not allowed in a data file: import what you reference, and export only resources declared here`
|
|
239
|
+
: "export { … } is not allowed in a data file: export each resource where it is declared, `export const name = <vendor>.<type>({ … })`",
|
|
240
|
+
);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (decl.type !== "VariableDeclaration") {
|
|
244
|
+
fail(s, `${describe(decl)} is not allowed in a data file: ${SHAPE}`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (decl.kind !== "const") {
|
|
248
|
+
fail(s, `\`export ${decl.kind}\` is not allowed in a data file: use \`export const\``);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (decl.declarations.length !== 1) {
|
|
252
|
+
fail(s, "declare one resource per `export const`");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const [d] = decl.declarations;
|
|
256
|
+
if (d.id.type !== "Identifier") {
|
|
257
|
+
fail(d.id, "an `export const` names one resource: `export const name = <vendor>.<type>({ … })`");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const name: string = d.id.name;
|
|
261
|
+
// A second declaration of the name was reported where it is bound.
|
|
262
|
+
if (exports.some((e) => e.name === name)) return;
|
|
263
|
+
const init = d.init;
|
|
264
|
+
const callee = init?.type === "CallExpression" ? init.callee : undefined;
|
|
265
|
+
if (
|
|
266
|
+
callee?.type !== "MemberExpression" ||
|
|
267
|
+
callee.computed ||
|
|
268
|
+
callee.object.type !== "Identifier" ||
|
|
269
|
+
callee.property.type !== "Identifier"
|
|
270
|
+
) {
|
|
271
|
+
fail(
|
|
272
|
+
init ?? d,
|
|
273
|
+
`export const ${name} must be a resource constructor call, \`<vendor>.<type>({ … })\`, with <vendor> imported from a connector's resources entry`,
|
|
274
|
+
);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const vendor: string = callee.object.name;
|
|
278
|
+
const type: string = callee.property.name;
|
|
279
|
+
if (refused.has(vendor)) return;
|
|
280
|
+
if (imports.find((i) => i.local === vendor)?.kind !== "constructors") {
|
|
281
|
+
fail(
|
|
282
|
+
callee.object,
|
|
283
|
+
`${vendor} is not a connector's resource constructors: import it from the connector's resources entry, \`import { ${vendor} } from "@sanoma-ai/connector-${vendor}/resources"\``,
|
|
284
|
+
);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const [arg] = init.arguments;
|
|
288
|
+
if (init.arguments.length !== 1 || arg.type !== "ObjectExpression") {
|
|
289
|
+
fail(init, `${vendor}.${type} takes one object literal: \`${vendor}.${type}({ … })\``);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const desired = value(arg);
|
|
293
|
+
if (desired !== undefined) {
|
|
294
|
+
exports.push({ name, vendor, type, desired: desired as DataFileExport["desired"], start: s.start, end: s.end });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function readDefault(s: Node) {
|
|
299
|
+
const list = s.declaration;
|
|
300
|
+
if (list.type !== "ArrayExpression") {
|
|
301
|
+
fail(list, "export default must list this file's resources: `export default [a, b]`");
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
for (const el of list.elements) {
|
|
305
|
+
if (el?.type !== "Identifier") {
|
|
306
|
+
fail(el ?? list, "export default must list this file's resources by name: `export default [a, b]`");
|
|
307
|
+
} else if (!declared.has(el.name)) {
|
|
308
|
+
fail(el, `${el.name} is not declared in this file: export default lists this file's resources`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The value an expression stands for, or undefined (with a problem) when the subset has none. */
|
|
314
|
+
function value(node: Node): DataValue | undefined {
|
|
315
|
+
switch (node.type) {
|
|
316
|
+
case "Literal":
|
|
317
|
+
if (node.regex) return refuse(node, "a regular expression is not allowed in a data file: write it as a string");
|
|
318
|
+
if (node.bigint !== undefined && node.bigint !== null)
|
|
319
|
+
return refuse(node, "a bigint is not allowed in a data file: write a number, or a string");
|
|
320
|
+
return node.value as null | boolean | number | string;
|
|
321
|
+
case "TemplateLiteral":
|
|
322
|
+
if (node.expressions.length) {
|
|
323
|
+
return refuse(node, "a template with `${…}` is not allowed in a data file: write the string out");
|
|
324
|
+
}
|
|
325
|
+
return node.quasis[0].value.cooked as string;
|
|
326
|
+
case "UnaryExpression":
|
|
327
|
+
if (node.operator === "-" && node.argument.type === "Literal" && typeof node.argument.value === "number") {
|
|
328
|
+
return -node.argument.value;
|
|
329
|
+
}
|
|
330
|
+
return refuse(node, `\`${node.operator}\` is not allowed in a data file: write the value out`);
|
|
331
|
+
case "ArrayExpression": {
|
|
332
|
+
const items: DataValue[] = [];
|
|
333
|
+
let ok = true;
|
|
334
|
+
for (const el of node.elements) {
|
|
335
|
+
const item =
|
|
336
|
+
el === null
|
|
337
|
+
? refuse(node, "an empty array slot is not allowed in a data file: write null")
|
|
338
|
+
: el.type === "SpreadElement"
|
|
339
|
+
? refuse(el, "a spread is not allowed in a data file: write the items out, so the reader sees each")
|
|
340
|
+
: value(el);
|
|
341
|
+
if (item === undefined) ok = false;
|
|
342
|
+
else items.push(item);
|
|
343
|
+
}
|
|
344
|
+
return ok ? items : undefined;
|
|
345
|
+
}
|
|
346
|
+
case "ObjectExpression": {
|
|
347
|
+
const fields = new Map<string, DataValue>();
|
|
348
|
+
let ok = true;
|
|
349
|
+
for (const p of node.properties) {
|
|
350
|
+
const field = property(p);
|
|
351
|
+
if (field && fields.has(field[0])) fail(p, `${field[0]} is given twice: give each field once`);
|
|
352
|
+
if (!field || fields.has(field[0])) ok = false;
|
|
353
|
+
else fields.set(...field);
|
|
354
|
+
}
|
|
355
|
+
// fromEntries makes each field an own property, even one named `__proto__`.
|
|
356
|
+
return ok ? Object.fromEntries(fields) : undefined;
|
|
357
|
+
}
|
|
358
|
+
case "Identifier":
|
|
359
|
+
return reference(node);
|
|
360
|
+
case "MemberExpression": {
|
|
361
|
+
let base = node;
|
|
362
|
+
while (base.type === "MemberExpression") base = base.object;
|
|
363
|
+
if (base.type === "Identifier" && base.name === "process") {
|
|
364
|
+
return refuse(
|
|
365
|
+
node,
|
|
366
|
+
"process is not allowed in a data file: it is read without running, so it cannot read the environment; write the value out, and leave secrets to drivers",
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
return refuse(
|
|
370
|
+
node,
|
|
371
|
+
"member access is not allowed in a data file: write the value out, or name the resource itself (`site`), not one of its fields",
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
case "CallExpression":
|
|
375
|
+
if (node.callee.type === "Identifier" && secrets.has(node.callee.name)) return readSecret(node);
|
|
376
|
+
if (node.callee.type === "Identifier" && node.callee.name === "secret" && !bound.has("secret")) {
|
|
377
|
+
return refuse(node, `import secret to name one: \`import { secret } from "sanoma/resources"\``);
|
|
378
|
+
}
|
|
379
|
+
return refuse(
|
|
380
|
+
node,
|
|
381
|
+
node.callee.type === "MemberExpression" &&
|
|
382
|
+
node.callee.object.type === "Identifier" &&
|
|
383
|
+
imports.some((i) => i.kind === "constructors" && i.local === node.callee.object.name)
|
|
384
|
+
? "a resource is declared at the top of a data file: give it its own `export const` and name it here"
|
|
385
|
+
: "a call is not allowed in a data file: write the value out; the reader never runs code",
|
|
386
|
+
);
|
|
387
|
+
case "NewExpression":
|
|
388
|
+
return refuse(node, "`new` is not allowed in a data file: write the value out (a date as an ISO 8601 string)");
|
|
389
|
+
case "ArrowFunctionExpression":
|
|
390
|
+
case "FunctionExpression":
|
|
391
|
+
return refuse(
|
|
392
|
+
node,
|
|
393
|
+
"a function is not allowed in a data file: a field holds a value, and code belongs in a workflow",
|
|
394
|
+
);
|
|
395
|
+
case "TSAsExpression":
|
|
396
|
+
case "TSSatisfiesExpression":
|
|
397
|
+
case "TSTypeAssertion":
|
|
398
|
+
case "TSNonNullExpression":
|
|
399
|
+
return refuse(
|
|
400
|
+
node,
|
|
401
|
+
"a type assertion is not allowed in a data file: leave it out; the constructor types the value",
|
|
402
|
+
);
|
|
403
|
+
default:
|
|
404
|
+
return refuse(node, `${describe(node)} is not allowed in a data file: ${VALUES}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function property(p: Node): [string, DataValue] | undefined {
|
|
409
|
+
if (p.type === "SpreadElement") {
|
|
410
|
+
return refuse(p, "a spread is not allowed in a data file: write the fields out, so the reader sees each");
|
|
411
|
+
}
|
|
412
|
+
if (p.kind !== "init" || p.method) {
|
|
413
|
+
return refuse(p, "a method or accessor is not allowed in a data file: a field holds a value");
|
|
414
|
+
}
|
|
415
|
+
if (p.computed) return refuse(p.key, "a computed key is not allowed in a data file: write the field's name");
|
|
416
|
+
const key = keyName(p.key);
|
|
417
|
+
if (key === undefined) return refuse(p.key, "write the field's name, or a string");
|
|
418
|
+
if (key === "$secret") return refuse(p.key, `name a secret with ${SECRET_CALL}`);
|
|
419
|
+
const v = value(p.value);
|
|
420
|
+
return v === undefined ? undefined : [key, v];
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** A `secret(…)` call's marker: literal arguments only. */
|
|
424
|
+
function readSecret(node: Node): SecretRef | undefined {
|
|
425
|
+
const [name, options, ...extra] = node.arguments;
|
|
426
|
+
if (name?.type !== "Literal" || typeof name.value !== "string" || extra.length) {
|
|
427
|
+
return refuse(node, `write the secret's name as a string: ${SECRET_CALL}`);
|
|
428
|
+
}
|
|
429
|
+
if (!SECRET_NAME.test(name.value))
|
|
430
|
+
return refuse(name, `${JSON.stringify(name.value)} is not a secret's name: ${SECRET_CALL}`);
|
|
431
|
+
if (options === undefined) return { $secret: name.value };
|
|
432
|
+
const [generate, ...others] = options.type === "ObjectExpression" ? options.properties : [];
|
|
433
|
+
if (
|
|
434
|
+
others.length ||
|
|
435
|
+
generate?.type !== "Property" ||
|
|
436
|
+
generate.computed ||
|
|
437
|
+
(generate.key.name ?? generate.key.value) !== "generate" ||
|
|
438
|
+
generate.value.type !== "Literal" ||
|
|
439
|
+
typeof generate.value.value !== "boolean"
|
|
440
|
+
) {
|
|
441
|
+
return refuse(options, `a secret's options are \`{ generate: true }\` or none: ${SECRET_CALL}`);
|
|
442
|
+
}
|
|
443
|
+
return generate.value.value ? { $secret: name.value, generate: true } : { $secret: name.value };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function reference(node: Node): Reference | undefined {
|
|
447
|
+
const name: string = node.name;
|
|
448
|
+
if (name === "undefined")
|
|
449
|
+
return refuse(node, "undefined is not allowed in a data file: leave the field out, or write null");
|
|
450
|
+
const imported = imports.find((i) => i.local === name);
|
|
451
|
+
if (imported?.kind === "constructors") {
|
|
452
|
+
return refuse(node, `${name} is a connector's resource constructors, not a resource: name a declared resource`);
|
|
453
|
+
}
|
|
454
|
+
if (imported || declared.has(name)) return new Reference(name);
|
|
455
|
+
if (refused.has(name)) return undefined;
|
|
456
|
+
return refuse(
|
|
457
|
+
node,
|
|
458
|
+
`${name} is not a resource this file declares or imports from a data file: write the value out, or import the resource`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function refuse(node: { start: number; end: number }, message: string): undefined {
|
|
463
|
+
fail(node, message);
|
|
464
|
+
return undefined;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return { imports, exports, declared: [...declared], problems };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** A property's name: an identifier's, or a literal's as a string; undefined for any other key. */
|
|
471
|
+
export const keyName = (key: Node): string | undefined =>
|
|
472
|
+
key.type === "Identifier" ? key.name : key.type === "Literal" ? String(key.value) : undefined;
|
|
473
|
+
|
|
474
|
+
/** A node's kind, for a message: `a function declaration`, `an if statement`. */
|
|
475
|
+
function describe(node: Node): string {
|
|
476
|
+
const words = String(node.type)
|
|
477
|
+
.replace(/^TS/, "TypeScript ")
|
|
478
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
479
|
+
.toLowerCase();
|
|
480
|
+
return `${/^[aeiou]/.test(words) ? "an" : "a"} ${words}`;
|
|
481
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { DBOS, DBOSClient } from "@dbos-inc/dbos-sdk";
|
|
2
|
+
import { Client, Pool } from "pg";
|
|
3
|
+
import { warn } from "./log.ts";
|
|
4
|
+
import { errorMessage } from "./shared.ts";
|
|
5
|
+
|
|
6
|
+
// The runtime's own tables live in the config's database, beside DBOS's: the credentials and the
|
|
7
|
+
// ledger. What they share is here: the one preparation of a database, a pool that opens once it
|
|
8
|
+
// is prepared and survives a dropped connection, and a table creation two processes may race.
|
|
9
|
+
|
|
10
|
+
/** A connection or a pool: what the queries take. */
|
|
11
|
+
export type Db = Pick<Pool | Client, "query">;
|
|
12
|
+
|
|
13
|
+
/** Where the credentials set from the app live. */
|
|
14
|
+
export const CREDENTIALS_TABLE = "sanoma_credentials";
|
|
15
|
+
/** Where the Postgres ledger keeps its records. */
|
|
16
|
+
export const LEDGER_TABLE = "sanoma_ledger";
|
|
17
|
+
|
|
18
|
+
/** The runtime's tables, each with the word the messages name it by. */
|
|
19
|
+
const TABLES: readonly [what: string, ddl: string][] = [
|
|
20
|
+
[
|
|
21
|
+
"credentials",
|
|
22
|
+
`CREATE TABLE IF NOT EXISTS ${CREDENTIALS_TABLE} (name text PRIMARY KEY, value text NOT NULL, ` +
|
|
23
|
+
"set_by text NOT NULL, set_at timestamptz NOT NULL DEFAULT now())",
|
|
24
|
+
],
|
|
25
|
+
[
|
|
26
|
+
"ledger",
|
|
27
|
+
// `n` is the write order, `id` the record's, which the first write of takes, and `seq` the
|
|
28
|
+
// record's place in its run: a run reads in `seq` order, then write order.
|
|
29
|
+
`CREATE TABLE IF NOT EXISTS ${LEDGER_TABLE} (n bigserial PRIMARY KEY, id text NOT NULL UNIQUE, ` +
|
|
30
|
+
`run_id text NOT NULL, seq integer NOT NULL, record jsonb NOT NULL); ` +
|
|
31
|
+
`CREATE INDEX IF NOT EXISTS ${LEDGER_TABLE}_run_seq ON ${LEDGER_TABLE} (run_id, seq, n)`,
|
|
32
|
+
],
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/** The Postgres error code of `err`, or an empty string when it has none. */
|
|
36
|
+
export const pgCode = (err: unknown): string => (err as { code?: unknown } | null)?.code?.toString() ?? "";
|
|
37
|
+
|
|
38
|
+
/** Postgres's codes for a row type or a table another process made first. */
|
|
39
|
+
const MADE_ALREADY = new Set(["23505", "42P07"]);
|
|
40
|
+
|
|
41
|
+
/** True for the failure of a create that another process won: what was to be made is there. */
|
|
42
|
+
export const madeAlready = (err: unknown): boolean => MADE_ALREADY.has(pgCode(err));
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A pool on the config's database for `what` (a word for the messages). An idle connection that
|
|
46
|
+
* drops (Postgres restarted) is replaced on the next query; unheard, it would end the process.
|
|
47
|
+
*/
|
|
48
|
+
export function openPool(databaseUrl: string, what: string): Pool {
|
|
49
|
+
const pool = new Pool({ connectionString: databaseUrl });
|
|
50
|
+
pool.on("error", (err) => warn(`a ${what} connection failed: ${errorMessage(err)}`));
|
|
51
|
+
return pool;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What every DBOS handle on the config's database is given (`DBOS.setConfig`, `dbosClient`): DBOS
|
|
56
|
+
* is told not to set Postgres's `idle_in_transaction_session_timeout` on each connection of its
|
|
57
|
+
* pool. It sends that `SET` from the pool's `connect` event without awaiting it (dbos-sdk 5.2.11,
|
|
58
|
+
* dist/src/system_database.js:691), so the pool hands the connection out while it runs: two
|
|
59
|
+
* queries at once on one client, which pg deprecates. `prepareDatabase` sets the same 60 s guard
|
|
60
|
+
* on the database instead. Remove the switch when DBOS awaits its `SET`.
|
|
61
|
+
*/
|
|
62
|
+
export const DBOS_DATABASE_OPTIONS = { systemDatabaseIdleTransactionTimeoutMs: 0 } as const;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A DBOS client of the app `applicationName` on the database at `databaseUrl`, as every client of
|
|
66
|
+
* the runtime, its tests and `@sanoma-ai/testing` make one (`DBOS_DATABASE_OPTIONS`).
|
|
67
|
+
*/
|
|
68
|
+
export const dbosClient = (databaseUrl: string, applicationName?: string): Promise<DBOSClient> =>
|
|
69
|
+
DBOSClient.create({
|
|
70
|
+
systemDatabaseUrl: databaseUrl,
|
|
71
|
+
...(applicationName !== undefined && { applicationName }),
|
|
72
|
+
...DBOS_DATABASE_OPTIONS,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The `idle_in_transaction_session_timeout` `prepareDatabase` sets: DBOS's own default
|
|
77
|
+
* (`DEFAULT_IDLE_TRANSACTION_TIMEOUT_MS`, 60_000, in dbos-sdk 5.2.11 dist/src/system_database.js),
|
|
78
|
+
* how long a frozen or unreachable client may hold the system database's locks.
|
|
79
|
+
*/
|
|
80
|
+
const IDLE_IN_TRANSACTION_TIMEOUT = "60s";
|
|
81
|
+
|
|
82
|
+
/** Postgres's code for a statement the role lacks the privilege for. */
|
|
83
|
+
const INSUFFICIENT_PRIVILEGE = "42501";
|
|
84
|
+
|
|
85
|
+
/** A pool opened on first use, once its database is prepared, and ended by `close`. */
|
|
86
|
+
export interface LazyPool {
|
|
87
|
+
db(): Promise<Pool>;
|
|
88
|
+
close(): Promise<void>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A pool for `what` on the database at `databaseUrl`, opened on the first `db()` once the
|
|
93
|
+
* database is prepared (`prepareDatabase`). A preparation that fails lets the pool go, so the
|
|
94
|
+
* next `db()` tries again. `close` ends the pool, for good: a `db()` after it is refused.
|
|
95
|
+
*/
|
|
96
|
+
export function lazyPool(databaseUrl: string, what: string): LazyPool {
|
|
97
|
+
let pool: Pool | undefined;
|
|
98
|
+
let closed = false;
|
|
99
|
+
return {
|
|
100
|
+
async db() {
|
|
101
|
+
if (closed) throw new Error(`the ${what} store is closed`);
|
|
102
|
+
const current = (pool ??= openPool(databaseUrl, what));
|
|
103
|
+
try {
|
|
104
|
+
await prepareDatabase(databaseUrl);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (pool === current) pool = undefined;
|
|
107
|
+
await current.end().catch(() => undefined);
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
return current;
|
|
111
|
+
},
|
|
112
|
+
async close() {
|
|
113
|
+
closed = true;
|
|
114
|
+
const current = pool;
|
|
115
|
+
pool = undefined;
|
|
116
|
+
await current?.end();
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Each database prepared, or being prepared, by its URL. A failed preparation is forgotten. */
|
|
122
|
+
const prepared = new Map<string, Promise<void>>();
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Makes the database at `databaseUrl` ready for the runtime, once per process and URL: the
|
|
126
|
+
* database itself and DBOS's tables when they are not there yet (as a worker's launch makes
|
|
127
|
+
* them; credentials may be set, and a ledger read, before any worker has run), then the
|
|
128
|
+
* credentials and ledger tables, and its idle-transaction timeout (`guardIdleTransactions`).
|
|
129
|
+
* Two processes preparing one database both succeed. A
|
|
130
|
+
* preparation that fails is forgotten, so the next call tries again.
|
|
131
|
+
*/
|
|
132
|
+
export function prepareDatabase(databaseUrl: string): Promise<void> {
|
|
133
|
+
let preparing = prepared.get(databaseUrl);
|
|
134
|
+
if (!preparing) {
|
|
135
|
+
preparing = prepare(databaseUrl).catch((err: unknown) => {
|
|
136
|
+
if (prepared.get(databaseUrl) === preparing) prepared.delete(databaseUrl);
|
|
137
|
+
throw err;
|
|
138
|
+
});
|
|
139
|
+
prepared.set(databaseUrl, preparing);
|
|
140
|
+
}
|
|
141
|
+
return preparing;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function prepare(databaseUrl: string): Promise<void> {
|
|
145
|
+
try {
|
|
146
|
+
await DBOS.migrate(databaseUrl);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
// The database, or a DBOS table, made by another process meanwhile: it is there.
|
|
149
|
+
if (!madeAlready(err)) throw new Error(`could not prepare the database: ${errorMessage(err)}`, { cause: err });
|
|
150
|
+
}
|
|
151
|
+
const conn = new Client({ connectionString: databaseUrl });
|
|
152
|
+
await conn.connect();
|
|
153
|
+
try {
|
|
154
|
+
for (const [what, ddl] of TABLES) await createTable(conn, ddl, what);
|
|
155
|
+
await guardIdleTransactions(conn);
|
|
156
|
+
} finally {
|
|
157
|
+
await conn.end();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Sets `idle_in_transaction_session_timeout` on the database, for each session that starts after,
|
|
163
|
+
* as DBOS would per connection (`DBOS_DATABASE_OPTIONS`), unless the server, the role or the
|
|
164
|
+
* database sets it already. One process at a time (an advisory lock), so two preparing at once
|
|
165
|
+
* both succeed. A role that may not alter the database leaves it, quietly: its administrator decides.
|
|
166
|
+
*/
|
|
167
|
+
async function guardIdleTransactions(db: Db): Promise<void> {
|
|
168
|
+
try {
|
|
169
|
+
await db.query(`DO $$ BEGIN
|
|
170
|
+
PERFORM pg_advisory_xact_lock(hashtext('sanoma.idle_in_transaction_session_timeout'));
|
|
171
|
+
IF current_setting('idle_in_transaction_session_timeout') = '0' AND NOT EXISTS (
|
|
172
|
+
SELECT FROM pg_db_role_setting s JOIN pg_database d ON d.oid = s.setdatabase, unnest(s.setconfig) c
|
|
173
|
+
WHERE d.datname = current_database() AND s.setrole = 0 AND c LIKE 'idle_in_transaction_session_timeout=%'
|
|
174
|
+
) THEN
|
|
175
|
+
EXECUTE format('ALTER DATABASE %I SET idle_in_transaction_session_timeout = %L', current_database(),
|
|
176
|
+
'${IDLE_IN_TRANSACTION_TIMEOUT}');
|
|
177
|
+
END IF;
|
|
178
|
+
END $$`);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
if (pgCode(err) !== INSUFFICIENT_PRIVILEGE) {
|
|
181
|
+
throw new Error(`could not prepare the database: ${errorMessage(err)}`, { cause: err });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Runs `ddl` (`CREATE TABLE IF NOT EXISTS`, and the like) on `db`. Two processes racing to create
|
|
188
|
+
* the same table both succeed; any other failure names the `what` table, with its cause.
|
|
189
|
+
*/
|
|
190
|
+
export async function createTable(db: Db, ddl: string, what: string): Promise<void> {
|
|
191
|
+
try {
|
|
192
|
+
await db.query(ddl);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (madeAlready(err)) return;
|
|
195
|
+
throw new Error(`could not prepare the ${what} table: ${errorMessage(err)}`, { cause: err });
|
|
196
|
+
}
|
|
197
|
+
}
|