@sanoma-ai/workflows 0.0.0-stage → 0.2.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 (176) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +544 -2
  3. package/dist/apply-steps.d.ts +6 -0
  4. package/dist/apply-steps.js +93 -0
  5. package/dist/apply-steps.js.map +1 -0
  6. package/dist/apply.d.ts +197 -0
  7. package/dist/apply.js +470 -0
  8. package/dist/apply.js.map +1 -0
  9. package/dist/approvals.d.ts +46 -0
  10. package/dist/approvals.js +111 -0
  11. package/dist/approvals.js.map +1 -0
  12. package/dist/ast.d.ts +57 -0
  13. package/dist/ast.js +81 -0
  14. package/dist/ast.js.map +1 -0
  15. package/dist/call.d.ts +37 -0
  16. package/dist/call.js +417 -0
  17. package/dist/call.js.map +1 -0
  18. package/dist/client.d.ts +203 -0
  19. package/dist/client.js +457 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/config.d.ts +126 -0
  22. package/dist/config.js +264 -0
  23. package/dist/config.js.map +1 -0
  24. package/dist/credentials.d.ts +55 -0
  25. package/dist/credentials.js +261 -0
  26. package/dist/credentials.js.map +1 -0
  27. package/dist/cucumber.d.ts +64 -0
  28. package/dist/cucumber.js +158 -0
  29. package/dist/cucumber.js.map +1 -0
  30. package/dist/datafile.d.ts +71 -0
  31. package/dist/datafile.js +380 -0
  32. package/dist/datafile.js.map +1 -0
  33. package/dist/db.d.ts +58 -0
  34. package/dist/db.js +179 -0
  35. package/dist/db.js.map +1 -0
  36. package/dist/define.d.ts +205 -0
  37. package/dist/define.js +103 -0
  38. package/dist/define.js.map +1 -0
  39. package/dist/describe.d.ts +141 -0
  40. package/dist/describe.js +135 -0
  41. package/dist/describe.js.map +1 -0
  42. package/dist/drift.d.ts +127 -0
  43. package/dist/drift.js +167 -0
  44. package/dist/drift.js.map +1 -0
  45. package/dist/env.d.ts +49 -0
  46. package/dist/env.js +83 -0
  47. package/dist/env.js.map +1 -0
  48. package/dist/errors.d.ts +88 -0
  49. package/dist/errors.js +148 -0
  50. package/dist/errors.js.map +1 -0
  51. package/dist/expectations.d.ts +26 -0
  52. package/dist/expectations.js +162 -0
  53. package/dist/expectations.js.map +1 -0
  54. package/dist/fake.d.ts +55 -0
  55. package/dist/fake.js +87 -0
  56. package/dist/fake.js.map +1 -0
  57. package/dist/fill.d.ts +9 -0
  58. package/dist/fill.js +17 -0
  59. package/dist/fill.js.map +1 -0
  60. package/dist/host.d.ts +56 -0
  61. package/dist/host.js +45 -0
  62. package/dist/host.js.map +1 -0
  63. package/dist/index.d.ts +23 -0
  64. package/dist/index.js +29 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/ledger.d.ts +217 -0
  67. package/dist/ledger.js +251 -0
  68. package/dist/ledger.js.map +1 -0
  69. package/dist/lint.d.ts +32 -0
  70. package/dist/lint.js +168 -0
  71. package/dist/lint.js.map +1 -0
  72. package/dist/lockfile.d.ts +36 -0
  73. package/dist/lockfile.js +69 -0
  74. package/dist/lockfile.js.map +1 -0
  75. package/dist/log.d.ts +24 -0
  76. package/dist/log.js +83 -0
  77. package/dist/log.js.map +1 -0
  78. package/dist/op.d.ts +260 -0
  79. package/dist/op.js +188 -0
  80. package/dist/op.js.map +1 -0
  81. package/dist/outline.d.ts +179 -0
  82. package/dist/outline.js +506 -0
  83. package/dist/outline.js.map +1 -0
  84. package/dist/paths.d.ts +11 -0
  85. package/dist/paths.js +30 -0
  86. package/dist/paths.js.map +1 -0
  87. package/dist/plugin.d.ts +28 -0
  88. package/dist/plugin.js +25 -0
  89. package/dist/plugin.js.map +1 -0
  90. package/dist/policy.d.ts +114 -0
  91. package/dist/policy.js +60 -0
  92. package/dist/policy.js.map +1 -0
  93. package/dist/resource.d.ts +378 -0
  94. package/dist/resource.js +430 -0
  95. package/dist/resource.js.map +1 -0
  96. package/dist/resources.d.ts +77 -0
  97. package/dist/resources.js +244 -0
  98. package/dist/resources.js.map +1 -0
  99. package/dist/run.d.ts +101 -0
  100. package/dist/run.js +7 -0
  101. package/dist/run.js.map +1 -0
  102. package/dist/sandbox.d.ts +33 -0
  103. package/dist/sandbox.js +127 -0
  104. package/dist/sandbox.js.map +1 -0
  105. package/dist/scenario.d.ts +221 -0
  106. package/dist/scenario.js +1104 -0
  107. package/dist/scenario.js.map +1 -0
  108. package/dist/scenarios.d.ts +55 -0
  109. package/dist/scenarios.js +71 -0
  110. package/dist/scenarios.js.map +1 -0
  111. package/dist/secret.d.ts +42 -0
  112. package/dist/secret.js +58 -0
  113. package/dist/secret.js.map +1 -0
  114. package/dist/shared.d.ts +69 -0
  115. package/dist/shared.js +84 -0
  116. package/dist/shared.js.map +1 -0
  117. package/dist/source.d.ts +58 -0
  118. package/dist/source.js +54 -0
  119. package/dist/source.js.map +1 -0
  120. package/dist/steps.d.ts +76 -0
  121. package/dist/steps.js +23 -0
  122. package/dist/steps.js.map +1 -0
  123. package/dist/version.d.ts +29 -0
  124. package/dist/version.js +76 -0
  125. package/dist/version.js.map +1 -0
  126. package/dist/worker.d.ts +37 -0
  127. package/dist/worker.js +228 -0
  128. package/dist/worker.js.map +1 -0
  129. package/dist/writer.d.ts +35 -0
  130. package/dist/writer.js +201 -0
  131. package/dist/writer.js.map +1 -0
  132. package/oxlint.json +128 -0
  133. package/package.json +96 -3
  134. package/src/apply-steps.ts +110 -0
  135. package/src/apply.ts +626 -0
  136. package/src/approvals.ts +157 -0
  137. package/src/ast.ts +115 -0
  138. package/src/call.ts +483 -0
  139. package/src/client.ts +604 -0
  140. package/src/config.ts +404 -0
  141. package/src/credentials.ts +296 -0
  142. package/src/cucumber.ts +230 -0
  143. package/src/datafile.ts +481 -0
  144. package/src/db.ts +197 -0
  145. package/src/define.ts +285 -0
  146. package/src/describe.ts +282 -0
  147. package/src/drift.ts +259 -0
  148. package/src/env.ts +122 -0
  149. package/src/errors.ts +202 -0
  150. package/src/expectations.ts +191 -0
  151. package/src/fake.ts +156 -0
  152. package/src/fill.ts +24 -0
  153. package/src/host.ts +96 -0
  154. package/src/index.ts +143 -0
  155. package/src/ledger.ts +419 -0
  156. package/src/lint.ts +208 -0
  157. package/src/lockfile.ts +95 -0
  158. package/src/log.ts +82 -0
  159. package/src/op.ts +402 -0
  160. package/src/outline.ts +620 -0
  161. package/src/paths.ts +32 -0
  162. package/src/plugin.ts +37 -0
  163. package/src/policy.ts +139 -0
  164. package/src/resource.ts +670 -0
  165. package/src/resources.ts +311 -0
  166. package/src/run.ts +106 -0
  167. package/src/sandbox.ts +161 -0
  168. package/src/scenario.ts +1365 -0
  169. package/src/scenarios.ts +111 -0
  170. package/src/secret.ts +92 -0
  171. package/src/shared.ts +126 -0
  172. package/src/source.ts +82 -0
  173. package/src/steps.ts +90 -0
  174. package/src/version.ts +86 -0
  175. package/src/worker.ts +264 -0
  176. package/src/writer.ts +225 -0
package/src/worker.ts ADDED
@@ -0,0 +1,264 @@
1
+ import { DBOS } from "@dbos-inc/dbos-sdk";
2
+ import { buildCtx, isInfrastructureError } from "./call.ts";
3
+ import { dbosStatusesOf } from "./client.ts";
4
+ import { type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
5
+ import { storedSecrets, watchCredentials } from "./credentials.ts";
6
+ import { DBOS_DATABASE_OPTIONS, lazyPool, prepareDatabase } from "./db.ts";
7
+ import type { WorkflowDefinition } from "./define.ts";
8
+ import { credentialsOf, unconfigured, unconfiguredSummary } from "./env.ts";
9
+ import { errorInfo, parseOrThrow } from "./errors.ts";
10
+ import { entry, ownedLedger, skipped, write, writeFailure } from "./ledger.ts";
11
+ import { dbosLogger, quietIfSandbox, setLogLevel, warn } from "./log.ts";
12
+ import { lineStartsOf, readSource } from "./ast.ts";
13
+ import { callsOf, outlineWithSource } from "./outline.ts";
14
+ import { currentRun, type Run, type RunArgs, type WorkerState, type WorkflowOutline } from "./run.ts";
15
+ import type { Source } from "./source.ts";
16
+ import { errorMessage } from "./shared.ts";
17
+
18
+ export interface Worker {
19
+ /**
20
+ * The vendors whose drivers could not run when the worker started, in the config's order: a
21
+ * live call to one fails, saying why (`unconfigured` in env.ts), until its variables are set.
22
+ * The worker names them in one line at start, unless started `quiet`.
23
+ */
24
+ readonly unconfigured: readonly string[];
25
+ stop(): Promise<void>;
26
+ }
27
+
28
+ // DBOS registrations are process-wide and survive shutdown, so a name is registered once, and
29
+ // its function reads the state of the worker running when a run starts, definition and all; the
30
+ // run keeps that state.
31
+ let current: WorkerState | undefined;
32
+ const registered = new Set<string>();
33
+
34
+ export interface WorkerOptions {
35
+ /** The least severe level of the runtime's and DBOS's messages to show, for the whole process: `warn` by default. */
36
+ logLevel?: string;
37
+ /**
38
+ * Start without naming the vendors not configured yet (`unconfigured` still does): for a worker
39
+ * whose runs use the fakes alone, as `runScenarios`'s.
40
+ */
41
+ quiet?: boolean;
42
+ /**
43
+ * Make this worker's version the app's latest, so runs queued without a version come here.
44
+ * A brand-new version becomes the latest on its own; set this only when deliberately starting
45
+ * a worker on a version seen before (a rollback). Off by default, because an old instance
46
+ * restarting during a rolling deploy would otherwise take the new workers' runs.
47
+ */
48
+ promote?: boolean;
49
+ /** The company's repository, which apply commits the lockfile to; the launcher passes its checkout. */
50
+ source?: Source;
51
+ }
52
+
53
+ /**
54
+ * Registers the workflows, connects to Postgres and recovers any runs that were interrupted.
55
+ * When a driver declares variables the environment leaves unset, it first loads their stored
56
+ * values into `process.env` and keeps them current while it runs. It starts whether or not they
57
+ * are all there: it prints one line naming the vendors that lack one (`unconfigured`), and a live
58
+ * call to such a vendor fails, saying why, as the call's own final failure, until they are set.
59
+ */
60
+ export async function startWorker(config: SanomaConfig, options: WorkerOptions = {}): Promise<Worker> {
61
+ // Check everything before touching the state a running worker reads.
62
+ const resolved = resolveConfig(config);
63
+ setLogLevel(options.logLevel);
64
+ // The config's own, or a Postgres store made for this worker, which `stop` then closes.
65
+ const ledger = ownedLedger(resolved);
66
+ const secrets = lazyPool(resolved.databaseUrl, "secrets");
67
+ const state: WorkerState = {
68
+ app: resolved.appName,
69
+ ops: resolved.ops,
70
+ drivers: resolved.drivers,
71
+ fakes: resolved.fakes,
72
+ ...(resolved.scenarios && { scenarios: resolved.scenarios }),
73
+ ...(options.source && { source: options.source }),
74
+ types: resolved.types,
75
+ steps: resolved.steps,
76
+ policy: resolved.policy,
77
+ ledger: ledger.store,
78
+ secrets: storedSecrets(() => secrets.db(), "sanoma"),
79
+ workflows: resolved.workflows,
80
+ outlines: new Map([...resolved.workflows].map(([name, wf]) => [name, outlineOf(wf)])),
81
+ stopped: false,
82
+ };
83
+ const credentials = await watchCredentials(resolved.databaseUrl, resolved.driversByVendor);
84
+ const worker: Worker = {
85
+ unconfigured: [...credentialsOf(resolved.driversByVendor, (name) => process.env[name])]
86
+ .filter(([vendor, statuses]) => unconfigured(vendor, statuses))
87
+ .map(([vendor]) => vendor),
88
+ async stop() {
89
+ state.stopped = true;
90
+ // Each is closed whatever the one before did; the first failure is what stop throws.
91
+ let failed: { error: unknown } | undefined;
92
+ for (const close of [
93
+ () => DBOS.shutdown(),
94
+ () => credentials.close(),
95
+ () => secrets.close(),
96
+ () => ledger.close(),
97
+ ]) {
98
+ await close().catch((error: unknown) => (failed ??= { error }));
99
+ }
100
+ if (failed) throw failed.error;
101
+ },
102
+ };
103
+ // Runs recovered during launch start before it returns, so they must already find this
104
+ // state. If starting fails, a worker still running in the process gets its own back, once
105
+ // DBOS has stopped: until then a run DBOS dispatches finds this state stopped, and is refused.
106
+ const previous = current;
107
+ let launched = false;
108
+ try {
109
+ for (const name of resolved.workflows.keys()) {
110
+ if (!registered.has(name)) {
111
+ register(name);
112
+ registered.add(name);
113
+ }
114
+ }
115
+ current = state;
116
+ // Before DBOS's pool connects, so its connections start with the database's idle-transaction
117
+ // timeout (`DBOS_DATABASE_OPTIONS`); once per process and database.
118
+ await prepareDatabase(resolved.databaseUrl);
119
+ DBOS.setConfig({
120
+ name: resolved.appName,
121
+ systemDatabaseUrl: resolved.databaseUrl,
122
+ ...DBOS_DATABASE_OPTIONS,
123
+ // A logger of our own replaces DBOS's OTLP log export, so only while DBOS exports none (it
124
+ // does on DBOS Cloud); its level is then the threshold `setLogLevel` set, DBOS's own `logLevel` unread.
125
+ ...(process.env.DBOS__CLOUD === "true" ? { logLevel: options.logLevel ?? "warn" } : { logger: dbosLogger }),
126
+ applicationVersion: resolved.version,
127
+ });
128
+ await DBOS.launch();
129
+ launched = true;
130
+ // DBOS gives runs queued without a version only to the app's latest version, which is the
131
+ // newest one registered. A worker started on code seen before (a rollback) is not it.
132
+ const latest = (await DBOS.getLatestApplicationVersion()).versionName;
133
+ if (latest !== resolved.version) {
134
+ if (options.promote) await DBOS.setLatestApplicationVersion(resolved.version);
135
+ else {
136
+ warn(
137
+ `this worker runs version ${resolved.version} but the app's latest is ${latest}; ` +
138
+ "runs queued without a version go to the latest. Start with { promote: true } to take them here",
139
+ );
140
+ }
141
+ }
142
+ await DBOS.registerQueue(resolved.queueName);
143
+ await warnAboutStrandedRuns(resolved);
144
+ const summary = unconfiguredSummary(worker.unconfigured, resolved.driversByVendor.size);
145
+ if (summary && !options.quiet) warn(summary);
146
+ } catch (err) {
147
+ // Once DBOS is launched, the caller gets no worker to stop: stop it here, so no run goes on
148
+ // in a worker nobody holds, and the next startWorker launches afresh.
149
+ let stopFailed: { error: unknown } | undefined;
150
+ if (launched) await worker.stop().catch((error: unknown) => (stopFailed = { error }));
151
+ else {
152
+ await credentials
153
+ .close()
154
+ .catch((e: unknown) => warn(`closing the credentials listener failed: ${errorMessage(e)}`));
155
+ }
156
+ current = previous;
157
+ if (stopFailed) {
158
+ throw new AggregateError(
159
+ [err, stopFailed.error],
160
+ `${errorMessage(err)} (and stopping DBOS failed too: ${errorMessage(stopFailed.error)})`,
161
+ { cause: err },
162
+ );
163
+ }
164
+ throw err;
165
+ }
166
+ return worker;
167
+ }
168
+
169
+ /**
170
+ * The workflow's outline, read from its file, which every run is held to. A workflow whose
171
+ * outline cannot be read does not start: nothing could say where its calls are.
172
+ */
173
+ function outlineOf(wf: WorkflowDefinition<any, any>): WorkflowOutline {
174
+ const { outline, source } = outlineWithSource(wf, readSource(wf.file));
175
+ if ("error" in outline) {
176
+ throw new Error(
177
+ `Workflow "${wf.name}" cannot be outlined, so its runs could not be held to its code: ${outline.error}`,
178
+ );
179
+ }
180
+ return { file: outline.file, lineStarts: lineStartsOf(source!), calls: callsOf(outline.nodes) };
181
+ }
182
+
183
+ /**
184
+ * Warns about unfinished runs this worker will never pick up: started on another version of
185
+ * the app, or queued on another queue (such as the single "sanoma" queue before queues were
186
+ * named per app, or the sandbox queue before sandbox runs went on the app's).
187
+ */
188
+ async function warnAboutStrandedRuns({ appName, version, queueName }: ResolvedConfig) {
189
+ const runs = await DBOS.listWorkflows({
190
+ status: dbosStatusesOf("queued", "running"),
191
+ applicationName: appName,
192
+ loadInput: false,
193
+ loadOutput: false,
194
+ });
195
+ const otherVersion = runs.filter((r) => r.applicationVersion && r.applicationVersion !== version);
196
+ // Recovery moves a worker's own runs to DBOS's internal queues, which are fine.
197
+ const otherQueue = runs.filter(
198
+ (r) => !otherVersion.includes(r) && r.queueName && r.queueName !== queueName && !r.queueName.startsWith("_dbos_"),
199
+ );
200
+ if (!otherVersion.length && !otherQueue.length) return;
201
+ const ids = [...otherVersion, ...otherQueue].map((r) => r.workflowID);
202
+ warn(
203
+ `${otherVersion.length} unfinished run(s) of "${appName}" belong to another version and ${otherQueue.length} ` +
204
+ `wait on another queue, so this worker (version ${version}, queue ${queueName}) will not run them: ` +
205
+ `${ids.slice(0, 5).join(", ")}${ids.length > 5 ? ", …" : ""}. Run the version that started them, or move each ` +
206
+ `with DBOSClient.forkWorkflow(id, step, { applicationVersion: "${version}", queueName: "${queueName}" }) ` +
207
+ `and cancel the original; resumeWorkflow(id, { queueName: "${queueName}" }) moves a run on this version to this queue.`,
208
+ );
209
+ }
210
+
211
+ /** Registers the name with DBOS: its runs take the definition of that name from the running worker. */
212
+ function register(name: string) {
213
+ DBOS.registerWorkflow((args: RunArgs) => quietIfSandbox(args.sandbox !== undefined, () => runWorkflow(name, args)), {
214
+ name,
215
+ });
216
+ }
217
+
218
+ /** One run of the workflow registered as `name`, on the state of the worker running when it starts. */
219
+ async function runWorkflow(name: string, { input, startedBy, sandbox }: RunArgs): Promise<unknown> {
220
+ const state = current;
221
+ // A stopped worker's state stays current while DBOS shuts down.
222
+ if (!state || state.stopped) throw new Error(`Run of "${name}" started with no worker running`);
223
+ const wf = state.workflows.get(name);
224
+ const outline = state.outlines.get(name);
225
+ // Registered by an earlier worker in this process, whose config had it; this one's has not.
226
+ if (!wf || !outline) throw new Error(`Run of "${name}" refused: this worker's config has no workflow of that name`);
227
+ const run: Run = {
228
+ id: DBOS.workflowID!,
229
+ workflow: wf.name,
230
+ actor: startedBy,
231
+ ...(sandbox === undefined ? {} : { sandbox }),
232
+ approvals: [],
233
+ seq: 0,
234
+ tail: Promise.resolve(),
235
+ inAll: false,
236
+ ended: false,
237
+ outline,
238
+ state,
239
+ };
240
+ await write(run, entry(run, { type: "run.started", input }));
241
+ let output: unknown;
242
+ try {
243
+ try {
244
+ // The one parse of the input: the client checked it, but sent it as given.
245
+ const parsed = parseOrThrow(wf.input, input, `The input does not match ${wf.name}'s schema`);
246
+ if (sandbox !== undefined) {
247
+ // Imported here, so a live worker never loads the Gherkin parser or faker.
248
+ const { seedSandbox } = await import("./sandbox.ts");
249
+ await seedSandbox(run, sandbox);
250
+ }
251
+ output = await currentRun.run(run, () => wf.run(buildCtx(wf, run), parsed));
252
+ } finally {
253
+ // Before the outcome is written: a call still queued must find the run ended.
254
+ run.ended = true;
255
+ }
256
+ } catch (err) {
257
+ const record = entry(run, { type: "run.failed", error: errorInfo(err) });
258
+ if (isInfrastructureError(err, state)) skipped(record, `the run was interrupted (${errorMessage(err)})`);
259
+ else await writeFailure(run, record, err);
260
+ throw err;
261
+ }
262
+ await write(run, entry(run, { type: "run.finished", output }));
263
+ return output;
264
+ }
package/src/writer.ts ADDED
@@ -0,0 +1,225 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { FormatConfig } from "oxfmt";
4
+ import { type Node, parse } from "./ast.ts";
5
+ import {
6
+ type DataFileExport,
7
+ type DataFileImport,
8
+ type DataValue,
9
+ isReference,
10
+ keyName,
11
+ readDataFile,
12
+ SECRET_ENTRIES,
13
+ } from "./datafile.ts";
14
+ import { isSecret } from "./secret.ts";
15
+
16
+ // The data-file writer: what the app's Adopt, and setting up a company, write into the files
17
+ // `datafile.ts` reads. A file is changed in place, one declaration at a time, and everything
18
+ // outside that declaration is kept byte for byte; what is written is formatted with oxfmt, under
19
+ // the repository's own options, so it reads as if a person had written it and formatted it.
20
+
21
+ /** A whole data file to render: its imports and its resources, in order. */
22
+ export interface DataFileContent {
23
+ imports: readonly Pick<DataFileImport, "source" | "imported" | "local">[];
24
+ exports: readonly Pick<DataFileExport, "name" | "vendor" | "type" | "desired">[];
25
+ }
26
+
27
+ /**
28
+ * oxfmt's options from `<root>/.oxfmtrc.json`, what `oxfmt` itself reads there, less what only
29
+ * its CLI uses (`ignorePatterns`, `overrides`); none, oxfmt's defaults, without the file.
30
+ */
31
+ export function formatOptions(root: string): FormatConfig {
32
+ let text: string;
33
+ try {
34
+ text = readFileSync(join(root, ".oxfmtrc.json"), "utf8");
35
+ } catch (err) {
36
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return {};
37
+ throw err;
38
+ }
39
+ const { $schema: _, ignorePatterns: __, overrides: ___, ...options } = JSON.parse(text) as Record<string, unknown>;
40
+ return options as FormatConfig;
41
+ }
42
+
43
+ /**
44
+ * `source`, a data file's text, with fields of its resource `exportName` set to new values:
45
+ * `fields` by dotted path, a list item's with its index, as a drift report names them
46
+ * (`required_pull_request_reviews.0.dismiss_stale_reviews`). A field the declaration does not
47
+ * have is added at the end of the object that should hold it. Only that `export const` changes:
48
+ * it is formatted with oxfmt under `options` (`formatOptions`), and every other byte of the file
49
+ * stays as it was.
50
+ *
51
+ * A value naming a secret adds `import { secret } from "sanoma/resources"` after the file's
52
+ * imports when it has none.
53
+ *
54
+ * Throws when the file does not parse, declares no such resource, or a path cannot be set: it
55
+ * runs through a value that is not an object or a list, or ends at a reference to another
56
+ * resource (a person changes a reference; a vendor's value never replaces one).
57
+ */
58
+ export async function setFields(
59
+ source: string,
60
+ exportName: string,
61
+ fields: Readonly<Record<string, DataValue>>,
62
+ options: FormatConfig = {},
63
+ ): Promise<string> {
64
+ let text = source;
65
+ // One field at a time, each against the file as the last left it: no offset goes stale.
66
+ for (const [path, value] of Object.entries(fields)) {
67
+ const { start, end, replacement } = editAt(declarationOf(text, exportName).object, path, value);
68
+ text = text.slice(0, start) + replacement + text.slice(end);
69
+ }
70
+ if (Object.values(fields).some(holdsSecret)) text = withSecretImport(text);
71
+ // The declaration on its own: a top-level statement formats the same in its file as alone.
72
+ const { start, end } = declarationOf(text, exportName).statement;
73
+ const formatted = await format(text.slice(start, end), options);
74
+ return text.slice(0, start) + formatted.trimEnd() + text.slice(end);
75
+ }
76
+
77
+ /** What a data file imports `secret` from when it names one (`readDataFile` lists no such import). */
78
+ const SECRET_IMPORT = { source: "sanoma/resources", imported: "secret", local: "secret" };
79
+
80
+ /**
81
+ * A whole data file: the imports, one per source, then each resource as `export const`, then
82
+ * `export default` naming them all, formatted with oxfmt under `options`. `readDataFile` reads it
83
+ * back as it was given: what `readDataFile` returns renders the file again. A file whose values
84
+ * name a secret also imports `secret` from "sanoma/resources".
85
+ */
86
+ export async function renderDataFile({ imports, exports }: DataFileContent, options: FormatConfig = {}) {
87
+ const names = new Map<string, string[]>();
88
+ const hasSecret = imports.some((i) => i.local === SECRET_IMPORT.local);
89
+ const all = !hasSecret && exports.some((e) => holdsSecret(e.desired)) ? [...imports, SECRET_IMPORT] : imports;
90
+ for (const { source, imported, local } of all) {
91
+ const list = names.get(source) ?? [];
92
+ list.push(imported === local ? local : `${imported} as ${local}`);
93
+ names.set(source, list);
94
+ }
95
+ const parts = [
96
+ [...names].map(([source, list]) => `import { ${list.join(", ")} } from ${JSON.stringify(source)};`).join("\n"),
97
+ // The top-level object on lines of its own, so oxfmt keeps it expanded as people write it.
98
+ ...exports.map(({ name, vendor, type, desired }) => {
99
+ const body = Object.entries(desired).map(([key, value]) => `${keyOf(key)}: ${literal(value)},\n`);
100
+ return `export const ${name} = ${vendor}.${type}({${body.length ? `\n${body.join("")}` : ""}});`;
101
+ }),
102
+ `export default [${exports.map((e) => e.name).join(", ")}];`,
103
+ ];
104
+ return format(`${parts.filter(Boolean).join("\n\n")}\n`, options);
105
+ }
106
+
107
+ /** `text` with `secret` imported after its imports, unless it imports it already. */
108
+ function withSecretImport(text: string): string {
109
+ const imports = parse("/data-file.ts", text).program.body.filter((s: Node) => s.type === "ImportDeclaration");
110
+ const has = imports.some(
111
+ (s: Node) =>
112
+ SECRET_ENTRIES.has(s.source.value) &&
113
+ s.specifiers.some((sp: Node) => (sp.imported?.name ?? sp.imported?.value) === "secret"),
114
+ );
115
+ if (has) return text;
116
+ const at = imports.at(-1)?.end ?? 0;
117
+ const line = `import { ${SECRET_IMPORT.local} } from ${JSON.stringify(SECRET_IMPORT.source)};`;
118
+ return at ? `${text.slice(0, at)}\n${line}${text.slice(at)}` : `${line}\n${text}`;
119
+ }
120
+
121
+ /** The `export const` of the resource `name` in `text`, and the object literal its constructor takes. */
122
+ function declarationOf(text: string, name: string): { statement: Node; object: Node } {
123
+ // The name is never resolved, and a relative import is checked against the file's own directory.
124
+ const filename = "/data-file.ts";
125
+ const { program, errors } = parse(filename, text);
126
+ if (errors.length) throw new Error(`The data file does not parse: ${errors[0]!.message}`);
127
+ const found = readDataFile(program, filename).exports.find((e) => e.name === name);
128
+ if (!found) throw new Error(`The data file declares no resource ${name}`);
129
+ const statement: Node = program.body.find((s) => s.start === found.start);
130
+ return { statement, object: statement.declaration.declarations[0].init.arguments[0] };
131
+ }
132
+
133
+ /**
134
+ * What setting `path` to `value` in `object` replaces, and with what. A key may hold a dot
135
+ * (`metadata: { "app.owner": … }`), and a drift report's path does not say so: at each object,
136
+ * the longest run of the path's segments that names a field is that field.
137
+ */
138
+ function editAt(object: Node, path: string, value: DataValue): { start: number; end: number; replacement: string } {
139
+ const keys = path.split(".");
140
+ let node = object;
141
+ for (let i = 0; i < keys.length;) {
142
+ if (node.type === "ObjectExpression") {
143
+ const names = new Map<string, Node>(node.properties.map((p: Node) => [fieldName(p), p]));
144
+ let j = keys.length;
145
+ while (j > i && !names.has(keys.slice(i, j).join("."))) j--;
146
+ if (j === i) return added(node, keys.slice(i), path, value);
147
+ node = names.get(keys.slice(i, j).join("."))!.value;
148
+ i = j;
149
+ } else if (node.type === "ArrayExpression" && /^\d+$/.test(keys[i]!) && node.elements[Number(keys[i])]) {
150
+ node = node.elements[Number(keys[i])];
151
+ i++;
152
+ } else {
153
+ const at = keys.slice(0, i).join(".");
154
+ throw new Error(`Cannot set ${path}: ${at} is not an object or a list with item ${keys[i]}`);
155
+ }
156
+ }
157
+ const named = referenceIn(node);
158
+ if (named) {
159
+ throw new Error(`Cannot set ${path}: it names the resource ${named}; change a reference in the file itself`);
160
+ }
161
+ return { start: node.start, end: node.end, replacement: literal(value) };
162
+ }
163
+
164
+ /** The first resource a value names, itself or anywhere inside it: a vendor's value never replaces one. */
165
+ function referenceIn(node: Node): string | undefined {
166
+ if (node.type === "Identifier") return node.name;
167
+ const items =
168
+ node.type === "ArrayExpression" ? node.elements : node.type === "ObjectExpression" ? node.properties : [];
169
+ for (const item of items) {
170
+ const named = item && referenceIn(item.type === "Property" ? item.value : item);
171
+ if (named) return named;
172
+ }
173
+ return undefined;
174
+ }
175
+
176
+ /**
177
+ * The edit that adds `keys` (the first missing, then any under it) with `value` at the end of
178
+ * `object`. Only objects are made under it: a list item's index there is refused.
179
+ */
180
+ function added(object: Node, [key, ...under]: string[], path: string, value: DataValue) {
181
+ const index = under.find((k) => /^\d+$/.test(k));
182
+ if (index !== undefined) throw new Error(`Cannot set ${path}: the file has no list to hold item ${index}`);
183
+ const nested = under.reduceRight<DataValue>((v, k) => Object.fromEntries([[k, v]]), value);
184
+ const field = `${keyOf(key!)}: ${literal(nested)}`;
185
+ const last = object.properties.at(-1);
186
+ return last
187
+ ? { start: last.end, end: last.end, replacement: `, ${field}` }
188
+ : { start: object.start, end: object.end, replacement: `{ ${field} }` };
189
+ }
190
+
191
+ /** A property's name, as `readDataFile` reads it. */
192
+ const fieldName = (p: Node): string | undefined => (p.type === "Property" && !p.computed ? keyName(p.key) : undefined);
193
+
194
+ /** A field's name in source: bare when it can be, else a string. */
195
+ const keyOf = (key: string) => (/^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key));
196
+
197
+ /** True when a value names a secret, itself or anywhere inside it. */
198
+ const holdsSecret = (value: DataValue): boolean =>
199
+ isSecret(value) ||
200
+ (typeof value === "object" && value !== null && !isReference(value) && Object.values(value).some(holdsSecret));
201
+
202
+ /** A value in the data-file subset, as source, on one line: oxfmt lays it out. */
203
+ function literal(value: DataValue): string {
204
+ if (isReference(value)) return value.name;
205
+ if (isSecret(value)) return `secret(${JSON.stringify(value.$secret)}${value.generate ? ", { generate: true }" : ""})`;
206
+ if (value === null || typeof value === "boolean" || typeof value === "string") return JSON.stringify(value);
207
+ if (typeof value === "number") {
208
+ if (!Number.isFinite(value)) throw new Error(`${value} cannot be written in a data file: write a finite number`);
209
+ return Object.is(value, -0) ? "0" : String(value);
210
+ }
211
+ if (Array.isArray(value)) return `[${value.map(literal).join(", ")}]`;
212
+ if (typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
213
+ const fields = Object.entries(value).map(([key, v]) => `${keyOf(key)}: ${literal(v)}`);
214
+ return fields.length ? `{ ${fields.join(", ")} }` : "{}";
215
+ }
216
+ throw new Error(`${String(value)} cannot be written in a data file: ${typeof value} is not a data value`);
217
+ }
218
+
219
+ /** `text` formatted by oxfmt, loaded only when a file is written. */
220
+ async function format(text: string, options: FormatConfig): Promise<string> {
221
+ const oxfmt = await import("oxfmt");
222
+ const { code, errors } = await oxfmt.format("data-file.ts", text, options);
223
+ if (errors.length) throw new Error(`oxfmt could not format the data file: ${errors[0]!.message}`);
224
+ return code;
225
+ }