@sanoma-ai/workflows 0.2.0 → 0.4.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 (149) hide show
  1. package/README.md +146 -85
  2. package/dist/approvals.d.ts +11 -6
  3. package/dist/approvals.js +38 -10
  4. package/dist/approvals.js.map +1 -1
  5. package/dist/call-workflow.d.ts +16 -0
  6. package/dist/call-workflow.js +35 -0
  7. package/dist/call-workflow.js.map +1 -0
  8. package/dist/call.d.ts +33 -4
  9. package/dist/call.js +107 -42
  10. package/dist/call.js.map +1 -1
  11. package/dist/cedar.d.ts +87 -0
  12. package/dist/cedar.js +365 -0
  13. package/dist/cedar.js.map +1 -0
  14. package/dist/client.d.ts +85 -7
  15. package/dist/client.js +289 -25
  16. package/dist/client.js.map +1 -1
  17. package/dist/collection.d.ts +107 -0
  18. package/dist/collection.js +306 -0
  19. package/dist/collection.js.map +1 -0
  20. package/dist/config.d.ts +42 -14
  21. package/dist/config.js +82 -18
  22. package/dist/config.js.map +1 -1
  23. package/dist/constructors.d.ts +2 -0
  24. package/dist/constructors.js +5 -0
  25. package/dist/constructors.js.map +1 -0
  26. package/dist/data.d.ts +2 -0
  27. package/dist/data.js +3 -0
  28. package/dist/data.js.map +1 -0
  29. package/dist/datafile.d.ts +28 -2
  30. package/dist/datafile.js +118 -19
  31. package/dist/datafile.js.map +1 -1
  32. package/dist/db.d.ts +5 -3
  33. package/dist/db.js +88 -21
  34. package/dist/db.js.map +1 -1
  35. package/dist/define.d.ts +14 -5
  36. package/dist/define.js +1 -5
  37. package/dist/define.js.map +1 -1
  38. package/dist/describe.d.ts +45 -5
  39. package/dist/describe.js +72 -7
  40. package/dist/describe.js.map +1 -1
  41. package/dist/direct.d.ts +9 -0
  42. package/dist/direct.js +11 -0
  43. package/dist/direct.js.map +1 -0
  44. package/dist/errors.d.ts +16 -1
  45. package/dist/errors.js +30 -1
  46. package/dist/errors.js.map +1 -1
  47. package/dist/expectations.d.ts +3 -3
  48. package/dist/expectations.js +16 -7
  49. package/dist/expectations.js.map +1 -1
  50. package/dist/fill.d.ts +4 -0
  51. package/dist/fill.js +13 -0
  52. package/dist/fill.js.map +1 -1
  53. package/dist/index.d.ts +8 -4
  54. package/dist/index.js +10 -4
  55. package/dist/index.js.map +1 -1
  56. package/dist/ledger.d.ts +12 -1
  57. package/dist/ledger.js +18 -11
  58. package/dist/ledger.js.map +1 -1
  59. package/dist/lint.d.ts +5 -4
  60. package/dist/lint.js +24 -7
  61. package/dist/lint.js.map +1 -1
  62. package/dist/op.d.ts +6 -0
  63. package/dist/op.js.map +1 -1
  64. package/dist/outline.d.ts +2 -0
  65. package/dist/outline.js +1 -1
  66. package/dist/outline.js.map +1 -1
  67. package/dist/paths.d.ts +4 -0
  68. package/dist/paths.js +17 -3
  69. package/dist/paths.js.map +1 -1
  70. package/dist/person.d.ts +61 -0
  71. package/dist/person.js +69 -0
  72. package/dist/person.js.map +1 -0
  73. package/dist/plugin.d.ts +28 -0
  74. package/dist/plugin.js +52 -1
  75. package/dist/plugin.js.map +1 -1
  76. package/dist/policy.d.ts +51 -55
  77. package/dist/policy.js +14 -36
  78. package/dist/policy.js.map +1 -1
  79. package/dist/resource.d.ts +21 -5
  80. package/dist/resource.js +12 -3
  81. package/dist/resource.js.map +1 -1
  82. package/dist/resources.d.ts +58 -6
  83. package/dist/resources.js +119 -26
  84. package/dist/resources.js.map +1 -1
  85. package/dist/run.d.ts +17 -1
  86. package/dist/run.js.map +1 -1
  87. package/dist/scenario.d.ts +16 -9
  88. package/dist/scenario.js +149 -44
  89. package/dist/scenario.js.map +1 -1
  90. package/dist/scenarios.d.ts +10 -6
  91. package/dist/scenarios.js +8 -4
  92. package/dist/scenarios.js.map +1 -1
  93. package/dist/schema.d.ts +32 -0
  94. package/dist/schema.js +225 -0
  95. package/dist/schema.js.map +1 -0
  96. package/dist/sdk.d.ts +34 -0
  97. package/dist/sdk.js +168 -0
  98. package/dist/sdk.js.map +1 -0
  99. package/dist/shared.d.ts +181 -5
  100. package/dist/shared.js +160 -7
  101. package/dist/shared.js.map +1 -1
  102. package/dist/sites.d.ts +52 -0
  103. package/dist/sites.js +174 -0
  104. package/dist/sites.js.map +1 -0
  105. package/dist/version.d.ts +3 -2
  106. package/dist/version.js +3 -2
  107. package/dist/version.js.map +1 -1
  108. package/dist/worker.d.ts +3 -2
  109. package/dist/worker.js +24 -4
  110. package/dist/worker.js.map +1 -1
  111. package/oxlint.json +7 -20
  112. package/package.json +8 -3
  113. package/src/approvals.ts +49 -13
  114. package/src/call-workflow.ts +44 -0
  115. package/src/call.ts +157 -45
  116. package/src/cedar.ts +493 -0
  117. package/src/client.ts +368 -37
  118. package/src/collection.ts +431 -0
  119. package/src/config.ts +131 -34
  120. package/src/constructors.ts +4 -0
  121. package/src/data.ts +10 -0
  122. package/src/datafile.ts +141 -21
  123. package/src/db.ts +90 -22
  124. package/src/define.ts +15 -10
  125. package/src/describe.ts +113 -10
  126. package/src/direct.ts +15 -0
  127. package/src/errors.ts +36 -1
  128. package/src/expectations.ts +17 -8
  129. package/src/fill.ts +15 -0
  130. package/src/index.ts +33 -2
  131. package/src/ledger.ts +35 -17
  132. package/src/lint.ts +29 -12
  133. package/src/op.ts +6 -0
  134. package/src/outline.ts +1 -1
  135. package/src/paths.ts +18 -3
  136. package/src/person.ts +75 -0
  137. package/src/plugin.ts +74 -5
  138. package/src/policy.ts +59 -63
  139. package/src/resource.ts +35 -8
  140. package/src/resources.ts +165 -24
  141. package/src/run.ts +18 -1
  142. package/src/scenario.ts +186 -57
  143. package/src/scenarios.ts +15 -8
  144. package/src/schema.ts +261 -0
  145. package/src/sdk.ts +211 -0
  146. package/src/shared.ts +271 -8
  147. package/src/sites.ts +220 -0
  148. package/src/version.ts +3 -2
  149. package/src/worker.ts +29 -6
package/src/db.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { DBOS, DBOSClient } from "@dbos-inc/dbos-sdk";
2
- import { Client, Pool } from "pg";
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+ import { Client, escapeIdentifier, Pool } from "pg";
3
4
  import { warn } from "./log.ts";
4
5
  import { errorMessage } from "./shared.ts";
5
6
 
6
- // The runtime's own tables live in the config's database, beside DBOS's: the credentials and the
7
- // ledger. What they share is here: the one preparation of a database, a pool that opens once it
8
- // is prepared and survives a dropped connection, and a table creation two processes may race.
7
+ // The runtime's own tables live in the config's database, beside DBOS's: the credentials, the
8
+ // ledger and the collections' records. What they share is here: the one preparation of a
9
+ // database, a pool that opens once it is prepared and survives a dropped connection, and a table
10
+ // creation two processes may race.
9
11
 
10
12
  /** A connection or a pool: what the queries take. */
11
13
  export type Db = Pick<Pool | Client, "query">;
@@ -14,6 +16,8 @@ export type Db = Pick<Pool | Client, "query">;
14
16
  export const CREDENTIALS_TABLE = "sanoma_credentials";
15
17
  /** Where the Postgres ledger keeps its records. */
16
18
  export const LEDGER_TABLE = "sanoma_ledger";
19
+ /** Where the collections keep their records (`collection.ts`): one row per collection and key, the record as JSON. */
20
+ export const DATA_TABLE = "sanoma_data";
17
21
 
18
22
  /** The runtime's tables, each with the word the messages name it by. */
19
23
  const TABLES: readonly [what: string, ddl: string][] = [
@@ -30,13 +34,23 @@ const TABLES: readonly [what: string, ddl: string][] = [
30
34
  `run_id text NOT NULL, seq integer NOT NULL, record jsonb NOT NULL); ` +
31
35
  `CREATE INDEX IF NOT EXISTS ${LEDGER_TABLE}_run_seq ON ${LEDGER_TABLE} (run_id, seq, n)`,
32
36
  ],
37
+ [
38
+ "data",
39
+ `CREATE TABLE IF NOT EXISTS ${DATA_TABLE} (collection text NOT NULL, key text NOT NULL, record jsonb NOT NULL, ` +
40
+ "updated_at timestamptz NOT NULL DEFAULT now(), updated_by text NOT NULL, PRIMARY KEY (collection, key))",
41
+ ],
33
42
  ];
34
43
 
35
44
  /** The Postgres error code of `err`, or an empty string when it has none. */
36
45
  export const pgCode = (err: unknown): string => (err as { code?: unknown } | null)?.code?.toString() ?? "";
37
46
 
38
- /** Postgres's codes for a row type or a table another process made first. */
39
- const MADE_ALREADY = new Set(["23505", "42P07"]);
47
+ /**
48
+ * Postgres's codes for a database, a row type or a table another process made first. Two racing
49
+ * `CREATE TABLE IF NOT EXISTS` both pass the check, and the loser fails on the table's row type in
50
+ * `pg_type`: as a unique violation (23505), or as a duplicate object (42710, "type ... already
51
+ * exists"). Two racing `CREATE DATABASE` fail as 42P04, or on `pg_database` as 23505.
52
+ */
53
+ const MADE_ALREADY = new Set(["23505", "42P04", "42P07", "42710"]);
40
54
 
41
55
  /** True for the failure of a create that another process won: what was to be made is there. */
42
56
  export const madeAlready = (err: unknown): boolean => MADE_ALREADY.has(pgCode(err));
@@ -82,6 +96,9 @@ const IDLE_IN_TRANSACTION_TIMEOUT = "60s";
82
96
  /** Postgres's code for a statement the role lacks the privilege for. */
83
97
  const INSUFFICIENT_PRIVILEGE = "42501";
84
98
 
99
+ /** Postgres's code for a database that is not there (`invalid_catalog_name`). */
100
+ const NO_DATABASE = "3D000";
101
+
85
102
  /** A pool opened on first use, once its database is prepared, and ended by `close`. */
86
103
  export interface LazyPool {
87
104
  db(): Promise<Pool>;
@@ -125,9 +142,9 @@ const prepared = new Map<string, Promise<void>>();
125
142
  * Makes the database at `databaseUrl` ready for the runtime, once per process and URL: the
126
143
  * database itself and DBOS's tables when they are not there yet (as a worker's launch makes
127
144
  * them; credentials may be set, and a ledger read, before any worker has run), then the
128
- * credentials and ledger tables, and its idle-transaction timeout (`guardIdleTransactions`).
129
- * Two processes preparing one database both succeed. A
130
- * preparation that fails is forgotten, so the next call tries again.
145
+ * credentials, ledger and data tables, and its idle-transaction timeout (`guardIdleTransactions`).
146
+ * Two processes preparing one database both succeed: the second waits for the first
147
+ * (`lockPreparation`). A preparation that fails is forgotten, so the next call tries again.
131
148
  */
132
149
  export function prepareDatabase(databaseUrl: string): Promise<void> {
133
150
  let preparing = prepared.get(databaseUrl);
@@ -141,33 +158,86 @@ export function prepareDatabase(databaseUrl: string): Promise<void> {
141
158
  return preparing;
142
159
  }
143
160
 
161
+ /** The error a preparation fails with, its cause `err`. */
162
+ const unprepared = (err: unknown): Error =>
163
+ new Error(`could not prepare the database: ${errorMessage(err)}`, { cause: err });
164
+
144
165
  async function prepare(databaseUrl: string): Promise<void> {
166
+ const conn = await lockPreparation(databaseUrl).catch((err: unknown) => {
167
+ throw unprepared(err);
168
+ });
145
169
  try {
146
- await DBOS.migrate(databaseUrl);
170
+ await DBOS.migrate(databaseUrl).catch((err: unknown) => {
171
+ // A DBOS table made meanwhile by a release from before the lock, mid-upgrade: it is there.
172
+ if (!madeAlready(err)) throw unprepared(err);
173
+ });
174
+ for (const [what, ddl] of TABLES) await createTable(conn, ddl, what);
175
+ await guardIdleTransactions(conn);
176
+ } finally {
177
+ // Ending the session releases the lock; a failure to end it must not hide the preparation's.
178
+ await conn.end().catch(() => undefined);
179
+ }
180
+ }
181
+
182
+ /**
183
+ * A connection to the database at `databaseUrl` (made first when missing) holding the
184
+ * preparation's session lock until it ends, so a second process waits, then finds everything
185
+ * made. Racing `DBOS.migrate`s lose to each other in many ways (40P01, XX000), so the race is
186
+ * prevented rather than each loss forgiven. The lock is polled, never waited for by a blocked
187
+ * statement: `CREATE INDEX CONCURRENTLY` waits on that statement's snapshot, a deadlock Postgres
188
+ * cannot see (the lock's holder is this connection, not the migration's).
189
+ */
190
+ async function lockPreparation(databaseUrl: string): Promise<Client> {
191
+ const conn = await connect(databaseUrl).catch(async (err: unknown) => {
192
+ if (pgCode(err) !== NO_DATABASE) throw err;
193
+ await createDatabase(databaseUrl);
194
+ return connect(databaseUrl);
195
+ });
196
+ try {
197
+ const ask = "SELECT pg_try_advisory_lock(hashtext('sanoma.prepareDatabase')) AS locked";
198
+ while (!(await conn.query<{ locked: boolean }>(ask)).rows[0]!.locked) await delay(50);
199
+ return conn;
147
200
  } catch (err) {
148
- // The database, or a DBOS table, made by another process meanwhile: it is there.
149
- if (!madeAlready(err)) throw new Error(`could not prepare the database: ${errorMessage(err)}`, { cause: err });
201
+ await conn.end().catch(() => undefined);
202
+ throw err;
150
203
  }
151
- const conn = new Client({ connectionString: databaseUrl });
204
+ }
205
+
206
+ async function connect(databaseUrl: string): Promise<Client> {
207
+ const conn = new Client({ connectionString: databaseUrl, connectionTimeoutMillis: 10_000 });
208
+ // An error nobody listens for ends the process; the query in flight fails with it.
209
+ conn.on("error", (err) => warn(`a preparation connection failed: ${errorMessage(err)}`));
152
210
  await conn.connect();
211
+ return conn;
212
+ }
213
+
214
+ /**
215
+ * Makes the database at `databaseUrl` through its server's `postgres` database, as `DBOS.migrate`
216
+ * would. Another process making it first is no failure.
217
+ */
218
+ async function createDatabase(databaseUrl: string): Promise<void> {
219
+ const url = new URL(databaseUrl);
220
+ const name = decodeURI(url.pathname.slice(1));
221
+ url.pathname = "/postgres";
222
+ const admin = await connect(url.toString());
153
223
  try {
154
- for (const [what, ddl] of TABLES) await createTable(conn, ddl, what);
155
- await guardIdleTransactions(conn);
224
+ await admin.query(`CREATE DATABASE ${escapeIdentifier(name)}`);
225
+ } catch (err) {
226
+ if (!madeAlready(err)) throw err;
156
227
  } finally {
157
- await conn.end();
228
+ await admin.end();
158
229
  }
159
230
  }
160
231
 
161
232
  /**
162
233
  * Sets `idle_in_transaction_session_timeout` on the database, for each session that starts after,
163
234
  * as DBOS would per connection (`DBOS_DATABASE_OPTIONS`), unless the server, the role or the
164
- * database sets it already. One process at a time (an advisory lock), so two preparing at once
165
- * both succeed. A role that may not alter the database leaves it, quietly: its administrator decides.
235
+ * database sets it already. `db` holds the preparation's lock, so one process at a time. A role
236
+ * that may not alter the database leaves it, quietly: its administrator decides.
166
237
  */
167
238
  async function guardIdleTransactions(db: Db): Promise<void> {
168
239
  try {
169
240
  await db.query(`DO $$ BEGIN
170
- PERFORM pg_advisory_xact_lock(hashtext('sanoma.idle_in_transaction_session_timeout'));
171
241
  IF current_setting('idle_in_transaction_session_timeout') = '0' AND NOT EXISTS (
172
242
  SELECT FROM pg_db_role_setting s JOIN pg_database d ON d.oid = s.setdatabase, unnest(s.setconfig) c
173
243
  WHERE d.datname = current_database() AND s.setrole = 0 AND c LIKE 'idle_in_transaction_session_timeout=%'
@@ -177,9 +247,7 @@ async function guardIdleTransactions(db: Db): Promise<void> {
177
247
  END IF;
178
248
  END $$`);
179
249
  } catch (err) {
180
- if (pgCode(err) !== INSUFFICIENT_PRIVILEGE) {
181
- throw new Error(`could not prepare the database: ${errorMessage(err)}`, { cause: err });
182
- }
250
+ if (pgCode(err) !== INSUFFICIENT_PRIVILEGE) throw unprepared(err);
183
251
  }
184
252
  }
185
253
 
package/src/define.ts CHANGED
@@ -2,20 +2,24 @@ import { fileURLToPath } from "node:url";
2
2
  import { getCallSites } from "node:util";
3
3
  import { z } from "zod";
4
4
  import { isOp, type Op } from "./op.ts";
5
- import { approverLabel } from "./shared.ts";
6
5
 
7
6
  /**
8
- * Who is acting: the person or service that starts a run or decides an approval. `id` is
9
- * whatever the deployment identifies people by; `groups` are the roles it vouches for.
7
+ * Who is acting: the person or agent that starts a run, calls an operation or decides an
8
+ * approval. With people declared (`sanoma.person`, `sanoma.agent`, `sanoma.group` in data files),
9
+ * a principal is a declared one, its `groups` every group that lists it; an agent is in the group
10
+ * `agents`, and `onBehalfOf` names the person it acts for.
10
11
  */
11
12
  export interface Principal {
12
13
  id: string;
13
14
  groups?: string[];
15
+ /** For an agent: the id of the person whose instruction it carries. */
16
+ onBehalfOf?: string;
14
17
  }
15
18
 
16
19
  export const Principal: z.ZodType<Principal> = z.object({
17
20
  id: z.string().regex(/\S/, "id must not be blank"),
18
21
  groups: z.array(z.string()).optional(),
22
+ onBehalfOf: z.string().regex(/\S/, "onBehalfOf must not be blank").optional(),
19
23
  });
20
24
 
21
25
  export type Builtin = "approval" | "sleep" | "all";
@@ -29,12 +33,6 @@ export const Approver: z.ZodType<Approver> = z.union(
29
33
  { error: 'needs an approver: a name, or { group: "name" }' },
30
34
  );
31
35
 
32
- /** Why `by` may not decide the approval, naming who may. */
33
- export const notApprover = ({ approver }: Pick<ApprovalState, "approver">, by: Principal): string =>
34
- typeof approver === "string"
35
- ? `${by.id} is not the approver; ${approverLabel(approver)} is`
36
- : `${by.id} is not in ${approverLabel(approver)}`;
37
-
38
36
  /** True when the two name the same approver: the same id, or the same group. */
39
37
  export const sameApprover = (a: Approver, b: Approver): boolean =>
40
38
  typeof a === "string" || typeof b === "string" ? a === b : a.group === b.group;
@@ -92,6 +90,8 @@ export interface ApprovalState extends Omit<ApprovalRequest, "covers"> {
92
90
  opSeq?: number;
93
91
  /** For a policy request: the held call's input. */
94
92
  input?: unknown;
93
+ /** For a policy request: the policies that held the call, by id, when the policy names them. */
94
+ reasons?: string[];
95
95
  status: "pending" | "approved" | "rejected";
96
96
  requestedAt: number;
97
97
  decidedBy?: string;
@@ -99,6 +99,8 @@ export interface ApprovalState extends Omit<ApprovalRequest, "covers"> {
99
99
  note?: string;
100
100
  /** The `id` of the decision message the run decided with, when it had one. */
101
101
  decidedWith?: string;
102
+ /** Set when the run's own actor decided it: nobody else could (`mayDecide`). */
103
+ alone?: true;
102
104
  /**
103
105
  * Messages that were ignored: decisions sent by someone who may not decide the approval,
104
106
  * and messages that were not a decision. `by` is the sender, when the message named one, and
@@ -193,7 +195,10 @@ interface AllCtx {
193
195
  }
194
196
 
195
197
  interface BaseCtx {
196
- /** The current time, recorded so replays see the same value. Use instead of `Date.now()`. */
198
+ /**
199
+ * The current time in epoch milliseconds, recorded so replays see the same value. Use instead
200
+ * of `Date.now()`, which the lint refuses; a date is `new Date(await ctx.now()).toISOString()`.
201
+ */
197
202
  now(): Promise<number>;
198
203
  readonly runId: string;
199
204
  }
package/src/describe.ts CHANGED
@@ -1,29 +1,56 @@
1
1
  import type { z } from "zod";
2
2
  import { type LintProblem, readSource, type SourceFile } from "./ast.ts";
3
- import { resolveConfig, type SanomaConfig } from "./config.ts";
3
+ import { type Field, recordSchemaOf } from "./collection.ts";
4
+ import { connectorsOf, resolveConfig, rootOf, type SanomaConfig } from "./config.ts";
4
5
  import { type Builtin, jsonSchemaOf, opIdsOf, type Use } from "./define.ts";
6
+ import { isDirect } from "./direct.ts";
5
7
  import { lintWorkflow } from "./lint.ts";
6
8
  import { type Effect, VENDOR, type VendorInfo } from "./op.ts";
7
9
  import { type Outline, outlineWithSource } from "./outline.ts";
8
10
  import { allowAll, policyOpOf } from "./policy.ts";
9
11
  import { type ResourceFields, resourceTypesOf } from "./resource.ts";
10
- import { type DataFiles, type DeclaredResource, readDataFiles, type ResourceProblem } from "./resources.ts";
11
- import { BUILT_IN_WORKFLOWS } from "./shared.ts";
12
+ import {
13
+ type DataFiles,
14
+ type DeclaredCollection,
15
+ type DeclaredResource,
16
+ readDataFiles,
17
+ type ResourceProblem,
18
+ } from "./resources.ts";
19
+ import { apiNameProblem } from "./sdk.ts";
20
+ import { apiNameOf, BUILT_IN_WORKFLOWS, DATA_VENDOR, problemLine } from "./shared.ts";
21
+ import { readSites, type SiteEntry } from "./sites.ts";
12
22
 
13
23
  // `@sanoma-ai/workflows/describe`: what a UI renders from, apart from the main entry.
14
24
  export { outlineWorkflow, type Outline, type OutlineNode, type Span } from "./outline.ts";
15
25
  // The reader parses data files with oxc-parser too.
16
26
  export type { LintProblem } from "./ast.ts";
17
27
  export {
28
+ admit,
18
29
  readDataFiles,
19
- readResources,
30
+ readPeople,
20
31
  type DataFiles,
32
+ type DeclaredCollection,
33
+ type DeclaredPeople,
21
34
  type DeclaredResource,
22
35
  type ResourceProblem,
23
36
  } from "./resources.ts";
24
37
  // The writer: setting a resource's fields in its data file, and rendering a new one.
25
38
  export { formatOptions, renderDataFile, setFields, type DataFileContent } from "./writer.ts";
26
39
  export { Reference, type DataValue } from "./datafile.ts";
40
+
41
+ /**
42
+ * The resources the config's data files declare, read without importing or running them; throws
43
+ * one error listing every problem, each at `file:line:column`, for a test or CI to fail on.
44
+ * `describeConfig` lists the same, with the problems beside them instead.
45
+ */
46
+ export function readResources(config: SanomaConfig): DeclaredResource[] {
47
+ const { resources, problems } = readDataFiles(rootOf(config), connectorsOf(config));
48
+ if (problems.length) {
49
+ const lines = problems.map((p) => ` ${problemLine(p)}`);
50
+ throw new Error(`The resources' data files have problems:\n${lines.join("\n")}`);
51
+ }
52
+ return resources;
53
+ }
27
54
  export {
28
55
  LOCKFILE,
29
56
  readLockfile,
@@ -34,6 +61,12 @@ export {
34
61
  type Bindings,
35
62
  type Rebind,
36
63
  } from "./lockfile.ts";
64
+ // What the sites call, read from their source: a workflow's Used by.
65
+ export { readSites, type SiteCall, type SiteEntry } from "./sites.ts";
66
+ // The generated SDK, `.sanoma/api.ts`: a site's `api` typed from this description.
67
+ export { API_FILE, generateApi, writeApi, writeGenerated } from "./sdk.ts";
68
+ // The Cedar schema, `.sanoma/schema.cedarschema`: what a company's policies are validated and decided against.
69
+ export { SCHEMA_FILE, schemaOf, schemaText, type CedarSchemaJson } from "./schema.ts";
37
70
  // The apply workflow's plan and report, which the app renders.
38
71
  export type { ApplyInput, ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanStep } from "./apply.ts";
39
72
 
@@ -43,7 +76,7 @@ export interface WorkflowEntry {
43
76
  title?: string;
44
77
  trigger: "manual";
45
78
  /**
46
- * Set for a workflow the runtime adds itself, `drift` or `apply`, rather than the config: a label. It
79
+ * Set for a workflow the runtime adds itself, `drift`, `apply` or `call`, rather than the config: a label. It
47
80
  * starts like any other.
48
81
  */
49
82
  builtin?: true;
@@ -84,6 +117,8 @@ export interface OpEntry {
84
117
  description?: string;
85
118
  /** How a scenario's steps name it: Cucumber expressions over its input's fields. */
86
119
  phrases?: { given?: string; expect?: string };
120
+ /** Whether it may be called directly, outside a run (`isDirect`): the one rule, read here, never derived again. */
121
+ direct: boolean;
87
122
  /** What a call sends, as JSON Schema (`io: "input"`: fields with defaults are optional). */
88
123
  input: Record<string, unknown>;
89
124
  /** What a call returns once its schema has parsed the vendor's reply (`io: "output"`). */
@@ -124,6 +159,22 @@ export interface ResourceTypeEntry {
124
159
  ops: string[];
125
160
  }
126
161
 
162
+ /** A collection the config declares (`collection()`). */
163
+ export interface CollectionEntry {
164
+ name: string;
165
+ title?: string;
166
+ /** The field each record is found by. */
167
+ key: string;
168
+ /** Its fields as declared. */
169
+ fields: Record<string, Field>;
170
+ /** What `save` accepts, as JSON Schema, with `$id` `sanoma:collection/<name>`. */
171
+ record: Record<string, unknown>;
172
+ /** Its operations' ids: `data.<name>.get`, `.list`, `.save` and `.delete`. */
173
+ ops: string[];
174
+ /** Where `resources/data.ts` declares it, read as the data files are; absent for one declared elsewhere. */
175
+ declared?: DeclaredCollection;
176
+ }
177
+
127
178
  /**
128
179
  * A plain-JSON description of what a config can do: its workflows, the operations they call,
129
180
  * and whether a policy gates them. The app server builds it from the config and sends it to the
@@ -139,6 +190,8 @@ export interface ConfigDescription {
139
190
  vendors: Record<string, VendorEntry>;
140
191
  /** The resource types the connectors declare, by `id`. */
141
192
  resourceTypes: ResourceTypeEntry[];
193
+ /** The config's collections, by name. */
194
+ collections: CollectionEntry[];
142
195
  /**
143
196
  * The resources the data files under `resources/` declare, by file and then in file order,
144
197
  * less any with a problem and any that names one.
@@ -148,8 +201,16 @@ export interface ConfigDescription {
148
201
  orphans: DataFiles["orphans"];
149
202
  /** What is wrong in the data files (`readDataFiles`), each at its file, relative to the root, line and column. A workflow's are on its entry. */
150
203
  problems: ResourceProblem[];
151
- /** `defined` is false for `allowAll`. */
152
- policy: { defined: boolean; version?: string };
204
+ /**
205
+ * `defined` when the root has `policies/**\/*.cedar` (else the policy is `allowAll`), with their
206
+ * `version`, or while they have `problems` (`policyProblems`), none: then every call and start is refused.
207
+ */
208
+ policy: { defined: boolean; version?: string; problems?: string[] };
209
+ /**
210
+ * The sites under `sites/`, by name, with what each calls (`readSites`), and its calls to what
211
+ * the config does not have as its problems; none when the root has no `sites/`.
212
+ */
213
+ sites: SiteEntry[];
153
214
  }
154
215
 
155
216
  /**
@@ -157,7 +218,8 @@ export interface ConfigDescription {
157
218
  * credentials (`SanomaClient.credentials` gives their statuses) and what is wrong in a workflow's
158
219
  * file, which the worker refuses when it reads the outline: that is data, on each workflow's
159
220
  * entry, as the data files' problems are, `problems`, beside the resources read without any.
160
- * Each workflow's file is read and parsed once, however many workflows it defines.
221
+ * Each workflow's file is read and parsed once, however many workflows it defines. The sites'
222
+ * code is read too (`readSites`): what each calls, and its calls to what the config lacks as its problems.
161
223
  */
162
224
  export function describeConfig(config: SanomaConfig): ConfigDescription {
163
225
  const resolved = resolveConfig(config);
@@ -168,7 +230,7 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
168
230
  const { id, info } = connector[VENDOR];
169
231
  vendors[id] ??= vendorEntry(id, info, resolved.fakes.has(id));
170
232
  }
171
- const resources = resourceTypesOf(config.connectors);
233
+ const resources = resourceTypesOf(connectorsOf(config));
172
234
  // Each resource type's state is described once, on its entry, and referenced from its operations.
173
235
  const refs = new Map<z.ZodType, string>([...resources].map(([id, r]) => [r.schema, resourceTypeUri(id)]));
174
236
  const resourceTypes: ResourceTypeEntry[] = [...resources]
@@ -186,11 +248,26 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
186
248
  }))
187
249
  .toSorted((a, b) => a.id.localeCompare(b.id));
188
250
 
251
+ const dataFiles = readDataFiles(resolved.root, connectorsOf(config), resources);
252
+ const declared = new Map(dataFiles.collections.map((d) => [d.name, d]));
253
+ const collections: CollectionEntry[] = [...resolved.collections.values()]
254
+ .map((c) => ({
255
+ name: c.name,
256
+ ...(c.title !== undefined && { title: c.title }),
257
+ key: c.key,
258
+ fields: c.fields,
259
+ record: { $id: `sanoma:collection/${c.name}`, ...toJsonSchema(recordSchemaOf(c), `${c.name} record`, "input") },
260
+ ops: [c.get, c.list, c.save, c.delete].map((op) => op.id),
261
+ ...(declared.has(c.name) && { declared: declared.get(c.name) }),
262
+ }))
263
+ .toSorted((a, b) => a.name.localeCompare(b.name));
264
+
189
265
  const ops: OpEntry[] = [...resolved.ops.values()].map((op) => ({
190
266
  ...policyOpOf(op),
191
267
  idempotent: op.idempotent,
192
268
  description: op.description,
193
269
  ...(op.phrases && { phrases: op.phrases }),
270
+ direct: isDirect(op, resolved.types),
194
271
  input: toJsonSchema(op.input, `${op.id} input`, "input", refs),
195
272
  output: toJsonSchema(op.output, `${op.id} output`, "output", refs),
196
273
  }));
@@ -225,7 +302,7 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
225
302
  };
226
303
  });
227
304
  workflows.sort((a, b) => a.name.localeCompare(b.name));
228
- const dataFiles = readDataFiles(resolved.root, config.connectors, resources);
305
+ addApiNameProblems(workflows, new Set(ops.map((op) => op.vendor)));
229
306
 
230
307
  return {
231
308
  appName: resolved.appName,
@@ -234,16 +311,42 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
234
311
  ops,
235
312
  vendors,
236
313
  resourceTypes,
314
+ collections,
237
315
  resources: dataFiles.resources,
238
316
  orphans: dataFiles.orphans,
239
317
  problems: dataFiles.problems,
240
318
  policy: {
241
319
  defined: resolved.policy !== allowAll,
242
320
  ...(resolved.policy.version === undefined ? {} : { version: resolved.policy.version }),
321
+ ...(resolved.policyProblems.length ? { problems: resolved.policyProblems } : {}),
243
322
  },
323
+ sites: readSites(resolved.root, { workflows, collections, ops }),
244
324
  };
245
325
  }
246
326
 
327
+ /**
328
+ * A site's `api` names each workflow `apiNameOf(name)` beside the vendors' operations and the
329
+ * collections (`api.data`), so a workflow whose name comes out as another's, a vendor's id or
330
+ * `data` is a problem, at 1:1 of its file. A built-in is never the one renamed.
331
+ */
332
+ function addApiNameProblems(workflows: WorkflowEntry[], vendors: ReadonlySet<string>) {
333
+ for (const wf of workflows) {
334
+ if (wf.builtin) continue;
335
+ const api = apiNameOf(wf.name);
336
+ const others = workflows.filter((other) => other !== wf && apiNameOf(other.name) === api);
337
+ const taken = others.length
338
+ ? `as workflow ${others.map((other) => `"${other.name}"`).join(" and ")} is`
339
+ : api === DATA_VENDOR
340
+ ? "which is the collections'"
341
+ : vendors.has(api)
342
+ ? `which is the vendor ${api}'s operations`
343
+ : api === "then"
344
+ ? "which a promise would take for its own"
345
+ : undefined;
346
+ if (taken) wf.problems.push({ line: 1, column: 1, message: apiNameProblem(api, taken) });
347
+ }
348
+ }
349
+
247
350
  /** `fn`, called once per argument for as long as the returned function lives. */
248
351
  function memoised<A, R>(fn: (arg: A) => R): (arg: A) => R {
249
352
  const seen = new Map<A, R>();
package/src/direct.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { Op } from "./op.ts";
2
+ import { isResourceOp } from "./resource.ts";
3
+ import { DATA_VENDOR } from "./shared.ts";
4
+
5
+ /**
6
+ * Whether an operation may be called directly, outside a run (`SanomaClient.call`), as the
7
+ * person: a collection's (`data.<name>.<op>`), or a `read` that is neither the runtime's own
8
+ * (the vendor `sanoma`) nor a resource type's (`isResourceOp`), which only drift and apply call.
9
+ * Anything else (an effect at a vendor) is a workflow's. The one rule: the built-in `call`
10
+ * workflow, the SDK and the app read it, from here or from `OpEntry.direct`, and never derive it.
11
+ */
12
+ export const isDirect = (
13
+ op: Pick<Op, "vendor" | "resource" | "effect">,
14
+ types: ReadonlyMap<string, unknown>,
15
+ ): boolean => op.vendor === DATA_VENDOR || (op.effect === "read" && op.vendor !== "sanoma" && !isResourceOp(op, types));
package/src/errors.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
2
2
  import type { z } from "zod";
3
3
  import type { Principal } from "./define.ts";
4
- import { errorMessage } from "./shared.ts";
4
+ import { CALL_WORKFLOW, errorMessage } from "./shared.ts";
5
5
 
6
6
  const CODES = [
7
7
  "policy_denied",
@@ -15,6 +15,8 @@ const CODES = [
15
15
  "run_ended",
16
16
  "run_running",
17
17
  "call_not_in_outline",
18
+ "approval_pending",
19
+ "not_admitted",
18
20
  ] as const;
19
21
 
20
22
  /**
@@ -79,6 +81,39 @@ export class PolicyDeniedError extends SanomaError {
79
81
  }
80
82
  }
81
83
 
84
+ /**
85
+ * Thrown by a direct call (`SanomaClient.call`) the policy holds for an approval: the call is now
86
+ * run `runId` of the built-in `call` workflow, which makes it once approved.
87
+ */
88
+ export class ApprovalPendingError extends SanomaError {
89
+ constructor(op: string, runId: string) {
90
+ super("approval_pending", `${op} needs an approval: it waits in run ${runId}`, {
91
+ runId,
92
+ workflow: CALL_WORKFLOW,
93
+ op,
94
+ });
95
+ this.name = "ApprovalPendingError";
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Thrown when the data files declare people (`sanoma.person`, in `resources/people.ts` by
101
+ * convention) and not this principal's id: nothing is started, called or decided.
102
+ */
103
+ export class NotAdmittedError extends SanomaError {
104
+ /** `problems`: what is wrong in the people's files, while nobody is admitted. */
105
+ constructor(id: string, problems: readonly string[] = []) {
106
+ super(
107
+ "not_admitted",
108
+ problems.length
109
+ ? `${id} cannot be admitted while the people's files have problems: ${problems.join("; ")}`
110
+ : `${id} is not declared in resources/people.ts`,
111
+ { principal: id, ...(problems.length && { problems }) },
112
+ );
113
+ this.name = "NotAdmittedError";
114
+ }
115
+ }
116
+
82
117
  /** Thrown into the run when an approver rejects. The run fails with it. */
83
118
  export class RejectedError extends SanomaError {
84
119
  /**
@@ -83,9 +83,13 @@ const words = (d: RecordedDecision) =>
83
83
  /** What the policy decided for the calls, each decision once: `allowed and held for finance`. */
84
84
  const decisions = (calls: CallRecord[]) => [...new Set(calls.map((r) => words(r.decision)))].join(" and ");
85
85
 
86
- /** True when `who` names the approver: a person by id, or a group by its name. */
87
- const isApprover = (approver: Approver, who: string) =>
88
- typeof approver === "string" ? approver === who : approver.group === who;
86
+ /** True when `who` names the approver: a person by id, or a group by its name; `{ group }` the group alone. */
87
+ const isApprover = (approver: Approver, who: Approver) =>
88
+ typeof who !== "string"
89
+ ? typeof approver !== "string" && approver.group === who.group
90
+ : typeof approver === "string"
91
+ ? approver === who
92
+ : approver.group === who;
89
93
 
90
94
  /** How a drift report found a resource, as words: `clean`, `gone`, `drifted in has_wiki, topics`. */
91
95
  const found = (r: DriftResult) =>
@@ -144,9 +148,9 @@ function applyDetail(
144
148
  * Why an expectation of the generic steps does not hold against a run's ledger records, or
145
149
  * undefined when it does. A call is an `op.called` record without an `error`; one with an error
146
150
  * (a policy denial, a rejected approval, a vendor's final failure) was attempted, not made. An
147
- * operation `was denied` when the policy's decision on some call to it is `deny`, and `was held
148
- * for` an approver when an approval for that approver holds it: one the policy asked for on its
149
- * call, or one that `covers` it. A drift or apply expectation reads the report a finished run
151
+ * operation `was denied` when the policy's decision on some call to it is `deny` (for the `reason`
152
+ * expected, when there is one), and `was held for` an approver (a group alone, as `{ group }`) when
153
+ * an approval for that approver holds it: one the policy asked for on its call, or one that `covers` it. A drift or apply expectation reads the report a finished run
150
154
  * of it returned. A step named in the workflow's outline is read by the `name` its records carry
151
155
  * (its calls made, and `waitsOf`); a record written before steps had names has none.
152
156
  */
@@ -158,13 +162,18 @@ export function detailOf(e: CoreExpectation, records: readonly LedgerRecord[]):
158
162
  if ("decision" in e) {
159
163
  const tries = callsTo(e.op);
160
164
  if (e.decision === "denied") {
161
- if (tries.some((r) => r.decision.kind === "deny")) return undefined;
165
+ const denials = tries.flatMap(({ decision: d }) => (d.kind === "deny" ? [d.reason] : []));
166
+ if (denials.some((reason) => e.reason === undefined || reason === e.reason)) return undefined;
167
+ if (denials.length) {
168
+ const said = [...new Set(denials)].map((reason) => JSON.stringify(reason)).join(" and ");
169
+ return `${e.op} was denied with ${said}, not ${JSON.stringify(e.reason)}`;
170
+ }
162
171
  return tries.length ? `${e.op} was ${decisions(tries)}, not denied` : `no call to ${e.op}`;
163
172
  }
164
173
  const holds = records.filter(isRequest).filter((r) => r.op === e.op || r.covers.includes(e.op));
165
174
  if (holds.some((r) => isApprover(r.approver, e.approver))) return undefined;
166
175
  return holds.length
167
- ? `${e.op} was held for ${[...new Set(holds.map((r) => approverLabel(r.approver)))].join(" and ")}, not ${e.approver}`
176
+ ? `${e.op} was held for ${[...new Set(holds.map((r) => approverLabel(r.approver)))].join(" and ")}, not ${approverLabel(e.approver)}`
168
177
  : tries.length
169
178
  ? `${e.op} was ${decisions(tries)}, not held`
170
179
  : `no call to ${e.op}`;
package/src/fill.ts CHANGED
@@ -19,6 +19,21 @@ export function fill(schema: z.ZodType, given: Record<string, unknown>): unknown
19
19
  export const shapeOf = (schema: z.ZodType): Record<string, z.ZodType> =>
20
20
  schema instanceof z.ZodObject ? schema.shape : {};
21
21
 
22
+ /** The schema of a field an object schema does not name, when it takes such fields (`z.looseObject`, `.catchall`). */
23
+ export function restOf(schema: z.ZodType): z.ZodType | undefined {
24
+ const rest = schema instanceof z.ZodObject ? (schema.def.catchall as z.ZodType | undefined) : undefined;
25
+ return rest instanceof z.ZodNever ? undefined : rest;
26
+ }
27
+
28
+ /** The object schema under `schema`'s optional, nullable and default wrappers, if it is one. */
29
+ export function objectUnder(schema: z.ZodType): z.ZodObject | undefined {
30
+ let inner = schema;
31
+ while (inner instanceof z.ZodOptional || inner instanceof z.ZodNullable || inner instanceof z.ZodDefault) {
32
+ inner = inner.unwrap() as z.ZodType;
33
+ }
34
+ return inner instanceof z.ZodObject ? inner : undefined;
35
+ }
36
+
22
37
  /** A mistake in code (ours, or a fake's), not in what it was given: thrown as it is, never reported as data. */
23
38
  export const isBug = (err: unknown) =>
24
39
  err instanceof TypeError || err instanceof RangeError || err instanceof ReferenceError;