@sanoma-ai/workflows 0.2.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 +121 -62
- 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 +33 -4
- package/dist/call.js +107 -42
- 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 +85 -7
- package/dist/client.js +289 -25
- 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 +82 -18
- 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/data.d.ts +2 -0
- package/dist/data.js +3 -0
- package/dist/data.js.map +1 -0
- package/dist/datafile.d.ts +28 -2
- package/dist/datafile.js +118 -19
- 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 +45 -5
- package/dist/describe.js +72 -7
- 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/errors.d.ts +16 -1
- package/dist/errors.js +30 -1
- package/dist/errors.js.map +1 -1
- package/dist/expectations.d.ts +3 -3
- package/dist/expectations.js +16 -7
- package/dist/expectations.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.js +10 -4
- 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/op.d.ts +6 -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 +21 -5
- package/dist/resource.js +12 -3
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +58 -6
- package/dist/resources.js +119 -26
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +17 -1
- package/dist/run.js.map +1 -1
- package/dist/scenario.d.ts +16 -9
- package/dist/scenario.js +93 -20
- 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/shared.d.ts +181 -5
- package/dist/shared.js +160 -7
- 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/version.d.ts +3 -2
- package/dist/version.js +3 -2
- package/dist/version.js.map +1 -1
- package/dist/worker.d.ts +3 -2
- package/dist/worker.js +24 -4
- package/dist/worker.js.map +1 -1
- package/oxlint.json +5 -16
- package/package.json +8 -3
- package/src/approvals.ts +49 -13
- package/src/call-workflow.ts +44 -0
- package/src/call.ts +157 -45
- package/src/cedar.ts +493 -0
- package/src/client.ts +368 -37
- package/src/collection.ts +431 -0
- package/src/config.ts +131 -34
- package/src/constructors.ts +4 -0
- package/src/data.ts +10 -0
- package/src/datafile.ts +141 -21
- package/src/db.ts +90 -22
- package/src/define.ts +11 -9
- package/src/describe.ts +113 -10
- package/src/direct.ts +15 -0
- package/src/errors.ts +36 -1
- package/src/expectations.ts +17 -8
- package/src/index.ts +33 -2
- package/src/ledger.ts +35 -17
- package/src/lint.ts +29 -12
- package/src/op.ts +6 -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 +35 -8
- package/src/resources.ts +165 -24
- package/src/run.ts +18 -1
- package/src/scenario.ts +128 -33
- package/src/scenarios.ts +15 -8
- package/src/schema.ts +261 -0
- package/src/sdk.ts +211 -0
- package/src/shared.ts +271 -8
- package/src/sites.ts +220 -0
- package/src/version.ts +3 -2
- package/src/worker.ts +29 -6
package/src/config.ts
CHANGED
|
@@ -4,10 +4,20 @@ import { pathToFileURL } from "node:url";
|
|
|
4
4
|
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
5
5
|
import { type ApplyDeclared, applyWorkflow } from "./apply.ts";
|
|
6
6
|
import { applySteps } from "./apply-steps.ts";
|
|
7
|
+
import { callWorkflow } from "./call-workflow.ts";
|
|
8
|
+
import {
|
|
9
|
+
type Collection,
|
|
10
|
+
collectionsConnector,
|
|
11
|
+
collectionsDriver,
|
|
12
|
+
collectionsFake,
|
|
13
|
+
isCollection,
|
|
14
|
+
} from "./collection.ts";
|
|
15
|
+
import { lazyPool, type LazyPool } from "./db.ts";
|
|
7
16
|
import { callerFile, type Use, type WorkflowDefinition } from "./define.ts";
|
|
8
17
|
import { DRIFT_WORKFLOW, driftWorkflow } from "./drift.ts";
|
|
9
18
|
import { statusesOf, unconfigured } from "./env.ts";
|
|
10
19
|
import type { Fake } from "./fake.ts";
|
|
20
|
+
import { peopleConnector } from "./person.ts";
|
|
11
21
|
import type { LedgerStore } from "./ledger.ts";
|
|
12
22
|
import {
|
|
13
23
|
type Connector,
|
|
@@ -22,19 +32,20 @@ import {
|
|
|
22
32
|
setupOf,
|
|
23
33
|
VENDOR,
|
|
24
34
|
} from "./op.ts";
|
|
35
|
+
import { policyAt } from "./cedar.ts";
|
|
25
36
|
import type { Policy } from "./policy.ts";
|
|
26
37
|
import { isChangeOp, type Resource, resourceTypesOf, withoutWriteOnly } from "./resource.ts";
|
|
27
|
-
import { APPLY_WORKFLOW, BUILT_IN_WORKFLOWS } from "./shared.ts";
|
|
38
|
+
import { APPLY_WORKFLOW, BUILT_IN_WORKFLOWS, CALL_WORKFLOW, DATA_VENDOR } from "./shared.ts";
|
|
28
39
|
import { sourceDriver, sourceOps } from "./source.ts";
|
|
29
40
|
import type { Steps } from "./steps.ts";
|
|
30
41
|
import { computeVersion } from "./version.ts";
|
|
31
42
|
|
|
32
43
|
/**
|
|
33
|
-
* What a worker runs, on which connectors
|
|
44
|
+
* What a worker runs, on which connectors: the argument to `startWorker`.
|
|
34
45
|
* A project usually keeps it in `sanoma.config.ts` and passes it in; nothing loads that file
|
|
35
46
|
* automatically. The rest is convention: each connector carries its driver and its fake
|
|
36
|
-
* (`withSetup`), the app's name is its `package.json`'s, and the scenarios
|
|
37
|
-
* `scenarios/` and `
|
|
47
|
+
* (`withSetup`), the app's name is its `package.json`'s, and the scenarios, data files and
|
|
48
|
+
* policies are `scenarios/`, `resources/` and `policies/` in `root`.
|
|
38
49
|
*/
|
|
39
50
|
export interface SanomaConfig {
|
|
40
51
|
workflows: WorkflowDefinition<any, any>[];
|
|
@@ -46,8 +57,6 @@ export interface SanomaConfig {
|
|
|
46
57
|
* package attaches the maker of.
|
|
47
58
|
*/
|
|
48
59
|
connectors: Connector<any, any>[];
|
|
49
|
-
/** Checked before every operation call. Required: `allowAll` says that every call is allowed. */
|
|
50
|
-
policy: Policy;
|
|
51
60
|
/**
|
|
52
61
|
* Where the audit record goes. Default: `postgresLedger` on the config's database, which the
|
|
53
62
|
* worker, the app and other processes share; the worker and the client each make their own
|
|
@@ -74,8 +83,15 @@ export interface SanomaConfig {
|
|
|
74
83
|
appName?: never;
|
|
75
84
|
/** @deprecated Gone: the scenarios are `<root>/scenarios/`. Leave it out. */
|
|
76
85
|
scenarios?: never;
|
|
86
|
+
/** @deprecated Gone: the policy is `<root>/policies/**\/*.cedar`. Leave it out. */
|
|
87
|
+
policy?: never;
|
|
77
88
|
/** The company's own steps for its scenarios (`defineSteps`), besides those its connectors ship. */
|
|
78
89
|
steps?: Steps[];
|
|
90
|
+
/**
|
|
91
|
+
* The company's collections (`collection()`): their operations are `data.<name>.<op>`, their
|
|
92
|
+
* records in the config's database, and in memory for a sandbox run.
|
|
93
|
+
*/
|
|
94
|
+
collections?: readonly Collection[];
|
|
79
95
|
}
|
|
80
96
|
|
|
81
97
|
/** A config, checked, with everything a worker, client or app derives from it. */
|
|
@@ -90,7 +106,10 @@ export interface ResolvedConfig {
|
|
|
90
106
|
version: string;
|
|
91
107
|
/** The DBOS queue runs are started on: `sanoma:<appName>`. */
|
|
92
108
|
queueName: string;
|
|
93
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* The operations the connectors declare, by id, the built-in `sanoma.source.resolve` when one
|
|
111
|
+
* declares a host type, and the collections' (`data.<name>.<op>`).
|
|
112
|
+
*/
|
|
94
113
|
ops: Map<string, Op>;
|
|
95
114
|
/** The drivers' functions, by operation id, each guarded by its driver's `env` (`indexDrivers`). */
|
|
96
115
|
drivers: Map<string, DriverFn>;
|
|
@@ -107,12 +126,27 @@ export interface ResolvedConfig {
|
|
|
107
126
|
types: ReadonlyMap<string, Resource>;
|
|
108
127
|
/** The steps scenarios can use besides the generic ones: each connector's (`withSetup`), once each, then the config's own. */
|
|
109
128
|
steps: readonly Steps[];
|
|
129
|
+
/** The config's collections, by name: their operations are in `ops` and `drivers`, their fake in `fakes`, under `DATA_VENDOR`. */
|
|
130
|
+
collections: ReadonlyMap<string, Collection>;
|
|
110
131
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
132
|
+
* The pool the collections' driver keeps their records through, when there are any: opened on
|
|
133
|
+
* its first call, and closed by whoever calls the driver (the worker, and the client, which
|
|
134
|
+
* calls it directly, when each stops).
|
|
135
|
+
*/
|
|
136
|
+
dataStore?: LazyPool;
|
|
137
|
+
/**
|
|
138
|
+
* By name; each checked against `ops` and `drivers`. The config's, the built-ins `drift`
|
|
139
|
+
* (`DRIFT_WORKFLOW`) and `apply` (`APPLY_WORKFLOW`) when its connectors declare resource types,
|
|
140
|
+
* and `call` (`CALL_WORKFLOW`) when an operation may be called directly (`isDirect`).
|
|
113
141
|
*/
|
|
114
142
|
workflows: Map<string, WorkflowDefinition<any, any>>;
|
|
143
|
+
/**
|
|
144
|
+
* Checked before every operation call and run start: the root's `policies/**\/*.cedar`
|
|
145
|
+
* (`policyAt`), `allowAll` without any, and one that refuses everything while they have problems.
|
|
146
|
+
*/
|
|
115
147
|
policy: Policy;
|
|
148
|
+
/** What is wrong in the policies, one printable line each (`file:line:col: <policy id>: <message>`). */
|
|
149
|
+
policyProblems: string[];
|
|
116
150
|
/** The config's `ledger`, when it gives one; the worker and the client otherwise make `postgresLedger` on `databaseUrl` (`ownedLedger`). */
|
|
117
151
|
ledger?: LedgerStore;
|
|
118
152
|
/**
|
|
@@ -162,12 +196,13 @@ export function rootOf(config: Pick<SanomaConfig, "root" | "file">): string | un
|
|
|
162
196
|
|
|
163
197
|
/**
|
|
164
198
|
* Checks a config and derives what the runtime needs from it: the drivers and fakes from the
|
|
165
|
-
* connectors' setups (`withSetup`),
|
|
166
|
-
*
|
|
167
|
-
* `
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* `
|
|
199
|
+
* connectors' setups (`withSetup`), the collections' operations, driver and fake, and from
|
|
200
|
+
* `root`, the app's name (`package.json`), the scenarios (`scenarios/`) and the policy
|
|
201
|
+
* (`policies/`, `policyAt`). Throws what
|
|
202
|
+
* `startWorker` would refuse, with the same message, so `describeConfig`, `SanomaClient.connect`
|
|
203
|
+
* and the app fail the same way the worker does. It opens nothing and reads no file outside
|
|
204
|
+
* `root`. Each entry point resolves once: the worker and the client when they start, the app at
|
|
205
|
+
* `startApp`, the `sanoma` bin when it loads the config.
|
|
171
206
|
*/
|
|
172
207
|
export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
173
208
|
if (!Array.isArray(config.connectors)) {
|
|
@@ -177,14 +212,16 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
177
212
|
if (!Array.isArray(config.workflows)) {
|
|
178
213
|
throw new Error("The config needs `workflows`: an array of defineWorkflow definitions");
|
|
179
214
|
}
|
|
180
|
-
if (typeof config.policy !== "function") {
|
|
181
|
-
throw new Error("The config needs a `policy`; use `allowAll` to allow every operation call");
|
|
182
|
-
}
|
|
183
215
|
// An untyped config naming fake drivers would otherwise run live. Delete once sanoma-ops has migrated.
|
|
184
216
|
if (config.drivers !== undefined || config.fakes !== undefined)
|
|
185
217
|
throw new Error(
|
|
186
218
|
"The config's `drivers` and `fakes` are gone: each connector carries its driver and its fake; leave them out",
|
|
187
219
|
);
|
|
220
|
+
// An untyped config naming its policy would otherwise run allowing everything. Delete once sanoma-ops has migrated.
|
|
221
|
+
if (config.policy !== undefined) {
|
|
222
|
+
throw new Error("The config's `policy` is gone: the policy is policies/*.cedar in the root; leave it out");
|
|
223
|
+
}
|
|
224
|
+
const collections = collectionsOf(config);
|
|
188
225
|
const ledger = ledgerOf(config);
|
|
189
226
|
const root = rootOf(config);
|
|
190
227
|
const appName = appNameAt(root);
|
|
@@ -192,10 +229,26 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
192
229
|
const types = resourceTypesOf(config.connectors);
|
|
193
230
|
// Built-in: `sanoma.source.resolve`, which apply and drift call for a host's revision.
|
|
194
231
|
const hosts = [...types.values()].some((type) => type.host);
|
|
195
|
-
|
|
232
|
+
// Built-in: the collections' operations, `data.<name>.<op>`, on the config's database.
|
|
233
|
+
const databaseUrl = resolveDatabaseUrl(config);
|
|
234
|
+
const declared = [...collections.values()];
|
|
235
|
+
const dataStore = declared.length ? lazyPool(databaseUrl, "data") : undefined;
|
|
236
|
+
const ops = indexConnectors([
|
|
237
|
+
...config.connectors,
|
|
238
|
+
...(hosts ? [sourceOps] : []),
|
|
239
|
+
...(dataStore ? [collectionsConnector(declared)] : []),
|
|
240
|
+
]);
|
|
196
241
|
const driversByVendor = new Map([...makersOf(config.connectors, "driver")].map(([vendor, make]) => [vendor, make()]));
|
|
197
|
-
const drivers = indexDrivers(
|
|
242
|
+
const drivers = indexDrivers(
|
|
243
|
+
[
|
|
244
|
+
...driversByVendor.values(),
|
|
245
|
+
...(hosts ? [sourceDriver] : []),
|
|
246
|
+
...(dataStore ? [collectionsDriver(declared, dataStore)] : []),
|
|
247
|
+
],
|
|
248
|
+
ops,
|
|
249
|
+
);
|
|
198
250
|
const fakes = makersOf(config.connectors, "fake");
|
|
251
|
+
if (dataStore) fakes.set(DATA_VENDOR, () => collectionsFake(declared));
|
|
199
252
|
const names = new Map<string, WorkflowDefinition<any, any>>();
|
|
200
253
|
for (const wf of config.workflows) {
|
|
201
254
|
if (BUILT_IN_WORKFLOWS.has(wf.name)) {
|
|
@@ -212,16 +265,23 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
212
265
|
}
|
|
213
266
|
// Built-in: checking the resources the data files declare against their vendors, and applying them.
|
|
214
267
|
if (types.size) {
|
|
215
|
-
const
|
|
268
|
+
const connectors = connectorsOf(config);
|
|
269
|
+
// The types the data files may declare: the vendors' and the people's, whose resources are left out.
|
|
270
|
+
const declarable = resourceTypesOf(connectors);
|
|
271
|
+
const read = () => readDeclared(root, connectors, declarable, types);
|
|
216
272
|
names.set(
|
|
217
273
|
DRIFT_WORKFLOW,
|
|
218
274
|
driftWorkflow(types, ops, drivers, () => DBOS.runStep(read, { name: "drift:data-files" })),
|
|
219
275
|
);
|
|
220
276
|
names.set(APPLY_WORKFLOW, applyWorkflow(types, ops, drivers, applySteps(appName, root, read)));
|
|
221
277
|
}
|
|
278
|
+
// Built-in: a direct call the policy holds for an approval, made a run, when any operation may be called directly.
|
|
279
|
+
const call = callWorkflow(ops, drivers, types);
|
|
280
|
+
if (call.uses.length) names.set(CALL_WORKFLOW, call);
|
|
281
|
+
const { policy, problems: policyProblems } = policyAt(root, { ops, workflows: names, collections });
|
|
222
282
|
return {
|
|
223
283
|
appName,
|
|
224
|
-
databaseUrl
|
|
284
|
+
databaseUrl,
|
|
225
285
|
// The built-in's code and operations are part of the app's version, like the config's own.
|
|
226
286
|
version: computeVersion({ appName, workflows: [...names.values()] }),
|
|
227
287
|
queueName: `sanoma:${appName}`,
|
|
@@ -232,13 +292,46 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
232
292
|
...(scenarios && { scenarios }),
|
|
233
293
|
types,
|
|
234
294
|
steps: [...new Set([...config.connectors.flatMap((c) => setupOf(c).steps ?? []), ...(config.steps ?? [])])],
|
|
295
|
+
collections,
|
|
296
|
+
...(dataStore && { dataStore }),
|
|
235
297
|
workflows: names,
|
|
236
|
-
policy
|
|
298
|
+
policy,
|
|
299
|
+
policyProblems,
|
|
237
300
|
...(ledger !== undefined && { ledger }),
|
|
238
301
|
...(root !== undefined && { root }),
|
|
239
302
|
};
|
|
240
303
|
}
|
|
241
304
|
|
|
305
|
+
/**
|
|
306
|
+
* The config's collections by name, checked: each one `collection()` made, no two with one name,
|
|
307
|
+
* and no connector of their vendor, `DATA_VENDOR`.
|
|
308
|
+
*/
|
|
309
|
+
function collectionsOf(config: SanomaConfig): Map<string, Collection> {
|
|
310
|
+
const list: unknown = config.collections ?? [];
|
|
311
|
+
if (!Array.isArray(list)) {
|
|
312
|
+
throw new Error("The config's `collections` must be an array of the collections `collection()` declares");
|
|
313
|
+
}
|
|
314
|
+
if (config.connectors.some((c) => c[VENDOR].id === DATA_VENDOR)) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
`A connector in \`connectors\` is of the vendor "${DATA_VENDOR}", which the collections' operations are of; name it otherwise`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
const collections = new Map<string, Collection>();
|
|
320
|
+
for (const c of list) {
|
|
321
|
+
if (!isCollection(c)) throw new Error("`collections` holds something `collection()` did not make");
|
|
322
|
+
const other = collections.get(c.name);
|
|
323
|
+
if (other && other !== c) throw new Error(`Two collections are named "${c.name}"; name each once`);
|
|
324
|
+
collections.set(c.name, c);
|
|
325
|
+
}
|
|
326
|
+
return collections;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The config's connectors and the runtime's people types' (`peopleConnector`): what its data files are read against. */
|
|
330
|
+
export const connectorsOf = (config: Pick<SanomaConfig, "connectors">): Connector<any, any>[] => [
|
|
331
|
+
...config.connectors,
|
|
332
|
+
peopleConnector,
|
|
333
|
+
];
|
|
334
|
+
|
|
242
335
|
/**
|
|
243
336
|
* What the connectors' setups attach under `key` (`withSetup`), by vendor id in `connectors`
|
|
244
337
|
* order. One vendor's connectors may name one maker, as copies of one connector do, never two.
|
|
@@ -295,22 +388,26 @@ function scenariosAt(root: string | undefined): URL | undefined {
|
|
|
295
388
|
async function readDeclared(
|
|
296
389
|
root: string | undefined,
|
|
297
390
|
connectors: readonly Connector<any, any>[],
|
|
391
|
+
declarable: ReadonlyMap<string, Resource>,
|
|
298
392
|
types: ReadonlyMap<string, Resource>,
|
|
299
393
|
): Promise<Omit<ApplyDeclared, "rebinds">> {
|
|
300
394
|
// Loaded when a run reads the files, not with the worker: the reader parses with oxc-parser.
|
|
301
395
|
const { readDataFiles } = await import("./resources.ts");
|
|
302
|
-
const { resources, bindings, orphans, problems } = readDataFiles(root, connectors,
|
|
396
|
+
const { resources, bindings, orphans, problems } = readDataFiles(root, connectors, declarable);
|
|
303
397
|
return {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
vendor
|
|
307
|
-
type,
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
398
|
+
// Only `types`' resources have a vendor object to check or apply: not the people's (vendorless).
|
|
399
|
+
resources: resources
|
|
400
|
+
.filter((r) => types.has(`${r.vendor}.${r.type}`))
|
|
401
|
+
.map(({ id, vendor, type, name, identity, binding, desired, refs }) => ({
|
|
402
|
+
id,
|
|
403
|
+
vendor,
|
|
404
|
+
type,
|
|
405
|
+
name,
|
|
406
|
+
...(identity !== undefined && { identity }),
|
|
407
|
+
...(binding !== undefined && { binding }),
|
|
408
|
+
desired: withoutWriteOnly(types.get(`${vendor}.${type}`)!.fields, desired),
|
|
409
|
+
refs,
|
|
410
|
+
})),
|
|
314
411
|
bindings,
|
|
315
412
|
orphans,
|
|
316
413
|
problems,
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// `@sanoma-ai/workflows/resources`: what a data file imports from the runtime: `secret`, which
|
|
2
|
+
// names a stored secret, and `sanoma`, the constructors of its people types (`sanoma.person`, …).
|
|
3
|
+
export { secret, type Secret } from "./secret.ts";
|
|
4
|
+
export { sanoma } from "./person.ts";
|
package/src/data.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// `@sanoma-ai/workflows/data`: what a company's `resources/data.ts` declares its collections with.
|
|
2
|
+
export {
|
|
3
|
+
collection,
|
|
4
|
+
type Collection,
|
|
5
|
+
type Field,
|
|
6
|
+
type FieldType,
|
|
7
|
+
type RecordOf,
|
|
8
|
+
type RequiredTextKeys,
|
|
9
|
+
} from "./collection.ts";
|
|
10
|
+
export type { ListQuery, Saved } from "./shared.ts";
|
package/src/datafile.ts
CHANGED
|
@@ -1,7 +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
4
|
import { type Secret, SECRET_NAME } from "./secret.ts";
|
|
5
|
+
import { checkCollection, errorMessage } from "./shared.ts";
|
|
5
6
|
|
|
6
7
|
// The one definition of what a data file may hold, over an ESTree program from oxc: the reader
|
|
7
8
|
// (`resources.ts`), `lintResources` (`lint.ts`) and the oxlint plugin (`plugin.ts`) all
|
|
@@ -68,11 +69,24 @@ export interface DataFileExport {
|
|
|
68
69
|
end: number;
|
|
69
70
|
}
|
|
70
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
|
+
|
|
71
83
|
export interface DataFile {
|
|
72
84
|
imports: DataFileImport[];
|
|
73
85
|
/** Each `export const` whose value is in the subset. */
|
|
74
86
|
exports: DataFileExport[];
|
|
75
|
-
/**
|
|
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`. */
|
|
76
90
|
declared: string[];
|
|
77
91
|
problems: DataFileProblem[];
|
|
78
92
|
}
|
|
@@ -80,8 +94,22 @@ export interface DataFile {
|
|
|
80
94
|
/** A connector's constructors entry: a package specifier that ends in `/resources`. */
|
|
81
95
|
const CONSTRUCTORS = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*\/resources$/;
|
|
82
96
|
|
|
83
|
-
/**
|
|
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
|
+
*/
|
|
84
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`;
|
|
85
113
|
const SECRET_CALL = '`secret("NAME")` or `secret("NAME", { generate: true })`, NAME upper case (A-Z, 0-9 and _)';
|
|
86
114
|
|
|
87
115
|
const SHAPE =
|
|
@@ -101,6 +129,10 @@ const VALUES = "write a string, number, boolean, null, object or array, or name
|
|
|
101
129
|
* `export const <name> = <vendor>.<type>({ … })`, one per statement, its argument built of
|
|
102
130
|
* strings (template literals without `${}` too), numbers, booleans, null, objects and arrays,
|
|
103
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.
|
|
104
136
|
*
|
|
105
137
|
* `root` is the directory relative imports must stay in: by default the nearest `resources/`
|
|
106
138
|
* directory above `filename`, else the file's own.
|
|
@@ -115,10 +147,15 @@ export function readDataFile(
|
|
|
115
147
|
problems.push({ start: node.start, end: node.end, message });
|
|
116
148
|
const imports: DataFileImport[] = [];
|
|
117
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;
|
|
118
154
|
// Names a refused import or declaration binds: reported once, where they are bound.
|
|
119
155
|
const refused = new Set<string>();
|
|
120
|
-
// The local names of `secret`.
|
|
156
|
+
// The local names of `secret`, and of `collection`.
|
|
121
157
|
const secrets = new Set<string>();
|
|
158
|
+
const collectionCalls = new Set<string>();
|
|
122
159
|
|
|
123
160
|
// Imports and top-level names first, each once: imports are hoisted, and a reference may name
|
|
124
161
|
// a resource declared further down. An unexported const is refused where it is declared, not
|
|
@@ -155,20 +192,20 @@ export function readDataFile(
|
|
|
155
192
|
readDefault(s);
|
|
156
193
|
break;
|
|
157
194
|
case "ExportAllDeclaration":
|
|
158
|
-
fail(s, `export * is not allowed in a data file: ${
|
|
195
|
+
fail(s, `export * is not allowed in a data file: ${shape}`);
|
|
159
196
|
break;
|
|
160
197
|
case "VariableDeclaration": {
|
|
161
198
|
const name = s.declarations[0]?.id?.name;
|
|
162
199
|
fail(
|
|
163
200
|
s,
|
|
164
201
|
name === undefined
|
|
165
|
-
? `an unexported declaration is not allowed in a data file: ${
|
|
166
|
-
: `\`${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`,
|
|
167
204
|
);
|
|
168
205
|
break;
|
|
169
206
|
}
|
|
170
207
|
default:
|
|
171
|
-
fail(s, `${describe(s)} is not allowed in a data file: ${
|
|
208
|
+
fail(s, `${describe(s)} is not allowed in a data file: ${shape}`);
|
|
172
209
|
}
|
|
173
210
|
}
|
|
174
211
|
|
|
@@ -178,6 +215,16 @@ export function readDataFile(
|
|
|
178
215
|
fail(s, "`import type` is not allowed in a data file: it holds values only, and the constructors type them");
|
|
179
216
|
return;
|
|
180
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
|
+
}
|
|
181
228
|
const refusal = whyRefused(spec);
|
|
182
229
|
if (refusal) {
|
|
183
230
|
fail(s.source, `import "${spec}" is not allowed in a data file: ${refusal}`);
|
|
@@ -188,16 +235,26 @@ export function readDataFile(
|
|
|
188
235
|
fail(s, `import "${spec}" is not allowed in a data file: it imports nothing, and a data file runs no code`);
|
|
189
236
|
return;
|
|
190
237
|
}
|
|
191
|
-
|
|
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) {
|
|
192
245
|
for (const sp of s.specifiers) {
|
|
193
|
-
|
|
194
|
-
sp.type === "ImportSpecifier" &&
|
|
195
|
-
|
|
196
|
-
(sp.
|
|
197
|
-
) {
|
|
198
|
-
|
|
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 });
|
|
199
253
|
} else {
|
|
200
|
-
|
|
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}"\``);
|
|
201
258
|
refused.add(sp.local.name);
|
|
202
259
|
}
|
|
203
260
|
}
|
|
@@ -221,8 +278,11 @@ export function readDataFile(
|
|
|
221
278
|
|
|
222
279
|
/** Why an import's specifier is not allowed in a data file, or undefined when it is. */
|
|
223
280
|
function whyRefused(spec: string): string | undefined {
|
|
224
|
-
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;
|
|
225
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
|
+
}
|
|
226
286
|
if (!inside(root, join(dirname(filename), spec))) {
|
|
227
287
|
return `it reaches outside ${basename(root)}/; import only other data files`;
|
|
228
288
|
}
|
|
@@ -241,7 +301,7 @@ export function readDataFile(
|
|
|
241
301
|
return;
|
|
242
302
|
}
|
|
243
303
|
if (decl.type !== "VariableDeclaration") {
|
|
244
|
-
fail(s, `${describe(decl)} is not allowed in a data file: ${
|
|
304
|
+
fail(s, `${describe(decl)} is not allowed in a data file: ${shape}`);
|
|
245
305
|
return;
|
|
246
306
|
}
|
|
247
307
|
if (decl.kind !== "const") {
|
|
@@ -259,9 +319,16 @@ export function readDataFile(
|
|
|
259
319
|
}
|
|
260
320
|
const name: string = d.id.name;
|
|
261
321
|
// A second declaration of the name was reported where it is bound.
|
|
262
|
-
if (exports.some((e) => e.name === name)) return;
|
|
322
|
+
if (exports.some((e) => e.name === name) || collections.some((c) => c.export === name)) return;
|
|
263
323
|
const init = d.init;
|
|
264
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
|
+
}
|
|
265
332
|
if (
|
|
266
333
|
callee?.type !== "MemberExpression" ||
|
|
267
334
|
callee.computed ||
|
|
@@ -276,7 +343,6 @@ export function readDataFile(
|
|
|
276
343
|
}
|
|
277
344
|
const vendor: string = callee.object.name;
|
|
278
345
|
const type: string = callee.property.name;
|
|
279
|
-
if (refused.has(vendor)) return;
|
|
280
346
|
if (imports.find((i) => i.local === vendor)?.kind !== "constructors") {
|
|
281
347
|
fail(
|
|
282
348
|
callee.object,
|
|
@@ -295,7 +361,51 @@ export function readDataFile(
|
|
|
295
361
|
}
|
|
296
362
|
}
|
|
297
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
|
+
|
|
298
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
|
+
}
|
|
299
409
|
const list = s.declaration;
|
|
300
410
|
if (list.type !== "ArrayExpression") {
|
|
301
411
|
fail(list, "export default must list this file's resources: `export default [a, b]`");
|
|
@@ -373,6 +483,9 @@ export function readDataFile(
|
|
|
373
483
|
}
|
|
374
484
|
case "CallExpression":
|
|
375
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
|
+
}
|
|
376
489
|
if (node.callee.type === "Identifier" && node.callee.name === "secret" && !bound.has("secret")) {
|
|
377
490
|
return refuse(node, `import secret to name one: \`import { secret } from "sanoma/resources"\``);
|
|
378
491
|
}
|
|
@@ -464,9 +577,16 @@ export function readDataFile(
|
|
|
464
577
|
return undefined;
|
|
465
578
|
}
|
|
466
579
|
|
|
467
|
-
return { imports, exports, declared: [...declared], problems };
|
|
580
|
+
return { imports, exports, collections, declared: collectionsFile ? [] : [...declared], problems };
|
|
468
581
|
}
|
|
469
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
|
+
|
|
470
590
|
/** A property's name: an identifier's, or a literal's as a string; undefined for any other key. */
|
|
471
591
|
export const keyName = (key: Node): string | undefined =>
|
|
472
592
|
key.type === "Identifier" ? key.name : key.type === "Literal" ? String(key.value) : undefined;
|