@sanoma-ai/workflows 0.8.2 → 0.9.1

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 (66) hide show
  1. package/README.md +6 -19
  2. package/dist/apply.js +8 -8
  3. package/dist/apply.js.map +1 -1
  4. package/dist/client.d.ts +1 -13
  5. package/dist/client.js +2 -22
  6. package/dist/client.js.map +1 -1
  7. package/dist/config.d.ts +7 -46
  8. package/dist/config.js +13 -39
  9. package/dist/config.js.map +1 -1
  10. package/dist/cucumber.d.ts +4 -2
  11. package/dist/cucumber.js +8 -3
  12. package/dist/cucumber.js.map +1 -1
  13. package/dist/db.d.ts +0 -1
  14. package/dist/db.js +2 -12
  15. package/dist/db.js.map +1 -1
  16. package/dist/fake.d.ts +1 -9
  17. package/dist/fake.js +2 -12
  18. package/dist/fake.js.map +1 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/resource.d.ts +17 -0
  22. package/dist/resource.js +38 -0
  23. package/dist/resource.js.map +1 -1
  24. package/dist/run.d.ts +0 -2
  25. package/dist/run.js.map +1 -1
  26. package/dist/sandbox.d.ts +10 -38
  27. package/dist/sandbox.js +36 -66
  28. package/dist/sandbox.js.map +1 -1
  29. package/dist/scenario.d.ts +8 -18
  30. package/dist/scenario.js +60 -80
  31. package/dist/scenario.js.map +1 -1
  32. package/dist/scenarios.d.ts +1 -10
  33. package/dist/scenarios.js +2 -16
  34. package/dist/scenarios.js.map +1 -1
  35. package/dist/sites.d.ts +6 -0
  36. package/dist/sites.js +49 -10
  37. package/dist/sites.js.map +1 -1
  38. package/dist/source.d.ts +6 -0
  39. package/dist/source.js +13 -0
  40. package/dist/source.js.map +1 -1
  41. package/dist/steps.d.ts +3 -1
  42. package/dist/steps.js.map +1 -1
  43. package/dist/worker.d.ts +1 -7
  44. package/dist/worker.js +2 -3
  45. package/dist/worker.js.map +1 -1
  46. package/package.json +2 -2
  47. package/src/apply.ts +16 -8
  48. package/src/client.ts +2 -28
  49. package/src/config.ts +14 -91
  50. package/src/cucumber.ts +16 -3
  51. package/src/db.ts +2 -13
  52. package/src/fake.ts +1 -13
  53. package/src/index.ts +0 -1
  54. package/src/resource.ts +43 -0
  55. package/src/run.ts +0 -2
  56. package/src/sandbox.ts +38 -94
  57. package/src/scenario.ts +73 -100
  58. package/src/scenarios.ts +3 -18
  59. package/src/sites.ts +67 -11
  60. package/src/source.ts +14 -0
  61. package/src/steps.ts +3 -1
  62. package/src/worker.ts +2 -9
  63. package/dist/demo.d.ts +0 -39
  64. package/dist/demo.js +0 -87
  65. package/dist/demo.js.map +0 -1
  66. package/src/demo.ts +0 -118
package/src/config.ts CHANGED
@@ -41,7 +41,7 @@ import { type CedarPolicySource, policyAt } from "./cedar.ts";
41
41
  import type { Policy } from "./policy.ts";
42
42
  import { type Resource, resourceTypesOf, withoutWriteOnly } from "./resource.ts";
43
43
  import { APPLY_WORKFLOW, BUILT_IN_WORKFLOWS, CALL_WORKFLOW, CHANGE_WORKFLOW, DATA_VENDOR } from "./shared.ts";
44
- import { type RemoteHalf, sourceDriver, sourceOps } from "./source.ts";
44
+ import { fakeSourceDriver, type RemoteHalf, SOURCE_RESOLVE, sourceDriver, sourceOps } from "./source.ts";
45
45
  import { appsListDriver, appsListOps } from "./apps-list.ts";
46
46
  import type { Steps } from "./steps.ts";
47
47
  import { checkSchedule } from "./schedules.ts";
@@ -114,43 +114,6 @@ export interface SanomaConfig {
114
114
  * that sets it and declares a wakeup of its own.
115
115
  */
116
116
  wakeup?: true;
117
- /** How this repo runs as a demo (`sanoma demo`, or `SANOMA_DEMO=1` on a host). */
118
- demo?: DemoConfig;
119
- }
120
-
121
- /** A demo's settings (`SanomaConfig.demo`). */
122
- export interface DemoConfig {
123
- /**
124
- * The URL the banner's "Start with these apps" opens, such as the hosted wizard with the demo's
125
- * apps chosen; without one, the banner has no such link.
126
- */
127
- start?: string;
128
- /**
129
- * A session per visitor: each visitor gets a copy of the seeded database and a process of their
130
- * own over it, so the moments wait for them alone and nothing they type reaches another. At most
131
- * this many at once, or with `true` as many as the machine's memory and Postgres's connections
132
- * hold; past it, visitors share one demo instance. Without it, every visitor shares that instance.
133
- */
134
- sessions?: true | number;
135
- /** Minutes a visitor's session lives unused before it is dropped. Default 30. */
136
- idle?: number;
137
- /** Minutes between seeds of the demo afresh (its shared instance, or the sessions' seed). Default 60. */
138
- reseed?: number;
139
- }
140
-
141
- /** Refuses a `demo` whose numbers make no demo: `sessions` `true` or a whole number above 0, `idle` and `reseed` minutes above 0. */
142
- function checkDemo(demo: DemoConfig | undefined): void {
143
- const { sessions, idle, reseed } = demo ?? {};
144
- if (sessions !== undefined && sessions !== true && !(Number.isInteger(sessions) && sessions > 0)) {
145
- throw new Error(
146
- `The config's \`demo.sessions\` is \`true\` or how many at once, a whole number above 0, not ${JSON.stringify(sessions)}`,
147
- );
148
- }
149
- for (const [key, minutes] of Object.entries({ idle, reseed })) {
150
- if (minutes !== undefined && !(typeof minutes === "number" && minutes > 0)) {
151
- throw new Error(`The config's \`demo.${key}\` is minutes, a number above 0, not ${JSON.stringify(minutes)}`);
152
- }
153
- }
154
117
  }
155
118
 
156
119
  /** A config, checked, with everything a worker, client or app derives from it. */
@@ -180,18 +143,8 @@ export interface ResolvedConfig {
180
143
  * `connectors` order: what `drivers` indexes and the credentials are read from (`credentialsOf`).
181
144
  */
182
145
  driversByVendor: Map<string, Driver>;
183
- /**
184
- * The makers of the fakes the connectors attach, by vendor id in `connectors` order: each
185
- * sandbox run makes its own. Resolved with `fakes: "instance"`, each gives its vendor's
186
- * instance fake (`instanceFakes`).
187
- */
146
+ /** The makers of the fakes the connectors attach, by vendor id in `connectors` order: each sandbox run makes its own. */
188
147
  fakes: Map<string, () => Fake<any, any>>;
189
- /**
190
- * Resolved with `fakes: "instance"` (a demo): each vendor's one fake, by vendor id, made once
191
- * for this resolution. Each is its vendor's driver (`driversByVendor`), so live runs, sandbox
192
- * runs and direct calls all call it, and the world's Givens seed it (`applyGivens`).
193
- */
194
- instanceFakes?: ReadonlyMap<string, Fake<any, any>>;
195
148
  /** The makers of the login halves the connectors attach, by vendor id in `connectors` order: the app makes one per public site that names it. */
196
149
  logins: ReadonlyMap<string, NonNullable<ConnectorSetup["login"]>>;
197
150
  /** The makers of the model halves the connectors attach, by vendor id in `connectors` order: the app makes each once for its model registry. */
@@ -288,17 +241,6 @@ export function rootOf(config: Pick<SanomaConfig, "root" | "file">): string | un
288
241
  : undefined;
289
242
  }
290
243
 
291
- /** What `resolveConfig` is given besides the config. */
292
- export interface ResolveOptions {
293
- /**
294
- * `"instance"`: run the whole instance on the connectors' fakes, as a demo does: each vendor's
295
- * driver is one fake made for this resolution (`instanceFakes`), which every run and call
296
- * shares. The collections stay on the config's database. Refused for a vendor whose connector
297
- * attaches a driver and no fake, which would otherwise run live.
298
- */
299
- fakes?: "instance";
300
- }
301
-
302
244
  /**
303
245
  * Checks a config and derives what the runtime needs from it: the drivers and fakes from the
304
246
  * connectors' setups (`withSetup`), the collections' operations, driver and fake, and from
@@ -309,7 +251,7 @@ export interface ResolveOptions {
309
251
  * `root`. Each entry point resolves once: the worker and the client when they start, the app at
310
252
  * `startApp`, the `sanoma` bin when it loads the config.
311
253
  */
312
- export function resolveConfig(config: SanomaConfig, options: ResolveOptions = {}): ResolvedConfig {
254
+ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
313
255
  if (!Array.isArray(config.connectors)) {
314
256
  throw new Error("The config needs `connectors`: the connectors its workflows call, as their packages export them");
315
257
  }
@@ -326,7 +268,6 @@ export function resolveConfig(config: SanomaConfig, options: ResolveOptions = {}
326
268
  if (config.policy !== undefined) {
327
269
  throw new Error("The config's `policy` is gone: the policy is policies/*.cedar in the root; leave it out");
328
270
  }
329
- checkDemo(config.demo);
330
271
  const collections = collectionsOf(config);
331
272
  const ledger = ledgerOf(config);
332
273
  const root = rootOf(config);
@@ -352,22 +293,19 @@ export function resolveConfig(config: SanomaConfig, options: ResolveOptions = {}
352
293
  ]);
353
294
  checkWakeupOps(types, ops);
354
295
  checkDatabaseOps(types, ops);
355
- const fakes = makersOf(running, "fake");
356
- const instanceFakes = options.fakes === "instance" ? instanceFakesOf(running, fakes) : undefined;
357
- const driversByVendor = instanceFakes
358
- ? new Map([...instanceFakes].map(([vendor, fake]) => [vendor, fake.driver]))
359
- : new Map([...makersOf(running, "driver")].map(([vendor, make]) => [vendor, make()]));
296
+ const driversByVendor = new Map([...makersOf(running, "driver")].map(([vendor, make]) => [vendor, make()]));
360
297
  const remotes = new Map([...makersOf(config.connectors, "remote")].map(([vendor, make]) => [vendor, make()]));
361
298
  const drivers = indexDrivers(
362
299
  [
363
300
  ...driversByVendor.values(),
364
301
  changeDriver(remotes),
365
- ...sandboxDrivers,
302
+ appsListDriver,
366
303
  ...(hostTyped ? [sourceDriver] : []),
367
304
  ...(dataStore ? [collectionsDriver(declared, dataStore)] : []),
368
305
  ],
369
306
  ops,
370
307
  );
308
+ const fakes = makersOf(running, "fake");
371
309
  if (dataStore) fakes.set(DATA_VENDOR, () => collectionsFake(declared));
372
310
  const names = new Map<string, WorkflowDefinition<any, any>>();
373
311
  for (const wf of config.workflows ?? []) {
@@ -415,7 +353,6 @@ export function resolveConfig(config: SanomaConfig, options: ResolveOptions = {}
415
353
  drivers,
416
354
  driversByVendor,
417
355
  fakes,
418
- ...(instanceFakes && { instanceFakes }),
419
356
  logins: makersOf(config.connectors, "login"),
420
357
  models: makersOf(config.connectors, "model"),
421
358
  remotes,
@@ -459,10 +396,15 @@ function collectionsOf(config: SanomaConfig): Map<string, Collection> {
459
396
  }
460
397
 
461
398
  /**
462
- * The built-in drivers no fake implements that a sandbox run calls beside its fakes (`seedSandbox`):
463
- * those that need no credentials and change nothing, as `sanoma.apps.list` (GitHub's public search).
399
+ * The built-in drivers a sandbox run calls beside its fakes (`seedSandbox`), for the built-in
400
+ * operations among `ops` that no fake implements: `sanoma.apps.list`'s own, which needs no
401
+ * credentials and changes nothing (GitHub's public search), and `sanoma.source.resolve`'s fake
402
+ * (`fakeSourceDriver`), for a config with a host type.
464
403
  */
465
- export const sandboxDrivers: readonly Driver[] = [appsListDriver];
404
+ export const sandboxDrivers = (ops: ReadonlyMap<string, unknown>): Driver[] => [
405
+ appsListDriver,
406
+ ...(ops.has(SOURCE_RESOLVE) ? [fakeSourceDriver] : []),
407
+ ];
466
408
 
467
409
  /**
468
410
  * `sanoma.wakeup`, the runtime's own: the type's operations and `sanoma.wakeup.register` (effect
@@ -515,25 +457,6 @@ function makersOf<K extends keyof ConnectorSetup>(
515
457
  return makers;
516
458
  }
517
459
 
518
- /**
519
- * Each vendor's one fake, made now, for `resolveConfig`'s `fakes: "instance"`; `fakes` (the
520
- * makers) then give these. Refuses a connector that attaches a driver and no fake.
521
- */
522
- function instanceFakesOf(
523
- connectors: readonly Connector<any, any>[],
524
- fakes: Map<string, () => Fake<any, any>>,
525
- ): Map<string, Fake<any, any>> {
526
- const live = [...makersOf(connectors, "driver").keys()].filter((vendor) => !fakes.has(vendor));
527
- if (live.length) {
528
- throw new Error(
529
- `The instance runs on fakes, but the connector of ${live.join(", ")} attaches no fake, so it would run live; attach one (\`withSetup\`) or leave it out`,
530
- );
531
- }
532
- const made = new Map([...fakes].map(([vendor, make]) => [vendor, make()]));
533
- for (const [vendor, fake] of made) fakes.set(vendor, () => fake);
534
- return made;
535
- }
536
-
537
460
  /**
538
461
  * The `name` of `<root>/package.json`, kept whole (`@acme/ops`), when that file exists and names
539
462
  * one; else "sanoma", as for a config with no `root`. Never a `package.json` above the root: a
package/src/cucumber.ts CHANGED
@@ -173,7 +173,8 @@ const literal = (path: string) => path.replaceAll(/[*?[\]()+@]/g, "[$&]").replac
173
173
  * examples row at that line. `makeWorld` makes each scenario's World, given the report so far
174
174
  * (every file and pickle, read before the first scenario starts). A dry run matches the steps
175
175
  * and runs none. A step's error that is a bug in code (`isBug`) is rethrown once the run is
176
- * over, rather than reported as the step's failure.
176
+ * over, rather than reported as the step's failure. `timeoutMs` is how long a step may take
177
+ * (Cucumber's 5 s by default).
177
178
  */
178
179
  export async function runFeatures(
179
180
  library: ISupportCodeLibrary,
@@ -184,7 +185,15 @@ export async function runFeatures(
184
185
  line,
185
186
  dryRun = false,
186
187
  isBug,
187
- }: { cwd: string; files: string[]; line?: number; dryRun?: boolean; isBug: (err: unknown) => boolean },
188
+ timeoutMs,
189
+ }: {
190
+ cwd: string;
191
+ files: string[];
192
+ line?: number;
193
+ dryRun?: boolean;
194
+ isBug: (err: unknown) => boolean;
195
+ timeoutMs?: number;
196
+ },
188
197
  ): Promise<Report> {
189
198
  const report: Report = { query: new Query(), texts: new Map(), parseErrors: [] };
190
199
  let bug: unknown;
@@ -200,7 +209,11 @@ export async function runFeatures(
200
209
  }
201
210
  const configuration: IRunOptions = {
202
211
  sources: { defaultDialect: "en", paths: files.map(literal), names: [], tagExpression: "", order: "defined" },
203
- support: { ...library, World } as ISupportCodeLibrary,
212
+ support: {
213
+ ...library,
214
+ World,
215
+ ...(timeoutMs !== undefined && { defaultTimeout: timeoutMs }),
216
+ } as ISupportCodeLibrary,
204
217
  runtime: {
205
218
  dryRun,
206
219
  failFast: false,
package/src/db.ts CHANGED
@@ -81,20 +81,12 @@ const MADE_ALREADY = new Set(["23505", "42P04", "42P07", "42710"]);
81
81
  /** True for the failure of a create that another process won: what was to be made is there. */
82
82
  export const madeAlready = (err: unknown): boolean => MADE_ALREADY.has(pgCode(err));
83
83
 
84
- /**
85
- * `SANOMA_POOL_SIZE`, read once: the most connections each Postgres pool of the process opens, its
86
- * pg pools' and DBOS's system pool alike; unset, theirs (10 each). `sanoma demo`'s session host sets
87
- * it low for each visitor's process, so a session costs a few connections, not a dozen.
88
- */
89
- const askedPoolSize = Number(process.env.SANOMA_POOL_SIZE);
90
- const POOL_SIZE = Number.isInteger(askedPoolSize) && askedPoolSize > 0 ? askedPoolSize : undefined;
91
-
92
84
  /**
93
85
  * A pool on the config's database for `what` (a word for the messages). An idle connection that
94
86
  * drops (Postgres restarted) is replaced on the next query; unheard, it would end the process.
95
87
  */
96
88
  export function openPool(databaseUrl: string, what: string): Pool {
97
- const pool = new Pool({ connectionString: databaseUrl, ...(POOL_SIZE !== undefined && { max: POOL_SIZE }) });
89
+ const pool = new Pool({ connectionString: databaseUrl });
98
90
  pool.on("error", (err) => warn(`a ${what} connection failed: ${errorMessage(err)}`));
99
91
  return pool;
100
92
  }
@@ -107,10 +99,7 @@ export function openPool(databaseUrl: string, what: string): Pool {
107
99
  * queries at once on one client, which pg deprecates. `prepareDatabase` sets the same 60 s guard
108
100
  * on the database instead. Remove the switch when DBOS awaits its `SET`.
109
101
  */
110
- export const DBOS_DATABASE_OPTIONS = {
111
- systemDatabaseIdleTransactionTimeoutMs: 0,
112
- ...(POOL_SIZE !== undefined && { systemDatabasePoolSize: POOL_SIZE }),
113
- } as const;
102
+ export const DBOS_DATABASE_OPTIONS = { systemDatabaseIdleTransactionTimeoutMs: 0 } as const;
114
103
 
115
104
  /**
116
105
  * Stamps the change runs `ids` still unfinished on another version with the application version
package/src/fake.ts CHANGED
@@ -7,7 +7,6 @@ import {
7
7
  DriverError,
8
8
  type OpIdOf,
9
9
  type Specs,
10
- withSetup,
11
10
  } from "./op.ts";
12
11
  import { fail, type Instance, UNREGISTERED, type WakeupApi, wakeupCalls } from "./wakeup-driver.ts";
13
12
  import { INSTANCE_TOKEN, instanceUrl } from "./wakeup-service.ts";
@@ -73,7 +72,7 @@ type Fault =
73
72
  | { kind: "hold"; released: Promise<void> };
74
73
 
75
74
  /**
76
- * Builds a fake vendor for a connector, for tests and demos. `ops` implements the operations
75
+ * Builds a fake vendor for a connector, for tests and sandbox runs. `ops` implements the operations
77
76
  * against `state`, typed by the connector as `defineDriver` does. The fake adds what a real
78
77
  * vendor does around them: a repeated idempotency key gets the first reply and changes
79
78
  * nothing. A test can make the next call fail, lose its reply, hit a rate limit or wait.
@@ -159,17 +158,6 @@ export function defineFake<V extends string, S extends Specs, T extends Record<s
159
158
  };
160
159
  }
161
160
 
162
- /**
163
- * `connector` set up with this fake instead of the one its package attaches, for a test or a demo
164
- * that seeds or reads the fake its runs call: live runs call its driver, and sandbox runs call it
165
- * too, since its maker gives this very one. A driver swap alone is `withSetup(connector, { driver })`,
166
- * which keeps the package's fake, so each sandbox run has one of its own. The operations are the
167
- * same.
168
- */
169
- export function withFake<C extends Connector<any, any>>(connector: C, fake: Fake<any, any>): C {
170
- return withSetup(connector, { driver: () => fake.driver, fake: () => fake });
171
- }
172
-
173
161
  // The built-in `sanoma.wakeup`'s fake: an in-memory wakeup service that calls the host's `/wake`.
174
162
 
175
163
  /** An instance of the fake wakeup service, as it keeps one. */
package/src/index.ts CHANGED
@@ -55,7 +55,6 @@ export {
55
55
  } from "./resource.ts";
56
56
  export {
57
57
  declaredWakeup,
58
- type DemoConfig,
59
58
  defineConfig,
60
59
  resolveConfig,
61
60
  resolveDatabaseUrl,
package/src/resource.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { checkDatabase, type DatabaseSpec } from "./database.ts";
3
+ import { isRunControlError } from "./errors.ts";
3
4
  import { checkHost, type HostSpec } from "./host.ts";
4
5
  import { type Connector, type OpSpec, RESOURCE, VENDOR } from "./op.ts";
5
6
  import { isSanomaSecret, isSecret, type Secret } from "./secret.ts";
7
+ import { errorMessage } from "./shared.ts";
6
8
  import { checkWakeup, type WakeupSpec } from "./wakeup.ts";
7
9
 
8
10
  /** Which fields of a resource are flagged, by dotted path (`pages.html_url`; a list's items have no index). */
@@ -123,6 +125,47 @@ export interface AfterCreate {
123
125
  until?: (output: any) => boolean;
124
126
  }
125
127
 
128
+ /** How long apply waits between calls of an `afterCreate` operation with `until`, and how many calls it makes. */
129
+ export const AFTER_CREATE_SECONDS = 5;
130
+ export const AFTER_CREATE_CALLS = 120;
131
+ /** How long an `afterCreate` operation may be polled, in all, in milliseconds. */
132
+ export const AFTER_CREATE_MS = AFTER_CREATE_SECONDS * AFTER_CREATE_CALLS * 1000;
133
+
134
+ /** Why an `afterCreate` operation with `until` did not pass: the time it was given, and its last call's error if that failed. */
135
+ export const afterCreateStuck = (op: string, failed: unknown): string =>
136
+ `${op} did not pass in ${AFTER_CREATE_MS / 60_000} minutes` +
137
+ (failed === undefined ? "" : `; its last call failed: ${errorMessage(failed)}`);
138
+
139
+ /**
140
+ * Calls one `afterCreate` operation for a new object as apply does: with `until`, again every
141
+ * few seconds until its output passes, a call that fails meanwhile being one that did not pass;
142
+ * without, once, a failure thrown. Returns why it did not pass, if it did not. Apply inlines the
143
+ * same loop in its `run`, which may not close over its `ctx`; an operation's own test calls this.
144
+ */
145
+ export async function callAfterCreate(
146
+ hook: AfterCreate,
147
+ made: { id: string; export: string },
148
+ call: (op: string, input: unknown) => Promise<unknown>,
149
+ sleep: (seconds: number) => Promise<void>,
150
+ ): Promise<string | undefined> {
151
+ const given = hook.input(made);
152
+ let output: unknown;
153
+ let failed: unknown;
154
+ for (let n = 0; n < AFTER_CREATE_CALLS && (n === 0 || (hook.until && !hook.until(output))); n++) {
155
+ if (n > 0) await sleep(AFTER_CREATE_SECONDS);
156
+ try {
157
+ output = await call(hook.op, given);
158
+ failed = undefined;
159
+ } catch (err) {
160
+ if (isRunControlError(err) || !hook.until) throw err;
161
+ output = undefined;
162
+ failed = err;
163
+ }
164
+ }
165
+ if (hook.until && !hook.until(output)) return afterCreateStuck(hook.op, failed);
166
+ return undefined;
167
+ }
168
+
126
169
  /**
127
170
  * Where a declared resource keeps its brand, its type's `<vendor>.<type>`: a symbol, so a field
128
171
  * holding a resource is told from data; a registered one (`Symbol.for`), so a copy of this
package/src/run.ts CHANGED
@@ -46,8 +46,6 @@ export interface WorkerState {
46
46
  drivers: Map<string, DriverFn>;
47
47
  /** The makers of the config's fake vendors, by vendor id: a sandbox run makes its own of each when it is seeded. */
48
48
  fakes: Map<string, () => Fake<any, any>>;
49
- /** The instance's fakes, by vendor id, when it runs on them (`fakes: "instance"`): its sandbox runs seed and call these, not fakes of their own. */
50
- instanceFakes?: ReadonlyMap<string, Fake<any, any>>;
51
49
  /** Where sandbox runs' scenarios are, the resource types their steps may name, and the steps the connectors ship. */
52
50
  scenarios?: URL;
53
51
  /** The worker's `source`, when it was given one. */
package/src/sandbox.ts CHANGED
@@ -4,7 +4,7 @@ import { opIdsOf } from "./define.ts";
4
4
  import { SanomaError } from "./errors.ts";
5
5
  import type { Fake, ResourceFake } from "./fake.ts";
6
6
  import { entry, type LedgerRecord, write } from "./ledger.ts";
7
- import type { CallContext, DriverFn, Op } from "./op.ts";
7
+ import type { DriverFn } from "./op.ts";
8
8
  import type { Run } from "./run.ts";
9
9
  import { memorySecrets } from "./secret.ts";
10
10
  import { type Given, loadScenarios, type Scenario } from "./scenario.ts";
@@ -58,7 +58,7 @@ export function seedResource(
58
58
  scenario: string,
59
59
  copy: ResourceFake,
60
60
  given: Extract<Given, { resource: string }>,
61
- ): Seeds[number] {
61
+ ): Extract<LedgerRecord, { type: "scenario.seeded" }>["seeds"][number] {
62
62
  const { resource, type, name } = given;
63
63
  try {
64
64
  if ("gone" in given) {
@@ -77,81 +77,16 @@ export function seedResource(
77
77
  }
78
78
  }
79
79
 
80
- /** What a scenario's `Given` steps seeded, as its `scenario.seeded` record has it. */
81
- export type Seeds = Extract<LedgerRecord, { type: "scenario.seeded" }>["seeds"];
82
-
83
- /**
84
- * Where a scenario's `Given` steps go (`applyGivens`): a sandbox run's own fakes (`run`), or the
85
- * instance a demo runs (`instance`), its collections in the config's database and every vendor
86
- * on its one fake, which every run shares, so a fault, injected into a call some other run would
87
- * make, is refused there.
88
- */
89
- export interface GivenTarget {
90
- kind: "run" | "instance";
91
- /** What a `Given` operation is called through, by operation id. */
92
- drivers: ReadonlyMap<string, DriverFn>;
93
- /** The fakes, by vendor id: what a `Given` resource is put in, and a `run`'s faults injected into. */
94
- fakes: ReadonlyMap<string, Fake<unknown>>;
95
- /** The operations by id, for a fault's vendor. */
96
- ops: ReadonlyMap<string, Op>;
97
- /** The context of the call the `i`th `Given` makes. */
98
- call(i: number, op: string): CallContext;
99
- }
100
-
101
- /**
102
- * Applies the scenario's `Given` steps to the target: calls each operation through its drivers,
103
- * puts or removes each resource in its vendor's fake, then, for a `run`, injects the faults.
104
- * Checks first that every operation it seeds has a driver there, that every resource's vendor has
105
- * a fake holding resources, and, for an `instance`, that it injects no fault. Returns what it
106
- * seeded, in order.
107
- */
108
- export async function applyGivens(scenario: Pick<Scenario, "name" | "given">, target: GivenTarget): Promise<Seeds> {
109
- const { name, given } = scenario;
110
- const fault = given.find((g) => "fault" in g);
111
- if (fault && target.kind === "instance") {
112
- throw new SanomaError(
113
- "invalid_input",
114
- `Scenario "${name}" injects a fault ("${fault.step}"), but its Givens go to the instance, whose fakes every run shares`,
115
- { scenario: name, step: fault.step },
116
- );
117
- }
118
- const missing = [...new Set(given.flatMap((g) => ("op" in g && !target.drivers.has(g.op) ? [g.op] : [])))];
119
- if (missing.length) {
120
- throw new SanomaError(
121
- "invalid_input",
122
- `Scenario "${name}" seeds ${missing.join(", ")}, but no fake its connectors attach implements ${missing.length > 1 ? "them" : "it"}`,
123
- { scenario: name, missing },
124
- );
125
- }
126
- const plain = missingResourceFake(scenario, target.fakes);
127
- if (plain) throw plain;
128
- const seeded: Seeds = [];
129
- for (const [i, g] of given.entries()) {
130
- if ("fault" in g) continue;
131
- if ("op" in g) {
132
- const output = await target.drivers.get(g.op)!(g.input, target.call(i, g.op));
133
- seeded.push({ op: g.op, input: g.input, output });
134
- continue;
135
- }
136
- // Checked above: the vendor's fake holds resources.
137
- seeded.push(seedResource(name, target.fakes.get(g.vendor) as Fake<unknown> & ResourceFake, g));
138
- }
139
- for (const g of given) {
140
- if ("fault" in g) target.fakes.get(target.ops.get(g.op)!.vendor)![g.fault](g.op);
141
- }
142
- return seeded;
143
- }
144
-
145
80
  /**
146
81
  * Seeds a sandbox run of the scenario `name`, from the worker, in one step: reads the scenario,
147
- * checks that it runs this workflow, picks the run's fakes, checks that every operation the
148
- * workflow uses has one, then applies the scenario's `Given` steps to them
149
- * (`applyGivens`) and puts their functions on the run. The run then calls those only. Its fakes
150
- * are its own (each vendor's maker, called once for the run), so sandbox runs share no state when
151
- * the makers make a new fake each; on an instance that runs on fakes (`instanceFakes`) they are
152
- * the instance's, which its sandbox runs share, as its live drivers are. The step's checkpoint
153
- * never stands in for a run's own: a run whose seeding is replayed from it has none, and fails,
154
- * since a sandbox run does not survive a worker restart; on an instance's fakes it goes on with them.
82
+ * checks that it runs this workflow, makes the run's own fakes (each vendor's maker, called once
83
+ * for the run), checks that every operation the workflow uses or the scenario seeds has one and
84
+ * that every resource it seeds has a fake holding resources, calls each `Given` operation
85
+ * through them, puts or removes each `Given` resource in them, injects the faults into them,
86
+ * then puts their functions on the run. The run then calls those only, so sandbox runs share no
87
+ * state when the makers make a new fake each. The step's checkpoint never stands in for them: a
88
+ * run whose seeding is replayed from it has none, and fails, since a sandbox run does not
89
+ * survive a worker restart.
155
90
  */
156
91
  export async function seedSandbox(run: Run, name: string): Promise<void> {
157
92
  const { state } = run;
@@ -168,19 +103,20 @@ export async function seedSandbox(run: Run, name: string): Promise<void> {
168
103
  throw new SanomaError("invalid_input", `No scenario named "${name}"${why}`, { scenario: name });
169
104
  }
170
105
  if (scenario.workflow !== run.workflow) {
171
- const runs = scenario.workflow === undefined ? "is a world, which runs nothing" : `runs ${scenario.workflow}`;
172
- throw new SanomaError("invalid_input", `Scenario "${name}" ${runs}, not ${run.workflow}`, {
106
+ throw new SanomaError("invalid_input", `Scenario "${name}" runs ${scenario.workflow}, not ${run.workflow}`, {
173
107
  scenario: name,
174
- ...(scenario.workflow !== undefined && { workflow: scenario.workflow }),
108
+ workflow: scenario.workflow,
175
109
  });
176
110
  }
177
- const instance = state.instanceFakes;
178
- const fakes = instance ?? new Map([...state.fakes].map(([vendor, make]) => [vendor, make()]));
179
- const drivers = instance
180
- ? state.drivers
181
- : indexDrivers([...Array.from(fakes.values(), (f) => f.driver), ...sandboxDrivers], state.ops);
182
- // What the scenario seeds is checked by `applyGivens`.
183
- const missing = opIdsOf(state.workflows.get(run.workflow)!).filter((id) => !drivers.has(id));
111
+ const fakes = new Map([...state.fakes].map(([vendor, make]) => [vendor, make()]));
112
+ // The built-ins first, so a connector's fake that implements one of their operations wins.
113
+ const drivers = indexDrivers(
114
+ [...sandboxDrivers(state.ops), ...Array.from(fakes.values(), (f) => f.driver)],
115
+ state.ops,
116
+ );
117
+ const uses = opIdsOf(state.workflows.get(run.workflow)!);
118
+ const seeding = scenario.given.flatMap((g) => ("op" in g ? [g.op] : []));
119
+ const missing = [...new Set([...uses, ...seeding])].filter((id) => !drivers.has(id));
184
120
  if (missing.length) {
185
121
  throw new SanomaError(
186
122
  "invalid_input",
@@ -188,20 +124,28 @@ export async function seedSandbox(run: Run, name: string): Promise<void> {
188
124
  { scenario: name, missing },
189
125
  );
190
126
  }
191
- const seeded = await applyGivens(scenario, {
192
- kind: instance ? "instance" : "run",
193
- drivers,
194
- fakes,
195
- ops: state.ops,
196
- call: (i, opId) => ({ idempotencyKey: `${run.id}:seed:${i}`, runId: run.id, opId, attempt: 1, secrets }),
197
- });
127
+ const plain = missingResourceFake(scenario, fakes);
128
+ if (plain) throw plain;
129
+ const seeded: Extract<LedgerRecord, { type: "scenario.seeded" }>["seeds"] = [];
130
+ for (const [i, given] of scenario.given.entries()) {
131
+ if ("fault" in given) continue;
132
+ if ("op" in given) {
133
+ const call = { idempotencyKey: `${run.id}:seed:${i}`, runId: run.id, opId: given.op, attempt: 1, secrets };
134
+ const output = await drivers.get(given.op)!(given.input, call);
135
+ seeded.push({ op: given.op, input: given.input, output });
136
+ continue;
137
+ }
138
+ // Checked above: the vendor's fake holds resources.
139
+ seeded.push(seedResource(name, fakes.get(given.vendor) as Fake<unknown> & ResourceFake, given));
140
+ }
141
+ for (const given of scenario.given) {
142
+ if ("fault" in given) fakes.get(state.ops.get(given.op)!.vendor)![given.fault](given.op);
143
+ }
198
144
  copies = drivers;
199
145
  return { seeds: seeded };
200
146
  },
201
147
  { name: "sandbox:seed" },
202
148
  );
203
- // Replayed on an instance that runs on fakes: they are the instance's, still there in this process.
204
- if (!copies && state.instanceFakes) copies = state.drivers;
205
149
  if (!copies) {
206
150
  // Replayed from its checkpoint (after a worker restart, or in a fork), so the run has no fakes.
207
151
  // The records written before the restart hold the seqs from here on: the failure goes after them.