@sanoma-ai/workflows 0.2.0 → 0.4.1

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.
Files changed (149) hide show
  1. package/README.md +146 -85
  2. package/dist/approvals.d.ts +11 -6
  3. package/dist/approvals.js +38 -10
  4. package/dist/approvals.js.map +1 -1
  5. package/dist/call-workflow.d.ts +16 -0
  6. package/dist/call-workflow.js +35 -0
  7. package/dist/call-workflow.js.map +1 -0
  8. package/dist/call.d.ts +33 -4
  9. package/dist/call.js +107 -42
  10. package/dist/call.js.map +1 -1
  11. package/dist/cedar.d.ts +87 -0
  12. package/dist/cedar.js +365 -0
  13. package/dist/cedar.js.map +1 -0
  14. package/dist/client.d.ts +85 -7
  15. package/dist/client.js +289 -25
  16. package/dist/client.js.map +1 -1
  17. package/dist/collection.d.ts +107 -0
  18. package/dist/collection.js +306 -0
  19. package/dist/collection.js.map +1 -0
  20. package/dist/config.d.ts +42 -14
  21. package/dist/config.js +82 -18
  22. package/dist/config.js.map +1 -1
  23. package/dist/constructors.d.ts +2 -0
  24. package/dist/constructors.js +5 -0
  25. package/dist/constructors.js.map +1 -0
  26. package/dist/data.d.ts +2 -0
  27. package/dist/data.js +3 -0
  28. package/dist/data.js.map +1 -0
  29. package/dist/datafile.d.ts +28 -2
  30. package/dist/datafile.js +118 -19
  31. package/dist/datafile.js.map +1 -1
  32. package/dist/db.d.ts +5 -3
  33. package/dist/db.js +88 -21
  34. package/dist/db.js.map +1 -1
  35. package/dist/define.d.ts +14 -5
  36. package/dist/define.js +1 -5
  37. package/dist/define.js.map +1 -1
  38. package/dist/describe.d.ts +45 -5
  39. package/dist/describe.js +72 -7
  40. package/dist/describe.js.map +1 -1
  41. package/dist/direct.d.ts +9 -0
  42. package/dist/direct.js +11 -0
  43. package/dist/direct.js.map +1 -0
  44. package/dist/errors.d.ts +16 -1
  45. package/dist/errors.js +30 -1
  46. package/dist/errors.js.map +1 -1
  47. package/dist/expectations.d.ts +3 -3
  48. package/dist/expectations.js +16 -7
  49. package/dist/expectations.js.map +1 -1
  50. package/dist/fill.d.ts +4 -0
  51. package/dist/fill.js +13 -0
  52. package/dist/fill.js.map +1 -1
  53. package/dist/index.d.ts +8 -4
  54. package/dist/index.js +10 -4
  55. package/dist/index.js.map +1 -1
  56. package/dist/ledger.d.ts +12 -1
  57. package/dist/ledger.js +18 -11
  58. package/dist/ledger.js.map +1 -1
  59. package/dist/lint.d.ts +5 -4
  60. package/dist/lint.js +24 -7
  61. package/dist/lint.js.map +1 -1
  62. package/dist/op.d.ts +6 -0
  63. package/dist/op.js.map +1 -1
  64. package/dist/outline.d.ts +2 -0
  65. package/dist/outline.js +1 -1
  66. package/dist/outline.js.map +1 -1
  67. package/dist/paths.d.ts +4 -0
  68. package/dist/paths.js +17 -3
  69. package/dist/paths.js.map +1 -1
  70. package/dist/person.d.ts +61 -0
  71. package/dist/person.js +69 -0
  72. package/dist/person.js.map +1 -0
  73. package/dist/plugin.d.ts +28 -0
  74. package/dist/plugin.js +52 -1
  75. package/dist/plugin.js.map +1 -1
  76. package/dist/policy.d.ts +51 -55
  77. package/dist/policy.js +14 -36
  78. package/dist/policy.js.map +1 -1
  79. package/dist/resource.d.ts +21 -5
  80. package/dist/resource.js +12 -3
  81. package/dist/resource.js.map +1 -1
  82. package/dist/resources.d.ts +58 -6
  83. package/dist/resources.js +119 -26
  84. package/dist/resources.js.map +1 -1
  85. package/dist/run.d.ts +17 -1
  86. package/dist/run.js.map +1 -1
  87. package/dist/scenario.d.ts +16 -9
  88. package/dist/scenario.js +149 -44
  89. package/dist/scenario.js.map +1 -1
  90. package/dist/scenarios.d.ts +10 -6
  91. package/dist/scenarios.js +8 -4
  92. package/dist/scenarios.js.map +1 -1
  93. package/dist/schema.d.ts +32 -0
  94. package/dist/schema.js +225 -0
  95. package/dist/schema.js.map +1 -0
  96. package/dist/sdk.d.ts +34 -0
  97. package/dist/sdk.js +168 -0
  98. package/dist/sdk.js.map +1 -0
  99. package/dist/shared.d.ts +181 -5
  100. package/dist/shared.js +160 -7
  101. package/dist/shared.js.map +1 -1
  102. package/dist/sites.d.ts +52 -0
  103. package/dist/sites.js +174 -0
  104. package/dist/sites.js.map +1 -0
  105. package/dist/version.d.ts +3 -2
  106. package/dist/version.js +3 -2
  107. package/dist/version.js.map +1 -1
  108. package/dist/worker.d.ts +3 -2
  109. package/dist/worker.js +24 -4
  110. package/dist/worker.js.map +1 -1
  111. package/oxlint.json +7 -20
  112. package/package.json +8 -3
  113. package/src/approvals.ts +49 -13
  114. package/src/call-workflow.ts +44 -0
  115. package/src/call.ts +157 -45
  116. package/src/cedar.ts +493 -0
  117. package/src/client.ts +368 -37
  118. package/src/collection.ts +431 -0
  119. package/src/config.ts +131 -34
  120. package/src/constructors.ts +4 -0
  121. package/src/data.ts +10 -0
  122. package/src/datafile.ts +141 -21
  123. package/src/db.ts +90 -22
  124. package/src/define.ts +15 -10
  125. package/src/describe.ts +113 -10
  126. package/src/direct.ts +15 -0
  127. package/src/errors.ts +36 -1
  128. package/src/expectations.ts +17 -8
  129. package/src/fill.ts +15 -0
  130. package/src/index.ts +33 -2
  131. package/src/ledger.ts +35 -17
  132. package/src/lint.ts +29 -12
  133. package/src/op.ts +6 -0
  134. package/src/outline.ts +1 -1
  135. package/src/paths.ts +18 -3
  136. package/src/person.ts +75 -0
  137. package/src/plugin.ts +74 -5
  138. package/src/policy.ts +59 -63
  139. package/src/resource.ts +35 -8
  140. package/src/resources.ts +165 -24
  141. package/src/run.ts +18 -1
  142. package/src/scenario.ts +186 -57
  143. package/src/scenarios.ts +15 -8
  144. package/src/schema.ts +261 -0
  145. package/src/sdk.ts +211 -0
  146. package/src/shared.ts +271 -8
  147. package/src/sites.ts +220 -0
  148. package/src/version.ts +3 -2
  149. 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, under which policy: the argument to `startWorker`.
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 and data files are
37
- * `scenarios/` and `resources/` in `root`.
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
- /** The operations the connectors declare, by id, and the built-in `sanoma.source.resolve` when one declares a host type. */
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
- * By name; each checked against `ops` and `drivers`. The config's, and the built-ins `drift`
112
- * (`DRIFT_WORKFLOW`) and `apply` (`APPLY_WORKFLOW`) when its connectors declare resource types.
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`), and from `root`, the app's name (`package.json`) and the
166
- * scenarios (`scenarios/`). Throws what `startWorker` would refuse, with the same message, so
167
- * `describeConfig`, `SanomaClient.connect` and the app fail the same way the worker does. It
168
- * opens nothing and reads no file outside `root`. Each entry
169
- * point resolves once: the worker and the client when they start, the app at `startApp`, the
170
- * `sanoma` bin when it loads the config.
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
- const ops = indexConnectors([...config.connectors, ...(hosts ? [sourceOps] : [])]);
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([...driversByVendor.values(), ...(hosts ? [sourceDriver] : [])], ops);
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 read = () => readDeclared(root, config.connectors, types);
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: resolveDatabaseUrl(config),
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: config.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, types);
396
+ const { resources, bindings, orphans, problems } = readDataFiles(root, connectors, declarable);
303
397
  return {
304
- resources: resources.map(({ id, vendor, type, name, identity, binding, desired, refs }) => ({
305
- id,
306
- vendor,
307
- type,
308
- name,
309
- ...(identity !== undefined && { identity }),
310
- ...(binding !== undefined && { binding }),
311
- desired: withoutWriteOnly(types.get(`${vendor}.${type}`)!.fields, desired),
312
- refs,
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
- /** Every name an `export const` declares, its value in the subset or not. */
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
- /** Where a data file imports `secret` from; checked before `CONSTRUCTORS`, which they match too. */
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: ${SHAPE}`);
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: ${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`,
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: ${SHAPE}`);
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
- if (SECRET_ENTRIES.has(spec)) {
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
- if (
194
- sp.type === "ImportSpecifier" &&
195
- sp.importKind !== "type" &&
196
- (sp.imported.name ?? sp.imported.value) === "secret"
197
- ) {
198
- secrets.add(sp.local.name);
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
- fail(sp, `a data file imports only \`secret\` from "${spec}": \`import { secret } from "${spec}"\``);
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: ${SHAPE}`);
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;