@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.
- package/README.md +32 -30
- package/dist/apply-steps.d.ts +4 -2
- package/dist/apply-steps.js +5 -3
- package/dist/apply-steps.js.map +1 -1
- package/dist/apply.d.ts +6 -1
- package/dist/apply.js +55 -20
- package/dist/apply.js.map +1 -1
- package/dist/bundled.d.ts +29 -0
- package/dist/bundled.js +16 -0
- package/dist/bundled.js.map +1 -0
- package/dist/cedar.d.ts +4 -1
- package/dist/cedar.js +8 -1
- package/dist/cedar.js.map +1 -1
- package/dist/config.d.ts +12 -1
- package/dist/config.js +75 -9
- package/dist/config.js.map +1 -1
- package/dist/constructors.d.ts +1 -1
- package/dist/db.d.ts +7 -0
- package/dist/db.js +30 -1
- package/dist/db.js.map +1 -1
- package/dist/define.d.ts +2 -1
- package/dist/define.js +4 -2
- package/dist/define.js.map +1 -1
- package/dist/definitions.d.ts +5 -0
- package/dist/definitions.js.map +1 -1
- package/dist/describe.d.ts +1 -1
- package/dist/describe.js +16 -11
- package/dist/describe.js.map +1 -1
- package/dist/drift.d.ts +7 -0
- package/dist/drift.js +15 -3
- package/dist/drift.js.map +1 -1
- package/dist/host-derive.d.ts +145 -0
- package/dist/host-derive.js +403 -0
- package/dist/host-derive.js.map +1 -0
- package/dist/host.d.ts +70 -5
- package/dist/host.js +56 -1
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +9 -5
- package/dist/index.js +11 -5
- package/dist/index.js.map +1 -1
- package/dist/load.js +3 -2
- package/dist/load.js.map +1 -1
- package/dist/resource.d.ts +8 -1
- package/dist/resource.js +11 -0
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +1 -1
- package/dist/resources.js +6 -2
- package/dist/resources.js.map +1 -1
- package/dist/schema.d.ts +12 -2
- package/dist/schema.js +12 -6
- package/dist/schema.js.map +1 -1
- package/dist/shared.d.ts +25 -0
- package/dist/shared.js +7 -0
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +6 -0
- package/dist/sites.js +28 -4
- package/dist/sites.js.map +1 -1
- package/dist/source.d.ts +12 -5
- package/dist/source.js +32 -13
- package/dist/source.js.map +1 -1
- package/dist/wakeup-type.d.ts +1 -1
- package/dist/wakeup-type.js +1 -1
- package/dist/wakeup-type.js.map +1 -1
- package/package.json +1 -1
- package/src/apply-steps.ts +5 -4
- package/src/apply.ts +87 -24
- package/src/bundled.ts +42 -0
- package/src/cedar.ts +7 -1
- package/src/config.ts +81 -10
- package/src/db.ts +35 -1
- package/src/define.ts +4 -2
- package/src/definitions.ts +5 -0
- package/src/describe.ts +23 -10
- package/src/drift.ts +16 -3
- package/src/host-derive.ts +492 -0
- package/src/host.ts +101 -5
- package/src/index.ts +26 -2
- package/src/load.ts +5 -2
- package/src/resource.ts +33 -2
- package/src/resources.ts +5 -2
- package/src/schema.ts +17 -4
- package/src/shared.ts +20 -0
- package/src/sites.ts +33 -3
- package/src/source.ts +35 -12
- package/src/wakeup-type.ts +1 -1
package/src/apply-steps.ts
CHANGED
|
@@ -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
|
|
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
|
-
| {
|
|
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
|
-
|
|
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
|
|
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
|
|
448
|
-
|
|
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
|
|
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 &&
|
|
577
|
-
if (host && source !== undefined &&
|
|
620
|
+
const ref = host && refOf(desired, host);
|
|
621
|
+
if (host && source !== undefined && ref !== undefined) {
|
|
578
622
|
try {
|
|
579
|
-
const
|
|
580
|
-
revisions.set(id, { revision });
|
|
581
|
-
hosts.set(id, { source,
|
|
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
|
|
637
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
743
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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 = (
|
|
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
|
-
|
|
418
|
-
|
|
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
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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;
|
|
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/definitions.ts
CHANGED
|
@@ -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" })`. */
|