@sanoma-ai/workflows 0.9.1 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +193 -50
  2. package/SECRET-SUPPORT.md +33 -0
  3. package/dist/apply-steps.js +5 -2
  4. package/dist/apply-steps.js.map +1 -1
  5. package/dist/apply.d.ts +3 -0
  6. package/dist/apply.js +27 -13
  7. package/dist/apply.js.map +1 -1
  8. package/dist/approvals.d.ts +1 -0
  9. package/dist/approvals.js +6 -1
  10. package/dist/approvals.js.map +1 -1
  11. package/dist/apps.d.ts +13 -24
  12. package/dist/apps.js +69 -68
  13. package/dist/apps.js.map +1 -1
  14. package/dist/ast.d.ts +9 -7
  15. package/dist/ast.js +2 -0
  16. package/dist/ast.js.map +1 -1
  17. package/dist/authentication.d.ts +54 -0
  18. package/dist/authentication.js +138 -0
  19. package/dist/authentication.js.map +1 -0
  20. package/dist/call-workflow.d.ts +5 -0
  21. package/dist/call-workflow.js +4 -3
  22. package/dist/call-workflow.js.map +1 -1
  23. package/dist/call.d.ts +3 -2
  24. package/dist/call.js +36 -7
  25. package/dist/call.js.map +1 -1
  26. package/dist/cedar.js +14 -1
  27. package/dist/cedar.js.map +1 -1
  28. package/dist/change-steps.js +7 -5
  29. package/dist/change-steps.js.map +1 -1
  30. package/dist/change.d.ts +7 -0
  31. package/dist/change.js +7 -0
  32. package/dist/change.js.map +1 -1
  33. package/dist/client.d.ts +62 -10
  34. package/dist/client.js +272 -47
  35. package/dist/client.js.map +1 -1
  36. package/dist/collection.d.ts +7 -0
  37. package/dist/collection.js +5 -2
  38. package/dist/collection.js.map +1 -1
  39. package/dist/config.d.ts +16 -4
  40. package/dist/config.js +89 -21
  41. package/dist/config.js.map +1 -1
  42. package/dist/connection.d.ts +389 -0
  43. package/dist/connection.js +657 -0
  44. package/dist/connection.js.map +1 -0
  45. package/dist/credentials.d.ts +7 -53
  46. package/dist/credentials.js +84 -247
  47. package/dist/credentials.js.map +1 -1
  48. package/dist/db.js +34 -2
  49. package/dist/db.js.map +1 -1
  50. package/dist/define.d.ts +9 -2
  51. package/dist/define.js.map +1 -1
  52. package/dist/definitions.d.ts +20 -0
  53. package/dist/definitions.js +16 -0
  54. package/dist/definitions.js.map +1 -0
  55. package/dist/deployment-key.d.ts +2 -0
  56. package/dist/deployment-key.js +22 -0
  57. package/dist/deployment-key.js.map +1 -0
  58. package/dist/describe.d.ts +15 -175
  59. package/dist/describe.js +122 -35
  60. package/dist/describe.js.map +1 -1
  61. package/dist/description.d.ts +532 -0
  62. package/dist/description.js +381 -0
  63. package/dist/description.js.map +1 -0
  64. package/dist/direct.js +7 -0
  65. package/dist/direct.js.map +1 -1
  66. package/dist/drift.d.ts +1 -1
  67. package/dist/drift.js +1 -1
  68. package/dist/drift.js.map +1 -1
  69. package/dist/env.d.ts +2 -0
  70. package/dist/env.js +2 -2
  71. package/dist/env.js.map +1 -1
  72. package/dist/fake.d.ts +13 -1
  73. package/dist/fake.js +70 -0
  74. package/dist/fake.js.map +1 -1
  75. package/dist/index.d.ts +11 -3
  76. package/dist/index.js +10 -2
  77. package/dist/index.js.map +1 -1
  78. package/dist/ledger.d.ts +2 -0
  79. package/dist/ledger.js +1 -1
  80. package/dist/ledger.js.map +1 -1
  81. package/dist/lint.d.ts +0 -9
  82. package/dist/lint.js +2 -17
  83. package/dist/lint.js.map +1 -1
  84. package/dist/load.d.ts +85 -0
  85. package/dist/load.js +391 -0
  86. package/dist/load.js.map +1 -0
  87. package/dist/lockfile.d.ts +2 -0
  88. package/dist/lockfile.js +8 -3
  89. package/dist/lockfile.js.map +1 -1
  90. package/dist/op.d.ts +22 -5
  91. package/dist/op.js +20 -2
  92. package/dist/op.js.map +1 -1
  93. package/dist/outline.d.ts +5 -0
  94. package/dist/outline.js +46 -5
  95. package/dist/outline.js.map +1 -1
  96. package/dist/paths.d.ts +18 -2
  97. package/dist/paths.js +19 -7
  98. package/dist/paths.js.map +1 -1
  99. package/dist/plugin.d.ts +0 -11
  100. package/dist/plugin.js +3 -18
  101. package/dist/plugin.js.map +1 -1
  102. package/dist/policy.d.ts +2 -0
  103. package/dist/policy.js.map +1 -1
  104. package/dist/resource.d.ts +10 -4
  105. package/dist/resource.js +11 -4
  106. package/dist/resource.js.map +1 -1
  107. package/dist/resources.d.ts +33 -76
  108. package/dist/resources.js +219 -227
  109. package/dist/resources.js.map +1 -1
  110. package/dist/run.d.ts +4 -0
  111. package/dist/run.js.map +1 -1
  112. package/dist/sandbox.d.ts +5 -2
  113. package/dist/sandbox.js +56 -7
  114. package/dist/sandbox.js.map +1 -1
  115. package/dist/scenario.d.ts +5 -0
  116. package/dist/scenario.js +3 -2
  117. package/dist/scenario.js.map +1 -1
  118. package/dist/scenarios.js +7 -1
  119. package/dist/scenarios.js.map +1 -1
  120. package/dist/schema.js +8 -0
  121. package/dist/schema.js.map +1 -1
  122. package/dist/secret-store-check.d.ts +11 -0
  123. package/dist/secret-store-check.js +76 -0
  124. package/dist/secret-store-check.js.map +1 -0
  125. package/dist/secret-store.d.ts +106 -0
  126. package/dist/secret-store.js +228 -0
  127. package/dist/secret-store.js.map +1 -0
  128. package/dist/secret.d.ts +2 -0
  129. package/dist/secret.js +32 -0
  130. package/dist/secret.js.map +1 -1
  131. package/dist/shared.d.ts +21 -11
  132. package/dist/shared.js +41 -7
  133. package/dist/shared.js.map +1 -1
  134. package/dist/sites.d.ts +4 -93
  135. package/dist/sites.js +76 -84
  136. package/dist/sites.js.map +1 -1
  137. package/dist/steps.d.ts +3 -0
  138. package/dist/steps.js.map +1 -1
  139. package/dist/version.d.ts +2 -1
  140. package/dist/version.js +2 -1
  141. package/dist/version.js.map +1 -1
  142. package/dist/wakeup-driver.js +15 -6
  143. package/dist/wakeup-driver.js.map +1 -1
  144. package/dist/worker.js +12 -8
  145. package/dist/worker.js.map +1 -1
  146. package/dist/writer.d.ts +53 -14
  147. package/dist/writer.js +113 -43
  148. package/dist/writer.js.map +1 -1
  149. package/oxlint.json +1 -5
  150. package/package.json +6 -1
  151. package/src/apply-steps.ts +5 -2
  152. package/src/apply.ts +54 -21
  153. package/src/approvals.ts +7 -1
  154. package/src/apps.ts +71 -86
  155. package/src/ast.ts +6 -6
  156. package/src/authentication.ts +191 -0
  157. package/src/call-workflow.ts +5 -4
  158. package/src/call.ts +52 -9
  159. package/src/cedar.ts +14 -1
  160. package/src/change-steps.ts +7 -4
  161. package/src/change.ts +10 -0
  162. package/src/client.ts +346 -45
  163. package/src/collection.ts +10 -2
  164. package/src/config.ts +107 -19
  165. package/src/connection.ts +912 -0
  166. package/src/credentials.ts +94 -281
  167. package/src/db.ts +34 -2
  168. package/src/define.ts +7 -2
  169. package/src/definitions.ts +31 -0
  170. package/src/deployment-key.ts +23 -0
  171. package/src/describe.ts +150 -227
  172. package/src/description.ts +426 -0
  173. package/src/direct.ts +7 -0
  174. package/src/drift.ts +10 -2
  175. package/src/env.ts +4 -2
  176. package/src/fake.ts +71 -0
  177. package/src/index.ts +53 -1
  178. package/src/ledger.ts +3 -1
  179. package/src/lint.ts +2 -17
  180. package/src/load.ts +466 -0
  181. package/src/lockfile.ts +10 -3
  182. package/src/op.ts +41 -7
  183. package/src/outline.ts +53 -7
  184. package/src/paths.ts +21 -6
  185. package/src/plugin.ts +3 -24
  186. package/src/policy.ts +2 -0
  187. package/src/resource.ts +24 -6
  188. package/src/resources.ts +246 -301
  189. package/src/run.ts +4 -0
  190. package/src/sandbox.ts +61 -7
  191. package/src/scenario.ts +24 -6
  192. package/src/scenarios.ts +7 -1
  193. package/src/schema.ts +10 -0
  194. package/src/secret-store-check.ts +93 -0
  195. package/src/secret-store.ts +314 -0
  196. package/src/secret.ts +29 -0
  197. package/src/shared.ts +44 -10
  198. package/src/sites.ts +85 -179
  199. package/src/steps.ts +3 -2
  200. package/src/version.ts +2 -1
  201. package/src/wakeup-driver.ts +22 -6
  202. package/src/worker.ts +12 -8
  203. package/src/writer.ts +145 -56
  204. package/dist/datafile.d.ts +0 -97
  205. package/dist/datafile.js +0 -479
  206. package/dist/datafile.js.map +0 -1
  207. package/src/datafile.ts +0 -601
package/src/shared.ts CHANGED
@@ -5,7 +5,7 @@ import type { OpEntry, WorkflowEntry } from "./describe.ts";
5
5
  import type { LedgerRecord } from "./ledger.ts";
6
6
  import type { Effect } from "./op.ts";
7
7
  import type { PreviewRequest } from "./policy.ts";
8
- import type { DeclaredResource, ResourceProblem } from "./resources.ts";
8
+ import type { DeclaredResource, ResourceProblem } from "./description.ts";
9
9
  import type { OutlineNode } from "./outline.ts";
10
10
 
11
11
  // Types only: resource types as a UI reads them. Erased from the bundle.
@@ -18,7 +18,7 @@ export type {
18
18
  ResourceFields,
19
19
  ResourceSpec,
20
20
  } from "./resource.ts";
21
- export type { DeclaredResource, ResourceProblem } from "./resources.ts";
21
+ export type { At, DeclaredResource, ResourceProblem } from "./description.ts";
22
22
  export type { DriftField, DriftReport, DriftResult, DriftStatus } from "./drift.ts";
23
23
  export type { ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanStep } from "./apply.ts";
24
24
  export type { CredentialStatus } from "./env.ts";
@@ -31,6 +31,8 @@ export type { AvailableApp, AvailableApps } from "./apps-list.ts";
31
31
 
32
32
  // Sanoma's claims, data (`sanoma/claims` re-exports them).
33
33
  export * from "./claims.ts";
34
+ // A site's `site.ts` declares itself with it (`sanoma/site` calls it, typing its icon).
35
+ export { defineSite, type SiteDefinition } from "./definitions.ts";
34
36
 
35
37
  /**
36
38
  * What a `ctx.sleep` without a `{ name }` is called, in its outline node and its ledger record
@@ -87,6 +89,15 @@ export const SITE_NAME = /^[a-z][a-z0-9-]*$/;
87
89
  */
88
90
  export const APP_NAME = /^[a-z][a-z0-9]*$/;
89
91
 
92
+ /** Where a company's apps are, under its root: `apps/<app>/`. */
93
+ export const APPS_DIR = "apps";
94
+
95
+ /** The app a path relative to a company's root is in: `<app>` under `apps/<app>/`; undefined for any other. */
96
+ export function appOfPath(rel: string): string | undefined {
97
+ const [top, app, ...rest] = rel.split("/");
98
+ return top === APPS_DIR && app !== undefined && rest.length && APP_NAME.test(app) ? app : undefined;
99
+ }
100
+
90
101
  /** A name declared under `apps/<app>/` as the runtime has it, `<app>.<name>`; one declared at the root as it is. */
91
102
  export const qualified = (app: string | undefined, name: string): string =>
92
103
  app === undefined ? name : `${app}.${name}`;
@@ -490,17 +501,40 @@ export function flatten(nodes: readonly OutlineNode[]): OutlineNode[] {
490
501
  return nodes.flatMap((node) => [node, ...flatten(childLists(node).flat())]);
491
502
  }
492
503
 
493
- /** Where a data-file problem is, `file:line:column`; undefined for one that is the config's, with no file. */
494
- export const problemAt = (p: ResourceProblem): string | undefined =>
495
- p.file === undefined ? undefined : `${p.file}:${p.line}:${p.column}`;
504
+ /**
505
+ * Where a problem is, `file:line:column`, `file#export` or `file`; undefined for one that is the
506
+ * config's, with no file.
507
+ */
508
+ export const problemAt = (p: ResourceProblem): string | undefined => {
509
+ if (p.file === undefined) return undefined;
510
+ if (p.line !== undefined) return `${p.file}:${p.line}:${p.column ?? 1}`;
511
+ return p.export === undefined ? p.file : `${p.file}#${p.export}`;
512
+ };
513
+
514
+ /**
515
+ * A problem as one line: `file:line:column: message`, `file#export: message` or `file: message`
516
+ * without a position, or the message alone without a file.
517
+ */
518
+ export function problemLine(p: ResourceProblem): string {
519
+ const at = problemAt(p);
520
+ return at === undefined ? p.message : `${at}: ${p.message}`;
521
+ }
496
522
 
497
523
  /**
498
- * A problem as one line: `file:line:column: message`, or `file: message` without a position, or
499
- * the message alone without a file.
524
+ * Where the export `name` (`default` for the default export) is in a file's text, as UTF-16
525
+ * offsets: from its `export` to the next `export`, `import`, `const` or comment at the start of a
526
+ * line, or the end, less trailing blank space. Read from the text alone, for highlighting it; undefined
527
+ * when no line starts it.
500
528
  */
501
- export function problemLine(p: { file?: string; line?: number; column?: number; message: string }): string {
502
- if (p.file === undefined) return p.message;
503
- return p.line === undefined ? `${p.file}: ${p.message}` : `${p.file}:${p.line}:${p.column ?? 1}: ${p.message}`;
529
+ export function exportSpan(text: string, name: string): [number, number] | undefined {
530
+ const declared = name === "default" ? "default" : `const\\s+${name.replaceAll("$", "\\$")}`;
531
+ const start = new RegExp(`^export\\s+${declared}(?![\\w$])`, "m").exec(text);
532
+ if (!start) return undefined;
533
+ const after = start.index + start[0].length;
534
+ // A comment at the start of a line is the next declaration's.
535
+ const next = /^(?:(?:export|import|const)\b|\/[/*])/m.exec(text.slice(after));
536
+ const end = next ? after + next.index : text.length;
537
+ return [start.index, start.index + text.slice(start.index, end).trimEnd().length];
504
538
  }
505
539
 
506
540
  /** A warning as one line, as `problemLine` with `warning: ` before its message: what is worth fixing but not wrong. */
package/src/sites.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  import { existsSync, readdirSync } from "node:fs";
2
- import { basename, join, relative, sep } from "node:path";
3
- import { childrenOf, type LintProblem, type Node, readSource, type SourceFile } from "./ast.ts";
2
+ import { basename, join } from "node:path";
3
+ import { z } from "zod";
4
+ import { childrenOf, keyName, type LintProblem, type Node, readSource, type SourceFile } from "./ast.ts";
4
5
  import { type CedarPolicySource, visitorPermits } from "./cedar.ts";
5
- import { keyName } from "./datafile.ts";
6
- import type { ConfigDescription } from "./describe.ts";
6
+ import type { ConfigDescription, SiteCall, SiteEntry, SitePage } from "./description.ts";
7
7
  import { stringValue, unwrap } from "./outline.ts";
8
- import { APPS_DIR, appFolders, filesUnder, isDirectory } from "./paths.ts";
8
+ import { exportsOf, loadModules } from "./load.ts";
9
+ import { APPS_DIR, appFolders, filesUnder, isDirectory, relPath, SITE_ENTRIES } from "./paths.ts";
10
+ import { isObject } from "./resource.ts";
11
+ import type { ResourceProblem } from "./resources.ts";
9
12
  import { START } from "./schema.ts";
10
13
  import {
11
14
  ANONYMOUS,
@@ -19,100 +22,14 @@ import {
19
22
  WORKFLOW_ACTION,
20
23
  } from "./shared.ts";
21
24
 
22
- // What a company's sites call, read from their source with oxc and never run: the reverse index
23
- // a workflow's page shows as Used by, and the names `sanoma check` holds to the config.
25
+ // What a company's sites call, read from their source with oxc and never run (their pages are
26
+ // browser code, the site's outline): the reverse index a workflow's page shows as Used by, and
27
+ // the names `sanoma check` holds to the config. A site's `site.ts` is loaded.
24
28
 
25
- /** A call a site makes, as written in its source. */
26
- export interface SiteCall {
27
- /** What it calls: a workflow, a collection (`api.data.<name>.<op>`) or an operation (`api.<vendor>.<resource>.<op>`). */
28
- kind: "workflow" | "collection" | "op";
29
- /**
30
- * The workflow's name (`RunForm`'s `workflow`, or the workflow whose `apiNameOf` is the name
31
- * `api` gives; that name itself when none is), the collection's name, or the operation's id.
32
- */
33
- target: string;
34
- /**
35
- * What it calls of the workflow (`start`, or a read of its runs: `result`, `latest`, …) or the
36
- * collection (`list`, `save`, …), as written; absent for a workflow called as itself or by
37
- * `RunForm`, which starts it, and for an operation.
38
- */
39
- method?: string;
40
- /** `<RunForm workflow="…" />` or a call on `api`, from `sanoma/site`. */
41
- via: "RunForm" | "api";
42
- /** The file, relative to the config's root, with `/`: `sites/sales/routes/index.tsx`. */
43
- file: string;
44
- /** From 1. */
45
- line: number;
46
- /** From 1, in UTF-16 code units. */
47
- column: number;
48
- /**
49
- * The path of the page the file is, from its `createFileRoute` (or `createLazyFileRoute`) id: groups and pathless
50
- * segments left out, no trailing slash (`/sites/sales`). Absent for a file that is no page,
51
- * such as a component's.
52
- */
53
- page?: string;
54
- }
55
-
56
- /** A page of a site: a route file's path, and for a page per content entry the kind it reads. */
57
- export interface SitePage {
58
- /** As `SiteCall.page` has it: `/sites/www`, `/sites/www/blog/$slug`. */
59
- path: string;
60
- /**
61
- * For a path with one parameter (`$slug`), the content kind whose entries are its pages: the one
62
- * kind its file reads by that parameter, `content.<kind>.get(params.slug)` (or `slug`,
63
- * destructured) with `content` from `sanoma/site`.
64
- */
65
- content?: string;
66
- /**
67
- * `false` when its route says `staticData: { sitemap: false }`, written out: the sitemap leaves it
68
- * out. Its index route's `sitemap` (`true` or `false`) decides over a layout's at its path; a
69
- * pathless or group layout's says nothing.
70
- */
71
- sitemap?: false;
72
- }
73
-
74
- /** A site, `sites/<name>/` or an app's `apps/<app>/sites/<name>/`, and what it calls. */
75
- export interface SiteEntry {
76
- /** Its folder's name, `<app>/<name>` for an app's (`siteKey`): where it is mounted, `/sites/<name>`. */
77
- name: string;
78
- /** The app whose folder holds it (`apps/<app>/sites/`); absent for one at the root. */
79
- app?: string;
80
- /** `defineSite({ title })` in its `site.ts`, when that is a string literal. */
81
- title?: string;
82
- /** `defineSite({ icon })` in its `site.ts`: a Lucide icon's name, as written. */
83
- icon?: string;
84
- /** `defineSite({ public: true })`: served to visitors, not people. */
85
- public?: true;
86
- /** `defineSite({ login })`, the connector whose login half signs visitors in, as written: one that names none is among `problems`. */
87
- login?: string;
88
- /**
89
- * `defineSite({ locales })`, a public site's languages, the first its default: kept only when
90
- * every one is a `LOCALE`, written once, on a public site with no login; else a problem.
91
- */
92
- locales?: string[];
93
- /** The host a public site answers on: set by `startApp` from its `domains`, never by `readSites`. */
94
- domain?: string;
95
- /** Its pages, by path: one for each path its `routes/` files give (a layout and its index are one). */
96
- pages: SitePage[];
97
- /** Its calls, by file and then in the file's order. */
98
- calls: SiteCall[];
99
- /**
100
- * Each call to a workflow, a collection or an operation the config does not have, or to an
101
- * operation a page may not call directly, or, on a public site, that no policy lets a visitor
102
- * make, at its file; then what is wrong in its `site.ts`.
103
- */
104
- problems: (LintProblem & { file: string })[];
105
- /**
106
- * On a public site, each page with one parameter whose file reads content the sitemap cannot
107
- * tie to it (`SitePage.content`), so the sitemap lists none of its pages: at the first read.
108
- */
109
- warnings: (LintProblem & { file: string })[];
110
- }
29
+ export type { SiteCall, SiteEntry, SitePage };
111
30
 
112
31
  /** Where a site's code is: its pages and its components. */
113
32
  const SITE_DIRS = ["routes", "components"];
114
- /** Where `api`, `RunForm` and `defineSite` come from: `sanoma/site`, which re-exports the app's. */
115
- const SITE_SOURCES: ReadonlySet<string> = new Set(["sanoma/site", "@sanoma-ai/app/site"]);
116
33
  const KIND_NAMES = { workflow: "workflow", collection: "collection", op: "operation" } as const;
117
34
  /** What a route's loader and head name its path's parameters by. */
118
35
  const PARAMS: ReadonlySet<string> = new Set(["params"]);
@@ -212,15 +129,14 @@ export function readSites(
212
129
  const read = readSiteFiles(root, dir);
213
130
  const resolved = read.flatMap(({ uses }) => uses.filter((use) => !isAppCollection(use)).map(resolve));
214
131
  const pages = pagesOf(read);
215
- const siteFile = `${dir}/site.ts`;
216
- const { problems: optionProblems, ...options } = siteOptionsOf(join(root, siteFile), connectors);
132
+ const { problems: optionProblems, ...options } = siteDefinitionOf(root, dir, connectors);
217
133
  const calls = resolved.map((r) => r.call);
218
134
  const problems = [
219
135
  ...resolved.flatMap(({ call, problem }) => {
220
136
  const message = problem ?? (options.public ? refused(call, options.login) : undefined);
221
137
  return message === undefined ? [] : [{ file: call.file, line: call.line, column: call.column, message }];
222
138
  }),
223
- ...optionProblems.map((p) => ({ file: siteFile, ...p })),
139
+ ...optionProblems,
224
140
  ];
225
141
  const sorted = [...pages.keys()].toSorted().map((path) => pages.get(path)!);
226
142
  // A page's file it warns of, unless another of its files (a lazy one, its layout) gives its kind.
@@ -261,7 +177,7 @@ const readSiteFiles = (root: string, dir: string) =>
261
177
  .toSorted()
262
178
  .flatMap((path) => {
263
179
  const file = readSource(path);
264
- return typeof file === "string" ? [] : [readSiteFile(file, relative(root, path).split(sep).join("/"))];
180
+ return typeof file === "string" ? [] : [readSiteFile(file, relPath(root, path))];
265
181
  });
266
182
 
267
183
  /**
@@ -282,13 +198,13 @@ export function siteContentPages(root: string): Record<string, string> {
282
198
  /**
283
199
  * Each site's languages (`defineSite({ locales })`, the first its default) under `root`, by name,
284
200
  * for the sites whose `site.ts` gives them without a problem there: what the app's build gives
285
- * its router (`virtual:sanoma/locales`). Read as `readSites` reads them, never run.
201
+ * its router (`virtual:sanoma/locales`). Loaded as `readSites` loads them.
286
202
  */
287
203
  export function siteLocales(root: string): Record<string, string[]> {
288
204
  const none = new Set<string>();
289
205
  return Object.fromEntries(
290
206
  sitesUnder(root).flatMap(({ name, dir }) => {
291
- const { locales } = siteOptionsOf(join(root, dir, "site.ts"), { vendors: none, logins: none });
207
+ const { locales } = siteDefinitionOf(root, dir, { vendors: none, logins: none });
292
208
  return locales ? [[name, locales]] : [];
293
209
  }),
294
210
  );
@@ -297,13 +213,13 @@ export function siteLocales(root: string): Record<string, string[]> {
297
213
  /**
298
214
  * Each site's icon (`defineSite({ icon })`, a Lucide icon's name as written) under `root`, by name,
299
215
  * for the sites whose `site.ts` gives one: the icons the app's build bundles (`virtual:sanoma/icons`).
300
- * Read as `readSites` reads it, never run.
216
+ * Loaded as `readSites` loads it.
301
217
  */
302
218
  export function siteIcons(root: string): Record<string, string> {
303
219
  const none = new Set<string>();
304
220
  return Object.fromEntries(
305
221
  sitesUnder(root).flatMap(({ name, dir }) => {
306
- const { icon } = siteOptionsOf(join(root, dir, "site.ts"), { vendors: none, logins: none });
222
+ const { icon } = siteDefinitionOf(root, dir, { vendors: none, logins: none });
307
223
  return icon === undefined ? [] : [[name, icon]];
308
224
  }),
309
225
  );
@@ -347,9 +263,9 @@ function readSiteFile(
347
263
  { program, at }: SourceFile,
348
264
  file: string,
349
265
  ): { uses: Written[]; route?: FileRoute; warning?: LintProblem & { file: string } } {
350
- const api = importedAs(program, SITE_SOURCES, "api");
351
- const runForm = importedAs(program, SITE_SOURCES, "RunForm");
352
- const content = importedAs(program, SITE_SOURCES, "content");
266
+ const api = importedAs(program, SITE_ENTRIES, "api");
267
+ const runForm = importedAs(program, SITE_ENTRIES, "RunForm");
268
+ const content = importedAs(program, SITE_ENTRIES, "content");
353
269
  if (!api.size && !runForm.size && !file.includes("/routes/")) return { uses: [] };
354
270
  const found: Omit<Written, "page">[] = [];
355
271
  /** Each `content.<kind>.get(arg)`: its kind, its argument and where it is. */
@@ -504,99 +420,89 @@ type SiteConnectors = { vendors: ReadonlySet<string>; logins: ReadonlySet<string
504
420
  /** A property of an object literal that is written out: not a spread, its name not computed. */
505
421
  const isWritten = (p: Node) => p.type === "Property" && !p.computed;
506
422
 
507
- /** What `site.ts` that `sanoma` cannot read without running it is told. */
508
- const unread = (what: string, as: string) => `${what} is read without running site.ts: write it as ${as}`;
423
+ /** What `site.ts`'s default export, `defineSite({ … })`, may say. */
424
+ const SiteDefinitionSchema = z.strictObject({
425
+ title: z.string().optional(),
426
+ icon: z.string().optional(),
427
+ public: z.boolean().optional(),
428
+ login: z.string().optional(),
429
+ locales: z.array(z.string()).optional(),
430
+ });
431
+
432
+ /** What `site.ts` says of who may see a site: read together or not at all. */
433
+ const ACCESS = ["public", "login", "locales"];
509
434
 
510
435
  /**
511
- * What `site.ts` says of its site, read without running it, and what is wrong there: its default
512
- * export must be `defineSite({ … })` written out, so it can be read at all; then `title` when it is
513
- * a string, `icon`, `public`, `login` and `locales`, which must be literals. `login` is kept as written; one on a
514
- * site that is not public, or naming no connector of the config with a login half, is a problem.
515
- * `locales` is kept only when it has no problem.
436
+ * What the site in `dir` (relative to `root`) says of itself in its `site.ts`, loaded
437
+ * (`loadModules`, `defineSite` served from `sanoma/site`), and what is wrong there: its default
438
+ * export, `defineSite({ … })`, held to its schema; then a `login` on a site that is not public, or
439
+ * naming no connector of the config with a login half, and `locales` that are not a public site's
440
+ * (with no login) language tags, each once. `locales` is kept only when it has no problem. Nothing
441
+ * for no file, or no default export.
516
442
  */
517
- function siteOptionsOf(
518
- path: string,
443
+ function siteDefinitionOf(
444
+ root: string,
445
+ dir: string,
519
446
  connectors: SiteConnectors,
520
- ): Pick<SiteEntry, "title" | "icon" | "public" | "login" | "locales"> & { problems: LintProblem[] } {
521
- const file = readSource(path);
522
- if (typeof file === "string") return { problems: [] };
523
- const problems: LintProblem[] = [...file.problems];
524
- const problem = (at: Node, message: string) => void problems.push({ ...file.at(at.start), message });
525
- const exported = file.program.body.find((s: Node) => s.type === "ExportDefaultDeclaration");
526
- if (!exported) return { problems };
527
- const call = unwrap(exported.declaration);
528
- const callee = unwrap(call?.callee);
529
- const options = unwrap(call?.arguments?.[0]);
530
- const defineSite = importedAs(file.program, SITE_SOURCES, "defineSite");
531
- // Anything not written out (a variable, a spread, a computed key) could make the site public unseen.
532
- const written =
533
- call?.type === "CallExpression" &&
534
- callee?.type === "Identifier" &&
535
- defineSite.has(callee.name) &&
536
- (options === undefined || options.type === "ObjectExpression");
537
- if (!written) {
538
- problem(
539
- exported,
540
- unread("its default export", '`defineSite({ … })` from "sanoma/site", its options an object literal'),
447
+ ): Pick<SiteEntry, "at" | "title" | "icon" | "public" | "login" | "locales"> & {
448
+ problems: (ResourceProblem & { file: string })[];
449
+ } {
450
+ const file = `${dir}/site.ts`;
451
+ if (!existsSync(join(root, file))) return { problems: [] };
452
+ // A helper it imports threw: said at the helper.
453
+ const loaded = exportsOf(loadModules(root, [join(root, file)], []), file);
454
+ if (loaded === undefined) return { problems: [] };
455
+ if ("problem" in loaded) return { problems: [loaded.problem] };
456
+ const value = loaded.exports.find(([name]) => name === "default")?.[1];
457
+ if (value === undefined) return { problems: [] };
458
+ const problem = (message: string) => ({ file, export: "default", message });
459
+ const problems: (ResourceProblem & { file: string })[] = [];
460
+ let parsed = SiteDefinitionSchema.safeParse(value);
461
+ if (!parsed.success) {
462
+ const why = z.prettifyError(parsed.error).replaceAll(/\s*\n\s*/g, " ");
463
+ problems.push(problem(`its default export is \`defineSite({ … })\` from "sanoma/site": ${why}`));
464
+ // The fields it names wrongly (and the keys it does not know) left out, the rest still read:
465
+ // a bad icon keeps a site's `public`, `login` and `locales`. Those three say who may see it,
466
+ // so a wrong one leaves out all three: never a site read as public without its login.
467
+ const wrong = new Set(
468
+ parsed.error.issues.flatMap((i) => (i.code === "unrecognized_keys" ? i.keys : i.path.slice(0, 1))),
541
469
  );
542
- return { problems };
543
- }
544
- for (const p of options?.properties ?? []) {
545
- if (!isWritten(p)) problem(p, unread("each of `defineSite`'s options", "a property with its name"));
470
+ if (ACCESS.some((key) => wrong.has(key))) for (const key of ACCESS) wrong.add(key);
471
+ if (!isObject(value) || !wrong.size) return { problems };
472
+ parsed = SiteDefinitionSchema.safeParse(
473
+ Object.fromEntries(Object.entries(value).filter(([key]) => !wrong.has(key))),
474
+ );
475
+ if (!parsed.success) return { problems };
546
476
  }
547
- // The last of a name, as the object it makes has.
548
- const property = (name: string): Node =>
549
- options?.properties.findLast((p: Node) => isWritten(p) && keyName(p.key) === name);
550
-
551
- const title = stringValue(property("title")?.value);
552
- // Cosmetic: one that is not a string (tsc refuses it) is shown as none, never a problem that stops a site.
553
- const icon = stringValue(property("icon")?.value);
554
- const publicAt = property("public");
555
- const flag = unwrap(publicAt?.value);
556
- const isPublic = flag?.type === "Literal" && flag.value === true;
557
- const publicUnread = publicAt !== undefined && !(flag?.type === "Literal" && typeof flag.value === "boolean");
558
- if (publicUnread) problem(publicAt, unread("`public`", "`true` or `false`"));
559
-
560
- const loginAt = property("login");
561
- const login = stringValue(loginAt?.value);
477
+ const { title, icon, public: isPublic, login, locales } = parsed.data;
562
478
  const loginProblem = (): string | undefined => {
563
- if (login === undefined) return unread("`login`", "a string");
564
- // When `public` is what is wrong, it is said there.
565
- if (!isPublic && !publicUnread) return "a login is for a public site: add `public: true`, or remove `login`";
566
- if (!connectors.vendors.has(login)) return `login names "${login}", which is not a connector in sanoma.config.ts`;
567
- if (!connectors.logins.has(login)) return `login names "${login}", whose connector has no login half`;
479
+ if (!isPublic) return "a login is for a public site: add `public: true`, or remove `login`";
480
+ if (!connectors.vendors.has(login!)) return `login names "${login}", which is not a connector in sanoma.config.ts`;
481
+ if (!connectors.logins.has(login!)) return `login names "${login}", whose connector has no login half`;
568
482
  return undefined;
569
483
  };
570
- const message = loginAt && loginProblem();
571
- if (message) problem(loginAt, message);
572
-
573
- const localesAt = property("locales");
574
- const list = unwrap(localesAt?.value);
575
- // A hole or a spread is no string.
576
- const tags: (string | undefined)[] | undefined =
577
- list?.type === "ArrayExpression" ? list.elements.map((e: Node) => stringValue(e)) : undefined;
578
- const locales = tags?.every((tag): tag is string => tag !== undefined) ? tags : undefined;
579
- const localesProblem = (): string | undefined => {
580
- if (locales === undefined) return unread("`locales`", "an array of strings");
581
- if (!locales.length) return "`locales` names a site's languages, its default first: give one or more, or remove it";
582
- const bad = locales.find((locale) => !LOCALE.test(locale));
484
+ const loginMessage = login !== undefined ? loginProblem() : undefined;
485
+ if (loginMessage) problems.push(problem(loginMessage));
486
+ const localesProblem = (list: string[]): string | undefined => {
487
+ if (!list.length) return "`locales` names a site's languages, its default first: give one or more, or remove it";
488
+ const bad = list.find((locale) => !LOCALE.test(locale));
583
489
  if (bad !== undefined) return `"${bad}" is not a language tag such as "en", "fr" or "pt-BR"`;
584
- const twice = locales.find((locale, i) => locales.indexOf(locale) !== i);
490
+ const twice = list.find((locale, i) => list.indexOf(locale) !== i);
585
491
  if (twice !== undefined) return `"${twice}" is in \`locales\` twice`;
586
- // When `public` is what is wrong, it is said there.
587
- if ((!isPublic && !publicUnread) || loginAt) {
492
+ if (!isPublic || login !== undefined) {
588
493
  return "`locales` are for a public site with no login: add `public: true`, or remove `locales`";
589
494
  }
590
495
  return undefined;
591
496
  };
592
- const localesMessage = localesAt && localesProblem();
593
- if (localesMessage) problem(localesAt, localesMessage);
497
+ const localesMessage = locales && localesProblem(locales);
498
+ if (localesMessage) problems.push(problem(localesMessage));
594
499
  return {
500
+ at: { file, export: "default" },
595
501
  ...(title !== undefined && { title }),
596
502
  ...(icon !== undefined && { icon }),
597
503
  ...(isPublic && { public: true as const }),
598
504
  ...(login !== undefined && { login }),
599
- ...(localesAt && !localesMessage && isPublic && !loginAt && { locales }),
505
+ ...(locales && !localesMessage && { locales }),
600
506
  problems,
601
507
  };
602
508
  }
package/src/steps.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { LedgerRecord } from "./ledger.ts";
2
2
  import type { Op } from "./op.ts";
3
+ import type { ConnectionRef } from "./connection.ts";
3
4
 
4
5
  /*
5
6
  * Steps a connector (or a company) ships for its Gherkin tests, as plain data: expressions and
@@ -19,8 +20,8 @@ type AnyOp<I = any, O = any> = Op<string, string, string, I, O>;
19
20
  * seed's name (else the type's) as the export.
20
21
  */
21
22
  export type Seed =
22
- | { op: AnyOp; input: Record<string, unknown>; as?: string }
23
- | { op: AnyOp; fault: "failNext" | "rateLimit" | "loseReply" };
23
+ | { op: AnyOp; input: Record<string, unknown>; as?: string; connection?: ConnectionRef }
24
+ | { op: AnyOp; fault: "failNext" | "rateLimit" | "loseReply"; connection?: ConnectionRef };
24
25
 
25
26
  /** A call through an operation: what it was given, and what it returned. */
26
27
  export interface Call<I, O> {
package/src/version.ts CHANGED
@@ -18,8 +18,9 @@ export const RUNTIME_VERSION = (
18
18
  * 3: a start the policy holds waits on its approval before the body (2026-10-21).
19
19
  * 4: a sleep for a duration reads DBOS's clock for its start (2026-10-25).
20
20
  * 5: a scheduled run admits its starter and asks the policy in two steps before the body (2026-10-25).
21
+ * 6: pin a named connection before policy and recheck its binding before use (2026-10-29).
21
22
  */
22
- export const STEP_LAYOUT = 5;
23
+ export const STEP_LAYOUT = 6;
23
24
 
24
25
  let dbosVersion: string | undefined;
25
26
 
@@ -154,8 +154,11 @@ const env = z.object({
154
154
  });
155
155
 
156
156
  /** The variable `name`, read when an operation is called, or the driver's refusal. */
157
- function required(name: "SANOMA_WAKEUP_URL" | "SANOMA_WAKEUP_ACCOUNT_TOKEN"): string {
158
- const value = process.env[name];
157
+ function required(
158
+ values: Record<string, string | undefined>,
159
+ name: "SANOMA_WAKEUP_URL" | "SANOMA_WAKEUP_ACCOUNT_TOKEN",
160
+ ): string {
161
+ const value = values[name];
159
162
  if (!value) throw refused(`${name} is not set (${env.shape[name].description})`);
160
163
  return value;
161
164
  }
@@ -167,10 +170,10 @@ const Problem = z.object({ error: z.string() });
167
170
  const instance = (id: string) => `/wakeup/instances/${encodeURIComponent(id)}`;
168
171
 
169
172
  /** The service at `SANOMA_WAKEUP_URL`, read when an operation is called: each request's failure a `DriverError`. */
170
- function serviceApi(timeoutMs: number): WakeupApi {
171
- const base = required("SANOMA_WAKEUP_URL").replace(/\/+$/, "");
173
+ function serviceApi(timeoutMs: number, values: Record<string, string | undefined>): WakeupApi {
174
+ const base = required(values, "SANOMA_WAKEUP_URL").replace(/\/+$/, "");
172
175
  if (!URL.canParse(base)) throw refused("SANOMA_WAKEUP_URL is not a URL");
173
- const account = () => required("SANOMA_WAKEUP_ACCOUNT_TOKEN");
176
+ const account = () => required(values, "SANOMA_WAKEUP_ACCOUNT_TOKEN");
174
177
  /**
175
178
  * One request: its reply parsed by `reply` (none: not read), a 404 `undefined` where `absent`
176
179
  * says it means no such instance, and any other answer but a 2xx a `DriverError`.
@@ -257,7 +260,20 @@ function serviceApi(timeoutMs: number): WakeupApi {
257
260
  * `register` send). `timeoutMs` is how long one request may take: 10 s by default.
258
261
  */
259
262
  export function sanomaWakeupDriver({ timeoutMs = 10_000 }: { timeoutMs?: number } = {}) {
260
- return defineDriver(wakeupOps, { wakeup: wakeupCalls(() => serviceApi(timeoutMs)) }, { env });
263
+ const call = async (name: keyof ReturnType<typeof wakeupCalls>, input: unknown, context: CallContext) => {
264
+ const values = Object.fromEntries(
265
+ await Promise.all(Object.keys(env.shape).map(async (key) => [key, await context.secrets.get(key)])),
266
+ );
267
+ const methods = wakeupCalls(() => serviceApi(timeoutMs, values));
268
+ return (methods[name] as (input: unknown, context: CallContext) => Promise<unknown>)(input, context);
269
+ };
270
+ const methods = Object.fromEntries(
271
+ ["import", "read", "plan", "create", "update", "delete", "register"].map((name) => [
272
+ name,
273
+ (input: unknown, context: CallContext) => call(name as keyof ReturnType<typeof wakeupCalls>, input, context),
274
+ ]),
275
+ );
276
+ return defineDriver(wakeupOps, { wakeup: methods } as never, { env });
261
277
  }
262
278
 
263
279
  /** The wakeup's step for Gherkin tests: an instance at the service before the run, named `wakeup` (`$wakeup.id`). */
package/src/worker.ts CHANGED
@@ -3,7 +3,7 @@ import { awaitApproval } from "./approvals.ts";
3
3
  import { buildCtx, decideStart, isInfrastructureError, startHoldOf } from "./call.ts";
4
4
  import { dbosStatusesOf } from "./client.ts";
5
5
  import { declaredWakeup, type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
6
- import { storedSecrets, watchCredentials } from "./credentials.ts";
6
+ import { configuredSecrets } from "./credentials.ts";
7
7
  import { type Db, DBOS_DATABASE_OPTIONS, lazyPool, prepareDatabase, stampVersion } from "./db.ts";
8
8
  import { deadlineRegistry, type Deadlines, idle, onOwnQueue, type SelfStop, stopWhenIdle } from "./deadlines.ts";
9
9
  import type { WorkflowDefinition } from "./define.ts";
@@ -123,6 +123,7 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
123
123
  const horizonMs = options.idleHorizonMs ?? 60_000;
124
124
  const state: WorkerState = {
125
125
  app: resolved.appName,
126
+ connections: resolved.connections,
126
127
  ops: resolved.ops,
127
128
  drivers: resolved.drivers,
128
129
  fakes: resolved.fakes,
@@ -134,7 +135,7 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
134
135
  policy: resolved.policy,
135
136
  ...(resolved.root !== undefined && { root: resolved.root }),
136
137
  ledger: ledger.store,
137
- secrets: storedSecrets(() => secrets.db(), "sanoma"),
138
+ secrets: configuredSecrets(resolved.secrets, () => secrets.db()),
138
139
  workflows: resolved.workflows,
139
140
  outlines: new Map([...resolved.workflows].map(([name, wf]) => [name, outlineOf(wf)])),
140
141
  // `registered` is filled below, before any run starts: every name of this config's is registered then.
@@ -146,7 +147,6 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
146
147
  stopped: false,
147
148
  deadlines: deadlineRegistry(),
148
149
  };
149
- const credentials = await watchCredentials(resolved.databaseUrl, resolved.driversByVendor);
150
150
  const worker: Worker = {
151
151
  unconfigured: [...credentialsOf(resolved.driversByVendor, (name) => process.env[name])]
152
152
  .filter(([vendor, statuses]) => unconfigured(vendor, statuses))
@@ -164,8 +164,8 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
164
164
  let failed: { error: unknown } | undefined;
165
165
  for (const close of [
166
166
  () => DBOS.shutdown(),
167
- () => credentials.close(),
168
167
  () => secrets.close(),
168
+ () => resolved.connectionPool.close(),
169
169
  () => ledger.close(),
170
170
  async () => resolved.dataStore?.close(),
171
171
  ]) {
@@ -184,6 +184,7 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
184
184
  const previous = current;
185
185
  let launched = false;
186
186
  try {
187
+ await configuredSecrets(resolved.secrets, () => secrets.db()).validate();
187
188
  for (const name of resolved.workflows.keys()) {
188
189
  if (!registered.has(name)) registered.set(name, register(name));
189
190
  }
@@ -252,9 +253,12 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
252
253
  let stopFailed: { error: unknown } | undefined;
253
254
  if (launched) await worker.stop().catch((error: unknown) => (stopFailed = { error }));
254
255
  else {
255
- await credentials
256
- .close()
257
- .catch((e: unknown) => warn(`closing the credentials listener failed: ${errorMessage(e)}`));
256
+ await Promise.all([
257
+ secrets.close(),
258
+ ledger.close(),
259
+ resolved.dataStore?.close(),
260
+ resolved.connectionPool.close(),
261
+ ]);
258
262
  }
259
263
  current = previous;
260
264
  if (stopFailed) {
@@ -438,7 +442,7 @@ async function runOn(state: WorkerState, name: string, start: RunArgs | Date): P
438
442
  if (sandbox !== undefined) {
439
443
  // Imported here, so a live worker never loads the Gherkin parser or faker.
440
444
  const { seedSandbox } = await import("./sandbox.ts");
441
- await seedSandbox(run, sandbox);
445
+ await seedSandbox(run, sandbox, parsed);
442
446
  }
443
447
  output = await currentRun.run(run, () => wf.run(buildCtx(wf, run), parsed));
444
448
  } finally {