@sanoma-ai/workflows 0.1.0 → 0.4.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 +186 -76
- 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/approvals.d.ts +11 -6
- package/dist/approvals.js +38 -10
- package/dist/approvals.js.map +1 -1
- package/dist/call-workflow.d.ts +16 -0
- package/dist/call-workflow.js +35 -0
- package/dist/call-workflow.js.map +1 -0
- package/dist/call.d.ts +35 -2
- package/dist/call.js +123 -43
- package/dist/call.js.map +1 -1
- package/dist/cedar.d.ts +87 -0
- package/dist/cedar.js +365 -0
- package/dist/cedar.js.map +1 -0
- package/dist/client.d.ts +123 -9
- package/dist/client.js +355 -34
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +107 -0
- package/dist/collection.js +306 -0
- package/dist/collection.js.map +1 -0
- package/dist/config.d.ts +42 -14
- package/dist/config.js +109 -28
- package/dist/config.js.map +1 -1
- package/dist/constructors.d.ts +2 -0
- package/dist/constructors.js +5 -0
- package/dist/constructors.js.map +1 -0
- package/dist/credentials.d.ts +25 -0
- package/dist/credentials.js +73 -5
- package/dist/credentials.js.map +1 -1
- package/dist/data.d.ts +2 -0
- package/dist/data.js +3 -0
- package/dist/data.js.map +1 -0
- package/dist/datafile.d.ts +38 -3
- package/dist/datafile.js +166 -13
- package/dist/datafile.js.map +1 -1
- package/dist/db.d.ts +5 -3
- package/dist/db.js +88 -21
- package/dist/db.js.map +1 -1
- package/dist/define.d.ts +10 -4
- package/dist/define.js +1 -5
- package/dist/define.js.map +1 -1
- package/dist/describe.d.ts +54 -8
- package/dist/describe.js +84 -11
- package/dist/describe.js.map +1 -1
- package/dist/direct.d.ts +9 -0
- package/dist/direct.js +11 -0
- package/dist/direct.js.map +1 -0
- package/dist/drift.d.ts +64 -12
- package/dist/drift.js +118 -41
- package/dist/drift.js.map +1 -1
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +30 -1
- package/dist/errors.js.map +1 -1
- package/dist/expectations.d.ts +4 -4
- package/dist/expectations.js +43 -9
- 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 +13 -5
- package/dist/index.js +15 -5
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +12 -1
- package/dist/ledger.js +18 -11
- package/dist/ledger.js.map +1 -1
- package/dist/lint.d.ts +5 -4
- package/dist/lint.js +24 -7
- package/dist/lint.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 +9 -0
- package/dist/op.js.map +1 -1
- package/dist/outline.d.ts +2 -0
- package/dist/outline.js +1 -1
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +4 -0
- package/dist/paths.js +17 -3
- package/dist/paths.js.map +1 -1
- package/dist/person.d.ts +61 -0
- package/dist/person.js +69 -0
- package/dist/person.js.map +1 -0
- package/dist/plugin.js +1 -1
- package/dist/plugin.js.map +1 -1
- package/dist/policy.d.ts +51 -55
- package/dist/policy.js +14 -36
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +207 -25
- package/dist/resource.js +184 -30
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +76 -9
- package/dist/resources.js +170 -30
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +31 -1
- 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 +25 -9
- package/dist/scenario.js +125 -27
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +10 -6
- package/dist/scenarios.js +8 -4
- package/dist/scenarios.js.map +1 -1
- package/dist/schema.d.ts +32 -0
- package/dist/schema.js +225 -0
- package/dist/schema.js.map +1 -0
- package/dist/sdk.d.ts +34 -0
- package/dist/sdk.js +168 -0
- package/dist/sdk.js.map +1 -0
- 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 +188 -4
- package/dist/shared.js +165 -5
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +52 -0
- package/dist/sites.js +174 -0
- package/dist/sites.js.map +1 -0
- 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 +3 -2
- package/dist/version.js +3 -2
- package/dist/version.js.map +1 -1
- package/dist/worker.d.ts +6 -2
- package/dist/worker.js +37 -8
- 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/oxlint.json +5 -16
- package/package.json +11 -1
- package/src/apply-steps.ts +110 -0
- package/src/apply.ts +626 -0
- package/src/approvals.ts +49 -13
- package/src/call-workflow.ts +44 -0
- package/src/call.ts +173 -46
- package/src/cedar.ts +493 -0
- package/src/client.ts +450 -49
- package/src/collection.ts +431 -0
- package/src/config.ts +168 -48
- package/src/constructors.ts +4 -0
- package/src/credentials.ts +90 -5
- package/src/data.ts +10 -0
- package/src/datafile.ts +207 -15
- package/src/db.ts +90 -22
- package/src/define.ts +11 -9
- package/src/describe.ts +146 -23
- package/src/direct.ts +15 -0
- package/src/drift.ts +155 -45
- package/src/errors.ts +36 -1
- package/src/expectations.ts +46 -11
- package/src/host.ts +96 -0
- package/src/index.ts +56 -2
- package/src/ledger.ts +35 -17
- package/src/lint.ts +29 -12
- package/src/lockfile.ts +95 -0
- package/src/op.ts +9 -0
- package/src/outline.ts +1 -1
- package/src/paths.ts +18 -3
- package/src/person.ts +75 -0
- package/src/plugin.ts +1 -1
- package/src/policy.ts +59 -63
- package/src/resource.ts +302 -52
- package/src/resources.ts +226 -30
- package/src/run.ts +33 -1
- package/src/sandbox.ts +5 -1
- package/src/scenario.ts +175 -43
- package/src/scenarios.ts +15 -8
- package/src/schema.ts +261 -0
- package/src/sdk.ts +211 -0
- package/src/secret.ts +92 -0
- package/src/shared.ts +279 -6
- package/src/sites.ts +220 -0
- package/src/source.ts +82 -0
- package/src/version.ts +3 -2
- package/src/worker.ts +45 -11
- package/src/writer.ts +225 -0
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 {
|
|
@@ -22,8 +24,8 @@ export interface ResourceFields {
|
|
|
22
24
|
|
|
23
25
|
/** Where a field names another declared resource: see `ResourceFields.references`. */
|
|
24
26
|
export interface FieldReference {
|
|
25
|
-
/** The `<vendor>.<type>` of the resource it names. */
|
|
26
|
-
readonly type: string;
|
|
27
|
+
/** The `<vendor>.<type>` of the resource it names, or the types it may name one of. */
|
|
28
|
+
readonly type: string | readonly string[];
|
|
27
29
|
/**
|
|
28
30
|
* The fields of that resource's state the vendor may hold here instead of its `name`, such as
|
|
29
31
|
* GitHub's `node_id`: a drift check takes any of their values, read in the same run, as the
|
|
@@ -47,20 +49,66 @@ 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
|
+
* True when there is no vendor object behind a declaration: the repo is all there is of it (the
|
|
90
|
+
* runtime's people, `sanoma.person`). Such a type has no operations, so no driver, and drift and
|
|
91
|
+
* apply pass its resources by. Default false.
|
|
92
|
+
*/
|
|
93
|
+
vendorless?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Operations apply calls, in order, right after it creates one of its objects and binds it: a
|
|
96
|
+
* Supabase project's health, until it is healthy, then its database URL stored.
|
|
97
|
+
*/
|
|
98
|
+
afterCreate?: readonly AfterCreate[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** One operation apply calls after a create (`ResourceSpec.afterCreate`). */
|
|
102
|
+
export interface AfterCreate {
|
|
103
|
+
/** The operation's id, among the config's: `supabase.database.storeUrl`. */
|
|
104
|
+
op: string;
|
|
105
|
+
/** Its input, from the new object's binding id and the data file's export that declares it. */
|
|
106
|
+
input: (made: { id: string; export: string }) => unknown;
|
|
107
|
+
/**
|
|
108
|
+
* Called again, every few seconds, until its output passes this, for up to 10 minutes; a call
|
|
109
|
+
* that fails meanwhile is one that did not pass, its output `undefined`.
|
|
110
|
+
*/
|
|
111
|
+
until?: (output: any) => boolean;
|
|
64
112
|
}
|
|
65
113
|
|
|
66
114
|
/**
|
|
@@ -75,8 +123,8 @@ export interface Declared<Id extends string = string, T = State> {
|
|
|
75
123
|
readonly [DECLARED]: Id;
|
|
76
124
|
readonly vendor: string;
|
|
77
125
|
readonly type: string;
|
|
78
|
-
/**
|
|
79
|
-
readonly name
|
|
126
|
+
/** Its identity, the import id that finds it unbound: what `find` made of `desired`; absent without `find`. */
|
|
127
|
+
readonly name?: string;
|
|
80
128
|
/**
|
|
81
129
|
* Its fields, each resource it names replaced by that resource's `name`: what its type
|
|
82
130
|
* checked, and what a drift check compares.
|
|
@@ -91,50 +139,123 @@ export interface Declared<Id extends string = string, T = State> {
|
|
|
91
139
|
|
|
92
140
|
/**
|
|
93
141
|
* 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`)
|
|
142
|
+
* names a path, a declared resource of that type instead of a value (`repository_id: site`), and
|
|
143
|
+
* where a string goes, a secret (`secret("NAME")`), which the constructor allows at a write-only
|
|
144
|
+
* field only.
|
|
95
145
|
*/
|
|
96
|
-
export type Declaring<T, R extends References = {}> = [keyof R] extends [never]
|
|
146
|
+
export type Declaring<T, R extends References = {}> = [keyof R] extends [never]
|
|
147
|
+
? WithSecrets<T>
|
|
148
|
+
: DeclaringAt<T, R, "">;
|
|
149
|
+
|
|
150
|
+
type WithSecrets<V> = V extends string
|
|
151
|
+
? V | Secret
|
|
152
|
+
: V extends readonly (infer I)[]
|
|
153
|
+
? WithSecrets<I>[]
|
|
154
|
+
: V extends object
|
|
155
|
+
? { [K in keyof V]: WithSecrets<V[K]> }
|
|
156
|
+
: V;
|
|
97
157
|
|
|
98
158
|
type DeclaringAt<T, R extends References, P extends string> = T extends readonly (infer I)[]
|
|
99
159
|
? DeclaringAt<I, R, P>[]
|
|
100
160
|
: T extends object
|
|
101
161
|
? { [K in keyof T]: K extends string ? DeclaringField<T[K], R, P extends "" ? K : `${P}.${K}`> : T[K] }
|
|
102
|
-
: T
|
|
162
|
+
: WithSecrets<T>;
|
|
103
163
|
|
|
104
164
|
type DeclaringField<V, R extends References, P extends string> = P extends keyof R
|
|
105
|
-
? V
|
|
165
|
+
? V extends readonly (infer I)[]
|
|
166
|
+
? (WithSecrets<I> | DeclaredOf<R[P]["type"]>)[]
|
|
167
|
+
: WithSecrets<V> | DeclaredOf<R[P]["type"]>
|
|
106
168
|
: [Extract<keyof R, `${P}.${string}`>] extends [never]
|
|
107
|
-
? V
|
|
169
|
+
? WithSecrets<V>
|
|
108
170
|
: DeclaringAt<V, R, P>;
|
|
109
171
|
|
|
110
|
-
type DeclaredOf<Id> = Id extends string ? Declared<Id> : never;
|
|
172
|
+
type DeclaredOf<Id> = Id extends string ? Declared<Id> : Id extends readonly (infer E)[] ? DeclaredOf<E> : never;
|
|
111
173
|
|
|
112
174
|
const importId = z.string().min(1);
|
|
113
175
|
/** What a driver keeps beside a state (an OpenTofu provider's private data and state version), passed back unchanged. */
|
|
114
176
|
const handle = z.string().optional().describe("The driver's data for the object, opaque: pass it back unchanged");
|
|
115
177
|
|
|
178
|
+
const record = z.record(z.string(), z.unknown());
|
|
179
|
+
|
|
180
|
+
/** What a plan of a change says will happen, as `PlanResponse.Action` names it. */
|
|
181
|
+
export const PLAN_ACTIONS = ["none", "create", "update", "replace", "delete"] as const;
|
|
182
|
+
export type PlanAction = (typeof PLAN_ACTIONS)[number];
|
|
183
|
+
|
|
184
|
+
/** The operations of a writable type that change its objects: only the apply workflow calls them (`isChangeOp`). */
|
|
185
|
+
export const CHANGE_OPS = ["create", "update", "delete"] as const;
|
|
186
|
+
|
|
116
187
|
const opsOf = <S extends z.ZodObject>(schema: S) => {
|
|
117
188
|
// Both answer `{ gone: true }` when the vendor has no such object, and its state when it has.
|
|
118
189
|
const output = z.object({ id: importId, gone: z.boolean(), state: schema.optional(), handle });
|
|
190
|
+
const known = { id: importId, state: record.optional(), handle };
|
|
191
|
+
// The declared fields, a secret marker (`{ $secret }`) at a write-only one: the driver resolves it.
|
|
192
|
+
const desired = record.describe("The declared fields; a write-only one may hold a secret marker");
|
|
193
|
+
const changed = z.object({ id: importId, state: schema, handle });
|
|
194
|
+
const action = z.enum(PLAN_ACTIONS);
|
|
195
|
+
/** Fields as dotted paths, a list item's with its index. */
|
|
196
|
+
const paths = z.array(z.string());
|
|
119
197
|
return {
|
|
120
198
|
import: { input: z.object({ id: importId }), output },
|
|
121
|
-
read: { input: z.object(
|
|
199
|
+
read: { input: z.object(known), output },
|
|
200
|
+
plan: {
|
|
201
|
+
input: z.object({ ...known, id: importId.optional(), desired: desired.optional() }),
|
|
202
|
+
output: z.object({
|
|
203
|
+
action,
|
|
204
|
+
// The new state, without write-only fields; a value known only after apply is `null`, its path in `unknown`.
|
|
205
|
+
planned: record.optional(),
|
|
206
|
+
unknown: paths,
|
|
207
|
+
replace: paths.describe("The fields whose change replaces the object: not empty exactly when `replace`"),
|
|
208
|
+
}),
|
|
209
|
+
},
|
|
210
|
+
create: { input: z.object({ desired }), output: changed },
|
|
211
|
+
update: {
|
|
212
|
+
input: z.object({ ...known, desired, expect: z.object({ action, replace: paths }).optional() }),
|
|
213
|
+
output: changed,
|
|
214
|
+
},
|
|
215
|
+
delete: { input: z.object(known), output: z.object({ id: importId, gone: z.literal(true) }) },
|
|
122
216
|
};
|
|
123
217
|
};
|
|
124
218
|
|
|
125
219
|
/** What the ledger keeps of a resource operation's `handle`: its name, never its bytes. */
|
|
126
220
|
const opaque = ["handle"] as const;
|
|
127
221
|
|
|
128
|
-
/** The policy's `target` for a resource's operations: the
|
|
222
|
+
/** The policy's `target` for a resource's operations: the id, an identity or a binding id. */
|
|
129
223
|
const target = ({ id }: { id: string }) => id;
|
|
130
224
|
|
|
131
225
|
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"]>;
|
|
226
|
+
type Specs<S extends z.ZodObject, N extends keyof OpsOf<S>> = {
|
|
227
|
+
[K in N]: OpSpec<OpsOf<S>[K]["input"], OpsOf<S>[K]["output"]>;
|
|
136
228
|
};
|
|
137
229
|
|
|
230
|
+
/**
|
|
231
|
+
* A resource type's operation specs: `read` and `import`, and for a writable type (`W`) the
|
|
232
|
+
* change operations `plan`, `create`, `update` and `delete`.
|
|
233
|
+
*/
|
|
234
|
+
export type ResourceOps<S extends z.ZodObject, W extends boolean = false> = Specs<S, "read" | "import"> &
|
|
235
|
+
([W] extends [true] ? Specs<S, "plan" | (typeof CHANGE_OPS)[number]> : {});
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* True for an operation that changes a writable type's objects (`CHANGE_OPS`), which only the
|
|
239
|
+
* apply workflow may call: by its vendor, resource and name, so a copy of a connector is told too.
|
|
240
|
+
*/
|
|
241
|
+
export function isChangeOp(
|
|
242
|
+
op: { readonly vendor: string; readonly resource: string; readonly name: string },
|
|
243
|
+
types: ReadonlyMap<string, Pick<Resource, "writable">>,
|
|
244
|
+
): boolean {
|
|
245
|
+
return (
|
|
246
|
+
(CHANGE_OPS as readonly string[]).includes(op.name) && types.get(`${op.vendor}.${op.resource}`)?.writable === true
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* True for an operation of a declared resource type (`read`, `import`, and a writable type's
|
|
252
|
+
* `plan` and change operations), which only drift and apply call: by its vendor and resource.
|
|
253
|
+
*/
|
|
254
|
+
export const isResourceOp = (
|
|
255
|
+
op: { readonly vendor: string; readonly resource: string },
|
|
256
|
+
types: ReadonlyMap<string, unknown>,
|
|
257
|
+
): boolean => types.has(`${op.vendor}.${op.resource}`);
|
|
258
|
+
|
|
138
259
|
/**
|
|
139
260
|
* A resource type: call it to declare a resource in a data file, and give it to
|
|
140
261
|
* `defineConnector` as a group, under its `type`: `defineConnector("github", { repository })`.
|
|
@@ -143,10 +264,24 @@ export interface Resource<
|
|
|
143
264
|
S extends z.ZodObject = z.ZodObject,
|
|
144
265
|
Id extends string = string,
|
|
145
266
|
R extends References = {},
|
|
146
|
-
|
|
267
|
+
W extends boolean = boolean,
|
|
268
|
+
>
|
|
269
|
+
extends
|
|
270
|
+
Readonly<
|
|
271
|
+
Required<
|
|
272
|
+
Omit<ResourceSpec<S>, "identity" | "find" | "writable" | "host" | "stateless" | "afterCreate" | "vendorless">
|
|
273
|
+
>
|
|
274
|
+
>,
|
|
275
|
+
Readonly<Pick<ResourceSpec<S>, "identity" | "find" | "host" | "afterCreate">> {
|
|
147
276
|
(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>;
|
|
277
|
+
/** `read` and `import`, each effect `read`, idempotent, with the id as `target`; for a writable type, the change operations too. */
|
|
278
|
+
readonly ops: ResourceOps<S, W>;
|
|
279
|
+
/** True when apply may change its objects: it has the change operations. */
|
|
280
|
+
readonly writable: W;
|
|
281
|
+
/** True when apply may recreate or replace its objects as routine: they keep no data. */
|
|
282
|
+
readonly stateless: boolean;
|
|
283
|
+
/** True when there is no vendor object behind it (`ResourceSpec.vendorless`): it has no operations. */
|
|
284
|
+
readonly vendorless: boolean;
|
|
150
285
|
readonly [RESOURCE]: true;
|
|
151
286
|
}
|
|
152
287
|
|
|
@@ -262,21 +397,25 @@ function withAccepted(state: State, desired: State, accept: Readonly<Record<stri
|
|
|
262
397
|
if (!paths.length) return state;
|
|
263
398
|
const out = structuredClone(state);
|
|
264
399
|
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);
|
|
400
|
+
const at = holderOf(out, path);
|
|
401
|
+
if (at && values.includes(at.holder[at.key])) at.holder[at.key] = valueAt(desired, path);
|
|
274
402
|
}
|
|
275
403
|
return out;
|
|
276
404
|
}
|
|
277
405
|
|
|
406
|
+
/** The object or list holding the value at a dotted path (a list item's by its index), and its key there. */
|
|
407
|
+
export function holderOf(value: unknown, path: string): { holder: State; key: string } | undefined {
|
|
408
|
+
const keys = path.split(".");
|
|
409
|
+
const key = keys.pop()!;
|
|
410
|
+
const holder = keys.reduce<unknown>((v, k) => (isObject(v) || Array.isArray(v) ? (v as State)[k] : undefined), value);
|
|
411
|
+
return isObject(holder) || Array.isArray(holder) ? { holder: holder as State, key } : undefined;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** A field's dotted path as `fields` names it, without a list item's index: `teams.0` is `teams`. */
|
|
415
|
+
export const fieldPathOf = (at: string) => at.replaceAll(/\.\d+(?=\.|$)/g, "");
|
|
416
|
+
|
|
278
417
|
/** The value at a dotted path, a list item's by its index. */
|
|
279
|
-
const valueAt = (value: unknown, path: string) =>
|
|
418
|
+
export const valueAt = (value: unknown, path: string) =>
|
|
280
419
|
path.split(".").reduce<unknown>((v, key) => (isObject(v) || Array.isArray(v) ? (v as State)[key] : undefined), value);
|
|
281
420
|
|
|
282
421
|
/** True when the two are the same JSON, where a missing value is `null`. */
|
|
@@ -303,9 +442,19 @@ function changes(desired: unknown, actual: unknown, unordered: ReadonlySet<strin
|
|
|
303
442
|
return same(desired, actual) ? [] : [{ path: at, desired: desired ?? null, actual: actual ?? null }];
|
|
304
443
|
}
|
|
305
444
|
|
|
445
|
+
/** The secret markers in a write-only value, in its place: the value itself, or a map of them (none: `[]`). */
|
|
446
|
+
function markers(value: unknown): unknown[] {
|
|
447
|
+
if (isSecret(value)) return [value];
|
|
448
|
+
if (!isObject(value)) return [];
|
|
449
|
+
const kept = Object.entries(value).flatMap(([name, v]) => markers(v).map((m) => [name, m] as const));
|
|
450
|
+
return kept.length ? [Object.fromEntries(kept)] : [];
|
|
451
|
+
}
|
|
452
|
+
|
|
306
453
|
/**
|
|
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.
|
|
454
|
+
* `desired` without the values at the fields the type marks write-only (a webhook's `secret`):
|
|
455
|
+
* what a run may record of a declaration. A list's items lose them too. A secret marker
|
|
456
|
+
* (`secret("NAME")`) stays, there or inside a write-only map (a host's `secrets`): it names a
|
|
457
|
+
* stored secret and holds none, and the driver resolves it.
|
|
309
458
|
*/
|
|
310
459
|
export function withoutWriteOnly(fields: ResourceFields, desired: State): State {
|
|
311
460
|
if (!fields.writeOnly.length) return desired;
|
|
@@ -316,13 +465,17 @@ export function withoutWriteOnly(fields: ResourceFields, desired: State): State
|
|
|
316
465
|
return Object.fromEntries(
|
|
317
466
|
Object.entries(value).flatMap(([name, v]) => {
|
|
318
467
|
const at = joinPath(path, name);
|
|
319
|
-
return drop.has(at) ? [] : [[name, strip(v, at)]];
|
|
468
|
+
return drop.has(at) ? markers(v).map((m) => [name, m]) : [[name, strip(v, at)]];
|
|
320
469
|
}),
|
|
321
470
|
);
|
|
322
471
|
};
|
|
323
472
|
return strip(desired, "") as State;
|
|
324
473
|
}
|
|
325
474
|
|
|
475
|
+
/** 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. */
|
|
476
|
+
const underAny = (path: string, paths: ReadonlySet<string>) =>
|
|
477
|
+
path.split(".").some((_, i, keys) => paths.has(keys.slice(0, i + 1).join(".")));
|
|
478
|
+
|
|
326
479
|
/** The dotted paths in `value` that `paths` holds, looking into objects and lists' items. */
|
|
327
480
|
function pathsIn(value: unknown, paths: ReadonlySet<string>, prefix = ""): string[] {
|
|
328
481
|
if (Array.isArray(value)) return [...new Set(value.flatMap((item) => pathsIn(item, paths, prefix)))];
|
|
@@ -339,13 +492,31 @@ function pathsIn(value: unknown, paths: ReadonlySet<string>, prefix = ""): strin
|
|
|
339
492
|
* derives its operations, `<vendor>.<type>.read` and `<vendor>.<type>.import`, both effect
|
|
340
493
|
* `read` and idempotent, with the import id as the policy's `target`:
|
|
341
494
|
*
|
|
342
|
-
* - `import` takes `{ id }`,
|
|
495
|
+
* - `import` takes `{ id }`, an identity (what `find` makes) or a binding id (what `bindingId`
|
|
496
|
+
* makes), and returns `{ gone: false, state }`, the object's
|
|
343
497
|
* state (with the driver's opaque `handle` when it keeps one), or `{ gone: true }` when the
|
|
344
498
|
* vendor has no such object.
|
|
345
499
|
* - `read` takes `{ id, state?, handle? }`, a state from an earlier `import` or `read`, and
|
|
346
500
|
* returns the same: `{ gone: true }` when the object no longer exists, else its fresh
|
|
347
501
|
* `state`. A driver may import first when it is given no state.
|
|
348
502
|
*
|
|
503
|
+
* A `writable` type has the change operations too, which only the apply workflow may call
|
|
504
|
+
* (`isChangeOp`): `create`, `update` and `delete` effect `write` (`delete` idempotent), and `plan`:
|
|
505
|
+
*
|
|
506
|
+
* - `plan` (`read`, idempotent) takes `{ id?, state?, handle?, desired? }`, no `desired` for a
|
|
507
|
+
* delete and no `id` for a create, and returns `{ action, planned?, unknown, replace }`:
|
|
508
|
+
* what apply would do, the new state without write-only fields, the paths known only after
|
|
509
|
+
* apply, and those whose change replaces the object.
|
|
510
|
+
* - `create` (`target` `new:<vendor>.<type>`) takes `{ desired }` and returns `{ id, state,
|
|
511
|
+
* handle? }`, `id` its binding id.
|
|
512
|
+
* - `update` takes `{ id, state?, handle?, desired, expect? }` and returns the same; given
|
|
513
|
+
* `expect` (a plan's `action` and `replace`), it refuses a fresh plan that differs
|
|
514
|
+
* (`DriverError`, `vendorCode` `plan_changed`).
|
|
515
|
+
* - `delete` takes `{ id, state?, handle? }` and returns `{ id, gone: true }`.
|
|
516
|
+
*
|
|
517
|
+
* `desired` may hold secret markers at write-only fields, which the driver resolves; no output
|
|
518
|
+
* holds a write-only value.
|
|
519
|
+
*
|
|
349
520
|
* The ledger records a `handle` as `"<handle>"`, never its bytes (the operations' `opaque`).
|
|
350
521
|
*
|
|
351
522
|
* Calling the result declares one resource, for a data file: `repository({ name: "sanoma" })`
|
|
@@ -353,22 +524,66 @@ function pathsIn(value: unknown, paths: ReadonlySet<string>, prefix = ""): strin
|
|
|
353
524
|
* schema does not have, fields the vendor owns (unless the identity names them), values the
|
|
354
525
|
* schema rejects, and an empty identity. Where `fields.references` allows it, a field may name
|
|
355
526
|
* 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.
|
|
527
|
+
* schema and `find` read, that resource's `name`, and `refs` says where it was named. A type
|
|
528
|
+
* without `find` declares resources without a `name`, found only by their bindings.
|
|
357
529
|
*/
|
|
358
530
|
export function defineResource<
|
|
359
531
|
S extends z.ZodObject,
|
|
360
532
|
const V extends string,
|
|
361
533
|
const T extends string,
|
|
362
534
|
const R extends References = {},
|
|
363
|
-
|
|
364
|
-
|
|
535
|
+
const W extends boolean = false,
|
|
536
|
+
>(
|
|
537
|
+
spec: ResourceSpec<S> & { vendor: V; type: T; fields: { references?: R }; writable?: W },
|
|
538
|
+
): Resource<S, `${V}.${T}`, R, W> {
|
|
539
|
+
const { vendor, type, schema, fields, find, identity, host } = spec;
|
|
540
|
+
const writable = (spec.writable ?? false) as W;
|
|
365
541
|
const what: `${V}.${T}` = `${vendor}.${type}`;
|
|
542
|
+
if (host) checkHost(what, host, spec);
|
|
543
|
+
if (spec.vendorless && (writable || host))
|
|
544
|
+
throw new Error(`${what}: a vendorless type has no object to change or host`);
|
|
545
|
+
const title = spec.title.toLowerCase();
|
|
366
546
|
const io = opsOf(schema);
|
|
367
|
-
|
|
547
|
+
// The policy's `target` for a create: the type, as no object has an id yet.
|
|
548
|
+
const created = `new:${what}`;
|
|
549
|
+
const changeOps = {
|
|
550
|
+
plan: {
|
|
551
|
+
effect: "read",
|
|
552
|
+
idempotent: true,
|
|
553
|
+
description: `Plan how apply would make a ${title} match its declaration (or delete it, given none); nothing changes`,
|
|
554
|
+
...io.plan,
|
|
555
|
+
target: ({ id }: { id?: string }) => id ?? created,
|
|
556
|
+
opaque,
|
|
557
|
+
},
|
|
558
|
+
create: {
|
|
559
|
+
effect: "write",
|
|
560
|
+
description: `Create a ${title} as declared`,
|
|
561
|
+
...io.create,
|
|
562
|
+
target: () => created,
|
|
563
|
+
opaque,
|
|
564
|
+
},
|
|
565
|
+
update: {
|
|
566
|
+
effect: "write",
|
|
567
|
+
description: `Change a ${title} to match its declaration, replacing it where a field cannot change in place`,
|
|
568
|
+
...io.update,
|
|
569
|
+
target,
|
|
570
|
+
opaque,
|
|
571
|
+
},
|
|
572
|
+
delete: {
|
|
573
|
+
effect: "write",
|
|
574
|
+
// A retry reads first, and finds the object gone.
|
|
575
|
+
idempotent: true,
|
|
576
|
+
description: `Delete a ${title}; done already when it is gone`,
|
|
577
|
+
...io.delete,
|
|
578
|
+
target,
|
|
579
|
+
opaque,
|
|
580
|
+
},
|
|
581
|
+
} satisfies Specs<S, "plan" | (typeof CHANGE_OPS)[number]>;
|
|
582
|
+
const base = {
|
|
368
583
|
import: {
|
|
369
584
|
effect: "read",
|
|
370
585
|
idempotent: true,
|
|
371
|
-
description: `Find a ${
|
|
586
|
+
description: `Find a ${title} by its ${identity ? `${identity} or its ` : ""}binding id and read it; gone when there is none`,
|
|
372
587
|
...io.import,
|
|
373
588
|
target,
|
|
374
589
|
opaque,
|
|
@@ -376,27 +591,44 @@ export function defineResource<
|
|
|
376
591
|
read: {
|
|
377
592
|
effect: "read",
|
|
378
593
|
idempotent: true,
|
|
379
|
-
description: `Read a ${
|
|
594
|
+
description: `Read a ${title} as it is now; gone when it no longer exists`,
|
|
380
595
|
...io.read,
|
|
381
596
|
target,
|
|
382
597
|
opaque,
|
|
383
598
|
},
|
|
384
|
-
} satisfies
|
|
599
|
+
} satisfies Specs<S, "read" | "import">;
|
|
600
|
+
const ops = (spec.vendorless ? {} : writable ? { ...base, ...changeOps } : base) as unknown as ResourceOps<S, W>;
|
|
385
601
|
|
|
386
|
-
const named = new Set(
|
|
602
|
+
const named = new Set(identity?.match(/[A-Za-z_][\w.]*/g));
|
|
387
603
|
const owned = new Set(fields.vendorOwned.filter((path) => !named.has(path)));
|
|
388
604
|
const references: References = fields.references ?? {};
|
|
605
|
+
const writeOnly = new Set(fields.writeOnly);
|
|
389
606
|
const declare = (given: Declaring<z.input<S>, R>): Declared<`${V}.${T}`, z.input<S>> => {
|
|
390
607
|
const refs: Record<string, string> = {};
|
|
608
|
+
// Where a secret is given, with list items' indexes: the schema does not judge its marker.
|
|
609
|
+
const secrets = new Set<string>();
|
|
391
610
|
// Each resource `given` names, where `references` allows one of its type, replaced by its name.
|
|
392
611
|
// `path` is the field's, as `references` names it; `at` has list items' indexes too.
|
|
393
612
|
const resolve = (value: unknown, path: string, at: string): unknown => {
|
|
613
|
+
if (isSecret(value)) {
|
|
614
|
+
if (!underAny(path, writeOnly)) {
|
|
615
|
+
throw new Error(`${what}: ${at} is not write-only, so it takes a value, not a secret (${value.$secret})`);
|
|
616
|
+
}
|
|
617
|
+
if (isSanomaSecret(value.$secret) && !(host && underAny(path, new Set([host.inputs.secrets])))) {
|
|
618
|
+
throw new Error(`${what}: ${at} names ${value.$secret}, one of sanoma's own: only a host's secrets may`);
|
|
619
|
+
}
|
|
620
|
+
secrets.add(at);
|
|
621
|
+
return value;
|
|
622
|
+
}
|
|
394
623
|
if (isDeclared(value)) {
|
|
395
624
|
const id = value[DECLARED];
|
|
396
625
|
const wants = references[path];
|
|
397
626
|
if (wants === undefined) throw new Error(`${what}: ${at} takes a value, not a resource (${id} ${value.name})`);
|
|
398
|
-
|
|
399
|
-
|
|
627
|
+
const types = [wants.type].flat();
|
|
628
|
+
if (!types.includes(id))
|
|
629
|
+
throw new Error(`${what}: ${at} names a resource of type ${types.join(" or ")}, not ${id} (${value.name})`);
|
|
630
|
+
if (value.name === undefined)
|
|
631
|
+
throw new Error(`${what}: ${at} names a ${id}, which has no identity to name it by`);
|
|
400
632
|
refs[at] = `${id}:${value.name}`;
|
|
401
633
|
return value.name;
|
|
402
634
|
}
|
|
@@ -409,6 +641,10 @@ export function defineResource<
|
|
|
409
641
|
const desired = resolve(given, "", "") as z.input<S>;
|
|
410
642
|
const unknown = Object.keys(desired ?? {}).filter((name) => !Object.hasOwn(schema.shape, name));
|
|
411
643
|
if (unknown.length) throw new Error(`${what}: no field ${unknown.join(", ")}`);
|
|
644
|
+
// Apply fills a host's revision from its ref: the file names a branch, never a commit.
|
|
645
|
+
if (host && pathsIn(desired, new Set([host.revision])).length) {
|
|
646
|
+
throw new Error(`${what}: leave out ${host.revision}: apply sets it from ${host.inputs.ref}`);
|
|
647
|
+
}
|
|
412
648
|
const vendors = pathsIn(desired, owned);
|
|
413
649
|
if (vendors.length) {
|
|
414
650
|
throw new Error(
|
|
@@ -416,20 +652,34 @@ export function defineResource<
|
|
|
416
652
|
);
|
|
417
653
|
}
|
|
418
654
|
const parsed = schema.safeParse(desired);
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
655
|
+
const issues = parsed.error?.issues.filter((issue) => !secrets.has(issue.path.join(".")));
|
|
656
|
+
if (issues?.length) throw new Error(`${what}: ${z.prettifyError(new z.ZodError(issues))}`);
|
|
657
|
+
const name = find?.(desired);
|
|
658
|
+
if (find && (typeof name !== "string" || name === "")) throw new Error(`${what}: its ${identity} is missing`);
|
|
659
|
+
return Object.freeze({
|
|
660
|
+
[DECLARED]: what,
|
|
661
|
+
vendor,
|
|
662
|
+
type,
|
|
663
|
+
...(name !== undefined && { name }),
|
|
664
|
+
desired,
|
|
665
|
+
refs: Object.freeze(refs),
|
|
666
|
+
});
|
|
423
667
|
};
|
|
424
668
|
const resource = Object.assign(declare, {
|
|
425
669
|
vendor,
|
|
426
670
|
type,
|
|
427
671
|
title: spec.title,
|
|
428
|
-
identity
|
|
672
|
+
identity,
|
|
429
673
|
schema,
|
|
430
674
|
fields,
|
|
431
675
|
find,
|
|
676
|
+
bindingId: spec.bindingId ?? ((state: State) => String(state.id)),
|
|
432
677
|
normalize: spec.normalize ?? ((state: State, desired: z.input<S>) => compareDeclared(fields, state, desired)),
|
|
678
|
+
writable,
|
|
679
|
+
...(host && { host }),
|
|
680
|
+
stateless: spec.stateless ?? host !== undefined,
|
|
681
|
+
vendorless: spec.vendorless ?? false,
|
|
682
|
+
...(spec.afterCreate && { afterCreate: spec.afterCreate }),
|
|
433
683
|
ops,
|
|
434
684
|
// A symbol, so `defineConnector` tells a resource type from a group of operations.
|
|
435
685
|
[RESOURCE]: true as const,
|