@sanoma-ai/workflows 0.1.0 → 0.4.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/README.md +186 -76
- 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 +11 -6
- package/dist/approvals.js +38 -10
- package/dist/approvals.js.map +1 -1
- package/dist/call-workflow.d.ts +16 -0
- package/dist/call-workflow.js +35 -0
- package/dist/call-workflow.js.map +1 -0
- package/dist/call.d.ts +35 -2
- package/dist/call.js +123 -43
- package/dist/call.js.map +1 -1
- package/dist/cedar.d.ts +87 -0
- package/dist/cedar.js +365 -0
- package/dist/cedar.js.map +1 -0
- package/dist/client.d.ts +123 -9
- package/dist/client.js +355 -34
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +107 -0
- package/dist/collection.js +306 -0
- package/dist/collection.js.map +1 -0
- package/dist/config.d.ts +42 -14
- package/dist/config.js +109 -28
- package/dist/config.js.map +1 -1
- package/dist/constructors.d.ts +2 -0
- package/dist/constructors.js +5 -0
- package/dist/constructors.js.map +1 -0
- package/dist/credentials.d.ts +25 -0
- package/dist/credentials.js +73 -5
- package/dist/credentials.js.map +1 -1
- package/dist/data.d.ts +2 -0
- package/dist/data.js +3 -0
- package/dist/data.js.map +1 -0
- package/dist/datafile.d.ts +38 -3
- package/dist/datafile.js +166 -13
- package/dist/datafile.js.map +1 -1
- package/dist/db.d.ts +5 -3
- package/dist/db.js +88 -21
- package/dist/db.js.map +1 -1
- package/dist/define.d.ts +10 -4
- package/dist/define.js +1 -5
- package/dist/define.js.map +1 -1
- package/dist/describe.d.ts +54 -8
- package/dist/describe.js +84 -11
- package/dist/describe.js.map +1 -1
- package/dist/direct.d.ts +9 -0
- package/dist/direct.js +11 -0
- package/dist/direct.js.map +1 -0
- package/dist/drift.d.ts +64 -12
- package/dist/drift.js +118 -41
- package/dist/drift.js.map +1 -1
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +30 -1
- package/dist/errors.js.map +1 -1
- package/dist/expectations.d.ts +4 -4
- package/dist/expectations.js +43 -9
- package/dist/expectations.js.map +1 -1
- 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 +13 -5
- package/dist/index.js +15 -5
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +12 -1
- package/dist/ledger.js +18 -11
- package/dist/ledger.js.map +1 -1
- package/dist/lint.d.ts +5 -4
- package/dist/lint.js +24 -7
- package/dist/lint.js.map +1 -1
- package/dist/lockfile.d.ts +36 -0
- package/dist/lockfile.js +69 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/op.d.ts +9 -0
- package/dist/op.js.map +1 -1
- package/dist/outline.d.ts +2 -0
- package/dist/outline.js +1 -1
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +4 -0
- package/dist/paths.js +17 -3
- package/dist/paths.js.map +1 -1
- package/dist/person.d.ts +61 -0
- package/dist/person.js +69 -0
- package/dist/person.js.map +1 -0
- package/dist/plugin.js +1 -1
- package/dist/plugin.js.map +1 -1
- package/dist/policy.d.ts +51 -55
- package/dist/policy.js +14 -36
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +207 -25
- package/dist/resource.js +184 -30
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +76 -9
- package/dist/resources.js +170 -30
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +31 -1
- package/dist/run.js +6 -1
- package/dist/run.js.map +1 -1
- package/dist/sandbox.js +5 -1
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +25 -9
- package/dist/scenario.js +125 -27
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +10 -6
- package/dist/scenarios.js +8 -4
- package/dist/scenarios.js.map +1 -1
- package/dist/schema.d.ts +32 -0
- package/dist/schema.js +225 -0
- package/dist/schema.js.map +1 -0
- package/dist/sdk.d.ts +34 -0
- package/dist/sdk.js +168 -0
- package/dist/sdk.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 +188 -4
- package/dist/shared.js +165 -5
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +52 -0
- package/dist/sites.js +174 -0
- package/dist/sites.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/version.d.ts +3 -2
- package/dist/version.js +3 -2
- package/dist/version.js.map +1 -1
- package/dist/worker.d.ts +6 -2
- package/dist/worker.js +37 -8
- package/dist/worker.js.map +1 -1
- package/dist/writer.d.ts +35 -0
- package/dist/writer.js +201 -0
- package/dist/writer.js.map +1 -0
- package/oxlint.json +5 -16
- package/package.json +11 -1
- package/src/apply-steps.ts +110 -0
- package/src/apply.ts +626 -0
- package/src/approvals.ts +49 -13
- package/src/call-workflow.ts +44 -0
- package/src/call.ts +173 -46
- package/src/cedar.ts +493 -0
- package/src/client.ts +450 -49
- package/src/collection.ts +431 -0
- package/src/config.ts +168 -48
- package/src/constructors.ts +4 -0
- package/src/credentials.ts +90 -5
- package/src/data.ts +10 -0
- package/src/datafile.ts +207 -15
- package/src/db.ts +90 -22
- package/src/define.ts +11 -9
- package/src/describe.ts +146 -23
- package/src/direct.ts +15 -0
- package/src/drift.ts +155 -45
- package/src/errors.ts +36 -1
- package/src/expectations.ts +46 -11
- package/src/host.ts +96 -0
- package/src/index.ts +56 -2
- package/src/ledger.ts +35 -17
- package/src/lint.ts +29 -12
- package/src/lockfile.ts +95 -0
- package/src/op.ts +9 -0
- package/src/outline.ts +1 -1
- package/src/paths.ts +18 -3
- package/src/person.ts +75 -0
- package/src/plugin.ts +1 -1
- package/src/policy.ts +59 -63
- package/src/resource.ts +302 -52
- package/src/resources.ts +226 -30
- package/src/run.ts +33 -1
- package/src/sandbox.ts +5 -1
- package/src/scenario.ts +175 -43
- package/src/scenarios.ts +15 -8
- package/src/schema.ts +261 -0
- package/src/sdk.ts +211 -0
- package/src/secret.ts +92 -0
- package/src/shared.ts +279 -6
- package/src/sites.ts +220 -0
- package/src/source.ts +82 -0
- package/src/version.ts +3 -2
- package/src/worker.ts +45 -11
- package/src/writer.ts +225 -0
package/src/datafile.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { basename, dirname, join } from "node:path";
|
|
1
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
2
2
|
import type { Node } from "./ast.ts";
|
|
3
3
|
import { inside, nearestDir, RELATIVE } from "./paths.ts";
|
|
4
|
+
import { type Secret, SECRET_NAME } from "./secret.ts";
|
|
5
|
+
import { checkCollection, errorMessage } from "./shared.ts";
|
|
4
6
|
|
|
5
7
|
// The one definition of what a data file may hold, over an ESTree program from oxc: the reader
|
|
6
8
|
// (`resources.ts`), `lintResources` (`lint.ts`) and the oxlint plugin (`plugin.ts`) all
|
|
@@ -22,8 +24,26 @@ export class Reference {
|
|
|
22
24
|
}
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
const REFERENCE = Symbol.for("sanoma.reference");
|
|
28
|
+
Object.defineProperty(Reference.prototype, REFERENCE, { value: true });
|
|
29
|
+
|
|
30
|
+
/** True for a `Reference`, made by any copy of this package: read by a registered symbol, not `instanceof`. */
|
|
31
|
+
export const isReference = (value: unknown): value is Reference =>
|
|
32
|
+
typeof value === "object" && value !== null && REFERENCE in value;
|
|
33
|
+
|
|
34
|
+
/** What `secret("NAME")` stands for in a data file: its marker, `{ $secret: "NAME", generate?: true }`. */
|
|
35
|
+
export type SecretRef = Secret;
|
|
36
|
+
|
|
37
|
+
/** A value in a data file: a literal, a reference to a declared resource, or a secret's marker. */
|
|
38
|
+
export type DataValue =
|
|
39
|
+
| null
|
|
40
|
+
| boolean
|
|
41
|
+
| number
|
|
42
|
+
| string
|
|
43
|
+
| Reference
|
|
44
|
+
| SecretRef
|
|
45
|
+
| DataValue[]
|
|
46
|
+
| { [field: string]: DataValue };
|
|
27
47
|
|
|
28
48
|
/** An `import { name } from "…"`: of a connector's constructors (`from "…/resources"`), or of another data file's resource. */
|
|
29
49
|
export interface DataFileImport {
|
|
@@ -49,11 +69,24 @@ export interface DataFileExport {
|
|
|
49
69
|
end: number;
|
|
50
70
|
}
|
|
51
71
|
|
|
72
|
+
/** An `export const name = collection({ … })` in `resources/data.ts`, its declaration one `collection()` takes. */
|
|
73
|
+
export interface DataFileCollection {
|
|
74
|
+
/** The `export const`'s name. */
|
|
75
|
+
export: string;
|
|
76
|
+
/** The collection's name. */
|
|
77
|
+
name: string;
|
|
78
|
+
/** The `export const` statement. */
|
|
79
|
+
start: number;
|
|
80
|
+
end: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
52
83
|
export interface DataFile {
|
|
53
84
|
imports: DataFileImport[];
|
|
54
85
|
/** Each `export const` whose value is in the subset. */
|
|
55
86
|
exports: DataFileExport[];
|
|
56
|
-
/**
|
|
87
|
+
/** Each collection `resources/data.ts` declares (`COLLECTIONS_FILE`); none in any other data file. */
|
|
88
|
+
collections: DataFileCollection[];
|
|
89
|
+
/** Every name an `export const` declares as a resource, its value in the subset or not: none in `resources/data.ts`. */
|
|
57
90
|
declared: string[];
|
|
58
91
|
problems: DataFileProblem[];
|
|
59
92
|
}
|
|
@@ -61,12 +94,30 @@ export interface DataFile {
|
|
|
61
94
|
/** A connector's constructors entry: a package specifier that ends in `/resources`. */
|
|
62
95
|
const CONSTRUCTORS = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*\/resources$/;
|
|
63
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Where a data file imports `secret` from, and `sanoma`, the runtime's own resource constructors
|
|
99
|
+
* (its people: `sanoma.person`, `.agent`, `.group`); checked before `CONSTRUCTORS`, which they match too.
|
|
100
|
+
*/
|
|
101
|
+
export const SECRET_ENTRIES: ReadonlySet<string> = new Set(["sanoma/resources", "@sanoma-ai/workflows/resources"]);
|
|
102
|
+
/** Where `resources/data.ts` imports `collection` from, and the only imports it may make. */
|
|
103
|
+
export const DATA_ENTRIES: ReadonlySet<string> = new Set(["sanoma/data", "@sanoma-ai/workflows/data"]);
|
|
104
|
+
/**
|
|
105
|
+
* The one data file that holds collections, relative to `resources/`. Workflows and the runtime
|
|
106
|
+
* import it, so it imports only `collection` from "sanoma/data" and holds nothing else.
|
|
107
|
+
*/
|
|
108
|
+
export const COLLECTIONS_FILE = "data.ts";
|
|
109
|
+
const COLLECTION_CALL = '`export const <name> = collection({ name: "…", key: "…", fields: { … } })`';
|
|
110
|
+
const COLLECTIONS_ONLY =
|
|
111
|
+
"resources/data.ts holds collections only, since workflows and the runtime import it: " +
|
|
112
|
+
`\`import { collection } from "sanoma/data"\` and ${COLLECTION_CALL}; declare a connector's resources in another data file`;
|
|
113
|
+
const SECRET_CALL = '`secret("NAME")` or `secret("NAME", { generate: true })`, NAME upper case (A-Z, 0-9 and _)';
|
|
114
|
+
|
|
64
115
|
const SHAPE =
|
|
65
116
|
"a data file holds only imports, `export const <name> = <vendor>.<type>({ … })` and `export default [ … ]`";
|
|
66
117
|
const IMPORTS =
|
|
67
118
|
"import resource constructors from a connector's resources entry " +
|
|
68
119
|
'(`import { github } from "@sanoma-ai/connector-github/resources"`) ' +
|
|
69
|
-
'and resources from other data files (`import { site } from "./github.ts"`)';
|
|
120
|
+
'and resources from other data files (`import { site } from "./github.ts"`), and `secret` from "sanoma/resources"';
|
|
70
121
|
const VALUES = "write a string, number, boolean, null, object or array, or name a declared resource";
|
|
71
122
|
|
|
72
123
|
/**
|
|
@@ -78,6 +129,10 @@ const VALUES = "write a string, number, boolean, null, object or array, or name
|
|
|
78
129
|
* `export const <name> = <vendor>.<type>({ … })`, one per statement, its argument built of
|
|
79
130
|
* strings (template literals without `${}` too), numbers, booleans, null, objects and arrays,
|
|
80
131
|
* and names of resources this file declares or imports; and `export default [a, b]`.
|
|
132
|
+
* `resources/data.ts` (`COLLECTIONS_FILE`) is the exception, the one data file workflows and the
|
|
133
|
+
* runtime import: it imports only `collection` from "sanoma/data", and each `export const` is
|
|
134
|
+
* `collection({ … })` with its values written out (`DataFile.collections`). No other data file
|
|
135
|
+
* declares a collection, and none imports one.
|
|
81
136
|
*
|
|
82
137
|
* `root` is the directory relative imports must stay in: by default the nearest `resources/`
|
|
83
138
|
* directory above `filename`, else the file's own.
|
|
@@ -92,8 +147,15 @@ export function readDataFile(
|
|
|
92
147
|
problems.push({ start: node.start, end: node.end, message });
|
|
93
148
|
const imports: DataFileImport[] = [];
|
|
94
149
|
const exports: DataFileExport[] = [];
|
|
150
|
+
const collections: DataFileCollection[] = [];
|
|
151
|
+
const collectionsFile = relative(root, filename) === COLLECTIONS_FILE;
|
|
152
|
+
// What the file may hold, for the problems that say so.
|
|
153
|
+
const shape = collectionsFile ? COLLECTIONS_ONLY : SHAPE;
|
|
95
154
|
// Names a refused import or declaration binds: reported once, where they are bound.
|
|
96
155
|
const refused = new Set<string>();
|
|
156
|
+
// The local names of `secret`, and of `collection`.
|
|
157
|
+
const secrets = new Set<string>();
|
|
158
|
+
const collectionCalls = new Set<string>();
|
|
97
159
|
|
|
98
160
|
// Imports and top-level names first, each once: imports are hoisted, and a reference may name
|
|
99
161
|
// a resource declared further down. An unexported const is refused where it is declared, not
|
|
@@ -130,20 +192,20 @@ export function readDataFile(
|
|
|
130
192
|
readDefault(s);
|
|
131
193
|
break;
|
|
132
194
|
case "ExportAllDeclaration":
|
|
133
|
-
fail(s, `export * is not allowed in a data file: ${
|
|
195
|
+
fail(s, `export * is not allowed in a data file: ${shape}`);
|
|
134
196
|
break;
|
|
135
197
|
case "VariableDeclaration": {
|
|
136
198
|
const name = s.declarations[0]?.id?.name;
|
|
137
199
|
fail(
|
|
138
200
|
s,
|
|
139
201
|
name === undefined
|
|
140
|
-
? `an unexported declaration is not allowed in a data file: ${
|
|
141
|
-
: `\`${s.kind} ${name}\` is not allowed in a data file: export it as a resource, \`export const ${name} = <vendor>.<type>({ … })
|
|
202
|
+
? `an unexported declaration is not allowed in a data file: ${shape}`
|
|
203
|
+
: `\`${s.kind} ${name}\` is not allowed in a data file: export it as ${collectionsFile ? `a collection, \`export const ${name} = collection({ … })\`` : `a resource, \`export const ${name} = <vendor>.<type>({ … })\``}, or write its value where it is used`,
|
|
142
204
|
);
|
|
143
205
|
break;
|
|
144
206
|
}
|
|
145
207
|
default:
|
|
146
|
-
fail(s, `${describe(s)} is not allowed in a data file: ${
|
|
208
|
+
fail(s, `${describe(s)} is not allowed in a data file: ${shape}`);
|
|
147
209
|
}
|
|
148
210
|
}
|
|
149
211
|
|
|
@@ -153,6 +215,16 @@ export function readDataFile(
|
|
|
153
215
|
fail(s, "`import type` is not allowed in a data file: it holds values only, and the constructors type them");
|
|
154
216
|
return;
|
|
155
217
|
}
|
|
218
|
+
if (collectionsFile !== DATA_ENTRIES.has(spec)) {
|
|
219
|
+
fail(
|
|
220
|
+
s.source,
|
|
221
|
+
collectionsFile
|
|
222
|
+
? `import "${spec}" is not allowed in resources/data.ts: ${COLLECTIONS_ONLY}`
|
|
223
|
+
: `import "${spec}" is not allowed here: collections are declared in resources/data.ts, the one data file workflows and the runtime import; move it there`,
|
|
224
|
+
);
|
|
225
|
+
for (const sp of s.specifiers) refused.add(sp.local.name);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
156
228
|
const refusal = whyRefused(spec);
|
|
157
229
|
if (refusal) {
|
|
158
230
|
fail(s.source, `import "${spec}" is not allowed in a data file: ${refusal}`);
|
|
@@ -163,6 +235,31 @@ export function readDataFile(
|
|
|
163
235
|
fail(s, `import "${spec}" is not allowed in a data file: it imports nothing, and a data file runs no code`);
|
|
164
236
|
return;
|
|
165
237
|
}
|
|
238
|
+
// The one name each of these entries gives a data file.
|
|
239
|
+
const [only, locals] = SECRET_ENTRIES.has(spec)
|
|
240
|
+
? ["secret", secrets]
|
|
241
|
+
: DATA_ENTRIES.has(spec)
|
|
242
|
+
? ["collection", collectionCalls]
|
|
243
|
+
: [];
|
|
244
|
+
if (locals) {
|
|
245
|
+
for (const sp of s.specifiers) {
|
|
246
|
+
const imported =
|
|
247
|
+
sp.type === "ImportSpecifier" && sp.importKind !== "type" && (sp.imported.name ?? sp.imported.value);
|
|
248
|
+
if (imported === only) {
|
|
249
|
+
locals.add(sp.local.name);
|
|
250
|
+
} else if (imported === "sanoma" && SECRET_ENTRIES.has(spec)) {
|
|
251
|
+
// The runtime's own constructors, matched to its connector as a connector's are.
|
|
252
|
+
imports.push({ kind: "constructors", source: spec, imported, local: sp.local.name, start: sp.start });
|
|
253
|
+
} else {
|
|
254
|
+
const [names, allowed] = SECRET_ENTRIES.has(spec)
|
|
255
|
+
? ["`secret` and `sanoma`", `${only}, sanoma`]
|
|
256
|
+
: [`\`${only}\``, only];
|
|
257
|
+
fail(sp, `a data file imports only ${names} from "${spec}": \`import { ${allowed} } from "${spec}"\``);
|
|
258
|
+
refused.add(sp.local.name);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
166
263
|
for (const sp of s.specifiers) {
|
|
167
264
|
if (sp.type !== "ImportSpecifier") {
|
|
168
265
|
const how = sp.type === "ImportNamespaceSpecifier" ? `import * as ${sp.local.name}` : `import ${sp.local.name}`;
|
|
@@ -181,8 +278,11 @@ export function readDataFile(
|
|
|
181
278
|
|
|
182
279
|
/** Why an import's specifier is not allowed in a data file, or undefined when it is. */
|
|
183
280
|
function whyRefused(spec: string): string | undefined {
|
|
184
|
-
if (!RELATIVE.test(spec)) return CONSTRUCTORS.test(spec) ? undefined : IMPORTS;
|
|
281
|
+
if (!RELATIVE.test(spec)) return CONSTRUCTORS.test(spec) || DATA_ENTRIES.has(spec) ? undefined : IMPORTS;
|
|
185
282
|
if (!spec.endsWith(".ts")) return "name the data file with its `.ts` extension";
|
|
283
|
+
if (join(dirname(filename), spec) === join(root, COLLECTIONS_FILE)) {
|
|
284
|
+
return "resources/data.ts holds collections, which no resource names; import resources from other data files";
|
|
285
|
+
}
|
|
186
286
|
if (!inside(root, join(dirname(filename), spec))) {
|
|
187
287
|
return `it reaches outside ${basename(root)}/; import only other data files`;
|
|
188
288
|
}
|
|
@@ -201,7 +301,7 @@ export function readDataFile(
|
|
|
201
301
|
return;
|
|
202
302
|
}
|
|
203
303
|
if (decl.type !== "VariableDeclaration") {
|
|
204
|
-
fail(s, `${describe(decl)} is not allowed in a data file: ${
|
|
304
|
+
fail(s, `${describe(decl)} is not allowed in a data file: ${shape}`);
|
|
205
305
|
return;
|
|
206
306
|
}
|
|
207
307
|
if (decl.kind !== "const") {
|
|
@@ -219,9 +319,16 @@ export function readDataFile(
|
|
|
219
319
|
}
|
|
220
320
|
const name: string = d.id.name;
|
|
221
321
|
// A second declaration of the name was reported where it is bound.
|
|
222
|
-
if (exports.some((e) => e.name === name)) return;
|
|
322
|
+
if (exports.some((e) => e.name === name) || collections.some((c) => c.export === name)) return;
|
|
223
323
|
const init = d.init;
|
|
224
324
|
const callee = init?.type === "CallExpression" ? init.callee : undefined;
|
|
325
|
+
// A name a refused import binds was reported with the import.
|
|
326
|
+
const head = callee?.type === "MemberExpression" ? callee.object : callee;
|
|
327
|
+
if (head?.type === "Identifier" && refused.has(head.name)) return;
|
|
328
|
+
if (collectionsFile) {
|
|
329
|
+
readCollection(s, init, name);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
225
332
|
if (
|
|
226
333
|
callee?.type !== "MemberExpression" ||
|
|
227
334
|
callee.computed ||
|
|
@@ -236,7 +343,6 @@ export function readDataFile(
|
|
|
236
343
|
}
|
|
237
344
|
const vendor: string = callee.object.name;
|
|
238
345
|
const type: string = callee.property.name;
|
|
239
|
-
if (refused.has(vendor)) return;
|
|
240
346
|
if (imports.find((i) => i.local === vendor)?.kind !== "constructors") {
|
|
241
347
|
fail(
|
|
242
348
|
callee.object,
|
|
@@ -255,7 +361,51 @@ export function readDataFile(
|
|
|
255
361
|
}
|
|
256
362
|
}
|
|
257
363
|
|
|
364
|
+
/** An `export const` in resources/data.ts: a collection, its declaration plain values `collection()` takes. */
|
|
365
|
+
function readCollection(s: Node, init: Node | undefined, name: string) {
|
|
366
|
+
if (
|
|
367
|
+
init?.type !== "CallExpression" ||
|
|
368
|
+
init.callee.type !== "Identifier" ||
|
|
369
|
+
!collectionCalls.has(init.callee.name)
|
|
370
|
+
) {
|
|
371
|
+
fail(init ?? s, `export const ${name} must be a collection: ${COLLECTIONS_ONLY}`);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const [arg] = init.arguments;
|
|
375
|
+
if (init.arguments.length !== 1 || arg.type !== "ObjectExpression") {
|
|
376
|
+
fail(init, `collection() takes one object literal: ${COLLECTION_CALL}`);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const declaration = value(arg) as { [field: string]: DataValue } | undefined;
|
|
380
|
+
if (declaration === undefined) {
|
|
381
|
+
// What value() refused it reported; the collection is still declared here, which the
|
|
382
|
+
// runtime runs, when its name is written out.
|
|
383
|
+
const named = arg.properties.find(
|
|
384
|
+
(p: Node) => p.type === "Property" && !p.computed && keyName(p.key) === "name",
|
|
385
|
+
)?.value;
|
|
386
|
+
if (named?.type === "Literal" && typeof named.value === "string") {
|
|
387
|
+
collections.push({ export: name, name: named.value, start: s.start, end: s.end });
|
|
388
|
+
}
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (Object.keys(declaration).some((k) => !DECLARATION_KEYS.has(k)) || namesDeclaration(declaration)) {
|
|
392
|
+
fail(arg, `collection() takes \`{ name, key, fields, title? }\`, its values written out: ${COLLECTION_CALL}`);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
checkCollection(declaration);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
fail(arg, errorMessage(err));
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
collections.push({ export: name, name: declaration.name as string, start: s.start, end: s.end });
|
|
402
|
+
}
|
|
403
|
+
|
|
258
404
|
function readDefault(s: Node) {
|
|
405
|
+
if (collectionsFile) {
|
|
406
|
+
fail(s, `export default is not allowed in resources/data.ts: ${COLLECTIONS_ONLY}`);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
259
409
|
const list = s.declaration;
|
|
260
410
|
if (list.type !== "ArrayExpression") {
|
|
261
411
|
fail(list, "export default must list this file's resources: `export default [a, b]`");
|
|
@@ -332,6 +482,13 @@ export function readDataFile(
|
|
|
332
482
|
);
|
|
333
483
|
}
|
|
334
484
|
case "CallExpression":
|
|
485
|
+
if (node.callee.type === "Identifier" && secrets.has(node.callee.name)) return readSecret(node);
|
|
486
|
+
if (node.callee.type === "Identifier" && collectionCalls.has(node.callee.name)) {
|
|
487
|
+
return refuse(node, `a collection is declared at the top of resources/data.ts: ${COLLECTION_CALL}`);
|
|
488
|
+
}
|
|
489
|
+
if (node.callee.type === "Identifier" && node.callee.name === "secret" && !bound.has("secret")) {
|
|
490
|
+
return refuse(node, `import secret to name one: \`import { secret } from "sanoma/resources"\``);
|
|
491
|
+
}
|
|
335
492
|
return refuse(
|
|
336
493
|
node,
|
|
337
494
|
node.callee.type === "MemberExpression" &&
|
|
@@ -369,12 +526,36 @@ export function readDataFile(
|
|
|
369
526
|
return refuse(p, "a method or accessor is not allowed in a data file: a field holds a value");
|
|
370
527
|
}
|
|
371
528
|
if (p.computed) return refuse(p.key, "a computed key is not allowed in a data file: write the field's name");
|
|
372
|
-
const key =
|
|
529
|
+
const key = keyName(p.key);
|
|
373
530
|
if (key === undefined) return refuse(p.key, "write the field's name, or a string");
|
|
531
|
+
if (key === "$secret") return refuse(p.key, `name a secret with ${SECRET_CALL}`);
|
|
374
532
|
const v = value(p.value);
|
|
375
533
|
return v === undefined ? undefined : [key, v];
|
|
376
534
|
}
|
|
377
535
|
|
|
536
|
+
/** A `secret(…)` call's marker: literal arguments only. */
|
|
537
|
+
function readSecret(node: Node): SecretRef | undefined {
|
|
538
|
+
const [name, options, ...extra] = node.arguments;
|
|
539
|
+
if (name?.type !== "Literal" || typeof name.value !== "string" || extra.length) {
|
|
540
|
+
return refuse(node, `write the secret's name as a string: ${SECRET_CALL}`);
|
|
541
|
+
}
|
|
542
|
+
if (!SECRET_NAME.test(name.value))
|
|
543
|
+
return refuse(name, `${JSON.stringify(name.value)} is not a secret's name: ${SECRET_CALL}`);
|
|
544
|
+
if (options === undefined) return { $secret: name.value };
|
|
545
|
+
const [generate, ...others] = options.type === "ObjectExpression" ? options.properties : [];
|
|
546
|
+
if (
|
|
547
|
+
others.length ||
|
|
548
|
+
generate?.type !== "Property" ||
|
|
549
|
+
generate.computed ||
|
|
550
|
+
(generate.key.name ?? generate.key.value) !== "generate" ||
|
|
551
|
+
generate.value.type !== "Literal" ||
|
|
552
|
+
typeof generate.value.value !== "boolean"
|
|
553
|
+
) {
|
|
554
|
+
return refuse(options, `a secret's options are \`{ generate: true }\` or none: ${SECRET_CALL}`);
|
|
555
|
+
}
|
|
556
|
+
return generate.value.value ? { $secret: name.value, generate: true } : { $secret: name.value };
|
|
557
|
+
}
|
|
558
|
+
|
|
378
559
|
function reference(node: Node): Reference | undefined {
|
|
379
560
|
const name: string = node.name;
|
|
380
561
|
if (name === "undefined")
|
|
@@ -396,9 +577,20 @@ export function readDataFile(
|
|
|
396
577
|
return undefined;
|
|
397
578
|
}
|
|
398
579
|
|
|
399
|
-
return { imports, exports, declared: [...declared], problems };
|
|
580
|
+
return { imports, exports, collections, declared: collectionsFile ? [] : [...declared], problems };
|
|
400
581
|
}
|
|
401
582
|
|
|
583
|
+
/** What `collection()` reads of its declaration. */
|
|
584
|
+
const DECLARATION_KEYS: ReadonlySet<string> = new Set(["name", "key", "fields", "title"]);
|
|
585
|
+
|
|
586
|
+
/** True when a value names a declaration: a collection's declaration is plain values only. */
|
|
587
|
+
const namesDeclaration = (v: DataValue): boolean =>
|
|
588
|
+
v instanceof Reference || (typeof v === "object" && v !== null && Object.values(v).some(namesDeclaration));
|
|
589
|
+
|
|
590
|
+
/** A property's name: an identifier's, or a literal's as a string; undefined for any other key. */
|
|
591
|
+
export const keyName = (key: Node): string | undefined =>
|
|
592
|
+
key.type === "Identifier" ? key.name : key.type === "Literal" ? String(key.value) : undefined;
|
|
593
|
+
|
|
402
594
|
/** A node's kind, for a message: `a function declaration`, `an if statement`. */
|
|
403
595
|
function describe(node: Node): string {
|
|
404
596
|
const words = String(node.type)
|
package/src/db.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { DBOS, DBOSClient } from "@dbos-inc/dbos-sdk";
|
|
2
|
-
import {
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { Client, escapeIdentifier, Pool } from "pg";
|
|
3
4
|
import { warn } from "./log.ts";
|
|
4
5
|
import { errorMessage } from "./shared.ts";
|
|
5
6
|
|
|
6
|
-
// The runtime's own tables live in the config's database, beside DBOS's: the credentials
|
|
7
|
-
// ledger. What they share is here: the one preparation of a
|
|
8
|
-
// is prepared and survives a dropped connection, and a table
|
|
7
|
+
// The runtime's own tables live in the config's database, beside DBOS's: the credentials, the
|
|
8
|
+
// ledger and the collections' records. What they share is here: the one preparation of a
|
|
9
|
+
// database, a pool that opens once it is prepared and survives a dropped connection, and a table
|
|
10
|
+
// creation two processes may race.
|
|
9
11
|
|
|
10
12
|
/** A connection or a pool: what the queries take. */
|
|
11
13
|
export type Db = Pick<Pool | Client, "query">;
|
|
@@ -14,6 +16,8 @@ export type Db = Pick<Pool | Client, "query">;
|
|
|
14
16
|
export const CREDENTIALS_TABLE = "sanoma_credentials";
|
|
15
17
|
/** Where the Postgres ledger keeps its records. */
|
|
16
18
|
export const LEDGER_TABLE = "sanoma_ledger";
|
|
19
|
+
/** Where the collections keep their records (`collection.ts`): one row per collection and key, the record as JSON. */
|
|
20
|
+
export const DATA_TABLE = "sanoma_data";
|
|
17
21
|
|
|
18
22
|
/** The runtime's tables, each with the word the messages name it by. */
|
|
19
23
|
const TABLES: readonly [what: string, ddl: string][] = [
|
|
@@ -30,13 +34,23 @@ const TABLES: readonly [what: string, ddl: string][] = [
|
|
|
30
34
|
`run_id text NOT NULL, seq integer NOT NULL, record jsonb NOT NULL); ` +
|
|
31
35
|
`CREATE INDEX IF NOT EXISTS ${LEDGER_TABLE}_run_seq ON ${LEDGER_TABLE} (run_id, seq, n)`,
|
|
32
36
|
],
|
|
37
|
+
[
|
|
38
|
+
"data",
|
|
39
|
+
`CREATE TABLE IF NOT EXISTS ${DATA_TABLE} (collection text NOT NULL, key text NOT NULL, record jsonb NOT NULL, ` +
|
|
40
|
+
"updated_at timestamptz NOT NULL DEFAULT now(), updated_by text NOT NULL, PRIMARY KEY (collection, key))",
|
|
41
|
+
],
|
|
33
42
|
];
|
|
34
43
|
|
|
35
44
|
/** The Postgres error code of `err`, or an empty string when it has none. */
|
|
36
45
|
export const pgCode = (err: unknown): string => (err as { code?: unknown } | null)?.code?.toString() ?? "";
|
|
37
46
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Postgres's codes for a database, a row type or a table another process made first. Two racing
|
|
49
|
+
* `CREATE TABLE IF NOT EXISTS` both pass the check, and the loser fails on the table's row type in
|
|
50
|
+
* `pg_type`: as a unique violation (23505), or as a duplicate object (42710, "type ... already
|
|
51
|
+
* exists"). Two racing `CREATE DATABASE` fail as 42P04, or on `pg_database` as 23505.
|
|
52
|
+
*/
|
|
53
|
+
const MADE_ALREADY = new Set(["23505", "42P04", "42P07", "42710"]);
|
|
40
54
|
|
|
41
55
|
/** True for the failure of a create that another process won: what was to be made is there. */
|
|
42
56
|
export const madeAlready = (err: unknown): boolean => MADE_ALREADY.has(pgCode(err));
|
|
@@ -82,6 +96,9 @@ const IDLE_IN_TRANSACTION_TIMEOUT = "60s";
|
|
|
82
96
|
/** Postgres's code for a statement the role lacks the privilege for. */
|
|
83
97
|
const INSUFFICIENT_PRIVILEGE = "42501";
|
|
84
98
|
|
|
99
|
+
/** Postgres's code for a database that is not there (`invalid_catalog_name`). */
|
|
100
|
+
const NO_DATABASE = "3D000";
|
|
101
|
+
|
|
85
102
|
/** A pool opened on first use, once its database is prepared, and ended by `close`. */
|
|
86
103
|
export interface LazyPool {
|
|
87
104
|
db(): Promise<Pool>;
|
|
@@ -125,9 +142,9 @@ const prepared = new Map<string, Promise<void>>();
|
|
|
125
142
|
* Makes the database at `databaseUrl` ready for the runtime, once per process and URL: the
|
|
126
143
|
* database itself and DBOS's tables when they are not there yet (as a worker's launch makes
|
|
127
144
|
* them; credentials may be set, and a ledger read, before any worker has run), then the
|
|
128
|
-
* credentials and
|
|
129
|
-
* Two processes preparing one database both succeed
|
|
130
|
-
* preparation that fails is forgotten, so the next call tries again.
|
|
145
|
+
* credentials, ledger and data tables, and its idle-transaction timeout (`guardIdleTransactions`).
|
|
146
|
+
* Two processes preparing one database both succeed: the second waits for the first
|
|
147
|
+
* (`lockPreparation`). A preparation that fails is forgotten, so the next call tries again.
|
|
131
148
|
*/
|
|
132
149
|
export function prepareDatabase(databaseUrl: string): Promise<void> {
|
|
133
150
|
let preparing = prepared.get(databaseUrl);
|
|
@@ -141,33 +158,86 @@ export function prepareDatabase(databaseUrl: string): Promise<void> {
|
|
|
141
158
|
return preparing;
|
|
142
159
|
}
|
|
143
160
|
|
|
161
|
+
/** The error a preparation fails with, its cause `err`. */
|
|
162
|
+
const unprepared = (err: unknown): Error =>
|
|
163
|
+
new Error(`could not prepare the database: ${errorMessage(err)}`, { cause: err });
|
|
164
|
+
|
|
144
165
|
async function prepare(databaseUrl: string): Promise<void> {
|
|
166
|
+
const conn = await lockPreparation(databaseUrl).catch((err: unknown) => {
|
|
167
|
+
throw unprepared(err);
|
|
168
|
+
});
|
|
145
169
|
try {
|
|
146
|
-
await DBOS.migrate(databaseUrl)
|
|
170
|
+
await DBOS.migrate(databaseUrl).catch((err: unknown) => {
|
|
171
|
+
// A DBOS table made meanwhile by a release from before the lock, mid-upgrade: it is there.
|
|
172
|
+
if (!madeAlready(err)) throw unprepared(err);
|
|
173
|
+
});
|
|
174
|
+
for (const [what, ddl] of TABLES) await createTable(conn, ddl, what);
|
|
175
|
+
await guardIdleTransactions(conn);
|
|
176
|
+
} finally {
|
|
177
|
+
// Ending the session releases the lock; a failure to end it must not hide the preparation's.
|
|
178
|
+
await conn.end().catch(() => undefined);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* A connection to the database at `databaseUrl` (made first when missing) holding the
|
|
184
|
+
* preparation's session lock until it ends, so a second process waits, then finds everything
|
|
185
|
+
* made. Racing `DBOS.migrate`s lose to each other in many ways (40P01, XX000), so the race is
|
|
186
|
+
* prevented rather than each loss forgiven. The lock is polled, never waited for by a blocked
|
|
187
|
+
* statement: `CREATE INDEX CONCURRENTLY` waits on that statement's snapshot, a deadlock Postgres
|
|
188
|
+
* cannot see (the lock's holder is this connection, not the migration's).
|
|
189
|
+
*/
|
|
190
|
+
async function lockPreparation(databaseUrl: string): Promise<Client> {
|
|
191
|
+
const conn = await connect(databaseUrl).catch(async (err: unknown) => {
|
|
192
|
+
if (pgCode(err) !== NO_DATABASE) throw err;
|
|
193
|
+
await createDatabase(databaseUrl);
|
|
194
|
+
return connect(databaseUrl);
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
const ask = "SELECT pg_try_advisory_lock(hashtext('sanoma.prepareDatabase')) AS locked";
|
|
198
|
+
while (!(await conn.query<{ locked: boolean }>(ask)).rows[0]!.locked) await delay(50);
|
|
199
|
+
return conn;
|
|
147
200
|
} catch (err) {
|
|
148
|
-
|
|
149
|
-
|
|
201
|
+
await conn.end().catch(() => undefined);
|
|
202
|
+
throw err;
|
|
150
203
|
}
|
|
151
|
-
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function connect(databaseUrl: string): Promise<Client> {
|
|
207
|
+
const conn = new Client({ connectionString: databaseUrl, connectionTimeoutMillis: 10_000 });
|
|
208
|
+
// An error nobody listens for ends the process; the query in flight fails with it.
|
|
209
|
+
conn.on("error", (err) => warn(`a preparation connection failed: ${errorMessage(err)}`));
|
|
152
210
|
await conn.connect();
|
|
211
|
+
return conn;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Makes the database at `databaseUrl` through its server's `postgres` database, as `DBOS.migrate`
|
|
216
|
+
* would. Another process making it first is no failure.
|
|
217
|
+
*/
|
|
218
|
+
async function createDatabase(databaseUrl: string): Promise<void> {
|
|
219
|
+
const url = new URL(databaseUrl);
|
|
220
|
+
const name = decodeURI(url.pathname.slice(1));
|
|
221
|
+
url.pathname = "/postgres";
|
|
222
|
+
const admin = await connect(url.toString());
|
|
153
223
|
try {
|
|
154
|
-
|
|
155
|
-
|
|
224
|
+
await admin.query(`CREATE DATABASE ${escapeIdentifier(name)}`);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
if (!madeAlready(err)) throw err;
|
|
156
227
|
} finally {
|
|
157
|
-
await
|
|
228
|
+
await admin.end();
|
|
158
229
|
}
|
|
159
230
|
}
|
|
160
231
|
|
|
161
232
|
/**
|
|
162
233
|
* Sets `idle_in_transaction_session_timeout` on the database, for each session that starts after,
|
|
163
234
|
* as DBOS would per connection (`DBOS_DATABASE_OPTIONS`), unless the server, the role or the
|
|
164
|
-
* database sets it already.
|
|
165
|
-
*
|
|
235
|
+
* database sets it already. `db` holds the preparation's lock, so one process at a time. A role
|
|
236
|
+
* that may not alter the database leaves it, quietly: its administrator decides.
|
|
166
237
|
*/
|
|
167
238
|
async function guardIdleTransactions(db: Db): Promise<void> {
|
|
168
239
|
try {
|
|
169
240
|
await db.query(`DO $$ BEGIN
|
|
170
|
-
PERFORM pg_advisory_xact_lock(hashtext('sanoma.idle_in_transaction_session_timeout'));
|
|
171
241
|
IF current_setting('idle_in_transaction_session_timeout') = '0' AND NOT EXISTS (
|
|
172
242
|
SELECT FROM pg_db_role_setting s JOIN pg_database d ON d.oid = s.setdatabase, unnest(s.setconfig) c
|
|
173
243
|
WHERE d.datname = current_database() AND s.setrole = 0 AND c LIKE 'idle_in_transaction_session_timeout=%'
|
|
@@ -177,9 +247,7 @@ async function guardIdleTransactions(db: Db): Promise<void> {
|
|
|
177
247
|
END IF;
|
|
178
248
|
END $$`);
|
|
179
249
|
} catch (err) {
|
|
180
|
-
if (pgCode(err) !== INSUFFICIENT_PRIVILEGE)
|
|
181
|
-
throw new Error(`could not prepare the database: ${errorMessage(err)}`, { cause: err });
|
|
182
|
-
}
|
|
250
|
+
if (pgCode(err) !== INSUFFICIENT_PRIVILEGE) throw unprepared(err);
|
|
183
251
|
}
|
|
184
252
|
}
|
|
185
253
|
|
package/src/define.ts
CHANGED
|
@@ -2,20 +2,24 @@ import { fileURLToPath } from "node:url";
|
|
|
2
2
|
import { getCallSites } from "node:util";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { isOp, type Op } from "./op.ts";
|
|
5
|
-
import { approverLabel } from "./shared.ts";
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
|
-
* Who is acting: the person or
|
|
9
|
-
*
|
|
7
|
+
* Who is acting: the person or agent that starts a run, calls an operation or decides an
|
|
8
|
+
* approval. With people declared (`sanoma.person`, `sanoma.agent`, `sanoma.group` in data files),
|
|
9
|
+
* a principal is a declared one, its `groups` every group that lists it; an agent is in the group
|
|
10
|
+
* `agents`, and `onBehalfOf` names the person it acts for.
|
|
10
11
|
*/
|
|
11
12
|
export interface Principal {
|
|
12
13
|
id: string;
|
|
13
14
|
groups?: string[];
|
|
15
|
+
/** For an agent: the id of the person whose instruction it carries. */
|
|
16
|
+
onBehalfOf?: string;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
export const Principal: z.ZodType<Principal> = z.object({
|
|
17
20
|
id: z.string().regex(/\S/, "id must not be blank"),
|
|
18
21
|
groups: z.array(z.string()).optional(),
|
|
22
|
+
onBehalfOf: z.string().regex(/\S/, "onBehalfOf must not be blank").optional(),
|
|
19
23
|
});
|
|
20
24
|
|
|
21
25
|
export type Builtin = "approval" | "sleep" | "all";
|
|
@@ -29,12 +33,6 @@ export const Approver: z.ZodType<Approver> = z.union(
|
|
|
29
33
|
{ error: 'needs an approver: a name, or { group: "name" }' },
|
|
30
34
|
);
|
|
31
35
|
|
|
32
|
-
/** Why `by` may not decide the approval, naming who may. */
|
|
33
|
-
export const notApprover = ({ approver }: Pick<ApprovalState, "approver">, by: Principal): string =>
|
|
34
|
-
typeof approver === "string"
|
|
35
|
-
? `${by.id} is not the approver; ${approverLabel(approver)} is`
|
|
36
|
-
: `${by.id} is not in ${approverLabel(approver)}`;
|
|
37
|
-
|
|
38
36
|
/** True when the two name the same approver: the same id, or the same group. */
|
|
39
37
|
export const sameApprover = (a: Approver, b: Approver): boolean =>
|
|
40
38
|
typeof a === "string" || typeof b === "string" ? a === b : a.group === b.group;
|
|
@@ -92,6 +90,8 @@ export interface ApprovalState extends Omit<ApprovalRequest, "covers"> {
|
|
|
92
90
|
opSeq?: number;
|
|
93
91
|
/** For a policy request: the held call's input. */
|
|
94
92
|
input?: unknown;
|
|
93
|
+
/** For a policy request: the policies that held the call, by id, when the policy names them. */
|
|
94
|
+
reasons?: string[];
|
|
95
95
|
status: "pending" | "approved" | "rejected";
|
|
96
96
|
requestedAt: number;
|
|
97
97
|
decidedBy?: string;
|
|
@@ -99,6 +99,8 @@ export interface ApprovalState extends Omit<ApprovalRequest, "covers"> {
|
|
|
99
99
|
note?: string;
|
|
100
100
|
/** The `id` of the decision message the run decided with, when it had one. */
|
|
101
101
|
decidedWith?: string;
|
|
102
|
+
/** Set when the run's own actor decided it: nobody else could (`mayDecide`). */
|
|
103
|
+
alone?: true;
|
|
102
104
|
/**
|
|
103
105
|
* Messages that were ignored: decisions sent by someone who may not decide the approval,
|
|
104
106
|
* and messages that were not a decision. `by` is the sender, when the message named one, and
|