@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.
Files changed (184) hide show
  1. package/README.md +186 -76
  2. package/dist/apply-steps.d.ts +6 -0
  3. package/dist/apply-steps.js +93 -0
  4. package/dist/apply-steps.js.map +1 -0
  5. package/dist/apply.d.ts +197 -0
  6. package/dist/apply.js +470 -0
  7. package/dist/apply.js.map +1 -0
  8. package/dist/approvals.d.ts +11 -6
  9. package/dist/approvals.js +38 -10
  10. package/dist/approvals.js.map +1 -1
  11. package/dist/call-workflow.d.ts +16 -0
  12. package/dist/call-workflow.js +35 -0
  13. package/dist/call-workflow.js.map +1 -0
  14. package/dist/call.d.ts +35 -2
  15. package/dist/call.js +123 -43
  16. package/dist/call.js.map +1 -1
  17. package/dist/cedar.d.ts +87 -0
  18. package/dist/cedar.js +365 -0
  19. package/dist/cedar.js.map +1 -0
  20. package/dist/client.d.ts +123 -9
  21. package/dist/client.js +355 -34
  22. package/dist/client.js.map +1 -1
  23. package/dist/collection.d.ts +107 -0
  24. package/dist/collection.js +306 -0
  25. package/dist/collection.js.map +1 -0
  26. package/dist/config.d.ts +42 -14
  27. package/dist/config.js +109 -28
  28. package/dist/config.js.map +1 -1
  29. package/dist/constructors.d.ts +2 -0
  30. package/dist/constructors.js +5 -0
  31. package/dist/constructors.js.map +1 -0
  32. package/dist/credentials.d.ts +25 -0
  33. package/dist/credentials.js +73 -5
  34. package/dist/credentials.js.map +1 -1
  35. package/dist/data.d.ts +2 -0
  36. package/dist/data.js +3 -0
  37. package/dist/data.js.map +1 -0
  38. package/dist/datafile.d.ts +38 -3
  39. package/dist/datafile.js +166 -13
  40. package/dist/datafile.js.map +1 -1
  41. package/dist/db.d.ts +5 -3
  42. package/dist/db.js +88 -21
  43. package/dist/db.js.map +1 -1
  44. package/dist/define.d.ts +10 -4
  45. package/dist/define.js +1 -5
  46. package/dist/define.js.map +1 -1
  47. package/dist/describe.d.ts +54 -8
  48. package/dist/describe.js +84 -11
  49. package/dist/describe.js.map +1 -1
  50. package/dist/direct.d.ts +9 -0
  51. package/dist/direct.js +11 -0
  52. package/dist/direct.js.map +1 -0
  53. package/dist/drift.d.ts +64 -12
  54. package/dist/drift.js +118 -41
  55. package/dist/drift.js.map +1 -1
  56. package/dist/errors.d.ts +16 -1
  57. package/dist/errors.js +30 -1
  58. package/dist/errors.js.map +1 -1
  59. package/dist/expectations.d.ts +4 -4
  60. package/dist/expectations.js +43 -9
  61. package/dist/expectations.js.map +1 -1
  62. package/dist/host.d.ts +56 -0
  63. package/dist/host.js +45 -0
  64. package/dist/host.js.map +1 -0
  65. package/dist/index.d.ts +13 -5
  66. package/dist/index.js +15 -5
  67. package/dist/index.js.map +1 -1
  68. package/dist/ledger.d.ts +12 -1
  69. package/dist/ledger.js +18 -11
  70. package/dist/ledger.js.map +1 -1
  71. package/dist/lint.d.ts +5 -4
  72. package/dist/lint.js +24 -7
  73. package/dist/lint.js.map +1 -1
  74. package/dist/lockfile.d.ts +36 -0
  75. package/dist/lockfile.js +69 -0
  76. package/dist/lockfile.js.map +1 -0
  77. package/dist/op.d.ts +9 -0
  78. package/dist/op.js.map +1 -1
  79. package/dist/outline.d.ts +2 -0
  80. package/dist/outline.js +1 -1
  81. package/dist/outline.js.map +1 -1
  82. package/dist/paths.d.ts +4 -0
  83. package/dist/paths.js +17 -3
  84. package/dist/paths.js.map +1 -1
  85. package/dist/person.d.ts +61 -0
  86. package/dist/person.js +69 -0
  87. package/dist/person.js.map +1 -0
  88. package/dist/plugin.js +1 -1
  89. package/dist/plugin.js.map +1 -1
  90. package/dist/policy.d.ts +51 -55
  91. package/dist/policy.js +14 -36
  92. package/dist/policy.js.map +1 -1
  93. package/dist/resource.d.ts +207 -25
  94. package/dist/resource.js +184 -30
  95. package/dist/resource.js.map +1 -1
  96. package/dist/resources.d.ts +76 -9
  97. package/dist/resources.js +170 -30
  98. package/dist/resources.js.map +1 -1
  99. package/dist/run.d.ts +31 -1
  100. package/dist/run.js +6 -1
  101. package/dist/run.js.map +1 -1
  102. package/dist/sandbox.js +5 -1
  103. package/dist/sandbox.js.map +1 -1
  104. package/dist/scenario.d.ts +25 -9
  105. package/dist/scenario.js +125 -27
  106. package/dist/scenario.js.map +1 -1
  107. package/dist/scenarios.d.ts +10 -6
  108. package/dist/scenarios.js +8 -4
  109. package/dist/scenarios.js.map +1 -1
  110. package/dist/schema.d.ts +32 -0
  111. package/dist/schema.js +225 -0
  112. package/dist/schema.js.map +1 -0
  113. package/dist/sdk.d.ts +34 -0
  114. package/dist/sdk.js +168 -0
  115. package/dist/sdk.js.map +1 -0
  116. package/dist/secret.d.ts +42 -0
  117. package/dist/secret.js +58 -0
  118. package/dist/secret.js.map +1 -0
  119. package/dist/shared.d.ts +188 -4
  120. package/dist/shared.js +165 -5
  121. package/dist/shared.js.map +1 -1
  122. package/dist/sites.d.ts +52 -0
  123. package/dist/sites.js +174 -0
  124. package/dist/sites.js.map +1 -0
  125. package/dist/source.d.ts +58 -0
  126. package/dist/source.js +54 -0
  127. package/dist/source.js.map +1 -0
  128. package/dist/version.d.ts +3 -2
  129. package/dist/version.js +3 -2
  130. package/dist/version.js.map +1 -1
  131. package/dist/worker.d.ts +6 -2
  132. package/dist/worker.js +37 -8
  133. package/dist/worker.js.map +1 -1
  134. package/dist/writer.d.ts +35 -0
  135. package/dist/writer.js +201 -0
  136. package/dist/writer.js.map +1 -0
  137. package/oxlint.json +5 -16
  138. package/package.json +11 -1
  139. package/src/apply-steps.ts +110 -0
  140. package/src/apply.ts +626 -0
  141. package/src/approvals.ts +49 -13
  142. package/src/call-workflow.ts +44 -0
  143. package/src/call.ts +173 -46
  144. package/src/cedar.ts +493 -0
  145. package/src/client.ts +450 -49
  146. package/src/collection.ts +431 -0
  147. package/src/config.ts +168 -48
  148. package/src/constructors.ts +4 -0
  149. package/src/credentials.ts +90 -5
  150. package/src/data.ts +10 -0
  151. package/src/datafile.ts +207 -15
  152. package/src/db.ts +90 -22
  153. package/src/define.ts +11 -9
  154. package/src/describe.ts +146 -23
  155. package/src/direct.ts +15 -0
  156. package/src/drift.ts +155 -45
  157. package/src/errors.ts +36 -1
  158. package/src/expectations.ts +46 -11
  159. package/src/host.ts +96 -0
  160. package/src/index.ts +56 -2
  161. package/src/ledger.ts +35 -17
  162. package/src/lint.ts +29 -12
  163. package/src/lockfile.ts +95 -0
  164. package/src/op.ts +9 -0
  165. package/src/outline.ts +1 -1
  166. package/src/paths.ts +18 -3
  167. package/src/person.ts +75 -0
  168. package/src/plugin.ts +1 -1
  169. package/src/policy.ts +59 -63
  170. package/src/resource.ts +302 -52
  171. package/src/resources.ts +226 -30
  172. package/src/run.ts +33 -1
  173. package/src/sandbox.ts +5 -1
  174. package/src/scenario.ts +175 -43
  175. package/src/scenarios.ts +15 -8
  176. package/src/schema.ts +261 -0
  177. package/src/sdk.ts +211 -0
  178. package/src/secret.ts +92 -0
  179. package/src/shared.ts +279 -6
  180. package/src/sites.ts +220 -0
  181. package/src/source.ts +82 -0
  182. package/src/version.ts +3 -2
  183. package/src/worker.ts +45 -11
  184. 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
- /** A value in a data file: a literal, or a reference to a declared resource. */
26
- export type DataValue = null | boolean | number | string | Reference | DataValue[] | { [field: string]: DataValue };
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
- /** 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`. */
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: ${SHAPE}`);
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: ${SHAPE}`
141
- : `\`${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`,
142
204
  );
143
205
  break;
144
206
  }
145
207
  default:
146
- 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}`);
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: ${SHAPE}`);
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 = p.key.type === "Identifier" ? p.key.name : p.key.type === "Literal" ? String(p.key.value) : undefined;
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 { Client, Pool } from "pg";
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 and the
7
- // ledger. What they share is here: the one preparation of a database, a pool that opens once it
8
- // is prepared and survives a dropped connection, and a table creation two processes may race.
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
- /** Postgres's codes for a row type or a table another process made first. */
39
- const MADE_ALREADY = new Set(["23505", "42P07"]);
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 ledger tables, and its idle-transaction timeout (`guardIdleTransactions`).
129
- * Two processes preparing one database both succeed. A
130
- * preparation that fails is forgotten, so the next call tries again.
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
- // The database, or a DBOS table, made by another process meanwhile: it is there.
149
- if (!madeAlready(err)) throw new Error(`could not prepare the database: ${errorMessage(err)}`, { cause: err });
201
+ await conn.end().catch(() => undefined);
202
+ throw err;
150
203
  }
151
- const conn = new Client({ connectionString: databaseUrl });
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
- for (const [what, ddl] of TABLES) await createTable(conn, ddl, what);
155
- await guardIdleTransactions(conn);
224
+ await admin.query(`CREATE DATABASE ${escapeIdentifier(name)}`);
225
+ } catch (err) {
226
+ if (!madeAlready(err)) throw err;
156
227
  } finally {
157
- await conn.end();
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. One process at a time (an advisory lock), so two preparing at once
165
- * both succeed. A role that may not alter the database leaves it, quietly: its administrator decides.
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 service that starts a run or decides an approval. `id` is
9
- * whatever the deployment identifies people by; `groups` are the roles it vouches for.
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