@sanoma-ai/workflows 0.1.0 → 0.2.0
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 +72 -21
- package/dist/apply-steps.d.ts +6 -0
- package/dist/apply-steps.js +93 -0
- package/dist/apply-steps.js.map +1 -0
- package/dist/apply.d.ts +197 -0
- package/dist/apply.js +470 -0
- package/dist/apply.js.map +1 -0
- package/dist/call.d.ts +4 -0
- package/dist/call.js +19 -4
- package/dist/call.js.map +1 -1
- package/dist/client.d.ts +38 -2
- package/dist/client.js +67 -10
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +3 -3
- package/dist/config.js +33 -16
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +25 -0
- package/dist/credentials.js +73 -5
- package/dist/credentials.js.map +1 -1
- package/dist/datafile.d.ts +11 -2
- package/dist/datafile.js +56 -2
- package/dist/datafile.js.map +1 -1
- package/dist/describe.d.ts +11 -5
- package/dist/describe.js +13 -5
- package/dist/describe.js.map +1 -1
- package/dist/drift.d.ts +64 -12
- package/dist/drift.js +118 -41
- package/dist/drift.js.map +1 -1
- package/dist/expectations.d.ts +2 -2
- package/dist/expectations.js +28 -3
- package/dist/expectations.js.map +1 -1
- package/dist/host.d.ts +56 -0
- package/dist/host.js +45 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/lockfile.d.ts +36 -0
- package/dist/lockfile.js +69 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/op.d.ts +3 -0
- package/dist/op.js.map +1 -1
- package/dist/resource.d.ts +188 -22
- package/dist/resource.js +173 -28
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +18 -3
- package/dist/resources.js +53 -6
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +14 -0
- package/dist/run.js +6 -1
- package/dist/run.js.map +1 -1
- package/dist/sandbox.js +5 -1
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +12 -3
- package/dist/scenario.js +33 -8
- package/dist/scenario.js.map +1 -1
- package/dist/secret.d.ts +42 -0
- package/dist/secret.js +58 -0
- package/dist/secret.js.map +1 -0
- package/dist/shared.d.ts +8 -0
- package/dist/shared.js +7 -0
- package/dist/shared.js.map +1 -1
- package/dist/source.d.ts +58 -0
- package/dist/source.js +54 -0
- package/dist/source.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.d.ts +3 -0
- package/dist/worker.js +13 -4
- package/dist/worker.js.map +1 -1
- package/dist/writer.d.ts +35 -0
- package/dist/writer.js +201 -0
- package/dist/writer.js.map +1 -0
- package/package.json +6 -1
- package/src/apply-steps.ts +110 -0
- package/src/apply.ts +626 -0
- package/src/call.ts +19 -4
- package/src/client.ts +83 -13
- package/src/config.ts +48 -25
- package/src/credentials.ts +90 -5
- package/src/datafile.ts +76 -4
- package/src/describe.ts +36 -16
- package/src/drift.ts +155 -45
- package/src/expectations.ts +30 -4
- package/src/host.ts +96 -0
- package/src/index.ts +24 -1
- package/src/lockfile.ts +95 -0
- package/src/op.ts +3 -0
- package/src/resource.ts +270 -47
- package/src/resources.ts +63 -8
- package/src/run.ts +15 -0
- package/src/sandbox.ts +5 -1
- package/src/scenario.ts +50 -13
- package/src/secret.ts +92 -0
- package/src/shared.ts +10 -0
- package/src/source.ts +82 -0
- package/src/version.ts +1 -1
- package/src/worker.ts +16 -5
- package/src/writer.ts +225 -0
package/src/lockfile.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import type { ResourceProblem } from "./resources.ts";
|
|
5
|
+
|
|
6
|
+
// The lockfile: which vendor object each declaration means, kept apart from the data files (which
|
|
7
|
+
// hold intent only), as `package-lock.json` is from `package.json`. Only apply writes it.
|
|
8
|
+
|
|
9
|
+
/** Where the lockfile is, relative to the config's root. */
|
|
10
|
+
export const LOCKFILE = "resources/sanoma.lock.json";
|
|
11
|
+
|
|
12
|
+
/** Which vendor object a declaration means: its `<vendor>.<type>` and the vendor's stable id for it. */
|
|
13
|
+
export interface Binding {
|
|
14
|
+
type: string;
|
|
15
|
+
id: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The lockfile's content: by declaration key, `<file>#<export>` (the file relative to the config's root, with `/`). */
|
|
19
|
+
export type Bindings = Record<string, Binding>;
|
|
20
|
+
|
|
21
|
+
const lockfile = z.record(
|
|
22
|
+
z.string().regex(/^resources\/[^#]+#[^#]+$/),
|
|
23
|
+
z.strictObject({ type: z.string().regex(/^[^.\s]+\.[^.\s]+$/), id: z.string().min(1) }),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The lockfile under `root`: no bindings when there is none, and no bindings with a problem when
|
|
28
|
+
* it is not JSON or not a map of `<file>#<export>` to `{ type, id }`.
|
|
29
|
+
*/
|
|
30
|
+
export function readLockfile(root: string): { bindings: Bindings; problems: ResourceProblem[] } {
|
|
31
|
+
const path = join(root, LOCKFILE);
|
|
32
|
+
if (!existsSync(path)) return { bindings: {}, problems: [] };
|
|
33
|
+
let json: unknown;
|
|
34
|
+
try {
|
|
35
|
+
json = JSON.parse(readFileSync(path, "utf8"));
|
|
36
|
+
} catch (e) {
|
|
37
|
+
return { bindings: {}, problems: [{ file: LOCKFILE, message: `not JSON: ${(e as Error).message}` }] };
|
|
38
|
+
}
|
|
39
|
+
const parsed = lockfile.safeParse(json);
|
|
40
|
+
if (!parsed.success) {
|
|
41
|
+
// One line: zod's own spans lines.
|
|
42
|
+
return {
|
|
43
|
+
bindings: {},
|
|
44
|
+
problems: [{ file: LOCKFILE, message: z.prettifyError(parsed.error).replaceAll(/\s*\n\s*/g, " ") }],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return { bindings: parsed.data, problems: [] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The lockfile's text for `bindings`: keys sorted, two-space JSON, a trailing newline, so a diff shows only what changed. */
|
|
51
|
+
export function serializeLockfile(bindings: Bindings): string {
|
|
52
|
+
const sorted = Object.entries(bindings)
|
|
53
|
+
.toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
54
|
+
.map(([key, { type, id }]) => [key, { type, id }]);
|
|
55
|
+
return `${JSON.stringify(Object.fromEntries(sorted), null, 2)}\n`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Writes the lockfile under `root` whole or not at all: to a file beside it, then renamed over it. Apply only. */
|
|
59
|
+
export function writeLockfile(root: string, bindings: Bindings): void {
|
|
60
|
+
const path = join(root, LOCKFILE);
|
|
61
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
62
|
+
try {
|
|
63
|
+
writeFileSync(temporary, serializeLockfile(bindings));
|
|
64
|
+
renameSync(temporary, path);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
rmSync(temporary, { force: true });
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One change to the lockfile: a declaration bound for the first time (`bind`, `to`), pointed at
|
|
73
|
+
* another object (`rebind`, `from` and `to`), or no longer bound (`unbind`, `from`). `type` is
|
|
74
|
+
* the binding's `<vendor>.<type>`, the new one's for a rebind.
|
|
75
|
+
*/
|
|
76
|
+
export interface Rebind {
|
|
77
|
+
key: string;
|
|
78
|
+
type: string;
|
|
79
|
+
kind: "bind" | "rebind" | "unbind";
|
|
80
|
+
from?: string;
|
|
81
|
+
to?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** What changed from `before` to `after`, by key, sorted. */
|
|
85
|
+
export function diffLockfiles(before: Bindings, after: Bindings): Rebind[] {
|
|
86
|
+
const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].toSorted();
|
|
87
|
+
return keys.flatMap((key): Rebind[] => {
|
|
88
|
+
const was = before[key];
|
|
89
|
+
const now = after[key];
|
|
90
|
+
if (!now) return was ? [{ key, type: was.type, kind: "unbind", from: was.id }] : [];
|
|
91
|
+
if (!was) return [{ key, type: now.type, kind: "bind", to: now.id }];
|
|
92
|
+
if (was.id === now.id && was.type === now.type) return [];
|
|
93
|
+
return [{ key, type: now.type, kind: "rebind", from: was.id, to: now.id }];
|
|
94
|
+
});
|
|
95
|
+
}
|
package/src/op.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
2
|
import type { Fake } from "./fake.ts";
|
|
3
|
+
import type { Secrets } from "./secret.ts";
|
|
3
4
|
import type { Steps } from "./steps.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -235,6 +236,8 @@ export interface CallContext {
|
|
|
235
236
|
opId: string;
|
|
236
237
|
/** 1 on the first try; retries count up (idempotent operations only). */
|
|
237
238
|
attempt: number;
|
|
239
|
+
/** The secrets store, for the values `secret()` markers name: `resolveSecrets(input, call.secrets)`. In memory for a fake. */
|
|
240
|
+
secrets: Secrets;
|
|
238
241
|
}
|
|
239
242
|
|
|
240
243
|
export type DriverFn<I = any, O = unknown> = (input: I, call: CallContext) => Promise<O>;
|
package/src/resource.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { checkHost, type HostSpec } from "./host.ts";
|
|
2
3
|
import { type Connector, type OpSpec, RESOURCE, VENDOR } from "./op.ts";
|
|
4
|
+
import { isSanomaSecret, isSecret, type Secret } from "./secret.ts";
|
|
3
5
|
|
|
4
6
|
/** Which fields of a resource are flagged, by dotted path (`pages.html_url`; a list's items have no index). */
|
|
5
7
|
export interface ResourceFields {
|
|
@@ -47,20 +49,60 @@ export interface ResourceSpec<S extends z.ZodObject = z.ZodObject> {
|
|
|
47
49
|
title: string;
|
|
48
50
|
/**
|
|
49
51
|
* How `find` makes the import id, for people: `name`, `repository_id:pattern`. The fields it
|
|
50
|
-
* names stay declarable even when the vendor sets them
|
|
52
|
+
* names stay declarable even when the vendor sets them. Absent with `find`: a type the vendor
|
|
53
|
+
* names alone (Stripe's) is found only by its binding.
|
|
51
54
|
*/
|
|
52
|
-
identity
|
|
55
|
+
identity?: string;
|
|
53
56
|
/** The resource's fields as a read returns them; a data file declares them, less the vendor-owned ones. */
|
|
54
57
|
schema: S;
|
|
55
58
|
fields: ResourceFields;
|
|
56
|
-
/**
|
|
57
|
-
|
|
59
|
+
/**
|
|
60
|
+
* The import id that finds a declared resource before it is bound, from its declared fields:
|
|
61
|
+
* its identity. Absent when its fields cannot name it.
|
|
62
|
+
*/
|
|
63
|
+
find?: (desired: z.input<S>) => string;
|
|
64
|
+
/**
|
|
65
|
+
* The id a binding keeps for an object, from its state: stable for the object's life (GitHub's
|
|
66
|
+
* `node_id`, not its name), and one `import` accepts. Default: `String(state.id)`.
|
|
67
|
+
*/
|
|
68
|
+
bindingId?: (state: State) => string;
|
|
58
69
|
/**
|
|
59
70
|
* The part of a state that a drift check compares, given what was declared. The check
|
|
60
71
|
* compares `normalize(actual, desired)` with `normalize(desired, desired)`. Default:
|
|
61
72
|
* `compareDeclared`, which keeps the declared fields only.
|
|
62
73
|
*/
|
|
63
74
|
normalize?: (state: State, desired: z.input<S>) => State;
|
|
75
|
+
/**
|
|
76
|
+
* True when apply may change its objects: the type then has the change operations `plan`,
|
|
77
|
+
* `create`, `update` and `delete` besides `read` and `import`. Default false.
|
|
78
|
+
*/
|
|
79
|
+
writable?: boolean;
|
|
80
|
+
/** Set when the type meets the `Host` contract: it runs the company. Such a type is `writable` and `stateless`. */
|
|
81
|
+
host?: HostSpec;
|
|
82
|
+
/**
|
|
83
|
+
* True when its objects hold no data, so one gone at the vendor is created again by apply
|
|
84
|
+
* without asking (a host running the company's code); otherwise apply asks first, as a
|
|
85
|
+
* recreated database looks fine and has lost its data. Default: true for a host, else false.
|
|
86
|
+
*/
|
|
87
|
+
stateless?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Operations apply calls, in order, right after it creates one of its objects and binds it: a
|
|
90
|
+
* Supabase project's health, until it is healthy, then its database URL stored.
|
|
91
|
+
*/
|
|
92
|
+
afterCreate?: readonly AfterCreate[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One operation apply calls after a create (`ResourceSpec.afterCreate`). */
|
|
96
|
+
export interface AfterCreate {
|
|
97
|
+
/** The operation's id, among the config's: `supabase.database.storeUrl`. */
|
|
98
|
+
op: string;
|
|
99
|
+
/** Its input, from the new object's binding id and the data file's export that declares it. */
|
|
100
|
+
input: (made: { id: string; export: string }) => unknown;
|
|
101
|
+
/**
|
|
102
|
+
* Called again, every few seconds, until its output passes this, for up to 10 minutes; a call
|
|
103
|
+
* that fails meanwhile is one that did not pass, its output `undefined`.
|
|
104
|
+
*/
|
|
105
|
+
until?: (output: any) => boolean;
|
|
64
106
|
}
|
|
65
107
|
|
|
66
108
|
/**
|
|
@@ -75,8 +117,8 @@ export interface Declared<Id extends string = string, T = State> {
|
|
|
75
117
|
readonly [DECLARED]: Id;
|
|
76
118
|
readonly vendor: string;
|
|
77
119
|
readonly type: string;
|
|
78
|
-
/**
|
|
79
|
-
readonly name
|
|
120
|
+
/** Its identity, the import id that finds it unbound: what `find` made of `desired`; absent without `find`. */
|
|
121
|
+
readonly name?: string;
|
|
80
122
|
/**
|
|
81
123
|
* Its fields, each resource it names replaced by that resource's `name`: what its type
|
|
82
124
|
* checked, and what a drift check compares.
|
|
@@ -91,20 +133,32 @@ export interface Declared<Id extends string = string, T = State> {
|
|
|
91
133
|
|
|
92
134
|
/**
|
|
93
135
|
* What a resource type's constructor takes: its fields, where `R` (its `fields.references`)
|
|
94
|
-
* names a path, a declared resource of that type instead of a value (`repository_id: site`)
|
|
136
|
+
* names a path, a declared resource of that type instead of a value (`repository_id: site`), and
|
|
137
|
+
* where a string goes, a secret (`secret("NAME")`), which the constructor allows at a write-only
|
|
138
|
+
* field only.
|
|
95
139
|
*/
|
|
96
|
-
export type Declaring<T, R extends References = {}> = [keyof R] extends [never]
|
|
140
|
+
export type Declaring<T, R extends References = {}> = [keyof R] extends [never]
|
|
141
|
+
? WithSecrets<T>
|
|
142
|
+
: DeclaringAt<T, R, "">;
|
|
143
|
+
|
|
144
|
+
type WithSecrets<V> = V extends string
|
|
145
|
+
? V | Secret
|
|
146
|
+
: V extends readonly (infer I)[]
|
|
147
|
+
? WithSecrets<I>[]
|
|
148
|
+
: V extends object
|
|
149
|
+
? { [K in keyof V]: WithSecrets<V[K]> }
|
|
150
|
+
: V;
|
|
97
151
|
|
|
98
152
|
type DeclaringAt<T, R extends References, P extends string> = T extends readonly (infer I)[]
|
|
99
153
|
? DeclaringAt<I, R, P>[]
|
|
100
154
|
: T extends object
|
|
101
155
|
? { [K in keyof T]: K extends string ? DeclaringField<T[K], R, P extends "" ? K : `${P}.${K}`> : T[K] }
|
|
102
|
-
: T
|
|
156
|
+
: WithSecrets<T>;
|
|
103
157
|
|
|
104
158
|
type DeclaringField<V, R extends References, P extends string> = P extends keyof R
|
|
105
|
-
? V | DeclaredOf<R[P]["type"]>
|
|
159
|
+
? WithSecrets<V> | DeclaredOf<R[P]["type"]>
|
|
106
160
|
: [Extract<keyof R, `${P}.${string}`>] extends [never]
|
|
107
|
-
? V
|
|
161
|
+
? WithSecrets<V>
|
|
108
162
|
: DeclaringAt<V, R, P>;
|
|
109
163
|
|
|
110
164
|
type DeclaredOf<Id> = Id extends string ? Declared<Id> : never;
|
|
@@ -113,28 +167,78 @@ const importId = z.string().min(1);
|
|
|
113
167
|
/** What a driver keeps beside a state (an OpenTofu provider's private data and state version), passed back unchanged. */
|
|
114
168
|
const handle = z.string().optional().describe("The driver's data for the object, opaque: pass it back unchanged");
|
|
115
169
|
|
|
170
|
+
const record = z.record(z.string(), z.unknown());
|
|
171
|
+
|
|
172
|
+
/** What a plan of a change says will happen, as `PlanResponse.Action` names it. */
|
|
173
|
+
export const PLAN_ACTIONS = ["none", "create", "update", "replace", "delete"] as const;
|
|
174
|
+
export type PlanAction = (typeof PLAN_ACTIONS)[number];
|
|
175
|
+
|
|
176
|
+
/** The operations of a writable type that change its objects: only the apply workflow calls them (`isChangeOp`). */
|
|
177
|
+
export const CHANGE_OPS = ["create", "update", "delete"] as const;
|
|
178
|
+
|
|
116
179
|
const opsOf = <S extends z.ZodObject>(schema: S) => {
|
|
117
180
|
// Both answer `{ gone: true }` when the vendor has no such object, and its state when it has.
|
|
118
181
|
const output = z.object({ id: importId, gone: z.boolean(), state: schema.optional(), handle });
|
|
182
|
+
const known = { id: importId, state: record.optional(), handle };
|
|
183
|
+
// The declared fields, a secret marker (`{ $secret }`) at a write-only one: the driver resolves it.
|
|
184
|
+
const desired = record.describe("The declared fields; a write-only one may hold a secret marker");
|
|
185
|
+
const changed = z.object({ id: importId, state: schema, handle });
|
|
186
|
+
const action = z.enum(PLAN_ACTIONS);
|
|
187
|
+
/** Fields as dotted paths, a list item's with its index. */
|
|
188
|
+
const paths = z.array(z.string());
|
|
119
189
|
return {
|
|
120
190
|
import: { input: z.object({ id: importId }), output },
|
|
121
|
-
read: { input: z.object(
|
|
191
|
+
read: { input: z.object(known), output },
|
|
192
|
+
plan: {
|
|
193
|
+
input: z.object({ ...known, id: importId.optional(), desired: desired.optional() }),
|
|
194
|
+
output: z.object({
|
|
195
|
+
action,
|
|
196
|
+
// The new state, without write-only fields; a value known only after apply is `null`, its path in `unknown`.
|
|
197
|
+
planned: record.optional(),
|
|
198
|
+
unknown: paths,
|
|
199
|
+
replace: paths.describe("The fields whose change replaces the object: not empty exactly when `replace`"),
|
|
200
|
+
}),
|
|
201
|
+
},
|
|
202
|
+
create: { input: z.object({ desired }), output: changed },
|
|
203
|
+
update: {
|
|
204
|
+
input: z.object({ ...known, desired, expect: z.object({ action, replace: paths }).optional() }),
|
|
205
|
+
output: changed,
|
|
206
|
+
},
|
|
207
|
+
delete: { input: z.object(known), output: z.object({ id: importId, gone: z.literal(true) }) },
|
|
122
208
|
};
|
|
123
209
|
};
|
|
124
210
|
|
|
125
211
|
/** What the ledger keeps of a resource operation's `handle`: its name, never its bytes. */
|
|
126
212
|
const opaque = ["handle"] as const;
|
|
127
213
|
|
|
128
|
-
/** The policy's `target` for a resource's operations: the
|
|
214
|
+
/** The policy's `target` for a resource's operations: the id, an identity or a binding id. */
|
|
129
215
|
const target = ({ id }: { id: string }) => id;
|
|
130
216
|
|
|
131
217
|
type OpsOf<S extends z.ZodObject> = ReturnType<typeof opsOf<S>>;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
export type ResourceOps<S extends z.ZodObject> = {
|
|
135
|
-
[N in keyof OpsOf<S>]: OpSpec<OpsOf<S>[N]["input"], OpsOf<S>[N]["output"]>;
|
|
218
|
+
type Specs<S extends z.ZodObject, N extends keyof OpsOf<S>> = {
|
|
219
|
+
[K in N]: OpSpec<OpsOf<S>[K]["input"], OpsOf<S>[K]["output"]>;
|
|
136
220
|
};
|
|
137
221
|
|
|
222
|
+
/**
|
|
223
|
+
* A resource type's operation specs: `read` and `import`, and for a writable type (`W`) the
|
|
224
|
+
* change operations `plan`, `create`, `update` and `delete`.
|
|
225
|
+
*/
|
|
226
|
+
export type ResourceOps<S extends z.ZodObject, W extends boolean = false> = Specs<S, "read" | "import"> &
|
|
227
|
+
([W] extends [true] ? Specs<S, "plan" | (typeof CHANGE_OPS)[number]> : {});
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* True for an operation that changes a writable type's objects (`CHANGE_OPS`), which only the
|
|
231
|
+
* apply workflow may call: by its vendor, resource and name, so a copy of a connector is told too.
|
|
232
|
+
*/
|
|
233
|
+
export function isChangeOp(
|
|
234
|
+
op: { readonly vendor: string; readonly resource: string; readonly name: string },
|
|
235
|
+
types: ReadonlyMap<string, Pick<Resource, "writable">>,
|
|
236
|
+
): boolean {
|
|
237
|
+
return (
|
|
238
|
+
(CHANGE_OPS as readonly string[]).includes(op.name) && types.get(`${op.vendor}.${op.resource}`)?.writable === true
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
138
242
|
/**
|
|
139
243
|
* A resource type: call it to declare a resource in a data file, and give it to
|
|
140
244
|
* `defineConnector` as a group, under its `type`: `defineConnector("github", { repository })`.
|
|
@@ -143,10 +247,18 @@ export interface Resource<
|
|
|
143
247
|
S extends z.ZodObject = z.ZodObject,
|
|
144
248
|
Id extends string = string,
|
|
145
249
|
R extends References = {},
|
|
146
|
-
|
|
250
|
+
W extends boolean = boolean,
|
|
251
|
+
>
|
|
252
|
+
extends
|
|
253
|
+
Readonly<Required<Omit<ResourceSpec<S>, "identity" | "find" | "writable" | "host" | "stateless" | "afterCreate">>>,
|
|
254
|
+
Readonly<Pick<ResourceSpec<S>, "identity" | "find" | "host" | "afterCreate">> {
|
|
147
255
|
(desired: Declaring<z.input<S>, R>): Declared<Id, z.input<S>>;
|
|
148
|
-
/** `read` and `import`, each effect `read`, idempotent, with the
|
|
149
|
-
readonly ops: ResourceOps<S>;
|
|
256
|
+
/** `read` and `import`, each effect `read`, idempotent, with the id as `target`; for a writable type, the change operations too. */
|
|
257
|
+
readonly ops: ResourceOps<S, W>;
|
|
258
|
+
/** True when apply may change its objects: it has the change operations. */
|
|
259
|
+
readonly writable: W;
|
|
260
|
+
/** True when apply may recreate or replace its objects as routine: they keep no data. */
|
|
261
|
+
readonly stateless: boolean;
|
|
150
262
|
readonly [RESOURCE]: true;
|
|
151
263
|
}
|
|
152
264
|
|
|
@@ -262,21 +374,25 @@ function withAccepted(state: State, desired: State, accept: Readonly<Record<stri
|
|
|
262
374
|
if (!paths.length) return state;
|
|
263
375
|
const out = structuredClone(state);
|
|
264
376
|
for (const [path, values] of paths) {
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
const holder = keys.reduce<unknown>(
|
|
268
|
-
(v, key) => (isObject(v) || Array.isArray(v) ? (v as State)[key] : undefined),
|
|
269
|
-
out,
|
|
270
|
-
);
|
|
271
|
-
if (!isObject(holder) && !Array.isArray(holder)) continue;
|
|
272
|
-
const at = holder as State;
|
|
273
|
-
if (values.includes(at[last])) at[last] = valueAt(desired, path);
|
|
377
|
+
const at = holderOf(out, path);
|
|
378
|
+
if (at && values.includes(at.holder[at.key])) at.holder[at.key] = valueAt(desired, path);
|
|
274
379
|
}
|
|
275
380
|
return out;
|
|
276
381
|
}
|
|
277
382
|
|
|
383
|
+
/** The object or list holding the value at a dotted path (a list item's by its index), and its key there. */
|
|
384
|
+
export function holderOf(value: unknown, path: string): { holder: State; key: string } | undefined {
|
|
385
|
+
const keys = path.split(".");
|
|
386
|
+
const key = keys.pop()!;
|
|
387
|
+
const holder = keys.reduce<unknown>((v, k) => (isObject(v) || Array.isArray(v) ? (v as State)[k] : undefined), value);
|
|
388
|
+
return isObject(holder) || Array.isArray(holder) ? { holder: holder as State, key } : undefined;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** A field's dotted path as `fields` names it, without a list item's index: `teams.0` is `teams`. */
|
|
392
|
+
export const fieldPathOf = (at: string) => at.replaceAll(/\.\d+(?=\.|$)/g, "");
|
|
393
|
+
|
|
278
394
|
/** The value at a dotted path, a list item's by its index. */
|
|
279
|
-
const valueAt = (value: unknown, path: string) =>
|
|
395
|
+
export const valueAt = (value: unknown, path: string) =>
|
|
280
396
|
path.split(".").reduce<unknown>((v, key) => (isObject(v) || Array.isArray(v) ? (v as State)[key] : undefined), value);
|
|
281
397
|
|
|
282
398
|
/** True when the two are the same JSON, where a missing value is `null`. */
|
|
@@ -303,9 +419,19 @@ function changes(desired: unknown, actual: unknown, unordered: ReadonlySet<strin
|
|
|
303
419
|
return same(desired, actual) ? [] : [{ path: at, desired: desired ?? null, actual: actual ?? null }];
|
|
304
420
|
}
|
|
305
421
|
|
|
422
|
+
/** The secret markers in a write-only value, in its place: the value itself, or a map of them (none: `[]`). */
|
|
423
|
+
function markers(value: unknown): unknown[] {
|
|
424
|
+
if (isSecret(value)) return [value];
|
|
425
|
+
if (!isObject(value)) return [];
|
|
426
|
+
const kept = Object.entries(value).flatMap(([name, v]) => markers(v).map((m) => [name, m] as const));
|
|
427
|
+
return kept.length ? [Object.fromEntries(kept)] : [];
|
|
428
|
+
}
|
|
429
|
+
|
|
306
430
|
/**
|
|
307
|
-
* `desired` without the fields the type marks write-only (a webhook's `secret`):
|
|
308
|
-
* record of a declaration. A list's items lose them too.
|
|
431
|
+
* `desired` without the values at the fields the type marks write-only (a webhook's `secret`):
|
|
432
|
+
* what a run may record of a declaration. A list's items lose them too. A secret marker
|
|
433
|
+
* (`secret("NAME")`) stays, there or inside a write-only map (a host's `secrets`): it names a
|
|
434
|
+
* stored secret and holds none, and the driver resolves it.
|
|
309
435
|
*/
|
|
310
436
|
export function withoutWriteOnly(fields: ResourceFields, desired: State): State {
|
|
311
437
|
if (!fields.writeOnly.length) return desired;
|
|
@@ -316,13 +442,17 @@ export function withoutWriteOnly(fields: ResourceFields, desired: State): State
|
|
|
316
442
|
return Object.fromEntries(
|
|
317
443
|
Object.entries(value).flatMap(([name, v]) => {
|
|
318
444
|
const at = joinPath(path, name);
|
|
319
|
-
return drop.has(at) ? [] : [[name, strip(v, at)]];
|
|
445
|
+
return drop.has(at) ? markers(v).map((m) => [name, m]) : [[name, strip(v, at)]];
|
|
320
446
|
}),
|
|
321
447
|
);
|
|
322
448
|
};
|
|
323
449
|
return strip(desired, "") as State;
|
|
324
450
|
}
|
|
325
451
|
|
|
452
|
+
/** True when `path` is one of `paths`, or a field inside one: a secret's map (`secrets.KEY`) is as write-only as the map. */
|
|
453
|
+
const underAny = (path: string, paths: ReadonlySet<string>) =>
|
|
454
|
+
path.split(".").some((_, i, keys) => paths.has(keys.slice(0, i + 1).join(".")));
|
|
455
|
+
|
|
326
456
|
/** The dotted paths in `value` that `paths` holds, looking into objects and lists' items. */
|
|
327
457
|
function pathsIn(value: unknown, paths: ReadonlySet<string>, prefix = ""): string[] {
|
|
328
458
|
if (Array.isArray(value)) return [...new Set(value.flatMap((item) => pathsIn(item, paths, prefix)))];
|
|
@@ -339,13 +469,31 @@ function pathsIn(value: unknown, paths: ReadonlySet<string>, prefix = ""): strin
|
|
|
339
469
|
* derives its operations, `<vendor>.<type>.read` and `<vendor>.<type>.import`, both effect
|
|
340
470
|
* `read` and idempotent, with the import id as the policy's `target`:
|
|
341
471
|
*
|
|
342
|
-
* - `import` takes `{ id }`,
|
|
472
|
+
* - `import` takes `{ id }`, an identity (what `find` makes) or a binding id (what `bindingId`
|
|
473
|
+
* makes), and returns `{ gone: false, state }`, the object's
|
|
343
474
|
* state (with the driver's opaque `handle` when it keeps one), or `{ gone: true }` when the
|
|
344
475
|
* vendor has no such object.
|
|
345
476
|
* - `read` takes `{ id, state?, handle? }`, a state from an earlier `import` or `read`, and
|
|
346
477
|
* returns the same: `{ gone: true }` when the object no longer exists, else its fresh
|
|
347
478
|
* `state`. A driver may import first when it is given no state.
|
|
348
479
|
*
|
|
480
|
+
* A `writable` type has the change operations too, which only the apply workflow may call
|
|
481
|
+
* (`isChangeOp`): `create`, `update` and `delete` effect `write` (`delete` idempotent), and `plan`:
|
|
482
|
+
*
|
|
483
|
+
* - `plan` (`read`, idempotent) takes `{ id?, state?, handle?, desired? }`, no `desired` for a
|
|
484
|
+
* delete and no `id` for a create, and returns `{ action, planned?, unknown, replace }`:
|
|
485
|
+
* what apply would do, the new state without write-only fields, the paths known only after
|
|
486
|
+
* apply, and those whose change replaces the object.
|
|
487
|
+
* - `create` (`target` `new:<vendor>.<type>`) takes `{ desired }` and returns `{ id, state,
|
|
488
|
+
* handle? }`, `id` its binding id.
|
|
489
|
+
* - `update` takes `{ id, state?, handle?, desired, expect? }` and returns the same; given
|
|
490
|
+
* `expect` (a plan's `action` and `replace`), it refuses a fresh plan that differs
|
|
491
|
+
* (`DriverError`, `vendorCode` `plan_changed`).
|
|
492
|
+
* - `delete` takes `{ id, state?, handle? }` and returns `{ id, gone: true }`.
|
|
493
|
+
*
|
|
494
|
+
* `desired` may hold secret markers at write-only fields, which the driver resolves; no output
|
|
495
|
+
* holds a write-only value.
|
|
496
|
+
*
|
|
349
497
|
* The ledger records a `handle` as `"<handle>"`, never its bytes (the operations' `opaque`).
|
|
350
498
|
*
|
|
351
499
|
* Calling the result declares one resource, for a data file: `repository({ name: "sanoma" })`
|
|
@@ -353,22 +501,64 @@ function pathsIn(value: unknown, paths: ReadonlySet<string>, prefix = ""): strin
|
|
|
353
501
|
* schema does not have, fields the vendor owns (unless the identity names them), values the
|
|
354
502
|
* schema rejects, and an empty identity. Where `fields.references` allows it, a field may name
|
|
355
503
|
* another declared resource of that type (`repository_id: site`): `desired` holds, and the
|
|
356
|
-
* schema and `find` read, that resource's `name`, and `refs` says where it was named.
|
|
504
|
+
* schema and `find` read, that resource's `name`, and `refs` says where it was named. A type
|
|
505
|
+
* without `find` declares resources without a `name`, found only by their bindings.
|
|
357
506
|
*/
|
|
358
507
|
export function defineResource<
|
|
359
508
|
S extends z.ZodObject,
|
|
360
509
|
const V extends string,
|
|
361
510
|
const T extends string,
|
|
362
511
|
const R extends References = {},
|
|
363
|
-
|
|
364
|
-
|
|
512
|
+
const W extends boolean = false,
|
|
513
|
+
>(
|
|
514
|
+
spec: ResourceSpec<S> & { vendor: V; type: T; fields: { references?: R }; writable?: W },
|
|
515
|
+
): Resource<S, `${V}.${T}`, R, W> {
|
|
516
|
+
const { vendor, type, schema, fields, find, identity, host } = spec;
|
|
517
|
+
const writable = (spec.writable ?? false) as W;
|
|
365
518
|
const what: `${V}.${T}` = `${vendor}.${type}`;
|
|
519
|
+
if (host) checkHost(what, host, spec);
|
|
520
|
+
const title = spec.title.toLowerCase();
|
|
366
521
|
const io = opsOf(schema);
|
|
367
|
-
|
|
522
|
+
// The policy's `target` for a create: the type, as no object has an id yet.
|
|
523
|
+
const created = `new:${what}`;
|
|
524
|
+
const changeOps = {
|
|
525
|
+
plan: {
|
|
526
|
+
effect: "read",
|
|
527
|
+
idempotent: true,
|
|
528
|
+
description: `Plan how apply would make a ${title} match its declaration (or delete it, given none); nothing changes`,
|
|
529
|
+
...io.plan,
|
|
530
|
+
target: ({ id }: { id?: string }) => id ?? created,
|
|
531
|
+
opaque,
|
|
532
|
+
},
|
|
533
|
+
create: {
|
|
534
|
+
effect: "write",
|
|
535
|
+
description: `Create a ${title} as declared`,
|
|
536
|
+
...io.create,
|
|
537
|
+
target: () => created,
|
|
538
|
+
opaque,
|
|
539
|
+
},
|
|
540
|
+
update: {
|
|
541
|
+
effect: "write",
|
|
542
|
+
description: `Change a ${title} to match its declaration, replacing it where a field cannot change in place`,
|
|
543
|
+
...io.update,
|
|
544
|
+
target,
|
|
545
|
+
opaque,
|
|
546
|
+
},
|
|
547
|
+
delete: {
|
|
548
|
+
effect: "write",
|
|
549
|
+
// A retry reads first, and finds the object gone.
|
|
550
|
+
idempotent: true,
|
|
551
|
+
description: `Delete a ${title}; done already when it is gone`,
|
|
552
|
+
...io.delete,
|
|
553
|
+
target,
|
|
554
|
+
opaque,
|
|
555
|
+
},
|
|
556
|
+
} satisfies Specs<S, "plan" | (typeof CHANGE_OPS)[number]>;
|
|
557
|
+
const base = {
|
|
368
558
|
import: {
|
|
369
559
|
effect: "read",
|
|
370
560
|
idempotent: true,
|
|
371
|
-
description: `Find a ${
|
|
561
|
+
description: `Find a ${title} by its ${identity ? `${identity} or its ` : ""}binding id and read it; gone when there is none`,
|
|
372
562
|
...io.import,
|
|
373
563
|
target,
|
|
374
564
|
opaque,
|
|
@@ -376,27 +566,43 @@ export function defineResource<
|
|
|
376
566
|
read: {
|
|
377
567
|
effect: "read",
|
|
378
568
|
idempotent: true,
|
|
379
|
-
description: `Read a ${
|
|
569
|
+
description: `Read a ${title} as it is now; gone when it no longer exists`,
|
|
380
570
|
...io.read,
|
|
381
571
|
target,
|
|
382
572
|
opaque,
|
|
383
573
|
},
|
|
384
|
-
} satisfies
|
|
574
|
+
} satisfies Specs<S, "read" | "import">;
|
|
575
|
+
const ops = (writable ? { ...base, ...changeOps } : base) as unknown as ResourceOps<S, W>;
|
|
385
576
|
|
|
386
|
-
const named = new Set(
|
|
577
|
+
const named = new Set(identity?.match(/[A-Za-z_][\w.]*/g));
|
|
387
578
|
const owned = new Set(fields.vendorOwned.filter((path) => !named.has(path)));
|
|
388
579
|
const references: References = fields.references ?? {};
|
|
580
|
+
const writeOnly = new Set(fields.writeOnly);
|
|
389
581
|
const declare = (given: Declaring<z.input<S>, R>): Declared<`${V}.${T}`, z.input<S>> => {
|
|
390
582
|
const refs: Record<string, string> = {};
|
|
583
|
+
// Where a secret is given, with list items' indexes: the schema does not judge its marker.
|
|
584
|
+
const secrets = new Set<string>();
|
|
391
585
|
// Each resource `given` names, where `references` allows one of its type, replaced by its name.
|
|
392
586
|
// `path` is the field's, as `references` names it; `at` has list items' indexes too.
|
|
393
587
|
const resolve = (value: unknown, path: string, at: string): unknown => {
|
|
588
|
+
if (isSecret(value)) {
|
|
589
|
+
if (!underAny(path, writeOnly)) {
|
|
590
|
+
throw new Error(`${what}: ${at} is not write-only, so it takes a value, not a secret (${value.$secret})`);
|
|
591
|
+
}
|
|
592
|
+
if (isSanomaSecret(value.$secret) && !(host && underAny(path, new Set([host.inputs.secrets])))) {
|
|
593
|
+
throw new Error(`${what}: ${at} names ${value.$secret}, one of sanoma's own: only a host's secrets may`);
|
|
594
|
+
}
|
|
595
|
+
secrets.add(at);
|
|
596
|
+
return value;
|
|
597
|
+
}
|
|
394
598
|
if (isDeclared(value)) {
|
|
395
599
|
const id = value[DECLARED];
|
|
396
600
|
const wants = references[path];
|
|
397
601
|
if (wants === undefined) throw new Error(`${what}: ${at} takes a value, not a resource (${id} ${value.name})`);
|
|
398
602
|
if (wants.type !== id)
|
|
399
603
|
throw new Error(`${what}: ${at} names a resource of type ${wants.type}, not ${id} (${value.name})`);
|
|
604
|
+
if (value.name === undefined)
|
|
605
|
+
throw new Error(`${what}: ${at} names a ${id}, which has no identity to name it by`);
|
|
400
606
|
refs[at] = `${id}:${value.name}`;
|
|
401
607
|
return value.name;
|
|
402
608
|
}
|
|
@@ -409,6 +615,10 @@ export function defineResource<
|
|
|
409
615
|
const desired = resolve(given, "", "") as z.input<S>;
|
|
410
616
|
const unknown = Object.keys(desired ?? {}).filter((name) => !Object.hasOwn(schema.shape, name));
|
|
411
617
|
if (unknown.length) throw new Error(`${what}: no field ${unknown.join(", ")}`);
|
|
618
|
+
// Apply fills a host's revision from its ref: the file names a branch, never a commit.
|
|
619
|
+
if (host && pathsIn(desired, new Set([host.revision])).length) {
|
|
620
|
+
throw new Error(`${what}: leave out ${host.revision}: apply sets it from ${host.inputs.ref}`);
|
|
621
|
+
}
|
|
412
622
|
const vendors = pathsIn(desired, owned);
|
|
413
623
|
if (vendors.length) {
|
|
414
624
|
throw new Error(
|
|
@@ -416,20 +626,33 @@ export function defineResource<
|
|
|
416
626
|
);
|
|
417
627
|
}
|
|
418
628
|
const parsed = schema.safeParse(desired);
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
629
|
+
const issues = parsed.error?.issues.filter((issue) => !secrets.has(issue.path.join(".")));
|
|
630
|
+
if (issues?.length) throw new Error(`${what}: ${z.prettifyError(new z.ZodError(issues))}`);
|
|
631
|
+
const name = find?.(desired);
|
|
632
|
+
if (find && (typeof name !== "string" || name === "")) throw new Error(`${what}: its ${identity} is missing`);
|
|
633
|
+
return Object.freeze({
|
|
634
|
+
[DECLARED]: what,
|
|
635
|
+
vendor,
|
|
636
|
+
type,
|
|
637
|
+
...(name !== undefined && { name }),
|
|
638
|
+
desired,
|
|
639
|
+
refs: Object.freeze(refs),
|
|
640
|
+
});
|
|
423
641
|
};
|
|
424
642
|
const resource = Object.assign(declare, {
|
|
425
643
|
vendor,
|
|
426
644
|
type,
|
|
427
645
|
title: spec.title,
|
|
428
|
-
identity
|
|
646
|
+
identity,
|
|
429
647
|
schema,
|
|
430
648
|
fields,
|
|
431
649
|
find,
|
|
650
|
+
bindingId: spec.bindingId ?? ((state: State) => String(state.id)),
|
|
432
651
|
normalize: spec.normalize ?? ((state: State, desired: z.input<S>) => compareDeclared(fields, state, desired)),
|
|
652
|
+
writable,
|
|
653
|
+
...(host && { host }),
|
|
654
|
+
stateless: spec.stateless ?? host !== undefined,
|
|
655
|
+
...(spec.afterCreate && { afterCreate: spec.afterCreate }),
|
|
433
656
|
ops,
|
|
434
657
|
// A symbol, so `defineConnector` tells a resource type from a group of operations.
|
|
435
658
|
[RESOURCE]: true as const,
|