@sanoma-ai/workflows 0.11.2 → 0.11.4

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 (85) hide show
  1. package/README.md +32 -30
  2. package/dist/apply-steps.d.ts +4 -2
  3. package/dist/apply-steps.js +5 -3
  4. package/dist/apply-steps.js.map +1 -1
  5. package/dist/apply.d.ts +6 -1
  6. package/dist/apply.js +55 -20
  7. package/dist/apply.js.map +1 -1
  8. package/dist/bundled.d.ts +29 -0
  9. package/dist/bundled.js +16 -0
  10. package/dist/bundled.js.map +1 -0
  11. package/dist/cedar.d.ts +4 -1
  12. package/dist/cedar.js +8 -1
  13. package/dist/cedar.js.map +1 -1
  14. package/dist/config.d.ts +12 -1
  15. package/dist/config.js +75 -9
  16. package/dist/config.js.map +1 -1
  17. package/dist/constructors.d.ts +1 -1
  18. package/dist/db.d.ts +7 -0
  19. package/dist/db.js +30 -1
  20. package/dist/db.js.map +1 -1
  21. package/dist/define.d.ts +2 -1
  22. package/dist/define.js +4 -2
  23. package/dist/define.js.map +1 -1
  24. package/dist/definitions.d.ts +5 -0
  25. package/dist/definitions.js.map +1 -1
  26. package/dist/describe.d.ts +1 -1
  27. package/dist/describe.js +16 -11
  28. package/dist/describe.js.map +1 -1
  29. package/dist/drift.d.ts +7 -0
  30. package/dist/drift.js +15 -3
  31. package/dist/drift.js.map +1 -1
  32. package/dist/host-derive.d.ts +145 -0
  33. package/dist/host-derive.js +403 -0
  34. package/dist/host-derive.js.map +1 -0
  35. package/dist/host.d.ts +70 -5
  36. package/dist/host.js +56 -1
  37. package/dist/host.js.map +1 -1
  38. package/dist/index.d.ts +9 -5
  39. package/dist/index.js +11 -5
  40. package/dist/index.js.map +1 -1
  41. package/dist/load.js +3 -2
  42. package/dist/load.js.map +1 -1
  43. package/dist/resource.d.ts +8 -1
  44. package/dist/resource.js +11 -0
  45. package/dist/resource.js.map +1 -1
  46. package/dist/resources.d.ts +1 -1
  47. package/dist/resources.js +6 -2
  48. package/dist/resources.js.map +1 -1
  49. package/dist/schema.d.ts +12 -2
  50. package/dist/schema.js +12 -6
  51. package/dist/schema.js.map +1 -1
  52. package/dist/shared.d.ts +25 -0
  53. package/dist/shared.js +7 -0
  54. package/dist/shared.js.map +1 -1
  55. package/dist/sites.d.ts +6 -0
  56. package/dist/sites.js +28 -4
  57. package/dist/sites.js.map +1 -1
  58. package/dist/source.d.ts +12 -5
  59. package/dist/source.js +32 -13
  60. package/dist/source.js.map +1 -1
  61. package/dist/wakeup-type.d.ts +1 -1
  62. package/dist/wakeup-type.js +1 -1
  63. package/dist/wakeup-type.js.map +1 -1
  64. package/package.json +1 -1
  65. package/src/apply-steps.ts +5 -4
  66. package/src/apply.ts +87 -24
  67. package/src/bundled.ts +42 -0
  68. package/src/cedar.ts +7 -1
  69. package/src/config.ts +81 -10
  70. package/src/db.ts +35 -1
  71. package/src/define.ts +4 -2
  72. package/src/definitions.ts +5 -0
  73. package/src/describe.ts +23 -10
  74. package/src/drift.ts +16 -3
  75. package/src/host-derive.ts +492 -0
  76. package/src/host.ts +101 -5
  77. package/src/index.ts +26 -2
  78. package/src/load.ts +5 -2
  79. package/src/resource.ts +33 -2
  80. package/src/resources.ts +5 -2
  81. package/src/schema.ts +17 -4
  82. package/src/shared.ts +20 -0
  83. package/src/sites.ts +33 -3
  84. package/src/source.ts +35 -12
  85. package/src/wakeup-type.ts +1 -1
@@ -46,19 +46,19 @@ async function lastWritten(appName: string): Promise<Bindings | undefined> {
46
46
 
47
47
  /**
48
48
  * The apply workflow's steps for a config: `read`, the data files and the lockfile as drift reads
49
- * them, under `root`; `appName` scopes the search for the lockfile apply last wrote.
49
+ * them, under `root`, minting what a deriving host is given; `appName` scopes the search for the lockfile apply last wrote.
50
50
  */
51
51
  export function applySteps(
52
52
  appName: string,
53
53
  root: string | undefined,
54
- read: () => Promise<Omit<ApplyDeclared, "rebinds">>,
54
+ read: (options?: { mint?: boolean }) => Promise<Omit<ApplyDeclared, "rebinds">>,
55
55
  ): ApplySteps {
56
56
  return {
57
57
  // The data files, and the lockfile against the one apply last wrote: a change someone else made.
58
58
  declared: () =>
59
59
  DBOS.runStep(
60
60
  async () => {
61
- const declared = await read();
61
+ const declared = await read({ mint: true });
62
62
  const before = await lastWritten(appName);
63
63
  return { ...declared, rebinds: before ? diffLockfiles(before, declared.bindings) : [] };
64
64
  },
@@ -86,7 +86,7 @@ export function applySteps(
86
86
  // The one binding changed on the lockfile as it is now, written whole, then committed, in one
87
87
  // step whose output is what it wrote. A sandbox run keeps its lockfile in memory: the run's
88
88
  // report has it.
89
- write: async (key: string, binding: Binding | undefined, message: string) => {
89
+ write: async (key: string, binding: Binding | undefined, message: string, moved?: string) => {
90
90
  const run = runNow();
91
91
  if (run.sandbox !== undefined || root === undefined) return { written: false };
92
92
  const { source } = run.state;
@@ -95,6 +95,7 @@ export function applySteps(
95
95
  const now = readLockfile(root);
96
96
  if (now.problems.length) throw new Error(`${LOCKFILE} cannot be updated: ${now.problems[0]!.message}`);
97
97
  const { [key]: _, ...others } = now.bindings;
98
+ if (moved !== undefined) delete others[moved];
98
99
  const bindings = binding ? { ...others, [key]: binding } : others;
99
100
  writeLockfile(root, bindings);
100
101
  const paths = [
package/src/apply.ts CHANGED
@@ -10,12 +10,13 @@ import {
10
10
  importsOf,
11
11
  observeBy,
12
12
  observed,
13
+ refOf,
13
14
  type ResolveOp,
14
15
  type Revision,
15
16
  sourceUrl,
16
17
  } from "./drift.ts";
17
18
  import { isRunControlError } from "./errors.ts";
18
- import { type HostOutputs, wakeUrlOf } from "./host.ts";
19
+ import { detaching, type HostOutputs, wakeUrlOf } from "./host.ts";
19
20
  import type { Binding, Bindings, Rebind } from "./lockfile.ts";
20
21
  import type { Op } from "./op.ts";
21
22
  import {
@@ -72,7 +73,13 @@ export type ApplyInput = z.infer<typeof ApplyInput>;
72
73
  export type PlanStep = { id: string; vendor: string; type: string; connection?: ConnectionRef } & (
73
74
  | { action: "create"; planned: Record<string, unknown>; unknown: string[] }
74
75
  | { action: "update" | "replace"; binding: string; fields: DriftField[]; replace: string[]; unknown?: string[] }
75
- | { action: "bind"; binding: string; source: "found" | "given" }
76
+ | {
77
+ action: "bind";
78
+ binding: string;
79
+ source: "found" | "given";
80
+ /** The orphaned lockfile key that held the object found, whose binding the declaration takes over. */
81
+ from?: string;
82
+ }
76
83
  | { action: "gone"; binding: string; answer?: PlanAnswer }
77
84
  | { action: "orphan"; key: string; binding: string; answer?: PlanAnswer }
78
85
  | { action: "none"; binding: string }
@@ -113,6 +120,8 @@ export interface ApplyReport {
113
120
  export interface ApplyDeclared extends DriftDeclared {
114
121
  bindings: Bindings;
115
122
  orphans: (Binding & { key: string })[];
123
+ /** The declarations left out with a problem (a host that derives, which apply cannot give all it needs), which an input may still name. */
124
+ leftOut?: string[];
116
125
  rebinds: Rebind[];
117
126
  }
118
127
 
@@ -133,7 +142,13 @@ export interface ApplySteps {
133
142
  * another run made since this one read it stays, and commits it with `message`; a sandbox run
134
143
  * writes nothing.
135
144
  */
136
- write(key: string, binding: Binding | undefined, message: string): Promise<{ written: boolean; revision?: string }>;
145
+ /** Writes `key`'s binding (none: removed), and removes `moved`, the key it moved from, in the same commit. */
146
+ write(
147
+ key: string,
148
+ binding: Binding | undefined,
149
+ message: string,
150
+ moved?: string,
151
+ ): Promise<{ written: boolean; revision?: string }>;
137
152
  }
138
153
 
139
154
  type State = Record<string, unknown>;
@@ -193,7 +208,7 @@ interface Known {
193
208
  /** The lockfile's bindings, as the run has changed them so far. */
194
209
  bindings: Bindings;
195
210
  /** The clone URL and commit a host wanted runs. */
196
- hosts: ReadonlyMap<string, { source: string; revision: string }>;
211
+ hosts: ReadonlyMap<string, { source: string; revision: string; ref: string }>;
197
212
  /** Where each host's wakeup reaches it, as its state says (`wakeUrl`, else `url`). */
198
213
  urls: ReadonlyMap<string, string>;
199
214
  /**
@@ -232,11 +247,12 @@ function desiredOf(
232
247
  const named = holderOf(out, wakeup.inputs.host);
233
248
  if (url && named) named.holder[named.key] = url;
234
249
  }
235
- // A host is given its source's clone URL and the commit its ref names now.
250
+ // A host is given its source's clone URL, its ref (the default branch when it names none) and the commit it names now.
236
251
  const run = hosts.get(r.id);
237
252
  if (host && run) {
238
253
  for (const [path, value] of [
239
254
  [host.inputs.source, run.source],
255
+ [host.inputs.ref, run.ref],
240
256
  [host.revision, run.revision],
241
257
  ] as const) {
242
258
  const place = holderOf(out, path);
@@ -263,7 +279,7 @@ const cannotChange = (type: Pick<Resource, "vendor" | "type" | "writable"> | und
263
279
  const unknown = (what: string, list: readonly string[], known: (id: string) => boolean): ResourceProblem[] =>
264
280
  list.filter((id) => !known(id)).map((id) => ({ message: `${what}: ${id} is no declared resource` }));
265
281
 
266
- /** What the input names that is neither declared nor an orphaned binding. */
282
+ /** What the input names that is neither declared nor an orphaned binding (nor a declaration left out with a problem). */
267
283
  function inputProblems(
268
284
  input: ApplyInput,
269
285
  ids: ReadonlyMap<string, unknown>,
@@ -352,6 +368,32 @@ function fromPlan(
352
368
  return { problem: problemOf(r.id, `the vendor plans to ${p.action} it, which apply does not do here`) };
353
369
  }
354
370
 
371
+ /**
372
+ * An object found by its identity that an orphaned binding holds is that binding moved (an export
373
+ * renamed, a deploy key apply declares now): its `bind` step takes the binding over (`from`), and
374
+ * the orphan is none. The orphaned keys taken over.
375
+ */
376
+ function takeOver(
377
+ planned: PlanStep[],
378
+ orphans: ApplyDeclared["orphans"],
379
+ types: ReadonlyMap<string, Resource>,
380
+ ): Set<string> {
381
+ const taken = new Set<string>();
382
+ for (const step of planned) {
383
+ if (step.action !== "bind" || step.source !== "found") continue;
384
+ const type = `${step.vendor}.${step.type}`;
385
+ const held = orphans.find(
386
+ (o) =>
387
+ !taken.has(o.key) && o.id === step.binding && (o.type === type || types.get(type)?.formerly?.includes(o.type)),
388
+ );
389
+ if (held) {
390
+ step.from = held.key;
391
+ taken.add(held.key);
392
+ }
393
+ }
394
+ return taken;
395
+ }
396
+
355
397
  /** Each orphaned binding the input wants, after the declarations, and the answers that cannot be done. */
356
398
  function orphansOf(
357
399
  orphans: ApplyDeclared["orphans"],
@@ -444,8 +486,9 @@ const exportOf = (key: string) => key.slice(key.indexOf("#") + 1);
444
486
  function messageOf(step: PlanStep, before: Binding | undefined, after: Binding | undefined): string {
445
487
  const key = keyOf(step);
446
488
  if (!after) return `Apply: unbind ${key} (${before?.type} ${before?.id})`;
447
- const what = runOf(step) === "create" ? "create" : before ? "rebind" : "bind";
448
- return `Apply: ${what} ${key} (${after.type} ${after.id})`;
489
+ const from = step.action === "bind" ? step.from : undefined;
490
+ const what = runOf(step) === "create" ? "create" : before || from ? "rebind" : "bind";
491
+ return `Apply: ${what} ${key} (${after.type} ${after.id})${from ? `, from ${from}` : ""}`;
449
492
  }
450
493
 
451
494
  /**
@@ -518,10 +561,11 @@ export function applyWorkflow(
518
561
  ...(hooks.length ? (["sleep"] as const) : []),
519
562
  ],
520
563
  run: async (ctx, input) => {
521
- const { resources, problems: read, bindings: locked, orphans, rebinds } = await steps.declared();
564
+ const { resources, problems: read, bindings: locked, orphans, rebinds, leftOut } = await steps.declared();
522
565
  const wanted = (id: string) => input.only === undefined || input.only.includes(id);
523
566
  const declarations = new Map(resources.map((r) => [r.id, r]));
524
- const problems = [...read, ...inputProblems(input, declarations, new Set(orphans.map((o) => o.key)))];
567
+ const nameable = new Set([...orphans.map((o) => o.key), ...(leftOut ?? [])]);
568
+ const problems = [...read, ...inputProblems(input, declarations, nameable)];
525
569
  // Observed as drift does, every import first, by the binding given before the lockfile's.
526
570
  const found = new Map<string, Found>();
527
571
  for (const resource of resources) {
@@ -566,19 +610,19 @@ export function applyWorkflow(
566
610
  // A host wanted runs the commit its ref names now, resolved before planning: a merge is a change
567
611
  // to it. One bound by the input needs none.
568
612
  const revisions = new Map<string, Revision>();
569
- const hosts = new Map<string, { source: string; revision: string }>();
613
+ const hosts = new Map<string, { source: string; revision: string; ref: string }>();
570
614
  for (const { id, vendor, type, desired, refs } of resources.filter(
571
615
  (r) => wanted(r.id) && input.bind?.[r.id] === undefined,
572
616
  )) {
573
617
  const resource = types.get(`${vendor}.${type}`);
574
618
  const host = resource?.host;
575
619
  const source = host && sourceUrl(desired, refs[host.inputs.source], resource, found);
576
- const ref = host && valueAt(desired, host.inputs.ref);
577
- if (host && source !== undefined && typeof ref === "string" && ref) {
620
+ const ref = host && refOf(desired, host);
621
+ if (host && source !== undefined && ref !== undefined) {
578
622
  try {
579
- const { revision } = await ctx.sanoma!.source!.resolve({ url: source, ref });
580
- revisions.set(id, { revision });
581
- hosts.set(id, { source, revision });
623
+ const resolved = await ctx.sanoma!.source!.resolve({ url: source, ...(ref ? { ref } : {}) });
624
+ revisions.set(id, { revision: resolved.revision });
625
+ hosts.set(id, { source, ...resolved });
582
626
  } catch (err) {
583
627
  if (isRunControlError(err)) throw err;
584
628
  revisions.set(id, { error: errorMessage(err) });
@@ -633,10 +677,19 @@ export function applyWorkflow(
633
677
  if (left.length < unfilled.length) awaited.add(resource.id);
634
678
  const unresolved = revisions.get(resource.id);
635
679
  // A host whose ref could not be resolved has no commit to run (compare says so only for one it found).
636
- const next =
637
- unresolved && "error" in unresolved && result.status !== "error"
680
+ const now = found.get(resource.id)!;
681
+ const host = types.get(`${resource.vendor}.${resource.type}`)?.host;
682
+ const attached = host && "state" in now ? detaching(host, fields, now.state) : [];
683
+ const next = attached.length
684
+ ? {
685
+ problem: problemOf(
686
+ resource.id,
687
+ `${attached.join(", ")} ${attached.length > 1 ? "are" : "is"} attached and no site declares a domain, so a change would detach ${attached.length > 1 ? "them" : "it"}: put \`domain\` on each site, \`defineSite({ public: true, domain })\` in sites/<site>/site.ts`,
688
+ ),
689
+ }
690
+ : unresolved && "error" in unresolved && result.status !== "error"
638
691
  ? { problem: problemOf(resource.id, unresolved.error) }
639
- : decide(resource, result, found.get(resource.id)!, input, fields, types, usable);
692
+ : decide(resource, result, now, input, fields, types, usable);
640
693
  if (next.ask !== undefined) {
641
694
  try {
642
695
  const answered = await ctx[resource.vendor]![resource.type]!.plan(next.ask, {
@@ -653,7 +706,14 @@ export function applyWorkflow(
653
706
  if (next.step) add(next.step);
654
707
  if (next.problem) problems.push(next.problem);
655
708
  }
656
- const orphaned = orphansOf(orphans, input, wanted, usable, types);
709
+ const taken = takeOver(planned, orphans, types);
710
+ const orphaned = orphansOf(
711
+ orphans.filter((o) => !taken.has(o.key)),
712
+ input,
713
+ wanted,
714
+ usable,
715
+ types,
716
+ );
657
717
  const plan: ApplyPlan = {
658
718
  problems: [...problems, ...orphaned.problems],
659
719
  rebinds,
@@ -734,13 +794,16 @@ export function applyWorkflow(
734
794
  } else if (todo === "delete") {
735
795
  await ctx[step.vendor]![step.type]!.delete({ id: binding }, { connection: step.connection });
736
796
  delete bindings[key];
737
- } else if (todo === "bind")
797
+ } else if (todo === "bind") {
738
798
  bindings[key] = { type, id: binding, ...(step.connection && { connection: step.connection }) };
739
- else if (todo === "forget") delete bindings[key];
799
+ if (step.action === "bind" && step.from) delete bindings[step.from];
800
+ } else if (todo === "forget") delete bindings[key];
740
801
  if (state && declared) keep(declared, state);
741
802
  const after = bindings[key];
742
- if (after?.id !== before?.id) {
743
- const wrote = await steps.write(key, after, messageOf(step, before, after));
803
+ // A new id, or the same object under its type's new name (`formerly`): written.
804
+ if (after?.id !== before?.id || (after && before && after.type !== before.type)) {
805
+ const moved = step.action === "bind" ? step.from : undefined;
806
+ const wrote = await steps.write(key, after, messageOf(step, before, after), moved);
744
807
  written ||= wrote.written;
745
808
  revision = wrote.revision ?? revision;
746
809
  }
package/src/bundled.ts ADDED
@@ -0,0 +1,42 @@
1
+ import type { DeclaredPeople } from "./resources.ts";
2
+
3
+ // What the runtime reads from a company's root that a bundle carries where the root's files are
4
+ // not: the Cloudflare host's Worker, which serves the public sites without the working tree. Read
5
+ // once beside the files (`readRootSnapshot` in `config.ts`), bundled as data, and registered for
6
+ // the root it was read from (`useRootSnapshot`); `readPeople`, `readPolicyFiles`, the app's name
7
+ // and its versions then answer from it for that root, and never read a file there.
8
+
9
+ /** A root's people, policies, app name and versions, as read where its files are. */
10
+ export interface RootSnapshot {
11
+ /** The app's name, from the root's `package.json` (`sanoma` without one). */
12
+ appName: string;
13
+ /** `ResolvedConfig.version`: a hash of the workflows' source, which a bundle's code is not, so a run it queues is its worker's. */
14
+ version: string;
15
+ /** `ResolvedConfig.changeCode`, for the same reason. */
16
+ changeCode: string;
17
+ /** `readPolicyFiles`: each `.cedar` file under `policies/` (and an app's), by path from the root. */
18
+ policies: { file: string; text: string }[];
19
+ /** `readPeople`: the people, agents and groups the data files declare, with their problems. */
20
+ people: DeclaredPeople;
21
+ }
22
+
23
+ const snapshots = new Map<string, RootSnapshot>();
24
+
25
+ /** From now on, `root`'s people, policies, app name and versions are `snapshot`'s, not its files'. */
26
+ export function useRootSnapshot(root: string, snapshot: RootSnapshot): void {
27
+ snapshots.set(root, snapshot);
28
+ }
29
+
30
+ /** The snapshot registered for `root`, if any. */
31
+ export const rootSnapshot = (root: string | undefined): RootSnapshot | undefined =>
32
+ root === undefined ? undefined : snapshots.get(root);
33
+
34
+ /**
35
+ * Where a bundle says which company file it is evaluating (the Worker's build sets it at the top
36
+ * of each), for `callerFile` where V8's call sites name no file: a workflow's `file`, and a
37
+ * collection's app, are the file's that defined them.
38
+ */
39
+ export const BUNDLED_FILE: unique symbol = Symbol.for("sanoma.file") as never;
40
+
41
+ /** The company file a bundle is evaluating now, if it says. */
42
+ export const bundledFile = (): string | undefined => (globalThis as { [BUNDLED_FILE]?: string })[BUNDLED_FILE];
package/src/cedar.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  type PreviewRequest,
21
21
  VISITOR_DENIED,
22
22
  } from "./policy.ts";
23
+ import { rootSnapshot } from "./bundled.ts";
23
24
  import { admit, readPeople } from "./resources.ts";
24
25
  import { cedarValue, cedarWasm, contextTypeOf, messageOf, NAMESPACE, schemaOf, shapeOf } from "./schema.ts";
25
26
  import {
@@ -89,8 +90,13 @@ const ON_BEHALF_OF = "on-behalf-of";
89
90
 
90
91
  type Place = { line: number; column: number };
91
92
 
92
- /** `<root>/policies/**\/*.cedar`, then each app's `apps/<app>/policies/**\/*.cedar`, sorted by path in each, each relative to the root with `/` between its parts. */
93
+ /**
94
+ * `<root>/policies/**\/*.cedar`, then each app's `apps/<app>/policies/**\/*.cedar`, sorted by path in
95
+ * each, each relative to the root with `/` between its parts; a bundled snapshot's (`useRootSnapshot`).
96
+ */
93
97
  export function readPolicyFiles(root: string): { file: string; text: string }[] {
98
+ const bundled = rootSnapshot(root);
99
+ if (bundled) return bundled.policies;
94
100
  return kindDirs(root, POLICIES_DIR)
95
101
  .flatMap((dir) => filesUnder(dir, (name) => name.endsWith(".cedar")))
96
102
  .map((path) => ({
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";
@@ -405,7 +406,14 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
405
406
  const connectors = connectorsOf(config);
406
407
  // The types the data files may declare: the vendors' and the people's, whose resources are left out.
407
408
  const declarable = resourceTypesOf(connectors);
408
- const read = () => readDeclared(root, connectors, declarable, types, connections);
409
+ const read = (options: ReadOptions = {}) =>
410
+ readDeclared(root, connectors, declarable, types, connections, {
411
+ ...options,
412
+ drivers: driversByVendor,
413
+ builtInStore: !config.secrets?.store,
414
+ signingKeyBound: config.secrets?.bindings?.SANOMA_SIGNING_KEY !== undefined,
415
+ ...(isOp(config.login?.sender) && { sender: config.login.sender.vendor }),
416
+ });
409
417
  if (types.size) {
410
418
  names.set(
411
419
  DRIFT_WORKFLOW,
@@ -414,8 +422,13 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
414
422
  names.set(APPLY_WORKFLOW, applyWorkflow(types, ops, drivers, applySteps(appName, root, read)));
415
423
  }
416
424
  // Built-in, in every config: the life of a change, which deploys to the declared hosts after its merge.
417
- const hosts = async () =>
418
- (await read()).resources.filter((r) => types.get(`${r.vendor}.${r.type}`)?.host).map((r) => r.id);
425
+ // The declared hosts' ids, as the files say them: nothing derived, nothing minted.
426
+ const hosts = async () => {
427
+ const { loadDataFiles } = await import("./resources.ts");
428
+ return loadDataFiles(root, connectors, declarable)
429
+ .resources.filter((r) => types.get(`${r.vendor}.${r.type}`)?.host)
430
+ .map((r) => r.id);
431
+ };
419
432
  names.set(CHANGE_WORKFLOW, changeWorkflow(changeOps, changeSteps({ remotes, hosts })));
420
433
  const declaredObjects = () => readDeclaredObjects(root, connectors, declarable);
421
434
  // Built-in: a direct call the policy holds for an approval, made a run, when any operation may be called directly.
@@ -425,6 +438,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
425
438
  const login = config.login && checkLogin(config.login, ops, drivers, appName);
426
439
  if (login) names.set(LOGIN_CODE_WORKFLOW, loginCodeWorkflow(login, appName));
427
440
  const { policy, policies, problems: policyProblems } = policyAt(root, { ops, workflows: names, collections });
441
+ const bundled = rootSnapshot(root);
428
442
  return {
429
443
  secrets: config.secrets ?? {},
430
444
  connections,
@@ -432,9 +446,10 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
432
446
  identityProvisioning,
433
447
  appName,
434
448
  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)!),
449
+ // The built-in's code and operations are part of the app's version, like the config's own. A
450
+ // bundle's code is not the source's: it carries the hashes of the root it was read from.
451
+ version: bundled?.version ?? computeVersion({ appName, workflows: [...names.values()] }),
452
+ changeCode: bundled?.changeCode ?? changeCodeOf(names.get(CHANGE_WORKFLOW)!),
438
453
  queueName: `sanoma:${appName}`,
439
454
  ...(login && { login }),
440
455
  ops,
@@ -593,12 +608,26 @@ function makersOf<K extends keyof ConnectorSetup>(
593
608
  return makers;
594
609
  }
595
610
 
611
+ /**
612
+ * What the runtime reads from `config`'s root that a bundle carries where the files are not
613
+ * (`useRootSnapshot`): the app's name and versions as `resolved` has them, the policy files and
614
+ * the people the data files declare.
615
+ */
616
+ export function readRootSnapshot(config: SanomaConfig, resolved: ResolvedConfig = resolveConfig(config)): RootSnapshot {
617
+ const root = rootOf(config);
618
+ if (root === undefined) throw new Error("A config with no root has no files to carry");
619
+ const { appName, version, changeCode } = resolved;
620
+ return { appName, version, changeCode, policies: readPolicyFiles(root), people: readPeople(root) };
621
+ }
622
+
596
623
  /**
597
624
  * The `name` of `<root>/package.json`, kept whole (`@acme/ops`), when that file exists and names
598
625
  * one; else "sanoma", as for a config with no `root`. Never a `package.json` above the root: a
599
626
  * parent project's name must not become the app's. A file that is not JSON is refused, naming it.
600
627
  */
601
628
  function appNameAt(root: string | undefined): string {
629
+ const bundled = rootSnapshot(root);
630
+ if (bundled) return bundled.appName;
602
631
  const file = root === undefined ? undefined : join(root, "package.json");
603
632
  if (file === undefined || !existsSync(file)) return "sanoma";
604
633
  let manifest: unknown;
@@ -689,6 +718,11 @@ async function readDeclaredObjects(
689
718
  return { keys: new Map([...named, ...bound]), problems };
690
719
  }
691
720
 
721
+ /** How a run reads the data files: `mint`, apply's, mints the keys a deriving host is given (`hostFacts`). */
722
+ export interface ReadOptions {
723
+ mint?: boolean;
724
+ }
725
+
692
726
  /**
693
727
  * The data files under `root` and the lockfile beside them, in one read, as a drift or apply run
694
728
  * records them: each resource with its binding, without its write-only values (a secret marker
@@ -701,12 +735,48 @@ async function readDeclared(
701
735
  declarable: ReadonlyMap<string, Resource>,
702
736
  types: ReadonlyMap<string, Resource>,
703
737
  connections: ConnectionRegistry,
738
+ options: ReadOptions & {
739
+ drivers: ReadonlyMap<string, Driver>;
740
+ builtInStore: boolean;
741
+ signingKeyBound: boolean;
742
+ sender?: string;
743
+ },
704
744
  ): Promise<Omit<ApplyDeclared, "rebinds">> {
705
745
  // Loaded when a run reads the files, not with the worker.
706
746
  const { loadDataFiles } = await import("./resources.ts");
707
747
  const { resources, bindings, orphans, problems } = loadDataFiles(root, connectors, declarable);
708
- // Read inside the data-file durable step: every operation of the plan uses one pinned account.
709
748
  const run = currentRun.getStore();
749
+ // A host that derives is filled with what it is not told (`deriveHosts`), before it is checked
750
+ // or pinned: the deploy key it clones with among them. Minted only by a live apply.
751
+ const keep = new Set<string>();
752
+ const leftOut = new Set<string>();
753
+ if (root !== undefined) {
754
+ const { deriveHosts, hostFacts } = await import("./host-derive.ts");
755
+ const facts = await hostFacts({
756
+ root,
757
+ resources,
758
+ types,
759
+ drivers: options.drivers,
760
+ ...(run && { secrets: run.state.secrets }),
761
+ builtInStore: options.builtInStore,
762
+ signingKeyBound: options.signingKeyBound,
763
+ ...(options.sender !== undefined && { sender: options.sender }),
764
+ mint: !!options.mint && !!run && !run.sandbox,
765
+ });
766
+ if (facts) problems.push(...deriveHosts(resources, types, bindings, facts, { keep, leftOut }));
767
+ }
768
+ // A host apply cannot give all it needs is left out, with what names it (its wakeup), and what
769
+ // names that in turn: its problem says why, and nothing of it changes until it is fixed.
770
+ for (let grew = leftOut.size > 0; grew;) {
771
+ const before = leftOut.size;
772
+ for (const o of resources) if (Object.values(o.refs).some((id) => leftOut.has(id))) leftOut.add(o.id);
773
+ grew = leftOut.size > before;
774
+ }
775
+ for (const r of resources.filter((o) => leftOut.has(o.id))) resources.splice(resources.indexOf(r), 1);
776
+ // A host's deploy key apply declares, or cannot tell yet, is no orphan.
777
+ const declaredIds = new Set(resources.map((r) => r.id));
778
+ const kept = orphans.filter((o) => !declaredIds.has(o.key) && !keep.has(o.key));
779
+ // Read inside the data-file durable step: every operation of the plan uses one pinned account.
710
780
  if (run && !run.sandbox) {
711
781
  for (const resource of resources.filter((r) => types.has(`${r.vendor}.${r.type}`))) {
712
782
  try {
@@ -743,7 +813,8 @@ async function readDeclared(
743
813
  ...(connection && { connection }),
744
814
  })),
745
815
  bindings,
746
- orphans,
816
+ orphans: kept,
817
+ ...(leftOut.size > 0 && { leftOut: [...leftOut] }),
747
818
  problems,
748
819
  };
749
820
  }
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. */
@@ -23,6 +23,11 @@ export interface SiteDefinition {
23
23
  public?: boolean;
24
24
  login?: string;
25
25
  locales?: readonly [string, ...string[]];
26
+ /**
27
+ * A public site's domain (`www.acme.example`): the production host attaches it and serves the
28
+ * site at its root there; in a preview and locally the site stays at `/sites/<name>`.
29
+ */
30
+ domain?: string;
26
31
  }
27
32
 
28
33
  /** Declares a site in its folder's `site.ts`, its default export: `defineSite({ title: "Help desk" })`. */