@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.
Files changed (184) hide show
  1. package/README.md +186 -76
  2. package/dist/apply-steps.d.ts +6 -0
  3. package/dist/apply-steps.js +93 -0
  4. package/dist/apply-steps.js.map +1 -0
  5. package/dist/apply.d.ts +197 -0
  6. package/dist/apply.js +470 -0
  7. package/dist/apply.js.map +1 -0
  8. package/dist/approvals.d.ts +11 -6
  9. package/dist/approvals.js +38 -10
  10. package/dist/approvals.js.map +1 -1
  11. package/dist/call-workflow.d.ts +16 -0
  12. package/dist/call-workflow.js +35 -0
  13. package/dist/call-workflow.js.map +1 -0
  14. package/dist/call.d.ts +35 -2
  15. package/dist/call.js +123 -43
  16. package/dist/call.js.map +1 -1
  17. package/dist/cedar.d.ts +87 -0
  18. package/dist/cedar.js +365 -0
  19. package/dist/cedar.js.map +1 -0
  20. package/dist/client.d.ts +123 -9
  21. package/dist/client.js +355 -34
  22. package/dist/client.js.map +1 -1
  23. package/dist/collection.d.ts +107 -0
  24. package/dist/collection.js +306 -0
  25. package/dist/collection.js.map +1 -0
  26. package/dist/config.d.ts +42 -14
  27. package/dist/config.js +109 -28
  28. package/dist/config.js.map +1 -1
  29. package/dist/constructors.d.ts +2 -0
  30. package/dist/constructors.js +5 -0
  31. package/dist/constructors.js.map +1 -0
  32. package/dist/credentials.d.ts +25 -0
  33. package/dist/credentials.js +73 -5
  34. package/dist/credentials.js.map +1 -1
  35. package/dist/data.d.ts +2 -0
  36. package/dist/data.js +3 -0
  37. package/dist/data.js.map +1 -0
  38. package/dist/datafile.d.ts +38 -3
  39. package/dist/datafile.js +166 -13
  40. package/dist/datafile.js.map +1 -1
  41. package/dist/db.d.ts +5 -3
  42. package/dist/db.js +88 -21
  43. package/dist/db.js.map +1 -1
  44. package/dist/define.d.ts +10 -4
  45. package/dist/define.js +1 -5
  46. package/dist/define.js.map +1 -1
  47. package/dist/describe.d.ts +54 -8
  48. package/dist/describe.js +84 -11
  49. package/dist/describe.js.map +1 -1
  50. package/dist/direct.d.ts +9 -0
  51. package/dist/direct.js +11 -0
  52. package/dist/direct.js.map +1 -0
  53. package/dist/drift.d.ts +64 -12
  54. package/dist/drift.js +118 -41
  55. package/dist/drift.js.map +1 -1
  56. package/dist/errors.d.ts +16 -1
  57. package/dist/errors.js +30 -1
  58. package/dist/errors.js.map +1 -1
  59. package/dist/expectations.d.ts +4 -4
  60. package/dist/expectations.js +43 -9
  61. package/dist/expectations.js.map +1 -1
  62. package/dist/host.d.ts +56 -0
  63. package/dist/host.js +45 -0
  64. package/dist/host.js.map +1 -0
  65. package/dist/index.d.ts +13 -5
  66. package/dist/index.js +15 -5
  67. package/dist/index.js.map +1 -1
  68. package/dist/ledger.d.ts +12 -1
  69. package/dist/ledger.js +18 -11
  70. package/dist/ledger.js.map +1 -1
  71. package/dist/lint.d.ts +5 -4
  72. package/dist/lint.js +24 -7
  73. package/dist/lint.js.map +1 -1
  74. package/dist/lockfile.d.ts +36 -0
  75. package/dist/lockfile.js +69 -0
  76. package/dist/lockfile.js.map +1 -0
  77. package/dist/op.d.ts +9 -0
  78. package/dist/op.js.map +1 -1
  79. package/dist/outline.d.ts +2 -0
  80. package/dist/outline.js +1 -1
  81. package/dist/outline.js.map +1 -1
  82. package/dist/paths.d.ts +4 -0
  83. package/dist/paths.js +17 -3
  84. package/dist/paths.js.map +1 -1
  85. package/dist/person.d.ts +61 -0
  86. package/dist/person.js +69 -0
  87. package/dist/person.js.map +1 -0
  88. package/dist/plugin.js +1 -1
  89. package/dist/plugin.js.map +1 -1
  90. package/dist/policy.d.ts +51 -55
  91. package/dist/policy.js +14 -36
  92. package/dist/policy.js.map +1 -1
  93. package/dist/resource.d.ts +207 -25
  94. package/dist/resource.js +184 -30
  95. package/dist/resource.js.map +1 -1
  96. package/dist/resources.d.ts +76 -9
  97. package/dist/resources.js +170 -30
  98. package/dist/resources.js.map +1 -1
  99. package/dist/run.d.ts +31 -1
  100. package/dist/run.js +6 -1
  101. package/dist/run.js.map +1 -1
  102. package/dist/sandbox.js +5 -1
  103. package/dist/sandbox.js.map +1 -1
  104. package/dist/scenario.d.ts +25 -9
  105. package/dist/scenario.js +125 -27
  106. package/dist/scenario.js.map +1 -1
  107. package/dist/scenarios.d.ts +10 -6
  108. package/dist/scenarios.js +8 -4
  109. package/dist/scenarios.js.map +1 -1
  110. package/dist/schema.d.ts +32 -0
  111. package/dist/schema.js +225 -0
  112. package/dist/schema.js.map +1 -0
  113. package/dist/sdk.d.ts +34 -0
  114. package/dist/sdk.js +168 -0
  115. package/dist/sdk.js.map +1 -0
  116. package/dist/secret.d.ts +42 -0
  117. package/dist/secret.js +58 -0
  118. package/dist/secret.js.map +1 -0
  119. package/dist/shared.d.ts +188 -4
  120. package/dist/shared.js +165 -5
  121. package/dist/shared.js.map +1 -1
  122. package/dist/sites.d.ts +52 -0
  123. package/dist/sites.js +174 -0
  124. package/dist/sites.js.map +1 -0
  125. package/dist/source.d.ts +58 -0
  126. package/dist/source.js +54 -0
  127. package/dist/source.js.map +1 -0
  128. package/dist/version.d.ts +3 -2
  129. package/dist/version.js +3 -2
  130. package/dist/version.js.map +1 -1
  131. package/dist/worker.d.ts +6 -2
  132. package/dist/worker.js +37 -8
  133. package/dist/worker.js.map +1 -1
  134. package/dist/writer.d.ts +35 -0
  135. package/dist/writer.js +201 -0
  136. package/dist/writer.js.map +1 -0
  137. package/oxlint.json +5 -16
  138. package/package.json +11 -1
  139. package/src/apply-steps.ts +110 -0
  140. package/src/apply.ts +626 -0
  141. package/src/approvals.ts +49 -13
  142. package/src/call-workflow.ts +44 -0
  143. package/src/call.ts +173 -46
  144. package/src/cedar.ts +493 -0
  145. package/src/client.ts +450 -49
  146. package/src/collection.ts +431 -0
  147. package/src/config.ts +168 -48
  148. package/src/constructors.ts +4 -0
  149. package/src/credentials.ts +90 -5
  150. package/src/data.ts +10 -0
  151. package/src/datafile.ts +207 -15
  152. package/src/db.ts +90 -22
  153. package/src/define.ts +11 -9
  154. package/src/describe.ts +146 -23
  155. package/src/direct.ts +15 -0
  156. package/src/drift.ts +155 -45
  157. package/src/errors.ts +36 -1
  158. package/src/expectations.ts +46 -11
  159. package/src/host.ts +96 -0
  160. package/src/index.ts +56 -2
  161. package/src/ledger.ts +35 -17
  162. package/src/lint.ts +29 -12
  163. package/src/lockfile.ts +95 -0
  164. package/src/op.ts +9 -0
  165. package/src/outline.ts +1 -1
  166. package/src/paths.ts +18 -3
  167. package/src/person.ts +75 -0
  168. package/src/plugin.ts +1 -1
  169. package/src/policy.ts +59 -63
  170. package/src/resource.ts +302 -52
  171. package/src/resources.ts +226 -30
  172. package/src/run.ts +33 -1
  173. package/src/sandbox.ts +5 -1
  174. package/src/scenario.ts +175 -43
  175. package/src/scenarios.ts +15 -8
  176. package/src/schema.ts +261 -0
  177. package/src/sdk.ts +211 -0
  178. package/src/secret.ts +92 -0
  179. package/src/shared.ts +279 -6
  180. package/src/sites.ts +220 -0
  181. package/src/source.ts +82 -0
  182. package/src/version.ts +3 -2
  183. package/src/worker.ts +45 -11
  184. 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 (Stripe's `id`).
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: string;
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
- /** The vendor's id for a declared resource (the import id), from its declared fields. */
57
- find: (desired: z.input<S>) => string;
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
- /** The vendor's id for it: what `find` made of `desired`. */
79
- readonly name: string;
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] ? T : DeclaringAt<T, R, "">;
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 | DeclaredOf<R[P]["type"]>
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({ id: importId, state: z.record(z.string(), z.unknown()).optional(), handle }), output },
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 import id. */
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
- /** A resource type's operation specs: `read` and `import`. */
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
- > extends Readonly<Required<ResourceSpec<S>>> {
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 import id as `target`. */
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 keys = path.split(".");
266
- const last = keys.pop()!;
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`): what a run may
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 }`, the import id, and returns `{ gone: false, state }`, the object's
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
- >(spec: ResourceSpec<S> & { vendor: V; type: T; fields: { references?: R } }): Resource<S, `${V}.${T}`, R> {
364
- const { vendor, type, schema, fields, find } = spec;
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
- const ops = {
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 ${spec.title.toLowerCase()} by its ${spec.identity} and read it; gone when there is none`,
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 ${spec.title.toLowerCase()} as it is now; gone when it no longer exists`,
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 ResourceOps<S>;
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(spec.identity.match(/[A-Za-z_][\w.]*/g));
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
- if (wants.type !== id)
399
- throw new Error(`${what}: ${at} names a resource of type ${wants.type}, not ${id} (${value.name})`);
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
- if (!parsed.success) throw new Error(`${what}: ${z.prettifyError(parsed.error)}`);
420
- const name = find(desired);
421
- if (typeof name !== "string" || name === "") throw new Error(`${what}: its ${spec.identity} is missing`);
422
- return Object.freeze({ [DECLARED]: what, vendor, type, name, desired, refs: Object.freeze(refs) });
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: spec.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,