@sanoma-ai/workflows 0.11.2 → 0.11.3

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.
package/src/config.ts CHANGED
@@ -57,7 +57,8 @@ import {
57
57
  VENDOR,
58
58
  withSetup,
59
59
  } from "./op.ts";
60
- import { type CedarPolicySource, policyAt } from "./cedar.ts";
60
+ import { rootSnapshot, type RootSnapshot } from "./bundled.ts";
61
+ import { type CedarPolicySource, policyAt, readPolicyFiles } from "./cedar.ts";
61
62
  import type { Policy } from "./policy.ts";
62
63
  import { checkFromRefs, type Resource, resourceTypesOf, withoutWriteOnly } from "./resource.ts";
63
64
  import { codeMail, loginCodeWorkflow } from "./login-code.ts";
@@ -76,7 +77,7 @@ import { tokenStore } from "./tokens.ts";
76
77
  import type { Steps } from "./steps.ts";
77
78
  import { checkSchedule } from "./schedules.ts";
78
79
  import { changeCodeOf, computeVersion } from "./version.ts";
79
- import type { DeclaredResource, ResourceProblem } from "./resources.ts";
80
+ import { type DeclaredResource, readPeople, type ResourceProblem } from "./resources.ts";
80
81
  import { checkWakeupOps, type DeclaredWakeup, type EnvWakeup, HOST_URL } from "./wakeup.ts";
81
82
  import { instanceUrl, WAKEUP_TOKEN } from "./wakeup-service.ts";
82
83
  import { sanomaWakeupDriver, wakeupSteps } from "./wakeup-driver.ts";
@@ -425,6 +426,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
425
426
  const login = config.login && checkLogin(config.login, ops, drivers, appName);
426
427
  if (login) names.set(LOGIN_CODE_WORKFLOW, loginCodeWorkflow(login, appName));
427
428
  const { policy, policies, problems: policyProblems } = policyAt(root, { ops, workflows: names, collections });
429
+ const bundled = rootSnapshot(root);
428
430
  return {
429
431
  secrets: config.secrets ?? {},
430
432
  connections,
@@ -432,9 +434,10 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
432
434
  identityProvisioning,
433
435
  appName,
434
436
  databaseUrl,
435
- // The built-in's code and operations are part of the app's version, like the config's own.
436
- version: computeVersion({ appName, workflows: [...names.values()] }),
437
- changeCode: changeCodeOf(names.get(CHANGE_WORKFLOW)!),
437
+ // The built-in's code and operations are part of the app's version, like the config's own. A
438
+ // bundle's code is not the source's: it carries the hashes of the root it was read from.
439
+ version: bundled?.version ?? computeVersion({ appName, workflows: [...names.values()] }),
440
+ changeCode: bundled?.changeCode ?? changeCodeOf(names.get(CHANGE_WORKFLOW)!),
438
441
  queueName: `sanoma:${appName}`,
439
442
  ...(login && { login }),
440
443
  ops,
@@ -593,12 +596,26 @@ function makersOf<K extends keyof ConnectorSetup>(
593
596
  return makers;
594
597
  }
595
598
 
599
+ /**
600
+ * What the runtime reads from `config`'s root that a bundle carries where the files are not
601
+ * (`useRootSnapshot`): the app's name and versions as `resolved` has them, the policy files and
602
+ * the people the data files declare.
603
+ */
604
+ export function readRootSnapshot(config: SanomaConfig, resolved: ResolvedConfig = resolveConfig(config)): RootSnapshot {
605
+ const root = rootOf(config);
606
+ if (root === undefined) throw new Error("A config with no root has no files to carry");
607
+ const { appName, version, changeCode } = resolved;
608
+ return { appName, version, changeCode, policies: readPolicyFiles(root), people: readPeople(root) };
609
+ }
610
+
596
611
  /**
597
612
  * The `name` of `<root>/package.json`, kept whole (`@acme/ops`), when that file exists and names
598
613
  * one; else "sanoma", as for a config with no `root`. Never a `package.json` above the root: a
599
614
  * parent project's name must not become the app's. A file that is not JSON is refused, naming it.
600
615
  */
601
616
  function appNameAt(root: string | undefined): string {
617
+ const bundled = rootSnapshot(root);
618
+ if (bundled) return bundled.appName;
602
619
  const file = root === undefined ? undefined : join(root, "package.json");
603
620
  if (file === undefined || !existsSync(file)) return "sanoma";
604
621
  let manifest: unknown;
package/src/db.ts CHANGED
@@ -226,6 +226,19 @@ export function lazyPool(databaseUrl: string, what: string): LazyPool {
226
226
  /** Each database prepared, or being prepared, by its URL. A failed preparation is forgotten. */
227
227
  const prepared = new Map<string, Promise<void>>();
228
228
 
229
+ /** What prepares the databases instead of this process (`checkPreparedOnly`), which a refusal names. */
230
+ let preparedBy: string | undefined;
231
+
232
+ /**
233
+ * From now on, `prepareDatabase` makes nothing: it only checks that DBOS's tables and the
234
+ * runtime's are there, and refuses, naming `by`, what prepares them, when they are not. For a
235
+ * process beside the one that prepares the database, which must never migrate it: the Cloudflare
236
+ * host's Worker, whose container prepares it at its start.
237
+ */
238
+ export function checkPreparedOnly(by: string): void {
239
+ preparedBy = by;
240
+ }
241
+
229
242
  /**
230
243
  * Makes the database at `databaseUrl` ready for the runtime, once per process and URL: the
231
244
  * database itself and DBOS's tables when they are not there yet (as a worker's launch makes
@@ -237,7 +250,8 @@ const prepared = new Map<string, Promise<void>>();
237
250
  export function prepareDatabase(databaseUrl: string): Promise<void> {
238
251
  let preparing = prepared.get(databaseUrl);
239
252
  if (!preparing) {
240
- preparing = prepare(databaseUrl).catch((err: unknown) => {
253
+ const made = preparedBy === undefined ? prepare(databaseUrl) : checkPrepared(databaseUrl, preparedBy);
254
+ preparing = made.catch((err: unknown) => {
241
255
  if (prepared.get(databaseUrl) === preparing) prepared.delete(databaseUrl);
242
256
  throw err;
243
257
  });
@@ -267,6 +281,26 @@ async function prepare(databaseUrl: string): Promise<void> {
267
281
  }
268
282
  }
269
283
 
284
+ /** The last of the runtime's tables a preparation makes (`TABLES`), so its being there says they all are. */
285
+ const LAST_TABLE = API_TOKENS_TABLE;
286
+
287
+ /** Refuses when the database at `databaseUrl` lacks DBOS's tables or the runtime's: `by` makes them, not this process. */
288
+ async function checkPrepared(databaseUrl: string, by: string): Promise<void> {
289
+ const conn = await connect(databaseUrl);
290
+ try {
291
+ const { rows } = await conn.query<{ dbos: boolean; ours: boolean }>(
292
+ `SELECT to_regclass('dbos.workflow_status') IS NOT NULL AS dbos, to_regclass('${LAST_TABLE}') IS NOT NULL AS ours`,
293
+ );
294
+ const { dbos, ours } = rows[0]!;
295
+ if (!dbos || !ours) {
296
+ const missing = [...(dbos ? [] : ["DBOS's"]), ...(ours ? [] : ["Sanoma's"])].join(" and ");
297
+ throw new Error(`the database has no ${missing} tables yet: ${by} makes them, and this process never does`);
298
+ }
299
+ } finally {
300
+ await conn.end().catch(() => undefined);
301
+ }
302
+ }
303
+
270
304
  /**
271
305
  * A connection to the database at `databaseUrl` (made first when missing) holding the
272
306
  * preparation's session lock until it ends, so a second process waits, then finds everything
package/src/define.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { bundledFile } from "./bundled.ts";
1
2
  import type { ConnectionRef } from "./connection.ts";
2
3
  import type { WorkflowEntry } from "./description.ts";
3
4
  import { fileURLToPath } from "node:url";
@@ -396,11 +397,12 @@ const isTimeZone = (zone: unknown): boolean => {
396
397
 
397
398
  /**
398
399
  * The file of the code that called the function calling this one (`defineWorkflow`'s caller),
399
- * from V8's call sites, as a path; undefined where there is none.
400
+ * from V8's call sites, as a path; where they name none, the file a bundle says it is evaluating
401
+ * (`bundledFile`); undefined where there is neither.
400
402
  */
401
403
  export function callerFile(): string | undefined {
402
404
  // Frame 0 is this function, frame 1 its caller, frame 2 theirs: by index, since a bundle may hold all three.
403
- return scriptPath(getCallSites(3)[2]?.scriptName);
405
+ return scriptPath(getCallSites(3)[2]?.scriptName) ?? bundledFile();
404
406
  }
405
407
 
406
408
  /** A V8 script name (a `file:` URL for a module, a path for a script) as a path; undefined for none. */
package/src/index.ts CHANGED
@@ -67,6 +67,7 @@ export {
67
67
  defineConfig,
68
68
  envWakeup,
69
69
  type Login,
70
+ readRootSnapshot,
70
71
  resolveConfig,
71
72
  resolveDatabaseUrl,
72
73
  type ResolvedConfig,
@@ -242,6 +243,11 @@ export {
242
243
  } from "./client.ts";
243
244
  // A raw DBOS client on the runtime's database, for tests and tools that read DBOS directly.
244
245
  export { dbosClient } from "./db.ts";
246
+ // The runtime bundled where a company's files are not (the Cloudflare host's Worker): the root's
247
+ // people, policies and app name as data, Cedar's web build, and a database it only checks.
248
+ export { type RootSnapshot, useRootSnapshot } from "./bundled.ts";
249
+ export { checkPreparedOnly } from "./db.ts";
250
+ export { type CedarWasm, useCedarWasm } from "./schema.ts";
245
251
  // Also at `@sanoma-ai/workflows/shared`, which a browser bundle can import.
246
252
  export {
247
253
  AGENTS,
package/src/load.ts CHANGED
@@ -89,7 +89,8 @@ const registry = ((globalThis as Record<symbol, unknown>)[Symbol.for("sanoma.loa
89
89
  made: new WeakMap(),
90
90
  }) as Registry;
91
91
 
92
- const require = createRequire(import.meta.url);
92
+ /** This module's `require`, made on first use: a bundle with no file URL (the Cloudflare host's Worker) never loads a file. */
93
+ let require: NodeJS.Require | undefined;
93
94
 
94
95
  /** Where `value` was made, when a company file made it with a constructor the loader served. */
95
96
  export const madeOf = (value: unknown): Made | undefined =>
@@ -205,7 +206,9 @@ function loadOne(gen: Generation, file: string, rel: string): Loaded {
205
206
  if (done) return done;
206
207
  let loaded: Loaded;
207
208
  try {
208
- const { ns } = require(`${SCHEME}${gen.id}/${pathToFileURL(file).href}`) as { ns: Record<string, unknown> };
209
+ const { ns } = (require ??= createRequire(import.meta.url))(`${SCHEME}${gen.id}/${pathToFileURL(file).href}`) as {
210
+ ns: Record<string, unknown>;
211
+ };
209
212
  loaded = { exports: Object.entries(ns) };
210
213
  } catch (error) {
211
214
  // A file that imports one that threw throws the same object: reported once, where it was thrown.
package/src/resources.ts CHANGED
@@ -9,6 +9,7 @@ import { APPS_DIR, COLLECTIONS_FILE, filesUnder, kindDirs, relPath } from "./pat
9
9
  import { peopleConnector, sanoma } from "./person.ts";
10
10
  import { wakeupOps } from "./wakeup-type.ts";
11
11
  import { DECLARED, type Declared, isDeclared, isObject, type Resource, resourceTypesOf } from "./resource.ts";
12
+ import { rootSnapshot } from "./bundled.ts";
12
13
  import { Principal } from "./define.ts";
13
14
  import { NotAdmittedError } from "./errors.ts";
14
15
  import {
@@ -89,10 +90,12 @@ export const peopleSources = (root: string): ReadonlySet<string> => filesRead(pe
89
90
  * Only the files that may declare them (`mayDeclarePeople`) are loaded, with the people's and the
90
91
  * wakeup's constructors, which the same `sanoma` declares; loaded again only when one of them
91
92
  * changed (by its size and time), so a new colleague is admitted once their file is saved, and a
92
- * request costs a `stat` per file.
93
+ * request costs a `stat` per file. A root with a bundled snapshot (`useRootSnapshot`) answers its people.
93
94
  */
94
95
  export function readPeople(root: string | undefined): DeclaredPeople {
95
96
  if (root === undefined) return { people: [], problems: [] };
97
+ const bundled = rootSnapshot(root);
98
+ if (bundled) return bundled.people;
96
99
  const loaded = peopleLoad(root);
97
100
  const cached = peopleRead.get(loaded);
98
101
  if (cached) return cached;
package/src/schema.ts CHANGED
@@ -19,11 +19,24 @@ import { CHANGE_MERGE, DATA_VENDOR, EFFECTS, groupNamesOf, type PeopleResource,
19
19
  // resolved config and the people, in memory for the evaluator, and written as text to
20
20
  // `.sanoma/schema.cedarschema` for the policies' author.
21
21
 
22
- const require = createRequire(import.meta.url);
23
- let wasm: typeof import("@cedar-policy/cedar-wasm/nodejs") | undefined;
22
+ /** Cedar's bindings: its builds (`/nodejs`, `/web`) export the same functions. */
23
+ export type CedarWasm = typeof import("@cedar-policy/cedar-wasm/nodejs");
24
+ let wasm: CedarWasm | undefined;
24
25
 
25
- /** `@cedar-policy/cedar-wasm`'s Node build, loaded (synchronously, once) on first use, so a process that never decides with Cedar never loads it. */
26
- export const cedarWasm = () => (wasm ??= require("@cedar-policy/cedar-wasm/nodejs") as typeof wasm)!;
26
+ /**
27
+ * `@cedar-policy/cedar-wasm`'s Node build, loaded (synchronously, once) on first use, so a process
28
+ * that never decides with Cedar never loads it; or the build `useCedarWasm` gave.
29
+ */
30
+ export const cedarWasm = (): CedarWasm =>
31
+ (wasm ??= createRequire(import.meta.url)("@cedar-policy/cedar-wasm/nodejs") as CedarWasm);
32
+
33
+ /**
34
+ * From now on, Cedar is `bindings`: its web build, initialised, where the Node build cannot load
35
+ * (the Cloudflare host's Worker, which bundles the runtime).
36
+ */
37
+ export function useCedarWasm(bindings: CedarWasm): void {
38
+ wasm = bindings;
39
+ }
27
40
 
28
41
  /** The schema as Cedar's JSON format has it. */
29
42
  export type CedarSchemaJson = SchemaJson<string>;
package/src/shared.ts CHANGED
@@ -141,6 +141,26 @@ export const domainsText = (domains: Readonly<Record<string, string>>): string =
141
141
  .map((site) => `${site}=${domains[site]}`)
142
142
  .join(",");
143
143
 
144
+ /**
145
+ * Where a hosted container answers, with its instance token (`SANOMA_WAKEUP_TOKEN`), the bundle
146
+ * of its public sites that a host's Worker serves: the Cloudflare host downloads it at a deploy.
147
+ */
148
+ export const SITES_BUNDLE_PATH = "/__sanoma/sites-bundle";
149
+ /** The header every answer of that route carries: a runtime without the route answers without it. */
150
+ export const SITES_BUNDLE_HEADER = "x-sanoma-sites-bundle";
151
+
152
+ /** What that route answers: what a host's Worker runs a company's public sites from (`buildWorker` in `@sanoma-ai/app/build`). */
153
+ export interface SitesBundle {
154
+ /** The sites it serves, by key: public, with a domain and no login. */
155
+ sites: string[];
156
+ /** The main module, among `modules`. */
157
+ mainModule: string;
158
+ /** The Worker's modules, by name relative to the main module, each base64. */
159
+ modules: { name: string; type: "esm" | "wasm" | "text" | "data"; content: string }[];
160
+ /** The Worker's static assets, by path from `/`, each base64: the client build and the prerendered pages. */
161
+ assets: { path: string; content: string }[];
162
+ }
163
+
144
164
  /** Where a host with a `hostname` serves the app (`HostSpec`'s `hostname`): `https://<hostname>`. */
145
165
  export const hostnameUrl = (hostname: string): string => `https://${hostname}`;
146
166