@sanoma-ai/workflows 0.0.0-stage → 0.1.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 (148) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +493 -2
  3. package/dist/approvals.d.ts +46 -0
  4. package/dist/approvals.js +111 -0
  5. package/dist/approvals.js.map +1 -0
  6. package/dist/ast.d.ts +57 -0
  7. package/dist/ast.js +81 -0
  8. package/dist/ast.js.map +1 -0
  9. package/dist/call.d.ts +33 -0
  10. package/dist/call.js +402 -0
  11. package/dist/call.js.map +1 -0
  12. package/dist/client.d.ts +167 -0
  13. package/dist/client.js +400 -0
  14. package/dist/client.js.map +1 -0
  15. package/dist/config.d.ts +126 -0
  16. package/dist/config.js +247 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/credentials.d.ts +30 -0
  19. package/dist/credentials.js +193 -0
  20. package/dist/credentials.js.map +1 -0
  21. package/dist/cucumber.d.ts +64 -0
  22. package/dist/cucumber.js +158 -0
  23. package/dist/cucumber.js.map +1 -0
  24. package/dist/datafile.d.ts +62 -0
  25. package/dist/datafile.js +326 -0
  26. package/dist/datafile.js.map +1 -0
  27. package/dist/db.d.ts +58 -0
  28. package/dist/db.js +179 -0
  29. package/dist/db.js.map +1 -0
  30. package/dist/define.d.ts +205 -0
  31. package/dist/define.js +103 -0
  32. package/dist/define.js.map +1 -0
  33. package/dist/describe.d.ts +135 -0
  34. package/dist/describe.js +127 -0
  35. package/dist/describe.js.map +1 -0
  36. package/dist/drift.d.ts +75 -0
  37. package/dist/drift.js +90 -0
  38. package/dist/drift.js.map +1 -0
  39. package/dist/env.d.ts +49 -0
  40. package/dist/env.js +83 -0
  41. package/dist/env.js.map +1 -0
  42. package/dist/errors.d.ts +88 -0
  43. package/dist/errors.js +148 -0
  44. package/dist/errors.js.map +1 -0
  45. package/dist/expectations.d.ts +26 -0
  46. package/dist/expectations.js +137 -0
  47. package/dist/expectations.js.map +1 -0
  48. package/dist/fake.d.ts +55 -0
  49. package/dist/fake.js +87 -0
  50. package/dist/fake.js.map +1 -0
  51. package/dist/fill.d.ts +9 -0
  52. package/dist/fill.js +17 -0
  53. package/dist/fill.js.map +1 -0
  54. package/dist/index.d.ts +19 -0
  55. package/dist/index.js +25 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/ledger.d.ts +217 -0
  58. package/dist/ledger.js +251 -0
  59. package/dist/ledger.js.map +1 -0
  60. package/dist/lint.d.ts +32 -0
  61. package/dist/lint.js +168 -0
  62. package/dist/lint.js.map +1 -0
  63. package/dist/log.d.ts +24 -0
  64. package/dist/log.js +83 -0
  65. package/dist/log.js.map +1 -0
  66. package/dist/op.d.ts +257 -0
  67. package/dist/op.js +188 -0
  68. package/dist/op.js.map +1 -0
  69. package/dist/outline.d.ts +179 -0
  70. package/dist/outline.js +506 -0
  71. package/dist/outline.js.map +1 -0
  72. package/dist/paths.d.ts +11 -0
  73. package/dist/paths.js +30 -0
  74. package/dist/paths.js.map +1 -0
  75. package/dist/plugin.d.ts +28 -0
  76. package/dist/plugin.js +25 -0
  77. package/dist/plugin.js.map +1 -0
  78. package/dist/policy.d.ts +114 -0
  79. package/dist/policy.js +60 -0
  80. package/dist/policy.js.map +1 -0
  81. package/dist/resource.d.ts +212 -0
  82. package/dist/resource.js +285 -0
  83. package/dist/resource.js.map +1 -0
  84. package/dist/resources.d.ts +62 -0
  85. package/dist/resources.js +197 -0
  86. package/dist/resources.js.map +1 -0
  87. package/dist/run.d.ts +87 -0
  88. package/dist/run.js +2 -0
  89. package/dist/run.js.map +1 -0
  90. package/dist/sandbox.d.ts +33 -0
  91. package/dist/sandbox.js +123 -0
  92. package/dist/sandbox.js.map +1 -0
  93. package/dist/scenario.d.ts +212 -0
  94. package/dist/scenario.js +1079 -0
  95. package/dist/scenario.js.map +1 -0
  96. package/dist/scenarios.d.ts +55 -0
  97. package/dist/scenarios.js +71 -0
  98. package/dist/scenarios.js.map +1 -0
  99. package/dist/shared.d.ts +61 -0
  100. package/dist/shared.js +77 -0
  101. package/dist/shared.js.map +1 -0
  102. package/dist/steps.d.ts +76 -0
  103. package/dist/steps.js +23 -0
  104. package/dist/steps.js.map +1 -0
  105. package/dist/version.d.ts +29 -0
  106. package/dist/version.js +76 -0
  107. package/dist/version.js.map +1 -0
  108. package/dist/worker.d.ts +34 -0
  109. package/dist/worker.js +219 -0
  110. package/dist/worker.js.map +1 -0
  111. package/oxlint.json +128 -0
  112. package/package.json +91 -3
  113. package/src/approvals.ts +157 -0
  114. package/src/ast.ts +115 -0
  115. package/src/call.ts +468 -0
  116. package/src/client.ts +534 -0
  117. package/src/config.ts +381 -0
  118. package/src/credentials.ts +211 -0
  119. package/src/cucumber.ts +230 -0
  120. package/src/datafile.ts +409 -0
  121. package/src/db.ts +197 -0
  122. package/src/define.ts +285 -0
  123. package/src/describe.ts +262 -0
  124. package/src/drift.ts +149 -0
  125. package/src/env.ts +122 -0
  126. package/src/errors.ts +202 -0
  127. package/src/expectations.ts +165 -0
  128. package/src/fake.ts +156 -0
  129. package/src/fill.ts +24 -0
  130. package/src/index.ts +120 -0
  131. package/src/ledger.ts +419 -0
  132. package/src/lint.ts +208 -0
  133. package/src/log.ts +82 -0
  134. package/src/op.ts +399 -0
  135. package/src/outline.ts +620 -0
  136. package/src/paths.ts +32 -0
  137. package/src/plugin.ts +37 -0
  138. package/src/policy.ts +139 -0
  139. package/src/resource.ts +447 -0
  140. package/src/resources.ts +256 -0
  141. package/src/run.ts +91 -0
  142. package/src/sandbox.ts +157 -0
  143. package/src/scenario.ts +1328 -0
  144. package/src/scenarios.ts +111 -0
  145. package/src/shared.ts +116 -0
  146. package/src/steps.ts +90 -0
  147. package/src/version.ts +86 -0
  148. package/src/worker.ts +253 -0
package/src/config.ts ADDED
@@ -0,0 +1,381 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { DBOS } from "@dbos-inc/dbos-sdk";
5
+ import { callerFile, type Use, type WorkflowDefinition } from "./define.ts";
6
+ import { DRIFT_WORKFLOW, type DriftDeclared, driftWorkflow } from "./drift.ts";
7
+ import { statusesOf, unconfigured } from "./env.ts";
8
+ import type { Fake } from "./fake.ts";
9
+ import type { LedgerStore } from "./ledger.ts";
10
+ import {
11
+ type Connector,
12
+ type ConnectorSetup,
13
+ type Driver,
14
+ DriverError,
15
+ type DriverEnv,
16
+ type DriverFn,
17
+ isOp,
18
+ type Op,
19
+ checkConnector,
20
+ setupOf,
21
+ VENDOR,
22
+ } from "./op.ts";
23
+ import type { Policy } from "./policy.ts";
24
+ import { type Resource, resourceTypesOf, withoutWriteOnly } from "./resource.ts";
25
+ import type { Steps } from "./steps.ts";
26
+ import { computeVersion } from "./version.ts";
27
+
28
+ /**
29
+ * What a worker runs, on which connectors, under which policy: the argument to `startWorker`.
30
+ * A project usually keeps it in `sanoma.config.ts` and passes it in; nothing loads that file
31
+ * automatically. The rest is convention: each connector carries its driver and its fake
32
+ * (`withSetup`), the app's name is its `package.json`'s, and the scenarios and data files are
33
+ * `scenarios/` and `resources/` in `root`.
34
+ */
35
+ export interface SanomaConfig {
36
+ workflows: WorkflowDefinition<any, any>[];
37
+ /**
38
+ * The connectors (from `defineConnector`, as their packages export them). The worker takes
39
+ * each operation's effect, schemas and retry setting from here, not from the workflow, and
40
+ * refuses a workflow that declares an operation differently. A live run calls the driver its
41
+ * connector's package attaches; a sandbox run calls a fake of its own, which the connector's
42
+ * package attaches the maker of.
43
+ */
44
+ connectors: Connector<any, any>[];
45
+ /** Checked before every operation call. Required: `allowAll` says that every call is allowed. */
46
+ policy: Policy;
47
+ /**
48
+ * Where the audit record goes. Default: `postgresLedger` on the config's database, which the
49
+ * worker, the app and other processes share; the worker and the client each make their own
50
+ * and close it with themselves. A store given here is never closed by the runtime.
51
+ * `jsonlLedger(dir)` keeps it in files instead; `memoryLedger()` keeps it in this process
52
+ * only, for tests.
53
+ */
54
+ ledger?: LedgerStore;
55
+ /** Postgres for the runtime. Defaults to `SANOMA_DATABASE_URL`, then the local docker compose database. */
56
+ databaseUrl?: string;
57
+ /** The file that defined the config, absolute, when known. Set by `defineConfig` from its call site. */
58
+ file?: string;
59
+ /**
60
+ * The directory the config's conventions are relative to: its `package.json` (the app's name),
61
+ * `scenarios/` and `resources/`. Defaults to `file`'s directory; set it where that is not the
62
+ * source's, as in a bundle.
63
+ */
64
+ root?: string;
65
+ /** @deprecated Gone: each connector carries its driver (`withSetup`, from its package). Leave it out. */
66
+ drivers?: never;
67
+ /** @deprecated Gone: each connector carries the maker of its fake (`withSetup`, from its package). Leave it out. */
68
+ fakes?: never;
69
+ /** @deprecated Gone: the app's name is the `name` in `<root>/package.json`, else "sanoma". Leave it out. */
70
+ appName?: never;
71
+ /** @deprecated Gone: the scenarios are `<root>/scenarios/`. Leave it out. */
72
+ scenarios?: never;
73
+ /** The company's own steps for its scenarios (`defineSteps`), besides those its connectors ship. */
74
+ steps?: Steps[];
75
+ }
76
+
77
+ /** A config, checked, with everything a worker, client or app derives from it. */
78
+ export interface ResolvedConfig {
79
+ /**
80
+ * Scopes workflows, queues and versions in the system database: the `name` of
81
+ * `<root>/package.json` when it names one, else "sanoma".
82
+ */
83
+ appName: string;
84
+ databaseUrl: string;
85
+ /** The DBOS application version: `<appName>@<DBOS__APPVERSION, or a hash of the workflows>`. */
86
+ version: string;
87
+ /** The DBOS queue runs are started on: `sanoma:<appName>`. */
88
+ queueName: string;
89
+ /** The operations the connectors declare, by id. */
90
+ ops: Map<string, Op>;
91
+ /** The drivers' functions, by operation id, each guarded by its driver's `env` (`indexDrivers`). */
92
+ drivers: Map<string, DriverFn>;
93
+ /**
94
+ * The driver each vendor's connector attaches, made once for this resolution, by vendor id in
95
+ * `connectors` order: what `drivers` indexes and the credentials are read from (`credentialsOf`).
96
+ */
97
+ driversByVendor: Map<string, Driver>;
98
+ /** The makers of the fakes the connectors attach, by vendor id in `connectors` order: each sandbox run makes its own. */
99
+ fakes: Map<string, () => Fake<any, any>>;
100
+ /** `scenarios/` in `root`, as a `file:` URL, when that directory exists. */
101
+ scenarios?: URL;
102
+ /** The resource types the connectors declare, by `<vendor>.<type>`: what the drift workflow checks and scenarios seed. */
103
+ types: ReadonlyMap<string, Resource>;
104
+ /** The steps scenarios can use besides the generic ones: each connector's (`withSetup`), once each, then the config's own. */
105
+ steps: readonly Steps[];
106
+ /**
107
+ * By name; each checked against `ops` and `drivers`. The config's, and the built-in `drift`
108
+ * (`DRIFT_WORKFLOW`) when its connectors declare resource types.
109
+ */
110
+ workflows: Map<string, WorkflowDefinition<any, any>>;
111
+ policy: Policy;
112
+ /** The config's `ledger`, when it gives one; the worker and the client otherwise make `postgresLedger` on `databaseUrl` (`ownedLedger`). */
113
+ ledger?: LedgerStore;
114
+ /**
115
+ * The config's directory, absolute: its `root`, else its `file`'s directory. Its data files
116
+ * are `resources/` in it. Absent when the config has neither, so it has no data files.
117
+ */
118
+ root?: string;
119
+ }
120
+
121
+ export const DEFAULT_DATABASE_URL = "postgresql://postgres:dbos@localhost:5433/sanoma";
122
+
123
+ /** Returns the config, with the file it is called from as its `file`: its directory is the config's `root`. */
124
+ export function defineConfig(config: SanomaConfig): SanomaConfig {
125
+ return { ...config, file: config.file ?? callerFile() };
126
+ }
127
+
128
+ export function resolveDatabaseUrl(config: { databaseUrl?: string }, env: NodeJS.ProcessEnv = process.env): string {
129
+ return config.databaseUrl ?? env.SANOMA_DATABASE_URL ?? DEFAULT_DATABASE_URL;
130
+ }
131
+
132
+ const isStore = (x: unknown): x is LedgerStore =>
133
+ typeof (x as LedgerStore | null)?.append === "function" && typeof (x as LedgerStore).read === "function";
134
+
135
+ /** The config's `ledger`, checked, when it gives one. */
136
+ function ledgerOf(config: SanomaConfig): LedgerStore | undefined {
137
+ if (config.ledger !== undefined && !isStore(config.ledger)) {
138
+ throw new Error(
139
+ "The config's `ledger` must be a ledger store: leave it out for `postgresLedger` on the config's database, " +
140
+ "or use `jsonlLedger(dir)` to keep records in files, or `memoryLedger()` to keep them in memory, for tests",
141
+ );
142
+ }
143
+ return config.ledger;
144
+ }
145
+
146
+ /**
147
+ * The config's directory, absolute: its `root`, else its `file`'s directory; undefined when it
148
+ * has neither. No fallback to the working directory: a config found nowhere has no data files,
149
+ * and says so. What a read that needs only the root calls, rather than `resolveConfig`.
150
+ */
151
+ export function rootOf(config: Pick<SanomaConfig, "root" | "file">): string | undefined {
152
+ return config.root !== undefined
153
+ ? resolve(config.root)
154
+ : config.file !== undefined
155
+ ? dirname(config.file)
156
+ : undefined;
157
+ }
158
+
159
+ /**
160
+ * Checks a config and derives what the runtime needs from it: the drivers and fakes from the
161
+ * connectors' setups (`withSetup`), and from `root`, the app's name (`package.json`) and the
162
+ * scenarios (`scenarios/`). Throws what `startWorker` would refuse, with the same message, so
163
+ * `describeConfig`, `SanomaClient.connect` and the app fail the same way the worker does. It
164
+ * opens nothing and reads no file outside `root`. Each entry
165
+ * point resolves once: the worker and the client when they start, the app at `startApp`, the
166
+ * `sanoma` bin when it loads the config.
167
+ */
168
+ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
169
+ if (!Array.isArray(config.connectors)) {
170
+ throw new Error("The config needs `connectors`: the connectors its workflows call, as their packages export them");
171
+ }
172
+ for (const connector of config.connectors as unknown[]) checkConnector(connector);
173
+ if (!Array.isArray(config.workflows)) {
174
+ throw new Error("The config needs `workflows`: an array of defineWorkflow definitions");
175
+ }
176
+ if (typeof config.policy !== "function") {
177
+ throw new Error("The config needs a `policy`; use `allowAll` to allow every operation call");
178
+ }
179
+ // An untyped config naming fake drivers would otherwise run live. Delete once sanoma-ops has migrated.
180
+ if (config.drivers !== undefined || config.fakes !== undefined)
181
+ throw new Error(
182
+ "The config's `drivers` and `fakes` are gone: each connector carries its driver and its fake; leave them out",
183
+ );
184
+ const ledger = ledgerOf(config);
185
+ const root = rootOf(config);
186
+ const appName = appNameAt(root);
187
+ const scenarios = scenariosAt(root);
188
+ const ops = indexConnectors(config.connectors);
189
+ const driversByVendor = new Map([...makersOf(config.connectors, "driver")].map(([vendor, make]) => [vendor, make()]));
190
+ const drivers = indexDrivers([...driversByVendor.values()], ops);
191
+ const fakes = makersOf(config.connectors, "fake");
192
+ const names = new Map<string, WorkflowDefinition<any, any>>();
193
+ for (const wf of config.workflows) {
194
+ if (wf.name === DRIFT_WORKFLOW) {
195
+ throw new Error(
196
+ `A workflow is named "${DRIFT_WORKFLOW}", the name of the built-in drift workflow; name it otherwise`,
197
+ );
198
+ }
199
+ checkUses(wf, ops, drivers);
200
+ const other = names.get(wf.name);
201
+ if (other && other !== wf) {
202
+ throw new Error(
203
+ `Two different workflow definitions are named "${wf.name}"; a name can be registered once per process`,
204
+ );
205
+ }
206
+ names.set(wf.name, wf);
207
+ }
208
+ // Built-in: checking the resources the data files declare against their vendors.
209
+ const types = resourceTypesOf(config.connectors);
210
+ if (types.size) {
211
+ const declared = () =>
212
+ DBOS.runStep(() => readDeclared(root, config.connectors, types), { name: "drift:data-files" });
213
+ names.set(DRIFT_WORKFLOW, driftWorkflow(types, ops, drivers, declared));
214
+ }
215
+ return {
216
+ appName,
217
+ databaseUrl: resolveDatabaseUrl(config),
218
+ // The built-in's code and operations are part of the app's version, like the config's own.
219
+ version: computeVersion({ appName, workflows: [...names.values()] }),
220
+ queueName: `sanoma:${appName}`,
221
+ ops,
222
+ drivers,
223
+ driversByVendor,
224
+ fakes,
225
+ ...(scenarios && { scenarios }),
226
+ types,
227
+ steps: [...new Set([...config.connectors.flatMap((c) => setupOf(c).steps ?? []), ...(config.steps ?? [])])],
228
+ workflows: names,
229
+ policy: config.policy,
230
+ ...(ledger !== undefined && { ledger }),
231
+ ...(root !== undefined && { root }),
232
+ };
233
+ }
234
+
235
+ /**
236
+ * What the connectors' setups attach under `key` (`withSetup`), by vendor id in `connectors`
237
+ * order. One vendor's connectors may name one maker, as copies of one connector do, never two.
238
+ */
239
+ function makersOf<K extends keyof ConnectorSetup>(
240
+ connectors: readonly Connector<any, any>[],
241
+ key: K,
242
+ ): Map<string, NonNullable<ConnectorSetup[K]>> {
243
+ const makers = new Map<string, NonNullable<ConnectorSetup[K]>>();
244
+ for (const connector of connectors) {
245
+ const make = setupOf(connector)[key];
246
+ if (make === undefined) continue;
247
+ const vendor = connector[VENDOR].id;
248
+ const seen = makers.get(vendor);
249
+ if (seen && seen !== make) {
250
+ throw new Error(`Two connectors in \`connectors\` attach a ${key} for "${vendor}"; keep one per vendor`);
251
+ }
252
+ makers.set(vendor, make);
253
+ }
254
+ return makers;
255
+ }
256
+
257
+ /**
258
+ * The `name` of `<root>/package.json`, kept whole (`@acme/ops`), when that file exists and names
259
+ * one; else "sanoma", as for a config with no `root`. Never a `package.json` above the root: a
260
+ * parent project's name must not become the app's. A file that is not JSON is refused, naming it.
261
+ */
262
+ function appNameAt(root: string | undefined): string {
263
+ const file = root === undefined ? undefined : join(root, "package.json");
264
+ if (file === undefined || !existsSync(file)) return "sanoma";
265
+ let manifest: unknown;
266
+ try {
267
+ manifest = JSON.parse(readFileSync(file, "utf8"));
268
+ } catch (err) {
269
+ throw new Error(`${file} is not JSON, so it names no app: ${(err as Error).message}`, { cause: err });
270
+ }
271
+ const named = (manifest as { name?: unknown } | null)?.name;
272
+ return typeof named === "string" && named.trim() ? named.trim() : "sanoma";
273
+ }
274
+
275
+ /** `<root>/scenarios/`, as a `file:` URL to the directory (with its trailing slash), when it is a directory. */
276
+ function scenariosAt(root: string | undefined): URL | undefined {
277
+ const dir = root === undefined ? undefined : join(root, "scenarios");
278
+ if (dir === undefined || !statSync(dir, { throwIfNoEntry: false })?.isDirectory()) return undefined;
279
+ return pathToFileURL(`${dir}/`);
280
+ }
281
+
282
+ /**
283
+ * The data files under `root`, as a drift run records them: each resource without its write-only
284
+ * values, which a run never compares and never records, and the problems.
285
+ */
286
+ async function readDeclared(
287
+ root: string | undefined,
288
+ connectors: readonly Connector<any, any>[],
289
+ types: ReadonlyMap<string, Resource>,
290
+ ): Promise<DriftDeclared> {
291
+ // Loaded when a drift run reads the files, not with the worker: the reader parses with oxc-parser.
292
+ const { readDataFiles } = await import("./resources.ts");
293
+ const { resources, problems } = readDataFiles(root, connectors, types);
294
+ return {
295
+ resources: resources.map(({ id, vendor, type, name, desired, refs }) => ({
296
+ id,
297
+ vendor,
298
+ type,
299
+ name,
300
+ desired: withoutWriteOnly(types.get(`${vendor}.${type}`)!.fields, desired),
301
+ refs,
302
+ })),
303
+ problems,
304
+ };
305
+ }
306
+
307
+ function indexConnectors(list: Connector<any, any>[]) {
308
+ const map = new Map<string, Op>();
309
+ for (const connector of list) {
310
+ for (const resource of Object.values(connector as Record<string, Record<string, unknown>>)) {
311
+ for (const op of Object.values(resource)) {
312
+ if (!isOp(op)) continue;
313
+ const seen = map.get(op.id);
314
+ if (seen && seen !== op) throw new Error(`Two connectors in \`connectors\` declare ${op.id}`);
315
+ map.set(op.id, op);
316
+ }
317
+ }
318
+ }
319
+ return map;
320
+ }
321
+
322
+ /**
323
+ * The drivers' functions by operation id, refusing one no connector declares. A driver that
324
+ * declares an `env` has each function guarded by it (`guarded`); a fake declares none, so a
325
+ * sandbox run's calls are not.
326
+ */
327
+ export function indexDrivers(list: Driver[], ops: Map<string, Op>) {
328
+ const map = new Map<string, DriverFn>();
329
+ for (const d of list) {
330
+ for (const [key, fn] of Object.entries(d.ops)) {
331
+ const id = `${d.vendor}.${key}`;
332
+ if (!ops.has(id)) {
333
+ throw new Error(`Driver "${d.vendor}" implements ${id}, which no connector in \`connectors\` declares`);
334
+ }
335
+ map.set(id, d.env ? guarded(d.vendor, d.env, fn) : fn);
336
+ }
337
+ }
338
+ return map;
339
+ }
340
+
341
+ /**
342
+ * `fn`, refused while the environment leaves a variable `env` declares missing or invalid. The
343
+ * worker started without it (`startWorker` only warns), and stored credentials load into the
344
+ * environment as they are set, so the check is made when the function is called, inside the
345
+ * driver step, and nowhere earlier: not at start, and not before the policy's decision or an
346
+ * approval hold, so a credential set on the connector page while a call waits for approval is
347
+ * used when the approver approves. The refusal is the call's own failure, final: recorded as
348
+ * the call's, and never retried.
349
+ */
350
+ function guarded(vendor: string, env: DriverEnv, fn: DriverFn): DriverFn {
351
+ return async (input, call) => {
352
+ const why = unconfigured(
353
+ vendor,
354
+ statusesOf(env, (name) => process.env[name]),
355
+ );
356
+ if (why) throw new DriverError(why, { retryable: false });
357
+ return fn(input, call);
358
+ };
359
+ }
360
+
361
+ /**
362
+ * Every operation a workflow uses must be one the config's connectors declare, with a driver.
363
+ * The workflow normally imports the same connector object; a copy is accepted only if it
364
+ * declares the same effect and retry setting, and the worker's declaration is used either way.
365
+ */
366
+ function checkUses(wf: WorkflowDefinition<any, any>, ops: Map<string, Op>, impls: Map<string, unknown>) {
367
+ const problems: string[] = [];
368
+ for (const op of (wf.uses as readonly Use[]).filter(isOp)) {
369
+ const known = ops.get(op.id);
370
+ if (!known) problems.push(`${op.id} is not declared by any connector in \`connectors\``);
371
+ else if (known !== op && (known.effect !== op.effect || known.idempotent !== op.idempotent)) {
372
+ problems.push(
373
+ `${op.id} is declared with effect "${op.effect}"${op.idempotent ? " (idempotent)" : ""}, ` +
374
+ `but its connector says "${known.effect}"${known.idempotent ? " (idempotent)" : ""}`,
375
+ );
376
+ } else if (!impls.has(op.id)) {
377
+ problems.push(`\`${op.id}\` has no driver: the connector package must attach one`);
378
+ }
379
+ }
380
+ if (problems.length) throw new Error(`Workflow "${wf.name}": ${problems.join("; ")}`);
381
+ }
@@ -0,0 +1,211 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
2
+ import { Client } from "pg";
3
+ import { credentialsOf } from "./env.ts";
4
+ import { CREDENTIALS_TABLE as TABLE, type Db, prepareDatabase } from "./db.ts";
5
+ import { info, warn } from "./log.ts";
6
+ import type { Driver } from "./op.ts";
7
+ import { errorMessage } from "./shared.ts";
8
+
9
+ // Where the credentials set from the app live: one table in the config's database, beside DBOS's
10
+ // own (made by `prepareDatabase` in db.ts). A value is read here only, by the worker to load it
11
+ // into its environment and by the client to check it, and leaves neither. The write
12
+ // (`storeCredential`) and the read (`storedCredentials`) are the only places that touch `value`,
13
+ // so a hosted deployment's encryption goes there.
14
+
15
+ /** Where a change is announced, with the variable's name as the payload. */
16
+ const CHANNEL = "sanoma_credentials";
17
+
18
+ /** The stored rows of these names, values included: for loading into an environment or checking, never for showing. */
19
+ export async function storedCredentials(
20
+ db: Db,
21
+ names: readonly string[],
22
+ ): Promise<Map<string, { value: string; setBy: string; setAt: string }>> {
23
+ const { rows } = await db.query<{ name: string; value: string; set_by: string; set_at: Date }>(
24
+ `SELECT name, value, set_by, set_at FROM ${TABLE} WHERE name = ANY($1)`,
25
+ [names],
26
+ );
27
+ return new Map(rows.map((r) => [r.name, { value: r.value, setBy: r.set_by, setAt: r.set_at.toISOString() }]));
28
+ }
29
+
30
+ /** Upserts the value and announces it, in one statement, so a worker never hears of a change it cannot read. */
31
+ export async function storeCredential(
32
+ db: Db,
33
+ { name, value, setBy }: { name: string; value: string; setBy: string },
34
+ ): Promise<void> {
35
+ await db.query(
36
+ `WITH stored AS (INSERT INTO ${TABLE} (name, value, set_by) VALUES ($1, $2, $3) ` +
37
+ "ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value, set_by = EXCLUDED.set_by, set_at = now() " +
38
+ `RETURNING name) SELECT pg_notify('${CHANNEL}', name) FROM stored`,
39
+ [name, value, setBy],
40
+ );
41
+ }
42
+
43
+ /** Deletes the row, announcing it when there was one. */
44
+ export async function deleteCredential(db: Db, name: string): Promise<void> {
45
+ await db.query(
46
+ `WITH gone AS (DELETE FROM ${TABLE} WHERE name = $1 RETURNING name) SELECT pg_notify('${CHANNEL}', name) FROM gone`,
47
+ [name],
48
+ );
49
+ }
50
+
51
+ /** Stops a worker's credentials listener. */
52
+ export interface CredentialsWatch {
53
+ close(): Promise<void>;
54
+ }
55
+
56
+ /** The longest wait between two attempts to reconnect the listener, and between two warnings that one failed. */
57
+ const RETRY_MAX_MS = 30_000;
58
+
59
+ /**
60
+ * Loads the stored values of the declared variables the environment does not set into
61
+ * `process.env`, then keeps them current: on each change announced, and on reconnecting, it
62
+ * loads them again, so a value set replaces the one before and a value cleared is deleted. A
63
+ * name the environment set when this started is never touched, and the names it did set are
64
+ * deleted on `close`. Logs, per vendor, how many variables each load changed, never a value, and
65
+ * warns of a reloaded value its schema refuses. A listener that drops reconnects, waiting longer
66
+ * after each failure, up to 30 s, until it is closed. With no such variable, it connects to nothing.
67
+ */
68
+ export async function watchCredentials(
69
+ databaseUrl: string,
70
+ drivers: ReadonlyMap<string, Driver>,
71
+ ): Promise<CredentialsWatch> {
72
+ const vendors = new Map<string, string>();
73
+ for (const [vendor, list] of credentialsOf(drivers, () => undefined)) {
74
+ for (const { name } of list) if (!process.env[name] && !vendors.has(name)) vendors.set(name, vendor);
75
+ }
76
+ const names = [...vendors.keys()];
77
+ if (!names.length) return { close: async () => undefined };
78
+ /** The listener's connection, from when it connects until it drops or closes. */
79
+ let db: Client | undefined;
80
+ let closed = false;
81
+ let reconnecting = false;
82
+ // One load at a time, so close() can wait for it; the changes announced during one make one more.
83
+ let loading: Promise<void> | undefined;
84
+ let stale = false;
85
+
86
+ async function load(first: boolean) {
87
+ const conn = db;
88
+ if (closed || !conn) return;
89
+ const rows = await storedCredentials(conn, names);
90
+ // Nothing touches the environment once closed: a worker or an app started after may own it.
91
+ if (closed) return;
92
+ const changed = names.filter((name) => rows.get(name)?.value !== process.env[name]);
93
+ const counts = new Map<string, number>();
94
+ for (const name of changed) {
95
+ const value = rows.get(name)?.value;
96
+ if (value === undefined) delete process.env[name];
97
+ else process.env[name] = value;
98
+ const vendor = vendors.get(name)!;
99
+ counts.set(vendor, (counts.get(vendor) ?? 0) + 1);
100
+ }
101
+ const verb = first ? "loaded" : "reloaded";
102
+ for (const [vendor, n] of counts) info(`credentials ${verb} for ${vendor} (${n} variable${n === 1 ? "" : "s"})`);
103
+ // At start, startWorker warns of an invalid one, naming it.
104
+ if (first) return;
105
+ for (const list of credentialsOf(drivers, (name) =>
106
+ changed.includes(name) ? process.env[name] : undefined,
107
+ ).values()) {
108
+ for (const c of list) {
109
+ if (c.status === "invalid")
110
+ warn(`the stored ${c.name} is invalid (${c.problem}): calls that read it will fail`);
111
+ }
112
+ }
113
+ }
114
+
115
+ function reload(first = false): Promise<void> {
116
+ if (loading) {
117
+ stale = true;
118
+ return loading;
119
+ }
120
+ loading = (async () => {
121
+ try {
122
+ do {
123
+ stale = false;
124
+ await load(first);
125
+ first = false;
126
+ } while (stale);
127
+ } finally {
128
+ loading = undefined;
129
+ }
130
+ })();
131
+ return loading;
132
+ }
133
+
134
+ async function open(first: boolean) {
135
+ const conn = new Client({ connectionString: databaseUrl, keepAlive: true, connectionTimeoutMillis: 10_000 });
136
+ // Before anything can fail: an error nobody listens for ends the process.
137
+ conn.on("error", (err) => lost(conn, err));
138
+ // Before LISTEN, so no change announced from then on is missed.
139
+ conn.on("notification", () => {
140
+ if (!closed && conn === db) {
141
+ reload().catch((err: unknown) => warn(`could not reload the credentials: ${errorMessage(err)}`));
142
+ }
143
+ });
144
+ try {
145
+ if (first) await prepareDatabase(databaseUrl);
146
+ await conn.connect();
147
+ if (closed) {
148
+ await conn.end();
149
+ return;
150
+ }
151
+ db = conn;
152
+ await conn.query(`LISTEN ${CHANNEL}`);
153
+ await reload(first);
154
+ } catch (err) {
155
+ if (db === conn) db = undefined;
156
+ await conn.end().catch(() => undefined);
157
+ throw err;
158
+ }
159
+ }
160
+
161
+ /** A connection that dropped: the one listening is replaced; one still connecting fails its own open. */
162
+ function lost(conn: Client, err: unknown) {
163
+ if (closed || conn !== db) return;
164
+ db = undefined;
165
+ void conn.end().catch(() => undefined);
166
+ warn(`the credentials listener failed (${errorMessage(err)}); reconnecting`);
167
+ void reconnect();
168
+ }
169
+
170
+ async function reconnect() {
171
+ if (reconnecting) return;
172
+ reconnecting = true;
173
+ let wait = 1_000;
174
+ let warnedAt = 0;
175
+ try {
176
+ for (;;) {
177
+ await sleep(wait, undefined, { ref: false });
178
+ if (closed) return;
179
+ try {
180
+ await open(false);
181
+ return;
182
+ } catch (err) {
183
+ if (Date.now() - warnedAt >= RETRY_MAX_MS) {
184
+ warnedAt = Date.now();
185
+ warn(`could not reconnect the credentials listener (${errorMessage(err)}); trying again`);
186
+ }
187
+ wait = Math.min(wait * 2, RETRY_MAX_MS);
188
+ }
189
+ }
190
+ } finally {
191
+ reconnecting = false;
192
+ }
193
+ }
194
+
195
+ try {
196
+ await open(true);
197
+ } catch (err) {
198
+ closed = true;
199
+ throw err;
200
+ }
201
+ return {
202
+ async close() {
203
+ closed = true;
204
+ await loading?.catch(() => undefined);
205
+ for (const name of names) delete process.env[name];
206
+ const conn = db;
207
+ db = undefined;
208
+ await conn?.end();
209
+ },
210
+ };
211
+ }