@sanoma-ai/workflows 0.11.3 → 0.11.5

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 (61) hide show
  1. package/KMS-SECRETS.md +3 -1
  2. package/README.md +33 -31
  3. package/dist/apply-steps.d.ts +4 -2
  4. package/dist/apply-steps.js +5 -3
  5. package/dist/apply-steps.js.map +1 -1
  6. package/dist/apply.d.ts +6 -1
  7. package/dist/apply.js +55 -20
  8. package/dist/apply.js.map +1 -1
  9. package/dist/config.d.ts +4 -0
  10. package/dist/config.js +52 -5
  11. package/dist/config.js.map +1 -1
  12. package/dist/constructors.d.ts +1 -1
  13. package/dist/definitions.d.ts +5 -0
  14. package/dist/definitions.js.map +1 -1
  15. package/dist/describe.d.ts +1 -1
  16. package/dist/describe.js +16 -11
  17. package/dist/describe.js.map +1 -1
  18. package/dist/drift.d.ts +7 -0
  19. package/dist/drift.js +15 -3
  20. package/dist/drift.js.map +1 -1
  21. package/dist/host-derive.d.ts +145 -0
  22. package/dist/host-derive.js +403 -0
  23. package/dist/host-derive.js.map +1 -0
  24. package/dist/host.d.ts +70 -5
  25. package/dist/host.js +56 -1
  26. package/dist/host.js.map +1 -1
  27. package/dist/index.d.ts +5 -4
  28. package/dist/index.js +5 -4
  29. package/dist/index.js.map +1 -1
  30. package/dist/kms-secrets.js +24 -4
  31. package/dist/kms-secrets.js.map +1 -1
  32. package/dist/resource.d.ts +10 -2
  33. package/dist/resource.js +19 -4
  34. package/dist/resource.js.map +1 -1
  35. package/dist/resources.js +1 -1
  36. package/dist/resources.js.map +1 -1
  37. package/dist/sites.d.ts +6 -0
  38. package/dist/sites.js +28 -4
  39. package/dist/sites.js.map +1 -1
  40. package/dist/source.d.ts +12 -5
  41. package/dist/source.js +32 -13
  42. package/dist/source.js.map +1 -1
  43. package/dist/wakeup-type.d.ts +1 -1
  44. package/dist/wakeup-type.js +1 -1
  45. package/dist/wakeup-type.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/apply-steps.ts +5 -4
  48. package/src/apply.ts +87 -24
  49. package/src/config.ts +59 -5
  50. package/src/definitions.ts +5 -0
  51. package/src/describe.ts +23 -10
  52. package/src/drift.ts +16 -3
  53. package/src/host-derive.ts +492 -0
  54. package/src/host.ts +101 -5
  55. package/src/index.ts +20 -2
  56. package/src/kms-secrets.ts +34 -5
  57. package/src/resource.ts +45 -7
  58. package/src/resources.ts +1 -1
  59. package/src/sites.ts +33 -3
  60. package/src/source.ts +35 -12
  61. package/src/wakeup-type.ts +1 -1
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/config.ts CHANGED
@@ -406,7 +406,14 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
406
406
  const connectors = connectorsOf(config);
407
407
  // The types the data files may declare: the vendors' and the people's, whose resources are left out.
408
408
  const declarable = resourceTypesOf(connectors);
409
- 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
+ });
410
417
  if (types.size) {
411
418
  names.set(
412
419
  DRIFT_WORKFLOW,
@@ -415,8 +422,13 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
415
422
  names.set(APPLY_WORKFLOW, applyWorkflow(types, ops, drivers, applySteps(appName, root, read)));
416
423
  }
417
424
  // Built-in, in every config: the life of a change, which deploys to the declared hosts after its merge.
418
- const hosts = async () =>
419
- (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
+ };
420
432
  names.set(CHANGE_WORKFLOW, changeWorkflow(changeOps, changeSteps({ remotes, hosts })));
421
433
  const declaredObjects = () => readDeclaredObjects(root, connectors, declarable);
422
434
  // Built-in: a direct call the policy holds for an approval, made a run, when any operation may be called directly.
@@ -706,6 +718,11 @@ async function readDeclaredObjects(
706
718
  return { keys: new Map([...named, ...bound]), problems };
707
719
  }
708
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
+
709
726
  /**
710
727
  * The data files under `root` and the lockfile beside them, in one read, as a drift or apply run
711
728
  * records them: each resource with its binding, without its write-only values (a secret marker
@@ -718,12 +735,48 @@ async function readDeclared(
718
735
  declarable: ReadonlyMap<string, Resource>,
719
736
  types: ReadonlyMap<string, Resource>,
720
737
  connections: ConnectionRegistry,
738
+ options: ReadOptions & {
739
+ drivers: ReadonlyMap<string, Driver>;
740
+ builtInStore: boolean;
741
+ signingKeyBound: boolean;
742
+ sender?: string;
743
+ },
721
744
  ): Promise<Omit<ApplyDeclared, "rebinds">> {
722
745
  // Loaded when a run reads the files, not with the worker.
723
746
  const { loadDataFiles } = await import("./resources.ts");
724
747
  const { resources, bindings, orphans, problems } = loadDataFiles(root, connectors, declarable);
725
- // Read inside the data-file durable step: every operation of the plan uses one pinned account.
726
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.
727
780
  if (run && !run.sandbox) {
728
781
  for (const resource of resources.filter((r) => types.has(`${r.vendor}.${r.type}`))) {
729
782
  try {
@@ -760,7 +813,8 @@ async function readDeclared(
760
813
  ...(connection && { connection }),
761
814
  })),
762
815
  bindings,
763
- orphans,
816
+ orphans: kept,
817
+ ...(leftOut.size > 0 && { leftOut: [...leftOut] }),
764
818
  problems,
765
819
  };
766
820
  }
@@ -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" })`. */
package/src/describe.ts CHANGED
@@ -41,7 +41,8 @@ import {
41
41
  problemLine,
42
42
  siteAtHostname,
43
43
  } from "./shared.ts";
44
- import { readSites, type SiteEntry } from "./sites.ts";
44
+ import { readSites, type SiteEntry, siteDomains } from "./sites.ts";
45
+ import { sourceKeyIds } from "./host.ts";
45
46
 
46
47
  // `@sanoma-ai/workflows/describe`: what a UI renders from, apart from the main entry.
47
48
  // What `describeConfig` answers, as zod schemas and the types they infer (`./description`).
@@ -85,7 +86,7 @@ export {
85
86
  // The apps under `apps/`, with what their `app.ts` declares.
86
87
  export { crossAppImports, readApps } from "./apps.ts";
87
88
  // What the sites call, read from their source: a workflow's Used by.
88
- export { readSites, siteContentPages, siteIcons, siteLocales, sitesUnder } from "./sites.ts";
89
+ export { readSites, siteContentPages, siteDomains, siteIcons, siteLocales, sitesUnder } from "./sites.ts";
89
90
  // The generated SDK, `.sanoma/api.ts`: a site's `api` typed from this description.
90
91
  export { API_FILE, generateApi, writeApi, writeGenerated } from "./sdk.ts";
91
92
  // The Cedar schema, `.sanoma/schema.cedarschema`: what a company's policies are validated and decided against.
@@ -193,6 +194,7 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
193
194
  .toSorted((a, b) => a.id.localeCompare(b.id));
194
195
 
195
196
  const dataFiles = loadDataFiles(resolved.root, connectorsOf(config), resources);
197
+ const sourceKeys = sourceKeyIds(dataFiles.resources, resources);
196
198
  const declared = new Map(dataFiles.collections.map((d) => [d.name, d]));
197
199
  const collections: CollectionEntry[] = [...resolved.collections.values()]
198
200
  .map((c) => ({
@@ -289,12 +291,18 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
289
291
  collections,
290
292
  // An app's people are refused (`readPeople`), so the grid and the People page never show them.
291
293
  resources: dataFiles.resources.filter((r) => !inAppPeople(r)),
292
- orphans: dataFiles.orphans,
294
+ // A host's deploy key, which apply declares, is no orphan.
295
+ orphans: dataFiles.orphans.filter((o) => !sourceKeys.has(o.key)),
293
296
  problems: [
294
297
  ...dataFiles.problems,
295
298
  ...appProblems,
296
299
  ...appClashes(apps, collections, sites),
297
- ...domainProblems(dataFiles.resources, resources, sites),
300
+ ...domainProblems(
301
+ dataFiles.resources,
302
+ resources,
303
+ sites,
304
+ resolved.root === undefined ? {} : siteDomains(resolved.root),
305
+ ),
298
306
  ...loginProblems(resolved, dataFiles.resources),
299
307
  ],
300
308
  apps,
@@ -361,15 +369,17 @@ function appClashes(
361
369
  }
362
370
 
363
371
  /**
364
- * Each site a host's `domains` (its type's `host.inputs.domains`) names that is not a public
365
- * site, each domain it gives more than one site, and a domain that is the host's `hostname` too,
366
- * or another host's hostname or domain, at the host's declaration: the app serves only a public
367
- * site at a domain, one site at each, and itself at another, and one host holds each.
372
+ * Each site a host's `domains` (its type's `host.inputs.domains`; for a host that derives, the
373
+ * public sites' own, `declaredDomains`) names that is not a public site, each domain it gives more
374
+ * than one site, and a domain that is the host's `hostname` too, or another host's hostname or
375
+ * domain, at the host's declaration: the app serves only a public site at a domain, one site at
376
+ * each, and itself at another, and one host holds each.
368
377
  */
369
378
  function domainProblems(
370
379
  declared: readonly DeclaredResource[],
371
380
  types: ReadonlyMap<string, Resource>,
372
381
  sites: readonly SiteEntry[],
382
+ declaredDomains: Readonly<Record<string, string>>,
373
383
  ): ResourceProblem[] {
374
384
  const isPublic = new Set(sites.filter((site) => site.public).map((site) => site.name));
375
385
  const problems: ResourceProblem[] = [];
@@ -379,9 +389,12 @@ function domainProblems(
379
389
  const hold = (name: string, resource: DeclaredResource) =>
380
390
  held.set(name.toLowerCase(), (held.get(name.toLowerCase()) ?? new Set()).add(resource));
381
391
  for (const resource of declared) {
382
- const inputs = types.get(`${resource.vendor}.${resource.type}`)?.host?.inputs;
392
+ const host = types.get(`${resource.vendor}.${resource.type}`)?.host;
393
+ const inputs = host?.inputs;
383
394
  const path = inputs?.domains;
384
- const domains = (path === undefined ? {} : (valueAt(resource.desired, path) ?? {})) as Record<string, unknown>;
395
+ const domains = (
396
+ path === undefined ? {} : host?.derives ? declaredDomains : (valueAt(resource.desired, path) ?? {})
397
+ ) as Record<string, unknown>;
385
398
  const hostname = inputs?.hostname === undefined ? undefined : valueAt(resource.desired, inputs.hostname);
386
399
  const messages = Object.keys(domains)
387
400
  .filter((name) => !isPublic.has(name))
package/src/drift.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { defineWorkflow, type WorkflowDefinition } from "./define.ts";
3
3
  import { isRunControlError } from "./errors.ts";
4
+ import type { HostSpec } from "./host.ts";
4
5
  import type { Op } from "./op.ts";
5
6
  import { type DriftField, diffDeclared, fieldPathOf, type Resource, valueAt, withFrom } from "./resource.ts";
6
7
  import type { DeclaredResource, ResourceProblem } from "./resources.ts";
@@ -240,10 +241,11 @@ export function driftWorkflow(
240
241
  const resource = types.get(`${vendor}.${type}`);
241
242
  const host = resource?.host;
242
243
  const source = host && sourceUrl(desired, refs[host.inputs.source], resource, found);
243
- const ref = host && valueAt(desired, host.inputs.ref);
244
- if (host && "state" in found.get(id)! && source !== undefined && typeof ref === "string" && ref) {
244
+ const ref = host && refOf(desired, host);
245
+ if (host && "state" in found.get(id)! && source !== undefined && ref !== undefined) {
245
246
  try {
246
- revisions.set(id, await ctx.sanoma!.source!.resolve({ url: source, ref }));
247
+ const { revision } = await ctx.sanoma!.source!.resolve({ url: source, ...(ref ? { ref } : {}) });
248
+ revisions.set(id, { revision });
247
249
  } catch (err) {
248
250
  if (isRunControlError(err)) throw err;
249
251
  revisions.set(id, { error: errorMessage(err) });
@@ -256,6 +258,17 @@ export function driftWorkflow(
256
258
  });
257
259
  }
258
260
 
261
+ /**
262
+ * The ref a host runs: the one its data file gives, or `""` for a host that derives and names
263
+ * none (its repository's default branch, which `sanoma.source.resolve` tells); undefined when it
264
+ * has none to resolve.
265
+ */
266
+ export function refOf(desired: State, host: HostSpec): string | undefined {
267
+ const ref = valueAt(desired, host.inputs.ref);
268
+ if (typeof ref === "string" && ref) return ref;
269
+ return host.derives && ref == null ? "" : undefined;
270
+ }
271
+
259
272
  /**
260
273
  * A host's source: the clone URL its data file gives, or, when it names a declared repository
261
274
  * (`target`), the first of the reference's `by` fields that holds one in that repository's state,