@sanoma-ai/workflows 0.0.0-stage → 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/LICENSE +105 -0
- package/README.md +544 -2
- 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 +46 -0
- package/dist/approvals.js +111 -0
- package/dist/approvals.js.map +1 -0
- package/dist/ast.d.ts +57 -0
- package/dist/ast.js +81 -0
- package/dist/ast.js.map +1 -0
- package/dist/call.d.ts +37 -0
- package/dist/call.js +417 -0
- package/dist/call.js.map +1 -0
- package/dist/client.d.ts +203 -0
- package/dist/client.js +457 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +126 -0
- package/dist/config.js +264 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +55 -0
- package/dist/credentials.js +261 -0
- package/dist/credentials.js.map +1 -0
- package/dist/cucumber.d.ts +64 -0
- package/dist/cucumber.js +158 -0
- package/dist/cucumber.js.map +1 -0
- package/dist/datafile.d.ts +71 -0
- package/dist/datafile.js +380 -0
- package/dist/datafile.js.map +1 -0
- package/dist/db.d.ts +58 -0
- package/dist/db.js +179 -0
- package/dist/db.js.map +1 -0
- package/dist/define.d.ts +205 -0
- package/dist/define.js +103 -0
- package/dist/define.js.map +1 -0
- package/dist/describe.d.ts +141 -0
- package/dist/describe.js +135 -0
- package/dist/describe.js.map +1 -0
- package/dist/drift.d.ts +127 -0
- package/dist/drift.js +167 -0
- package/dist/drift.js.map +1 -0
- package/dist/env.d.ts +49 -0
- package/dist/env.js +83 -0
- package/dist/env.js.map +1 -0
- package/dist/errors.d.ts +88 -0
- package/dist/errors.js +148 -0
- package/dist/errors.js.map +1 -0
- package/dist/expectations.d.ts +26 -0
- package/dist/expectations.js +162 -0
- package/dist/expectations.js.map +1 -0
- package/dist/fake.d.ts +55 -0
- package/dist/fake.js +87 -0
- package/dist/fake.js.map +1 -0
- package/dist/fill.d.ts +9 -0
- package/dist/fill.js +17 -0
- package/dist/fill.js.map +1 -0
- 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 +23 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger.d.ts +217 -0
- package/dist/ledger.js +251 -0
- package/dist/ledger.js.map +1 -0
- package/dist/lint.d.ts +32 -0
- package/dist/lint.js +168 -0
- package/dist/lint.js.map +1 -0
- package/dist/lockfile.d.ts +36 -0
- package/dist/lockfile.js +69 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/log.d.ts +24 -0
- package/dist/log.js +83 -0
- package/dist/log.js.map +1 -0
- package/dist/op.d.ts +260 -0
- package/dist/op.js +188 -0
- package/dist/op.js.map +1 -0
- package/dist/outline.d.ts +179 -0
- package/dist/outline.js +506 -0
- package/dist/outline.js.map +1 -0
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +30 -0
- package/dist/paths.js.map +1 -0
- package/dist/plugin.d.ts +28 -0
- package/dist/plugin.js +25 -0
- package/dist/plugin.js.map +1 -0
- package/dist/policy.d.ts +114 -0
- package/dist/policy.js +60 -0
- package/dist/policy.js.map +1 -0
- package/dist/resource.d.ts +378 -0
- package/dist/resource.js +430 -0
- package/dist/resource.js.map +1 -0
- package/dist/resources.d.ts +77 -0
- package/dist/resources.js +244 -0
- package/dist/resources.js.map +1 -0
- package/dist/run.d.ts +101 -0
- package/dist/run.js +7 -0
- package/dist/run.js.map +1 -0
- package/dist/sandbox.d.ts +33 -0
- package/dist/sandbox.js +127 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/scenario.d.ts +221 -0
- package/dist/scenario.js +1104 -0
- package/dist/scenario.js.map +1 -0
- package/dist/scenarios.d.ts +55 -0
- package/dist/scenarios.js +71 -0
- package/dist/scenarios.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 +69 -0
- package/dist/shared.js +84 -0
- package/dist/shared.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/steps.d.ts +76 -0
- package/dist/steps.js +23 -0
- package/dist/steps.js.map +1 -0
- package/dist/version.d.ts +29 -0
- package/dist/version.js +76 -0
- package/dist/version.js.map +1 -0
- package/dist/worker.d.ts +37 -0
- package/dist/worker.js +228 -0
- package/dist/worker.js.map +1 -0
- package/dist/writer.d.ts +35 -0
- package/dist/writer.js +201 -0
- package/dist/writer.js.map +1 -0
- package/oxlint.json +128 -0
- package/package.json +96 -3
- package/src/apply-steps.ts +110 -0
- package/src/apply.ts +626 -0
- package/src/approvals.ts +157 -0
- package/src/ast.ts +115 -0
- package/src/call.ts +483 -0
- package/src/client.ts +604 -0
- package/src/config.ts +404 -0
- package/src/credentials.ts +296 -0
- package/src/cucumber.ts +230 -0
- package/src/datafile.ts +481 -0
- package/src/db.ts +197 -0
- package/src/define.ts +285 -0
- package/src/describe.ts +282 -0
- package/src/drift.ts +259 -0
- package/src/env.ts +122 -0
- package/src/errors.ts +202 -0
- package/src/expectations.ts +191 -0
- package/src/fake.ts +156 -0
- package/src/fill.ts +24 -0
- package/src/host.ts +96 -0
- package/src/index.ts +143 -0
- package/src/ledger.ts +419 -0
- package/src/lint.ts +208 -0
- package/src/lockfile.ts +95 -0
- package/src/log.ts +82 -0
- package/src/op.ts +402 -0
- package/src/outline.ts +620 -0
- package/src/paths.ts +32 -0
- package/src/plugin.ts +37 -0
- package/src/policy.ts +139 -0
- package/src/resource.ts +670 -0
- package/src/resources.ts +311 -0
- package/src/run.ts +106 -0
- package/src/sandbox.ts +161 -0
- package/src/scenario.ts +1365 -0
- package/src/scenarios.ts +111 -0
- package/src/secret.ts +92 -0
- package/src/shared.ts +126 -0
- package/src/source.ts +82 -0
- package/src/steps.ts +90 -0
- package/src/version.ts +86 -0
- package/src/worker.ts +264 -0
- package/src/writer.ts +225 -0
package/src/resource.ts
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { checkHost, type HostSpec } from "./host.ts";
|
|
3
|
+
import { type Connector, type OpSpec, RESOURCE, VENDOR } from "./op.ts";
|
|
4
|
+
import { isSanomaSecret, isSecret, type Secret } from "./secret.ts";
|
|
5
|
+
|
|
6
|
+
/** Which fields of a resource are flagged, by dotted path (`pages.html_url`; a list's items have no index). */
|
|
7
|
+
export interface ResourceFields {
|
|
8
|
+
/** Changing it replaces the object. */
|
|
9
|
+
readonly immutable: readonly string[];
|
|
10
|
+
/** Set by the vendor only: never drift, and a data file may not declare it. */
|
|
11
|
+
readonly vendorOwned: readonly string[];
|
|
12
|
+
/** Sensitive or write-only: never read back, never compared, never in an operation's output. */
|
|
13
|
+
readonly writeOnly: readonly string[];
|
|
14
|
+
/** Lists whose order is the vendor's (a set): compared as multisets, in no order. */
|
|
15
|
+
readonly unordered?: readonly string[];
|
|
16
|
+
/**
|
|
17
|
+
* Fields that name another declared resource, by dotted path: the `<vendor>.<type>` it is, and
|
|
18
|
+
* how the vendor spells it (`by`): `{ repository_id: { type: "github.repository", by: ["name",
|
|
19
|
+
* "node_id"] } }`. A data file gives that resource there (`repository_id: site`), which stands
|
|
20
|
+
* for its `name`; anywhere else a resource is refused.
|
|
21
|
+
*/
|
|
22
|
+
readonly references?: References;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Where a field names another declared resource: see `ResourceFields.references`. */
|
|
26
|
+
export interface FieldReference {
|
|
27
|
+
/** The `<vendor>.<type>` of the resource it names. */
|
|
28
|
+
readonly type: string;
|
|
29
|
+
/**
|
|
30
|
+
* The fields of that resource's state the vendor may hold here instead of its `name`, such as
|
|
31
|
+
* GitHub's `node_id`: a drift check takes any of their values, read in the same run, as the
|
|
32
|
+
* declared one. Without it, the field holds the resource's `name`, and is compared as it is.
|
|
33
|
+
*/
|
|
34
|
+
readonly by?: readonly string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A resource type's `fields.references`: by dotted path, the resource each names. */
|
|
38
|
+
export type References = { readonly [path: string]: FieldReference };
|
|
39
|
+
|
|
40
|
+
type State = Record<string, unknown>;
|
|
41
|
+
|
|
42
|
+
/** A resource type, as its connector declares it with `defineResource`. */
|
|
43
|
+
export interface ResourceSpec<S extends z.ZodObject = z.ZodObject> {
|
|
44
|
+
/** The connector's vendor id, such as `github`; `defineConnector` refuses a type of another vendor. */
|
|
45
|
+
vendor: string;
|
|
46
|
+
/** The type's name within the vendor, such as `repository`: its operations are `<vendor>.<type>.read` and `.import`. */
|
|
47
|
+
type: string;
|
|
48
|
+
/** What people call it, such as "Repository". */
|
|
49
|
+
title: string;
|
|
50
|
+
/**
|
|
51
|
+
* How `find` makes the import id, for people: `name`, `repository_id:pattern`. The fields it
|
|
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.
|
|
54
|
+
*/
|
|
55
|
+
identity?: string;
|
|
56
|
+
/** The resource's fields as a read returns them; a data file declares them, less the vendor-owned ones. */
|
|
57
|
+
schema: S;
|
|
58
|
+
fields: ResourceFields;
|
|
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;
|
|
69
|
+
/**
|
|
70
|
+
* The part of a state that a drift check compares, given what was declared. The check
|
|
71
|
+
* compares `normalize(actual, desired)` with `normalize(desired, desired)`. Default:
|
|
72
|
+
* `compareDeclared`, which keeps the declared fields only.
|
|
73
|
+
*/
|
|
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;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Where a declared resource keeps its brand, its type's `<vendor>.<type>`: a symbol, so a field
|
|
110
|
+
* holding a resource is told from data; a registered one (`Symbol.for`), so a copy of this
|
|
111
|
+
* package reads what another copy declared, as `VENDOR`.
|
|
112
|
+
*/
|
|
113
|
+
export const DECLARED: unique symbol = Symbol.for("sanoma.declared");
|
|
114
|
+
|
|
115
|
+
/** A resource a data file declares: `github.repository({ name: "sanoma" })`. */
|
|
116
|
+
export interface Declared<Id extends string = string, T = State> {
|
|
117
|
+
readonly [DECLARED]: Id;
|
|
118
|
+
readonly vendor: string;
|
|
119
|
+
readonly type: string;
|
|
120
|
+
/** Its identity, the import id that finds it unbound: what `find` made of `desired`; absent without `find`. */
|
|
121
|
+
readonly name?: string;
|
|
122
|
+
/**
|
|
123
|
+
* Its fields, each resource it names replaced by that resource's `name`: what its type
|
|
124
|
+
* checked, and what a drift check compares.
|
|
125
|
+
*/
|
|
126
|
+
readonly desired: T;
|
|
127
|
+
/**
|
|
128
|
+
* Where it names another resource: the field's dotted path (a list item's with its index,
|
|
129
|
+
* `teams.0`) to that resource's `<vendor>.<type>:<name>`.
|
|
130
|
+
*/
|
|
131
|
+
readonly refs: Readonly<Record<string, string>>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* What a resource type's constructor takes: its fields, where `R` (its `fields.references`)
|
|
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.
|
|
139
|
+
*/
|
|
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;
|
|
151
|
+
|
|
152
|
+
type DeclaringAt<T, R extends References, P extends string> = T extends readonly (infer I)[]
|
|
153
|
+
? DeclaringAt<I, R, P>[]
|
|
154
|
+
: T extends object
|
|
155
|
+
? { [K in keyof T]: K extends string ? DeclaringField<T[K], R, P extends "" ? K : `${P}.${K}`> : T[K] }
|
|
156
|
+
: WithSecrets<T>;
|
|
157
|
+
|
|
158
|
+
type DeclaringField<V, R extends References, P extends string> = P extends keyof R
|
|
159
|
+
? WithSecrets<V> | DeclaredOf<R[P]["type"]>
|
|
160
|
+
: [Extract<keyof R, `${P}.${string}`>] extends [never]
|
|
161
|
+
? WithSecrets<V>
|
|
162
|
+
: DeclaringAt<V, R, P>;
|
|
163
|
+
|
|
164
|
+
type DeclaredOf<Id> = Id extends string ? Declared<Id> : never;
|
|
165
|
+
|
|
166
|
+
const importId = z.string().min(1);
|
|
167
|
+
/** What a driver keeps beside a state (an OpenTofu provider's private data and state version), passed back unchanged. */
|
|
168
|
+
const handle = z.string().optional().describe("The driver's data for the object, opaque: pass it back unchanged");
|
|
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
|
+
|
|
179
|
+
const opsOf = <S extends z.ZodObject>(schema: S) => {
|
|
180
|
+
// Both answer `{ gone: true }` when the vendor has no such object, and its state when it has.
|
|
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());
|
|
189
|
+
return {
|
|
190
|
+
import: { input: z.object({ id: importId }), output },
|
|
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) }) },
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/** What the ledger keeps of a resource operation's `handle`: its name, never its bytes. */
|
|
212
|
+
const opaque = ["handle"] as const;
|
|
213
|
+
|
|
214
|
+
/** The policy's `target` for a resource's operations: the id, an identity or a binding id. */
|
|
215
|
+
const target = ({ id }: { id: string }) => id;
|
|
216
|
+
|
|
217
|
+
type OpsOf<S extends z.ZodObject> = ReturnType<typeof opsOf<S>>;
|
|
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"]>;
|
|
220
|
+
};
|
|
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
|
+
|
|
242
|
+
/**
|
|
243
|
+
* A resource type: call it to declare a resource in a data file, and give it to
|
|
244
|
+
* `defineConnector` as a group, under its `type`: `defineConnector("github", { repository })`.
|
|
245
|
+
*/
|
|
246
|
+
export interface Resource<
|
|
247
|
+
S extends z.ZodObject = z.ZodObject,
|
|
248
|
+
Id extends string = string,
|
|
249
|
+
R extends References = {},
|
|
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">> {
|
|
255
|
+
(desired: Declaring<z.input<S>, R>): Declared<Id, z.input<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;
|
|
262
|
+
readonly [RESOURCE]: true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** True for a plain object: not null, not a list. */
|
|
266
|
+
export const isObject = (v: unknown): v is State => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
267
|
+
|
|
268
|
+
/** `path.name`, or `name` at the top: a field's dotted path. */
|
|
269
|
+
export const joinPath = (path: string, name: string) => (path ? `${path}.${name}` : name);
|
|
270
|
+
|
|
271
|
+
const isDeclared = (v: unknown): v is Declared => isObject(v) && typeof (v as Partial<Declared>)[DECLARED] === "string";
|
|
272
|
+
|
|
273
|
+
/** One order for a list compared as a multiset: by each item's JSON. */
|
|
274
|
+
const canonical = (items: unknown[]) =>
|
|
275
|
+
items
|
|
276
|
+
.map((item) => [JSON.stringify(item) ?? "", item] as const)
|
|
277
|
+
.toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
278
|
+
.map(([, item]) => item);
|
|
279
|
+
|
|
280
|
+
interface Comparer {
|
|
281
|
+
pick: (state: State, desired: State) => State;
|
|
282
|
+
/** `fields.unordered`, as a set. */
|
|
283
|
+
unordered: ReadonlySet<string>;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const comparers = new WeakMap<ResourceFields, Comparer>();
|
|
287
|
+
|
|
288
|
+
/** The type's comparer, made once per `fields`. */
|
|
289
|
+
function comparerOf(fields: ResourceFields): Comparer {
|
|
290
|
+
let compare = comparers.get(fields);
|
|
291
|
+
if (!compare) {
|
|
292
|
+
compare = comparer(fields);
|
|
293
|
+
comparers.set(fields, compare);
|
|
294
|
+
}
|
|
295
|
+
return compare;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The fields of `state` that `desired` declares, minus vendor-owned and write-only ones: only
|
|
300
|
+
* declared fields are compared, as CloudFormation does. An object is picked field by field, a
|
|
301
|
+
* list item by item against the declared item at its index, and an `unordered` list (a set)
|
|
302
|
+
* item by item against what its declared items declare, then sorted, so its order never
|
|
303
|
+
* drifts. Anything else is kept whole.
|
|
304
|
+
*
|
|
305
|
+
* So a field the data file leaves out is never drift, whatever the vendor holds. That covers
|
|
306
|
+
* attributes the provider marks `computed` and `optional`: they are the user's to set, not
|
|
307
|
+
* vendor-owned, but the vendor fills them when nobody does (GitHub's `etag`, `topics`,
|
|
308
|
+
* `visibility`), so they are compared only when declared.
|
|
309
|
+
*/
|
|
310
|
+
export function compareDeclared(fields: ResourceFields, state: State, desired: State): State {
|
|
311
|
+
return comparerOf(fields).pick(state, desired);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function comparer(fields: ResourceFields): Comparer {
|
|
315
|
+
const skip = new Set([...fields.vendorOwned, ...fields.writeOnly]);
|
|
316
|
+
const unordered = new Set(fields.unordered);
|
|
317
|
+
const pick = (actual: unknown, declared: unknown, path: string): unknown => {
|
|
318
|
+
if (Array.isArray(declared) && Array.isArray(actual)) {
|
|
319
|
+
if (unordered.has(path)) {
|
|
320
|
+
// What any declared item declares: a set's items have no declared counterpart by index.
|
|
321
|
+
const objects = declared.filter(isObject);
|
|
322
|
+
const like = objects.length ? Object.assign({}, ...objects) : undefined;
|
|
323
|
+
return canonical(like ? actual.map((item) => pick(item, like, path)) : actual);
|
|
324
|
+
}
|
|
325
|
+
return actual.map((item, i) => (i < declared.length ? pick(item, declared[i], path) : item));
|
|
326
|
+
}
|
|
327
|
+
if (!isObject(declared) || !isObject(actual)) return actual ?? null;
|
|
328
|
+
const out: State = {};
|
|
329
|
+
for (const [name, value] of Object.entries(declared)) {
|
|
330
|
+
const at = joinPath(path, name);
|
|
331
|
+
if (value === undefined || skip.has(at)) continue;
|
|
332
|
+
out[name] = pick(actual[name], value, at);
|
|
333
|
+
}
|
|
334
|
+
return out;
|
|
335
|
+
};
|
|
336
|
+
return { pick: (state, desired) => pick(state, desired, "") as State, unordered };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** A declared field the vendor holds another value in. */
|
|
340
|
+
export interface DriftField {
|
|
341
|
+
/** Its dotted path, a list item's with its index: `required_pull_request_reviews.0.dismiss_stale_reviews`. */
|
|
342
|
+
path: string;
|
|
343
|
+
/** What the data file declares. */
|
|
344
|
+
desired: unknown;
|
|
345
|
+
/** What the vendor holds, `null` for nothing. */
|
|
346
|
+
actual: unknown;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Where `state` differs from what `desired` declares: the type's `normalize` of each (by default
|
|
351
|
+
* `compareDeclared`: declared fields only, never vendor-owned or write-only ones, `unordered`
|
|
352
|
+
* lists as sets), then leaf by leaf: objects field by field, lists of one length item by item,
|
|
353
|
+
* and anything else (a list whose length changed, an `unordered` one) whole.
|
|
354
|
+
*
|
|
355
|
+
* `accept` is, by a reference's path as `refs` has it (`repository_id`, `teams.0`), the values
|
|
356
|
+
* the vendor may hold there for the resource it names (its `node_id`, say: see
|
|
357
|
+
* `FieldReference.by`): any of them counts as the declared value.
|
|
358
|
+
*/
|
|
359
|
+
export function diffDeclared(
|
|
360
|
+
// `any`: a type's `normalize` takes its own declared fields.
|
|
361
|
+
resource: { readonly fields: ResourceFields; readonly normalize: (state: State, desired: any) => State },
|
|
362
|
+
state: State,
|
|
363
|
+
desired: State,
|
|
364
|
+
accept: Readonly<Record<string, readonly unknown[]>> = {},
|
|
365
|
+
): DriftField[] {
|
|
366
|
+
const actual = withAccepted(state, desired, accept);
|
|
367
|
+
const { unordered } = comparerOf(resource.fields);
|
|
368
|
+
return changes(resource.normalize(desired, desired), resource.normalize(actual, desired), unordered);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** `state`, with each value `accept` takes at its path replaced by the declared one there. */
|
|
372
|
+
function withAccepted(state: State, desired: State, accept: Readonly<Record<string, readonly unknown[]>>): State {
|
|
373
|
+
const paths = Object.entries(accept).filter(([, values]) => values.length);
|
|
374
|
+
if (!paths.length) return state;
|
|
375
|
+
const out = structuredClone(state);
|
|
376
|
+
for (const [path, values] of paths) {
|
|
377
|
+
const at = holderOf(out, path);
|
|
378
|
+
if (at && values.includes(at.holder[at.key])) at.holder[at.key] = valueAt(desired, path);
|
|
379
|
+
}
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
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
|
+
|
|
394
|
+
/** The value at a dotted path, a list item's by its index. */
|
|
395
|
+
export const valueAt = (value: unknown, path: string) =>
|
|
396
|
+
path.split(".").reduce<unknown>((v, key) => (isObject(v) || Array.isArray(v) ? (v as State)[key] : undefined), value);
|
|
397
|
+
|
|
398
|
+
/** True when the two are the same JSON, where a missing value is `null`. */
|
|
399
|
+
function same(a: unknown, b: unknown): boolean {
|
|
400
|
+
if (a === b || (a == null && b == null)) return true;
|
|
401
|
+
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((item, i) => same(item, b[i]));
|
|
402
|
+
if (isObject(a) && isObject(b)) return Object.keys({ ...a, ...b }).every((key) => same(a[key], b[key]));
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Where `actual` differs from `desired`, leaf by leaf. `path` names the field as `fields` does,
|
|
408
|
+
* without indexes; `at` as a report does, with them.
|
|
409
|
+
*/
|
|
410
|
+
function changes(desired: unknown, actual: unknown, unordered: ReadonlySet<string>, path = "", at = ""): DriftField[] {
|
|
411
|
+
if (isObject(desired) && isObject(actual)) {
|
|
412
|
+
return Object.keys({ ...desired, ...actual }).flatMap((key) =>
|
|
413
|
+
changes(desired[key], actual[key], unordered, joinPath(path, key), joinPath(at, key)),
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
if (Array.isArray(desired) && Array.isArray(actual) && desired.length === actual.length && !unordered.has(path)) {
|
|
417
|
+
return desired.flatMap((item, i) => changes(item, actual[i], unordered, path, joinPath(at, String(i))));
|
|
418
|
+
}
|
|
419
|
+
return same(desired, actual) ? [] : [{ path: at, desired: desired ?? null, actual: actual ?? null }];
|
|
420
|
+
}
|
|
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
|
+
|
|
430
|
+
/**
|
|
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.
|
|
435
|
+
*/
|
|
436
|
+
export function withoutWriteOnly(fields: ResourceFields, desired: State): State {
|
|
437
|
+
if (!fields.writeOnly.length) return desired;
|
|
438
|
+
const drop = new Set(fields.writeOnly);
|
|
439
|
+
const strip = (value: unknown, path: string): unknown => {
|
|
440
|
+
if (Array.isArray(value)) return value.map((item) => strip(item, path));
|
|
441
|
+
if (!isObject(value)) return value;
|
|
442
|
+
return Object.fromEntries(
|
|
443
|
+
Object.entries(value).flatMap(([name, v]) => {
|
|
444
|
+
const at = joinPath(path, name);
|
|
445
|
+
return drop.has(at) ? markers(v).map((m) => [name, m]) : [[name, strip(v, at)]];
|
|
446
|
+
}),
|
|
447
|
+
);
|
|
448
|
+
};
|
|
449
|
+
return strip(desired, "") as State;
|
|
450
|
+
}
|
|
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
|
+
|
|
456
|
+
/** The dotted paths in `value` that `paths` holds, looking into objects and lists' items. */
|
|
457
|
+
function pathsIn(value: unknown, paths: ReadonlySet<string>, prefix = ""): string[] {
|
|
458
|
+
if (Array.isArray(value)) return [...new Set(value.flatMap((item) => pathsIn(item, paths, prefix)))];
|
|
459
|
+
if (!isObject(value)) return [];
|
|
460
|
+
return Object.entries(value).flatMap(([name, v]) => {
|
|
461
|
+
const path = joinPath(prefix, name);
|
|
462
|
+
if (v === undefined) return [];
|
|
463
|
+
return paths.has(path) ? [path] : pathsIn(v, paths, path);
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Declares a resource type: its schema, flagged fields, identity and how it is compared, and
|
|
469
|
+
* derives its operations, `<vendor>.<type>.read` and `<vendor>.<type>.import`, both effect
|
|
470
|
+
* `read` and idempotent, with the import id as the policy's `target`:
|
|
471
|
+
*
|
|
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
|
|
474
|
+
* state (with the driver's opaque `handle` when it keeps one), or `{ gone: true }` when the
|
|
475
|
+
* vendor has no such object.
|
|
476
|
+
* - `read` takes `{ id, state?, handle? }`, a state from an earlier `import` or `read`, and
|
|
477
|
+
* returns the same: `{ gone: true }` when the object no longer exists, else its fresh
|
|
478
|
+
* `state`. A driver may import first when it is given no state.
|
|
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
|
+
*
|
|
497
|
+
* The ledger records a `handle` as `"<handle>"`, never its bytes (the operations' `opaque`).
|
|
498
|
+
*
|
|
499
|
+
* Calling the result declares one resource, for a data file: `repository({ name: "sanoma" })`
|
|
500
|
+
* is a `Declared`, `{ vendor, type, name: "sanoma", desired, refs: {} }`. It refuses fields the
|
|
501
|
+
* schema does not have, fields the vendor owns (unless the identity names them), values the
|
|
502
|
+
* schema rejects, and an empty identity. Where `fields.references` allows it, a field may name
|
|
503
|
+
* another declared resource of that type (`repository_id: site`): `desired` holds, and the
|
|
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.
|
|
506
|
+
*/
|
|
507
|
+
export function defineResource<
|
|
508
|
+
S extends z.ZodObject,
|
|
509
|
+
const V extends string,
|
|
510
|
+
const T extends string,
|
|
511
|
+
const R extends References = {},
|
|
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;
|
|
518
|
+
const what: `${V}.${T}` = `${vendor}.${type}`;
|
|
519
|
+
if (host) checkHost(what, host, spec);
|
|
520
|
+
const title = spec.title.toLowerCase();
|
|
521
|
+
const io = opsOf(schema);
|
|
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 = {
|
|
558
|
+
import: {
|
|
559
|
+
effect: "read",
|
|
560
|
+
idempotent: true,
|
|
561
|
+
description: `Find a ${title} by its ${identity ? `${identity} or its ` : ""}binding id and read it; gone when there is none`,
|
|
562
|
+
...io.import,
|
|
563
|
+
target,
|
|
564
|
+
opaque,
|
|
565
|
+
},
|
|
566
|
+
read: {
|
|
567
|
+
effect: "read",
|
|
568
|
+
idempotent: true,
|
|
569
|
+
description: `Read a ${title} as it is now; gone when it no longer exists`,
|
|
570
|
+
...io.read,
|
|
571
|
+
target,
|
|
572
|
+
opaque,
|
|
573
|
+
},
|
|
574
|
+
} satisfies Specs<S, "read" | "import">;
|
|
575
|
+
const ops = (writable ? { ...base, ...changeOps } : base) as unknown as ResourceOps<S, W>;
|
|
576
|
+
|
|
577
|
+
const named = new Set(identity?.match(/[A-Za-z_][\w.]*/g));
|
|
578
|
+
const owned = new Set(fields.vendorOwned.filter((path) => !named.has(path)));
|
|
579
|
+
const references: References = fields.references ?? {};
|
|
580
|
+
const writeOnly = new Set(fields.writeOnly);
|
|
581
|
+
const declare = (given: Declaring<z.input<S>, R>): Declared<`${V}.${T}`, z.input<S>> => {
|
|
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>();
|
|
585
|
+
// Each resource `given` names, where `references` allows one of its type, replaced by its name.
|
|
586
|
+
// `path` is the field's, as `references` names it; `at` has list items' indexes too.
|
|
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
|
+
}
|
|
598
|
+
if (isDeclared(value)) {
|
|
599
|
+
const id = value[DECLARED];
|
|
600
|
+
const wants = references[path];
|
|
601
|
+
if (wants === undefined) throw new Error(`${what}: ${at} takes a value, not a resource (${id} ${value.name})`);
|
|
602
|
+
if (wants.type !== id)
|
|
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`);
|
|
606
|
+
refs[at] = `${id}:${value.name}`;
|
|
607
|
+
return value.name;
|
|
608
|
+
}
|
|
609
|
+
if (Array.isArray(value)) return value.map((item, i) => resolve(item, path, `${at}.${i}`));
|
|
610
|
+
if (!isObject(value)) return value;
|
|
611
|
+
return Object.fromEntries(
|
|
612
|
+
Object.entries(value).map(([field, v]) => [field, resolve(v, joinPath(path, field), joinPath(at, field))]),
|
|
613
|
+
);
|
|
614
|
+
};
|
|
615
|
+
const desired = resolve(given, "", "") as z.input<S>;
|
|
616
|
+
const unknown = Object.keys(desired ?? {}).filter((name) => !Object.hasOwn(schema.shape, name));
|
|
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
|
+
}
|
|
622
|
+
const vendors = pathsIn(desired, owned);
|
|
623
|
+
if (vendors.length) {
|
|
624
|
+
throw new Error(
|
|
625
|
+
`${what}: leave out ${vendors.join(", ")}: the vendor sets ${vendors.length > 1 ? "them" : "it"}`,
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
const parsed = schema.safeParse(desired);
|
|
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
|
+
});
|
|
641
|
+
};
|
|
642
|
+
const resource = Object.assign(declare, {
|
|
643
|
+
vendor,
|
|
644
|
+
type,
|
|
645
|
+
title: spec.title,
|
|
646
|
+
identity,
|
|
647
|
+
schema,
|
|
648
|
+
fields,
|
|
649
|
+
find,
|
|
650
|
+
bindingId: spec.bindingId ?? ((state: State) => String(state.id)),
|
|
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 }),
|
|
656
|
+
ops,
|
|
657
|
+
// A symbol, so `defineConnector` tells a resource type from a group of operations.
|
|
658
|
+
[RESOURCE]: true as const,
|
|
659
|
+
});
|
|
660
|
+
return Object.freeze(resource);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Every resource type the connectors declare, by `<vendor>.<type>`. */
|
|
664
|
+
export function resourceTypesOf(connectors: readonly Connector<any, any>[]): Map<string, Resource> {
|
|
665
|
+
const types = new Map<string, Resource>();
|
|
666
|
+
for (const connector of connectors) {
|
|
667
|
+
for (const r of connector[VENDOR].resources as readonly Resource[]) types.set(`${r.vendor}.${r.type}`, r);
|
|
668
|
+
}
|
|
669
|
+
return types;
|
|
670
|
+
}
|