@sanoma-ai/workflows 0.9.0 → 0.10.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 (205) hide show
  1. package/README.md +169 -52
  2. package/dist/apply-steps.js +5 -2
  3. package/dist/apply-steps.js.map +1 -1
  4. package/dist/apply.d.ts +3 -0
  5. package/dist/apply.js +35 -21
  6. package/dist/apply.js.map +1 -1
  7. package/dist/approvals.d.ts +1 -0
  8. package/dist/approvals.js +6 -1
  9. package/dist/approvals.js.map +1 -1
  10. package/dist/apps.d.ts +7 -24
  11. package/dist/apps.js +51 -67
  12. package/dist/apps.js.map +1 -1
  13. package/dist/ast.d.ts +9 -7
  14. package/dist/ast.js +2 -0
  15. package/dist/ast.js.map +1 -1
  16. package/dist/authentication.d.ts +24 -0
  17. package/dist/authentication.js +68 -0
  18. package/dist/authentication.js.map +1 -0
  19. package/dist/call-workflow.d.ts +5 -0
  20. package/dist/call-workflow.js +4 -3
  21. package/dist/call-workflow.js.map +1 -1
  22. package/dist/call.d.ts +3 -2
  23. package/dist/call.js +34 -7
  24. package/dist/call.js.map +1 -1
  25. package/dist/cedar.js +14 -1
  26. package/dist/cedar.js.map +1 -1
  27. package/dist/change-steps.js +7 -5
  28. package/dist/change-steps.js.map +1 -1
  29. package/dist/change.d.ts +7 -0
  30. package/dist/change.js +7 -0
  31. package/dist/change.js.map +1 -1
  32. package/dist/client.d.ts +10 -6
  33. package/dist/client.js +96 -43
  34. package/dist/client.js.map +1 -1
  35. package/dist/collection.d.ts +7 -0
  36. package/dist/collection.js +5 -2
  37. package/dist/collection.js.map +1 -1
  38. package/dist/config.d.ts +14 -3
  39. package/dist/config.js +55 -14
  40. package/dist/config.js.map +1 -1
  41. package/dist/connection.d.ts +168 -0
  42. package/dist/connection.js +327 -0
  43. package/dist/connection.js.map +1 -0
  44. package/dist/credentials.d.ts +7 -53
  45. package/dist/credentials.js +84 -247
  46. package/dist/credentials.js.map +1 -1
  47. package/dist/cucumber.d.ts +4 -2
  48. package/dist/cucumber.js +8 -3
  49. package/dist/cucumber.js.map +1 -1
  50. package/dist/db.js +21 -2
  51. package/dist/db.js.map +1 -1
  52. package/dist/define.d.ts +9 -2
  53. package/dist/define.js.map +1 -1
  54. package/dist/definitions.d.ts +20 -0
  55. package/dist/definitions.js +16 -0
  56. package/dist/definitions.js.map +1 -0
  57. package/dist/deployment-key.d.ts +2 -0
  58. package/dist/deployment-key.js +22 -0
  59. package/dist/deployment-key.js.map +1 -0
  60. package/dist/describe.d.ts +9 -177
  61. package/dist/describe.js +71 -34
  62. package/dist/describe.js.map +1 -1
  63. package/dist/description.d.ts +532 -0
  64. package/dist/description.js +381 -0
  65. package/dist/description.js.map +1 -0
  66. package/dist/direct.js +3 -0
  67. package/dist/direct.js.map +1 -1
  68. package/dist/drift.d.ts +1 -1
  69. package/dist/drift.js +1 -1
  70. package/dist/drift.js.map +1 -1
  71. package/dist/env.d.ts +2 -0
  72. package/dist/env.js +2 -2
  73. package/dist/env.js.map +1 -1
  74. package/dist/index.d.ts +10 -2
  75. package/dist/index.js +10 -2
  76. package/dist/index.js.map +1 -1
  77. package/dist/ledger.d.ts +2 -0
  78. package/dist/ledger.js +1 -1
  79. package/dist/ledger.js.map +1 -1
  80. package/dist/lint.d.ts +0 -9
  81. package/dist/lint.js +1 -16
  82. package/dist/lint.js.map +1 -1
  83. package/dist/load.d.ts +74 -0
  84. package/dist/load.js +357 -0
  85. package/dist/load.js.map +1 -0
  86. package/dist/lockfile.d.ts +2 -0
  87. package/dist/lockfile.js +8 -3
  88. package/dist/lockfile.js.map +1 -1
  89. package/dist/op.d.ts +10 -1
  90. package/dist/op.js +1 -1
  91. package/dist/op.js.map +1 -1
  92. package/dist/outline.js +13 -4
  93. package/dist/outline.js.map +1 -1
  94. package/dist/paths.d.ts +18 -0
  95. package/dist/paths.js +21 -3
  96. package/dist/paths.js.map +1 -1
  97. package/dist/plugin.d.ts +0 -11
  98. package/dist/plugin.js +3 -18
  99. package/dist/plugin.js.map +1 -1
  100. package/dist/policy.d.ts +2 -0
  101. package/dist/policy.js.map +1 -1
  102. package/dist/resource.d.ts +27 -4
  103. package/dist/resource.js +49 -4
  104. package/dist/resource.js.map +1 -1
  105. package/dist/resources.d.ts +33 -76
  106. package/dist/resources.js +219 -227
  107. package/dist/resources.js.map +1 -1
  108. package/dist/run.d.ts +2 -0
  109. package/dist/run.js.map +1 -1
  110. package/dist/sandbox.d.ts +1 -1
  111. package/dist/sandbox.js +7 -3
  112. package/dist/sandbox.js.map +1 -1
  113. package/dist/scenario.d.ts +7 -0
  114. package/dist/scenario.js +33 -3
  115. package/dist/scenario.js.map +1 -1
  116. package/dist/scenarios.js +7 -1
  117. package/dist/scenarios.js.map +1 -1
  118. package/dist/schema.js +8 -0
  119. package/dist/schema.js.map +1 -1
  120. package/dist/secret-store-check.d.ts +11 -0
  121. package/dist/secret-store-check.js +76 -0
  122. package/dist/secret-store-check.js.map +1 -0
  123. package/dist/secret-store.d.ts +106 -0
  124. package/dist/secret-store.js +228 -0
  125. package/dist/secret-store.js.map +1 -0
  126. package/dist/shared.d.ts +17 -11
  127. package/dist/shared.js +34 -7
  128. package/dist/shared.js.map +1 -1
  129. package/dist/sites.d.ts +4 -93
  130. package/dist/sites.js +61 -84
  131. package/dist/sites.js.map +1 -1
  132. package/dist/source.d.ts +6 -0
  133. package/dist/source.js +13 -0
  134. package/dist/source.js.map +1 -1
  135. package/dist/steps.d.ts +3 -1
  136. package/dist/steps.js.map +1 -1
  137. package/dist/version.d.ts +2 -1
  138. package/dist/version.js +2 -1
  139. package/dist/version.js.map +1 -1
  140. package/dist/wakeup-driver.js +15 -6
  141. package/dist/wakeup-driver.js.map +1 -1
  142. package/dist/worker.js +12 -8
  143. package/dist/worker.js.map +1 -1
  144. package/dist/writer.d.ts +53 -14
  145. package/dist/writer.js +113 -43
  146. package/dist/writer.js.map +1 -1
  147. package/oxlint.json +0 -5
  148. package/package.json +6 -2
  149. package/src/apply-steps.ts +5 -2
  150. package/src/apply.ts +70 -29
  151. package/src/approvals.ts +7 -1
  152. package/src/apps.ts +54 -85
  153. package/src/ast.ts +6 -6
  154. package/src/authentication.ts +74 -0
  155. package/src/call-workflow.ts +5 -4
  156. package/src/call.ts +53 -9
  157. package/src/cedar.ts +14 -1
  158. package/src/change-steps.ts +7 -4
  159. package/src/change.ts +10 -0
  160. package/src/client.ts +108 -41
  161. package/src/collection.ts +10 -2
  162. package/src/config.ts +72 -13
  163. package/src/connection.ts +446 -0
  164. package/src/credentials.ts +94 -281
  165. package/src/cucumber.ts +16 -3
  166. package/src/db.ts +21 -2
  167. package/src/define.ts +7 -2
  168. package/src/definitions.ts +31 -0
  169. package/src/deployment-key.ts +23 -0
  170. package/src/describe.ts +102 -230
  171. package/src/description.ts +426 -0
  172. package/src/direct.ts +3 -0
  173. package/src/drift.ts +10 -2
  174. package/src/env.ts +4 -2
  175. package/src/index.ts +42 -1
  176. package/src/ledger.ts +3 -1
  177. package/src/lint.ts +1 -16
  178. package/src/load.ts +437 -0
  179. package/src/lockfile.ts +10 -3
  180. package/src/op.ts +10 -1
  181. package/src/outline.ts +18 -5
  182. package/src/paths.ts +24 -2
  183. package/src/plugin.ts +3 -24
  184. package/src/policy.ts +2 -0
  185. package/src/resource.ts +67 -6
  186. package/src/resources.ts +246 -301
  187. package/src/run.ts +2 -0
  188. package/src/sandbox.ts +11 -3
  189. package/src/scenario.ts +45 -3
  190. package/src/scenarios.ts +7 -1
  191. package/src/schema.ts +10 -0
  192. package/src/secret-store-check.ts +93 -0
  193. package/src/secret-store.ts +314 -0
  194. package/src/shared.ts +35 -10
  195. package/src/sites.ts +70 -180
  196. package/src/source.ts +14 -0
  197. package/src/steps.ts +3 -1
  198. package/src/version.ts +2 -1
  199. package/src/wakeup-driver.ts +22 -6
  200. package/src/worker.ts +12 -8
  201. package/src/writer.ts +145 -56
  202. package/dist/datafile.d.ts +0 -97
  203. package/dist/datafile.js +0 -479
  204. package/dist/datafile.js.map +0 -1
  205. package/src/datafile.ts +0 -601
package/src/resources.ts CHANGED
@@ -1,92 +1,32 @@
1
- import { readFileSync, statSync } from "node:fs";
2
- import { dirname, relative, resolve, sep } from "node:path";
3
- import { parsed } from "./ast.ts";
4
- import {
5
- type DataFile,
6
- type DataFileExport,
7
- type DataValue,
8
- readDataFile,
9
- Reference,
10
- SECRET_ENTRIES,
11
- } from "./datafile.ts";
1
+ import type { DeclaredCollection, DeclaredResource, ResourceProblem } from "./description.ts";
2
+ import { readFileSync } from "node:fs";
3
+ import { mayDeclarePeople } from "./change.ts";
4
+ import { isCollection } from "./collection.ts";
5
+ import { filesRead, type Loaded, loadModules, madeOf, madeWith } from "./load.ts";
12
6
  import { type Binding, type Bindings, LOCKFILE, readLockfile } from "./lockfile.ts";
13
- import { type Connector, VENDOR } from "./op.ts";
14
- import { APPS_DIR, filesUnder, kindDirs } from "./paths.ts";
7
+ import type { Connector } from "./op.ts";
8
+ import { APPS_DIR, COLLECTIONS_FILE, filesUnder, kindDirs, relPath } from "./paths.ts";
15
9
  import { peopleConnector, sanoma } from "./person.ts";
16
10
  import { wakeupOps } from "./wakeup-type.ts";
17
- import type { Span } from "./outline.ts";
18
- import { type Declared, type Resource, resourceTypesOf } from "./resource.ts";
11
+ import { DECLARED, type Declared, isDeclared, isObject, type Resource, resourceTypesOf } from "./resource.ts";
19
12
  import { Principal } from "./define.ts";
20
13
  import { NotAdmittedError } from "./errors.ts";
21
14
  import {
22
15
  AGENTS,
23
16
  ANONYMOUS,
24
17
  ANONYMOUS_PRINCIPAL,
18
+ type At,
25
19
  declaresPeople,
26
20
  isVisitor,
27
21
  type PeopleResource,
28
22
  personOf,
29
23
  principalOf,
30
24
  problemLine,
31
- qualified,
32
25
  RUNTIME_AGENTS,
33
26
  VISITORS,
34
27
  } from "./shared.ts";
35
28
 
36
- /** A resource a data file declares, as read from the file without running it. */
37
- export interface DeclaredResource {
38
- /** `<file>#<export>`, the file relative to the config's root, with `/`: `resources/identity/github.ts#website`. */
39
- id: string;
40
- /** The data file it is declared in, as `id` names it: `resources/identity/github.ts`. */
41
- file: string;
42
- vendor: string;
43
- type: string;
44
- /** What people call it: its identity, else its export's name. */
45
- name: string;
46
- /** The import id that finds it unbound: what its type's `find` makes of its fields; absent when the type has none. */
47
- identity?: string;
48
- /** The vendor's id of the object the lockfile binds it to (`LOCKFILE`); absent when unbound. */
49
- binding?: string;
50
- /** Its `export const` statement, as UTF-16 offsets into the file's text. */
51
- span: Span;
52
- /** Its fields, as its type checked them: each resource it names as that resource's `name`. */
53
- desired: Record<string, unknown>;
54
- /**
55
- * Where it names another declared resource: the field's dotted path (a list item's with its
56
- * index, `teams.0`) to that resource's id.
57
- */
58
- refs: Record<string, string>;
59
- }
60
-
61
- /**
62
- * A collection `resources/data.ts` declares, as read without running it: where it is, for the
63
- * Resources page. Its fields are the collection's own (`describeConfig`'s `collections`), from
64
- * the same file, which the runtime imports.
65
- */
66
- export interface DeclaredCollection {
67
- /** `<file>#<export>`, as a resource's: `resources/data.ts#outbound`. */
68
- id: string;
69
- /** `resources/data.ts`, or an app's `apps/<app>/resources/data.ts`. */
70
- file: string;
71
- /** The collection's name, its operations' middle segments (`data.<name>.get`): an app's with its app, `crm.contacts`. */
72
- name: string;
73
- /** Its `export const` statement, as UTF-16 offsets into the file's text. */
74
- span: Span;
75
- }
76
-
77
- /** Something wrong in the data files, or in finding them. */
78
- export interface ResourceProblem {
79
- /**
80
- * The data file, relative to the config's root, with `/`: `resources/identity/github.ts`.
81
- * Absent, with `line` and `column`, when the problem is the config's: it has no root.
82
- */
83
- file?: string;
84
- /** From 1. */
85
- line?: number;
86
- /** From 1, in UTF-16 code units. */
87
- column?: number;
88
- message: string;
89
- }
29
+ export type { DeclaredCollection, DeclaredResource, ResourceProblem };
90
30
 
91
31
  /** What the data files declare: each resource read without problems, the lockfile's bindings, and the problems. */
92
32
  export interface DataFiles {
@@ -98,7 +38,7 @@ export interface DataFiles {
98
38
  orphans: (Binding & { key: string })[];
99
39
  /** The collections `resources/data.ts` declares, in file order. */
100
40
  collections: DeclaredCollection[];
101
- /** By file, line and column. */
41
+ /** By file, then line and column. */
102
42
  problems: ResourceProblem[];
103
43
  }
104
44
 
@@ -113,21 +53,8 @@ export interface DeclaredPeople {
113
53
  problems: ResourceProblem[];
114
54
  }
115
55
 
116
- /** A file that imports the runtime's constructors, `sanoma` (by any local name): the files `readPeople` reads. */
117
- export const DECLARES_PEOPLE = /\{[^}]*\bsanoma\b[^}]*\}\s*from\s*["'](?:sanoma|@sanoma-ai\/workflows)\/resources["']/;
118
- /** The last read of the people under each root, by what its data files were then (`filesStamp`). */
119
- const peopleRead = new Map<string, { stamp: string; read: DeclaredPeople }>();
120
-
121
- /** Each data file under `root` with its size and modification time: unchanged while the files are. */
122
- const filesStamp = (root: string) =>
123
- kindDirs(root, "resources")
124
- .flatMap(dataFilesUnder)
125
- .map((path) => {
126
- // Gone since it was listed (an editor's save by rename): stamped as such, and read next time.
127
- const stat = statSync(path, { bigint: true, throwIfNoEntry: false });
128
- return `${path} ${stat?.size} ${stat?.mtimeNs}`;
129
- })
130
- .join("\n");
56
+ /** The people each load of the people's files declares: the same answer while none of them changed. */
57
+ const peopleRead = new WeakMap<Map<string, Loaded>, DeclaredPeople>();
131
58
 
132
59
  /** The names no data file may give a group: what each is already. */
133
60
  const RESERVED_GROUPS: ReadonlyMap<string, string> = new Map([
@@ -143,26 +70,39 @@ const RESERVED_GROUPS: ReadonlyMap<string, string> = new Map([
143
70
  export const inAppPeople = (r: DeclaredResource): boolean =>
144
71
  r.vendor === "sanoma" && Object.hasOwn(sanoma, r.type) && r.file.startsWith(`${APPS_DIR}/`);
145
72
 
73
+ const PEOPLE_CONNECTORS = [peopleConnector, wakeupOps];
74
+ const PEOPLE_TYPES = resourceTypesOf(PEOPLE_CONNECTORS);
75
+ const peopleLoad = (root: string) =>
76
+ loadModules(root, dataFilesOf(root), PEOPLE_CONNECTORS, { only: mayDeclarePeople, types: PEOPLE_TYPES });
77
+
78
+ /**
79
+ * The company files the people under `root` are read from (`readPeople`), by path relative to the
80
+ * root: each file that may declare them and every company file it imports, so an edit to a helper
81
+ * a people file reads its emails from is a change to the rules too.
82
+ */
83
+ export const peopleSources = (root: string): ReadonlySet<string> => filesRead(peopleLoad(root));
84
+
146
85
  /**
147
86
  * The people, agents and groups the data files under `root` declare (`sanoma.person`, `.agent`,
148
- * `.group`), read without running them, as `principalOf` takes them, with what is wrong in their
149
- * files; none without a root. Read again only when a data file changed (by its size and time),
150
- * so a new colleague is admitted once their file is saved, and a request costs a `stat` per file.
87
+ * `.group`), as `principalOf` takes them, with what is wrong in their files; none without a root.
88
+ * Only the files that may declare them (`mayDeclarePeople`) are loaded, with the people's and the
89
+ * wakeup's constructors, which the same `sanoma` declares; loaded again only when one of them
90
+ * changed (by its size and time), so a new colleague is admitted once their file is saved, and a
91
+ * request costs a `stat` per file.
151
92
  */
152
93
  export function readPeople(root: string | undefined): DeclaredPeople {
153
94
  if (root === undefined) return { people: [], problems: [] };
154
- const stamp = filesStamp(root);
155
- const cached = peopleRead.get(root);
156
- if (cached?.stamp === stamp) return cached.read;
157
- // With the wakeup's type, which the same constructors declare: read, and only the people's kept.
158
- const read = readDataFiles(root, [peopleConnector, wakeupOps], undefined, (source) => DECLARES_PEOPLE.test(source));
95
+ const loaded = peopleLoad(root);
96
+ const cached = peopleRead.get(loaded);
97
+ if (cached) return cached;
98
+ // The people are never bound, so the lockfile is not read.
99
+ const read = declarationsOf(root, loaded, PEOPLE_TYPES, { bindings: {}, problems: [] }, true);
159
100
  const ofPeople = read.resources.filter((r) => Object.hasOwn(sanoma, r.type));
160
101
  const people = ofPeople.filter((r) => !inAppPeople(r));
161
- // The people are never bound, so the lockfile's problems are not theirs.
162
- const own = read.problems.filter((p) => p.file !== LOCKFILE);
102
+ const own = read.problems;
163
103
  for (const r of ofPeople.filter(inAppPeople)) {
164
104
  own.push({
165
- file: r.file,
105
+ ...r.at,
166
106
  message: `${r.id} declares a ${r.type} in an app: the people are the company's, declared in resources/ at the root`,
167
107
  });
168
108
  }
@@ -170,15 +110,14 @@ export function readPeople(root: string | undefined): DeclaredPeople {
170
110
  for (const r of people) {
171
111
  if (r.type === "group") {
172
112
  const reserved = RESERVED_GROUPS.get(r.name);
173
- if (reserved)
174
- own.push({ file: r.file, message: `${r.id} is named "${r.name}", ${reserved}: give it another name` });
113
+ if (reserved) own.push({ ...r.at, message: `${r.id} is named "${r.name}", ${reserved}: give it another name` });
175
114
  continue;
176
115
  }
177
116
  const id = r.name;
178
117
  const other = ids.get(id);
179
118
  if (other || RUNTIME_AGENTS.includes(id) || isVisitor({ id })) {
180
119
  own.push({
181
- file: r.file,
120
+ ...r.at,
182
121
  message: other
183
122
  ? `${r.id} has the id "${id}", which ${other.id} has: give each person and agent their own`
184
123
  : isVisitor({ id })
@@ -189,7 +128,7 @@ export function readPeople(root: string | undefined): DeclaredPeople {
189
128
  ids.set(id, r);
190
129
  }
191
130
  const declared = { people, problems: own };
192
- peopleRead.set(root, { stamp, read: declared });
131
+ peopleRead.set(loaded, declared);
193
132
  return declared;
194
133
  }
195
134
 
@@ -230,263 +169,227 @@ export function admit(
230
169
  throw new NotAdmittedError(who);
231
170
  }
232
171
 
233
- interface FileEntry {
234
- /** Relative to the config's root, with `/`. */
235
- rel: string;
236
- data: DataFile;
237
- /** Records a problem at an offset into the file. */
238
- problem: (offset: number, message: string) => void;
239
- }
240
-
241
172
  /**
242
- * Reads `resources/` under `root`, the config's directory: each `.ts` file in it but
243
- * `*.test.ts`, `*.d.ts` and `node_modules`, parsed with oxc and held to the data-file subset
244
- * (`readDataFile`). A constructor import (`@sanoma-ai/connector-github/resources`) is matched to the
245
- * connector whose `info.package` it names, never imported; each resource is checked by calling
246
- * its type, as running the file would, with each resource it names given as that resource.
173
+ * Loads `resources/` under `root`, the config's directory, and each app's: each `.ts` file in it
174
+ * but `*.test.ts`, `*.d.ts` and `node_modules` (`loadModules`), its constructor imports (a
175
+ * connector's `<info.package>/resources`, `sanoma/resources`) served the constructors of
176
+ * `types`. Each export whose value a constructor made is a declaration, `<file>#<export>`, in the
177
+ * file that made it (its non-default name when the file gives two); each resource it names is the
178
+ * declaration that holds that very object.
247
179
  *
248
- * Problems: anything outside the subset, a constructor or data file the config does not know, a
249
- * type the connector does not declare, a value the type refuses (a reference where its type
250
- * takes none among them), a reference cycle, and two resources of one type with one identity.
180
+ * Problems: a file that throws as it loads (a value its type refuses, a type its vendor does not
181
+ * declare, an import of what the config does not serve), at the place its error's stack gives; a
182
+ * resource made but not exported, or exported under two names; one naming a resource no data file
183
+ * exports; a type that is not the config's; and two resources of one type with one identity (and
184
+ * connection). A declaration with a problem is left out, and so is any that names one.
251
185
  *
252
- * The collections `resources/data.ts` declares are listed as they are read (`collections`),
253
- * never matched to a connector, a resource type or the lockfile: the runtime imports that file.
186
+ * The collections `resources/data.ts` declares (`collection()`) are listed by their exports
187
+ * (`collections`), matched to no connector, resource type or lockfile: the runtime imports that
188
+ * file. A collection in another file, or anything else exported from `data.ts`, is a problem.
254
189
  *
255
190
  * Each declaration is joined to its binding in the lockfile (`readLockfile`) by its key,
256
- * `<file>#<export>`; a binding no export declares is an orphan. Problems: the lockfile's, a
257
- * binding of another type than its declaration's, and two declarations bound to one object.
258
- *
259
- * `partial` reads part of the files: only those whose source it passes, and in them, an import of
260
- * a connector not in `connectors`, or of a file left out, is left unread rather than reported.
191
+ * `<file>#<export>`; a binding no export declares is an orphan, unless its file failed to load.
192
+ * Problems: the lockfile's, a binding of another type than its declaration's, two declarations
193
+ * bound to one object, and several wakeups.
261
194
  */
262
- export function readDataFiles(
195
+ export function loadDataFiles(
263
196
  root: string | undefined,
264
197
  connectors: readonly Connector<any, any>[],
265
198
  types: ReadonlyMap<string, Resource> = resourceTypesOf(connectors),
266
- partial?: (source: string) => boolean,
267
199
  ): DataFiles {
268
- const problems: ResourceProblem[] = [];
269
200
  if (root === undefined) {
270
- problems.push({
271
- message:
272
- "The config has no root, so its data files cannot be found: make it with defineConfig, which records its file, or set its `root`",
273
- });
274
- return { resources: [], bindings: {}, orphans: [], collections: [], problems };
201
+ const message =
202
+ "The config has no root, so its data files cannot be found: make it with defineConfig, which records its file, or set its `root`";
203
+ return { resources: [], bindings: {}, orphans: [], collections: [], problems: [{ message }] };
275
204
  }
276
- const lock = readLockfile(root);
277
- problems.push(...lock.problems);
205
+ const loaded = loadModules(root, dataFilesOf(root), connectors, { types });
206
+ return declarationsOf(root, loaded, types, readLockfile(root), false);
207
+ }
208
+
209
+ /** What the loaded data files declare, joined to the lockfile's bindings. */
210
+ function declarationsOf(
211
+ root: string,
212
+ loaded: Map<string, Loaded>,
213
+ types: ReadonlyMap<string, Resource>,
214
+ lock: { bindings: Bindings; problems: ResourceProblem[] },
215
+ partial: boolean,
216
+ ): DataFiles {
217
+ const problems: ResourceProblem[] = [...lock.problems];
218
+ const failed = new Set<string>();
219
+ const collectionsFiles = new Set(
220
+ kindDirs(root, "resources").map((dir) => relPath(root, `${dir}/${COLLECTIONS_FILE}`)),
221
+ );
222
+ const COLLECTIONS_ONLY =
223
+ "resources/data.ts holds collections only, since workflows and the runtime import it: declare a connector's resources in another data file";
278
224
 
279
- const files = new Map<string, FileEntry>();
280
- const left = new Set<string>();
281
- // The root's, then each app's: a data file imports only from its own `resources/`.
282
- for (const [dir, path] of kindDirs(root, "resources").flatMap((d) => dataFilesUnder(d).map((p) => [d, p] as const))) {
283
- const rel = relative(root, path).split(sep).join("/");
284
- const source = readFileSync(path, "utf8");
285
- if (partial && !partial(source)) {
286
- left.add(path);
225
+ // Each exported value, by identity, to the names it is exported under.
226
+ const names = new Map<Declared, At[]>();
227
+ const exported = new Set<Declared>();
228
+ const collections: DeclaredCollection[] = [];
229
+ for (const [file, l] of loaded) {
230
+ if ("failedIn" in l) {
231
+ failed.add(file);
287
232
  continue;
288
233
  }
289
- const { program, problems: syntax, at } = parsed(source, path);
290
- const data = readDataFile(program, path, dir);
291
- const problem = (offset: number, message: string) => problems.push({ file: rel, ...at(offset), message });
292
- for (const p of syntax) problems.push({ file: rel, ...p });
293
- for (const p of data.problems) problem(p.start, p.message);
294
- files.set(path, { rel, data, problem });
295
- }
296
-
297
- // Each connector's constructors entry, `<info.package>/resources`, to its vendor.
298
- const entries = new Map<string, string>();
299
- for (const connector of connectors) {
300
- const { id, info } = connector[VENDOR];
301
- if (info?.package) entries.set(`${info.package}/resources`, id);
302
- }
303
- // The runtime's own (`peopleConnector`, package `sanoma`) is imported from either name of its entry.
304
- if (entries.get("sanoma/resources") === "sanoma") for (const entry of SECRET_ENTRIES) entries.set(entry, "sanoma");
305
-
306
- // What each file's names stand for, and every resource read by its id.
307
- const vendors = new Map<FileEntry, Map<string, string>>();
308
- const named = new Map<FileEntry, Map<string, string>>();
309
- const declarations = new Map<string, { file: FileEntry; exp: DataFileExport }>();
310
- for (const [path, file] of files) {
311
- const fileVendors = new Map<string, string>();
312
- const fileNamed = new Map<string, string>();
313
- for (const imp of file.data.imports) {
314
- if (imp.kind === "constructors") {
315
- const vendor = entries.get(imp.source);
316
- if (vendor === undefined && partial) continue;
317
- if (vendor === undefined) {
318
- file.problem(
319
- imp.start,
320
- `"${imp.source}" is not the resources entry of a connector in the config's \`connectors\`: add the connector, and import from its package's \`/resources\``,
321
- );
322
- } else if (imp.imported !== vendor) {
323
- file.problem(
324
- imp.start,
325
- `"${imp.source}" exports its constructors as ${vendor}: \`import { ${vendor} } from "${imp.source}"\``,
326
- );
234
+ if ("error" in l) {
235
+ failed.add(file);
236
+ const message = loadProblem(l.error, root, file, partial);
237
+ if (message !== undefined)
238
+ problems.push({ file, ...(l.line !== undefined && { line: l.line, column: l.column ?? 1 }), message });
239
+ continue;
240
+ }
241
+ for (const [name, value] of l.exports) {
242
+ const at = { file, export: name };
243
+ if (isCollection(value)) {
244
+ if (!collectionsFiles.has(file)) {
245
+ problems.push({
246
+ ...at,
247
+ message: `a collection is declared in resources/data.ts, which workflows and the runtime import: move ${name} there`,
248
+ });
327
249
  } else {
328
- fileVendors.set(imp.local, vendor);
250
+ collections.push({ id: `${file}#${name}`, file, name: value.name, at });
329
251
  }
330
- continue;
252
+ } else if (isDeclared(value)) {
253
+ exported.add(value);
254
+ if (collectionsFiles.has(file)) problems.push({ ...at, message: COLLECTIONS_ONLY });
255
+ else names.set(value, [...(names.get(value) ?? []), at]);
331
256
  }
332
- const target = resolve(dirname(path), imp.source);
333
- const other = files.get(target);
334
- if (!other && left.has(target)) continue;
335
- if (!other) {
336
- file.problem(imp.start, `"${imp.source}" is not a data file under resources/`);
337
- } else if (!other.data.declared.includes(imp.imported)) {
338
- file.problem(imp.start, `${imp.imported} is not a resource ${other.rel} declares`);
339
- } else if (other.data.exports.some((e) => e.name === imp.imported)) {
340
- fileNamed.set(imp.local, `${other.rel}#${imp.imported}`);
341
- }
342
- // Else its value was refused, and reported in its file: a reference to it fails quietly.
343
257
  }
344
- for (const exp of file.data.exports) {
345
- const id = `${file.rel}#${exp.name}`;
346
- fileNamed.set(exp.name, id);
347
- declarations.set(id, { file, exp });
258
+ }
259
+
260
+ // Each declaration: one name per object, a non-default one over `default`.
261
+ const declarations = new Map<string, { value: Declared; at: At }>();
262
+ const idOf = new Map<Declared, string>();
263
+ for (const [value, ats] of names) {
264
+ const named = ats.filter((at) => at.export !== "default");
265
+ const kept = named.length ? named : ats;
266
+ if (kept.length > 1) {
267
+ const [first, ...rest] = kept.map((at) => `${at.file}#${at.export}`);
268
+ problems.push({
269
+ ...kept[1]!,
270
+ message: `${rest.join(", ")} and ${first} are one ${value[DECLARED]}: export each resource under one name`,
271
+ });
272
+ continue;
273
+ }
274
+ const at = kept[0]!;
275
+ const type = types.get(value[DECLARED]);
276
+ if (type === undefined || madeOf(value) === undefined) {
277
+ if (!partial)
278
+ problems.push({
279
+ ...at,
280
+ message: `${value[DECLARED]} is not a resource type of a connector in the config's \`connectors\`: add the connector, and import from its package's \`/resources\``,
281
+ });
282
+ continue;
348
283
  }
349
- vendors.set(file, fileVendors);
350
- named.set(file, fileNamed);
284
+ const id = `${at.file}#${at.export}`;
285
+ declarations.set(id, { value, at });
286
+ idOf.set(value, id);
287
+ }
288
+ // A resource made and never exported is declared nowhere: a typo'd export, or one written inline.
289
+ for (const value of madeWith(loaded)) {
290
+ const made = madeOf(value)!;
291
+ if (!isDeclared(value) || exported.has(value) || failed.has(made.file)) continue;
292
+ problems.push({
293
+ file: made.file,
294
+ message: `${aResource(value)} is made here but not exported: declare each resource as its own \`export const <name> = <vendor>.<type>({ … })\``,
295
+ });
351
296
  }
352
297
 
353
- // Each resource after the ones it names, which it is given as they are declared.
354
- const done = new Map<string, { declared: Declared; refs: Record<string, string> } | undefined>();
355
- const visiting: string[] = [];
298
+ // Each declaration after the ones it names; one with a problem is left out, and so is any that names it.
299
+ const done = new Map<string, Record<string, string> | undefined>();
356
300
  const byName = new Map<string, string>();
357
- function declare(id: string) {
301
+ const leaveOut = (id: string, message?: string) => {
302
+ if (message !== undefined) problems.push({ ...declarations.get(id)!.at, message });
303
+ done.set(id, undefined);
304
+ };
305
+ function declare(id: string): Record<string, string> | undefined {
358
306
  if (done.has(id)) return done.get(id);
359
- const { file, exp } = declarations.get(id)!;
360
- if (visiting.includes(id)) {
361
- file.problem(exp.start, `${id} refers to itself: ${[...visiting.slice(visiting.indexOf(id)), id].join(" → ")}`);
362
- return undefined;
307
+ const { value } = declarations.get(id)!;
308
+ const refs: Record<string, string> = {};
309
+ for (const [path, target] of Object.entries(madeOf(value)!.refers)) {
310
+ const targetId = idOf.get(target);
311
+ // A partial load serves only some types: a resource of another (a wakeup's host) is not its to judge.
312
+ if (targetId === undefined && partial && !types.has(target[DECLARED])) continue;
313
+ if (targetId === undefined) {
314
+ leaveOut(
315
+ id,
316
+ `${path} names ${aResource(target)} that no data file exports: export it from a data file and import it here`,
317
+ );
318
+ return undefined;
319
+ }
320
+ if (!declare(targetId)) return void done.set(id, undefined);
321
+ refs[path] = targetId;
363
322
  }
364
- visiting.push(id);
365
- const result = build(file, exp);
366
- visiting.pop();
367
- if (result?.declared.name !== undefined) {
368
- const { vendor, type, name } = result.declared;
369
- const key = `${vendor}.${type} ${JSON.stringify(name)}`;
323
+ if (value.name !== undefined) {
324
+ const key = `${value[DECLARED]} ${JSON.stringify(value.name)}${value.connection ? ` (${value.connection.$connection})` : ""}`;
370
325
  const first = byName.get(key);
371
- if (first === undefined) byName.set(key, id);
372
- else {
373
- file.problem(
374
- exp.start,
326
+ if (first !== undefined) {
327
+ leaveOut(
328
+ id,
375
329
  `${key} is declared twice, also as ${first}: declare each resource once, and import it where it is used`,
376
330
  );
377
- done.set(id, undefined);
378
331
  return undefined;
379
332
  }
333
+ byName.set(key, id);
380
334
  }
381
- done.set(id, result);
382
- return result;
383
- }
384
-
385
- function build(file: FileEntry, exp: DataFileExport) {
386
- // An unknown constructors import was reported with the import.
387
- const vendor = vendors.get(file)!.get(exp.vendor);
388
- if (vendor === undefined) return undefined;
389
- const resource = types.get(`${vendor}.${exp.type}`);
390
- if (!resource) {
391
- const known = [...types.values()].filter((r) => r.vendor === vendor).map((r) => r.type);
392
- file.problem(exp.start, `${vendor} has no resource type ${exp.type}: its types are ${known.join(", ")}`);
393
- return undefined;
394
- }
395
- const refs: Record<string, string> = {};
396
- let ok = true;
397
- // The fields with each name of a resource as that resource, and where each is named.
398
- const given = (v: DataValue, at: string): unknown => {
399
- if (v instanceof Reference) {
400
- // A name that did not resolve was reported where it is bound.
401
- const target = named.get(file)!.get(v.name);
402
- const declared = target === undefined ? undefined : declare(target)?.declared;
403
- if (!declared) ok = false;
404
- else refs[at] = target!;
405
- return declared;
406
- }
407
- if (Array.isArray(v)) return v.map((item, i) => given(item, `${at}.${i}`));
408
- if (v === null || typeof v !== "object") return v;
409
- return Object.fromEntries(Object.entries(v).map(([k, item]) => [k, given(item, at ? `${at}.${k}` : k)]));
410
- };
411
- const fields = given(exp.desired, "");
412
- if (!ok) return undefined;
413
- try {
414
- // Checked as running the file would: by the resource type, which allows a resource only where its `fields.references` say.
415
- return { declared: resource(fields as never), refs };
416
- } catch (e) {
417
- // One line per problem: zod's own spans lines.
418
- file.problem(exp.start, `export const ${exp.name}: ${(e as Error).message.replaceAll(/\s*\n\s*/g, " ")}`);
419
- return undefined;
420
- }
335
+ done.set(id, refs);
336
+ return refs;
421
337
  }
422
338
 
423
339
  // Each binding against its declaration: of its type, and the only one bound to its object.
424
340
  const boundBy = new Map<string, string>();
425
- for (const [id, { file, exp }] of declarations) {
341
+ for (const [id, { value }] of declarations) {
426
342
  const binding = lock.bindings[id];
427
- // An unknown constructors import was reported with the import.
428
- const vendor = vendors.get(file)!.get(exp.vendor);
429
- if (binding === undefined || vendor === undefined) continue;
430
- const type = `${vendor}.${exp.type}`;
431
- const object = `${binding.type} ${binding.id}`;
343
+ if (binding === undefined) continue;
344
+ const object = `${binding.type} ${binding.id}${binding.connection ? ` (${binding.connection.$connection})` : ""}`;
432
345
  const first = boundBy.get(object);
433
346
  const why =
434
- binding.type !== type
435
- ? `${id} is bound to a ${binding.type} in ${LOCKFILE}, but declares a ${type}`
436
- : first !== undefined
437
- ? `${id} is bound to the same ${object} as ${first} in ${LOCKFILE}: bind each object once`
438
- : undefined;
347
+ value.connection !== undefined && binding.connection?.$connection !== value.connection.$connection
348
+ ? `${id} changes the connection of a bound resource: explicitly unbind it before selecting another account`
349
+ : binding.type !== value[DECLARED]
350
+ ? `${id} is bound to a ${binding.type} in ${LOCKFILE}, but declares a ${value[DECLARED]}`
351
+ : first !== undefined
352
+ ? `${id} is bound to the same ${object} as ${first} in ${LOCKFILE}: bind each object once`
353
+ : undefined;
439
354
  if (why === undefined) boundBy.set(object, id);
440
- else {
441
- // Left out, as any declaration with a problem, and so is any that names it.
442
- file.problem(exp.start, why);
443
- done.set(id, undefined);
444
- }
355
+ else leaveOut(id, why);
445
356
  }
446
357
  // An instance registers with one wakeup: several are each a problem, and left out, as is any that names one.
447
- const wakeups = [...declarations].filter(
448
- ([, { file, exp }]) => types.get(`${vendors.get(file)!.get(exp.vendor)}.${exp.type}`)?.wakeup,
449
- );
358
+ const wakeups = [...declarations.keys()].filter((id) => types.get(declarations.get(id)!.value[DECLARED])?.wakeup);
450
359
  if (wakeups.length > 1) {
451
- for (const [id, { file, exp }] of wakeups) {
452
- const others = wakeups.filter(([other]) => other !== id).map(([other]) => other);
453
- file.problem(exp.start, `an instance has one wakeup: leave out this one or ${others.join(", ")}`);
454
- done.set(id, undefined);
360
+ for (const id of wakeups) {
361
+ leaveOut(
362
+ id,
363
+ `an instance has one wakeup: leave out this one or ${wakeups.filter((other) => other !== id).join(", ")}`,
364
+ );
455
365
  }
456
366
  }
457
- const keys = new Set([...files.values()].flatMap((f) => f.data.declared.map((name) => `${f.rel}#${name}`)));
367
+ // A binding under a file that failed to load is no orphan: what it declares is not known.
458
368
  const orphans = Object.entries(lock.bindings)
459
- .filter(([key]) => !keys.has(key))
369
+ .filter(([key]) => !declarations.has(key) && !failed.has(key.slice(0, key.lastIndexOf("#"))))
460
370
  .map(([key, binding]) => ({ key, ...binding }));
461
371
 
462
372
  const resources: DeclaredResource[] = [];
463
- for (const [id, { file, exp }] of declarations) {
464
- const result = declare(id);
465
- if (!result) continue;
466
- const { vendor, type, name: identity, desired } = result.declared;
373
+ for (const [id, { value, at }] of declarations) {
374
+ const refs = declare(id);
375
+ if (!refs) continue;
376
+ const { vendor, type, name: identity, desired, connection } = value;
467
377
  const binding = lock.bindings[id]?.id;
468
378
  resources.push({
469
379
  id,
470
- file: file.rel,
380
+ file: at.file,
471
381
  vendor,
472
382
  type,
473
- name: identity ?? exp.name,
383
+ name: identity ?? at.export,
474
384
  ...(identity !== undefined && { identity }),
475
385
  ...(binding !== undefined && { binding }),
476
- span: [exp.start, exp.end],
477
- desired,
478
- refs: result.refs,
386
+ at,
387
+ // A copy: the loaded value is the generation's, read again while no file changes.
388
+ desired: structuredClone(desired) as Record<string, unknown>,
389
+ ...(connection && { connection: { ...connection } }),
390
+ refs,
479
391
  });
480
392
  }
481
- const collections = [...files.values()].flatMap(({ rel, data }) =>
482
- data.collections.map((c) => ({
483
- id: `${rel}#${c.export}`,
484
- file: rel,
485
- // An app's, `apps/<app>/resources/data.ts`, is named with its app, as `collection()` names it.
486
- name: qualified(rel.startsWith(`${APPS_DIR}/`) ? rel.split("/")[1] : undefined, c.name),
487
- span: [c.start, c.end] as Span,
488
- })),
489
- );
490
393
  problems.sort(
491
394
  (a, b) =>
492
395
  (a.file ?? "").localeCompare(b.file ?? "") || (a.line ?? 0) - (b.line ?? 0) || (a.column ?? 0) - (b.column ?? 0),
@@ -494,6 +397,48 @@ export function readDataFiles(
494
397
  return { resources, bindings: lock.bindings, orphans, collections, problems };
495
398
  }
496
399
 
400
+ /** A resource as a problem names it: `a github.repository "website"`, or without its identity when it has none. */
401
+ const aResource = (value: Declared) => `a ${value[DECLARED]}${value.name === undefined ? "" : ` "${value.name}"`}`;
402
+
403
+ /**
404
+ * What a data file that failed to load is told, on one line: an import Node could not find (a
405
+ * connector the config does not have, a file that is not there), top-level `await`, or the
406
+ * error's own message (a constructor's refusal: `github.repository: no field wiki`). Nothing, when
407
+ * `partial`, for a connector's constructors left out of the load in a file that declares no
408
+ * person, agent or group (`sanoma.wakeup` beside its host's connector).
409
+ */
410
+ function loadProblem(error: unknown, root: string, file: string, partial: boolean): string | undefined {
411
+ const { code, message } = isObject(error) ? (error as { code?: unknown; message?: unknown }) : {};
412
+ const text = typeof message === "string" ? message : String(error);
413
+ if (code === "ERR_REQUIRE_ASYNC_MODULE")
414
+ return "a data file is loaded synchronously: leave out top-level `await`, and compute values where they are used";
415
+ if (code === "ERR_MODULE_NOT_FOUND") {
416
+ const missing = /^Cannot find (?:package|module) '([^']+)'/.exec(text)?.[1];
417
+ const source = readFileSync(`${root}/${file}`, "utf8");
418
+ // The specifier as written: Node names a package by its name, a file by its path.
419
+ const written =
420
+ missing &&
421
+ [...source.matchAll(/from\s*["']([^"']+)["']/g)]
422
+ .map((m) => m[1]!)
423
+ .find(
424
+ (spec) =>
425
+ spec === missing ||
426
+ spec.startsWith(`${missing}/`) ||
427
+ (missing.startsWith("/") && missing.endsWith(`/${spec.replace(/^(?:\.\.?\/)+/, "")}`)),
428
+ );
429
+ if (written?.endsWith("/resources") && partial && !/\.\s*(?:person|agent|group)\s*\(/.test(source))
430
+ return undefined;
431
+ if (written?.endsWith("/resources"))
432
+ return `"${written}" is not the resources entry of a connector in the config's \`connectors\`: add the connector, and import from its package's \`/resources\``;
433
+ if (written !== undefined)
434
+ return `"${written}" cannot be found: import a file that is there, or a package the company has`;
435
+ }
436
+ return text.replaceAll(/\s*\n\s*/g, " ");
437
+ }
438
+
439
+ /** The data files under `root`: the root's `resources/`, then each app's, each sorted. */
440
+ const dataFilesOf = (root: string): string[] => kindDirs(root, "resources").flatMap(dataFilesUnder);
441
+
497
442
  /** The data files under `dir`, sorted: regular `.ts` files, less tests, declarations and `node_modules`. */
498
443
  const dataFilesUnder = (dir: string): string[] =>
499
444
  filesUnder(dir, (name) => name.endsWith(".ts") && !name.endsWith(".test.ts") && !name.endsWith(".d.ts"));