@sanoma-ai/workflows 0.1.0 → 0.2.0

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