@sanoma-ai/workflows 0.6.0 → 0.7.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 (166) hide show
  1. package/README.md +52 -14
  2. package/dist/apply.d.ts +2 -1
  3. package/dist/apply.js +29 -7
  4. package/dist/apply.js.map +1 -1
  5. package/dist/approvals.d.ts +27 -2
  6. package/dist/approvals.js +73 -9
  7. package/dist/approvals.js.map +1 -1
  8. package/dist/call.d.ts +3 -2
  9. package/dist/call.js +38 -30
  10. package/dist/call.js.map +1 -1
  11. package/dist/cedar.js +24 -9
  12. package/dist/cedar.js.map +1 -1
  13. package/dist/change.d.ts +219 -0
  14. package/dist/change.js +150 -0
  15. package/dist/change.js.map +1 -0
  16. package/dist/chats.d.ts +43 -0
  17. package/dist/chats.js +48 -0
  18. package/dist/chats.js.map +1 -0
  19. package/dist/client.d.ts +52 -6
  20. package/dist/client.js +88 -47
  21. package/dist/client.js.map +1 -1
  22. package/dist/config.d.ts +75 -8
  23. package/dist/config.js +103 -13
  24. package/dist/config.js.map +1 -1
  25. package/dist/constructors.d.ts +37 -1
  26. package/dist/constructors.js +6 -2
  27. package/dist/constructors.js.map +1 -1
  28. package/dist/database.d.ts +83 -0
  29. package/dist/database.js +92 -0
  30. package/dist/database.js.map +1 -0
  31. package/dist/db.d.ts +19 -1
  32. package/dist/db.js +51 -7
  33. package/dist/db.js.map +1 -1
  34. package/dist/deadlines.d.ts +91 -0
  35. package/dist/deadlines.js +165 -0
  36. package/dist/deadlines.js.map +1 -0
  37. package/dist/define.d.ts +25 -4
  38. package/dist/define.js +40 -0
  39. package/dist/define.js.map +1 -1
  40. package/dist/demo.d.ts +28 -0
  41. package/dist/demo.js +72 -0
  42. package/dist/demo.js.map +1 -0
  43. package/dist/describe.d.ts +5 -2
  44. package/dist/describe.js +10 -6
  45. package/dist/describe.js.map +1 -1
  46. package/dist/errors.d.ts +10 -1
  47. package/dist/errors.js +13 -0
  48. package/dist/errors.js.map +1 -1
  49. package/dist/fake.d.ts +34 -0
  50. package/dist/fake.js +134 -1
  51. package/dist/fake.js.map +1 -1
  52. package/dist/grid.d.ts +34 -0
  53. package/dist/grid.js +62 -0
  54. package/dist/grid.js.map +1 -0
  55. package/dist/host.d.ts +12 -2
  56. package/dist/host.js +2 -2
  57. package/dist/host.js.map +1 -1
  58. package/dist/index.d.ts +15 -8
  59. package/dist/index.js +10 -5
  60. package/dist/index.js.map +1 -1
  61. package/dist/ledger.d.ts +11 -2
  62. package/dist/ledger.js.map +1 -1
  63. package/dist/lint.js +2 -2
  64. package/dist/lint.js.map +1 -1
  65. package/dist/op.d.ts +21 -2
  66. package/dist/op.js.map +1 -1
  67. package/dist/person.d.ts +5 -0
  68. package/dist/person.js +3 -1
  69. package/dist/person.js.map +1 -1
  70. package/dist/policy.d.ts +7 -1
  71. package/dist/policy.js +2 -1
  72. package/dist/policy.js.map +1 -1
  73. package/dist/resource.d.ts +13 -1
  74. package/dist/resource.js +9 -1
  75. package/dist/resource.js.map +1 -1
  76. package/dist/resources.d.ts +2 -0
  77. package/dist/resources.js +15 -4
  78. package/dist/resources.js.map +1 -1
  79. package/dist/run.d.ts +5 -0
  80. package/dist/run.js.map +1 -1
  81. package/dist/sandbox.d.ts +38 -10
  82. package/dist/sandbox.js +66 -35
  83. package/dist/sandbox.js.map +1 -1
  84. package/dist/scenario.d.ts +18 -1
  85. package/dist/scenario.js +77 -27
  86. package/dist/scenario.js.map +1 -1
  87. package/dist/scenarios.d.ts +10 -1
  88. package/dist/scenarios.js +19 -4
  89. package/dist/scenarios.js.map +1 -1
  90. package/dist/schedules.d.ts +66 -0
  91. package/dist/schedules.js +158 -0
  92. package/dist/schedules.js.map +1 -0
  93. package/dist/schema.d.ts +6 -0
  94. package/dist/schema.js +13 -3
  95. package/dist/schema.js.map +1 -1
  96. package/dist/shared.d.ts +30 -2
  97. package/dist/shared.js +42 -4
  98. package/dist/shared.js.map +1 -1
  99. package/dist/source.d.ts +185 -2
  100. package/dist/source.js +9 -0
  101. package/dist/source.js.map +1 -1
  102. package/dist/supervisor.d.ts +39 -0
  103. package/dist/supervisor.js +68 -0
  104. package/dist/supervisor.js.map +1 -0
  105. package/dist/version.d.ts +3 -1
  106. package/dist/version.js +3 -1
  107. package/dist/version.js.map +1 -1
  108. package/dist/wakeup-driver.d.ts +128 -0
  109. package/dist/wakeup-driver.js +216 -0
  110. package/dist/wakeup-driver.js.map +1 -0
  111. package/dist/wakeup-service.d.ts +83 -0
  112. package/dist/wakeup-service.js +415 -0
  113. package/dist/wakeup-service.js.map +1 -0
  114. package/dist/wakeup-type.d.ts +43 -0
  115. package/dist/wakeup-type.js +80 -0
  116. package/dist/wakeup-type.js.map +1 -0
  117. package/dist/wakeup.d.ts +77 -0
  118. package/dist/wakeup.js +78 -0
  119. package/dist/wakeup.js.map +1 -0
  120. package/dist/worker.d.ts +39 -1
  121. package/dist/worker.js +125 -38
  122. package/dist/worker.js.map +1 -1
  123. package/oxlint.json +3 -1
  124. package/package.json +10 -2
  125. package/src/apply.ts +25 -6
  126. package/src/approvals.ts +89 -16
  127. package/src/call.ts +40 -29
  128. package/src/cedar.ts +24 -6
  129. package/src/change.ts +222 -0
  130. package/src/chats.ts +114 -0
  131. package/src/client.ts +145 -55
  132. package/src/config.ts +149 -21
  133. package/src/constructors.ts +8 -2
  134. package/src/database.ts +152 -0
  135. package/src/db.ts +58 -9
  136. package/src/deadlines.ts +228 -0
  137. package/src/define.ts +71 -4
  138. package/src/demo.ts +87 -0
  139. package/src/describe.ts +18 -6
  140. package/src/errors.ts +14 -0
  141. package/src/fake.ts +149 -0
  142. package/src/grid.ts +100 -0
  143. package/src/host.ts +12 -4
  144. package/src/index.ts +66 -3
  145. package/src/ledger.ts +15 -3
  146. package/src/lint.ts +2 -2
  147. package/src/op.ts +22 -2
  148. package/src/person.ts +4 -1
  149. package/src/policy.ts +7 -1
  150. package/src/resource.ts +22 -3
  151. package/src/resources.ts +17 -4
  152. package/src/run.ts +5 -0
  153. package/src/sandbox.ts +97 -37
  154. package/src/scenario.ts +97 -28
  155. package/src/scenarios.ts +21 -5
  156. package/src/schedules.ts +206 -0
  157. package/src/schema.ts +16 -3
  158. package/src/shared.ts +51 -4
  159. package/src/source.ts +176 -3
  160. package/src/supervisor.ts +102 -0
  161. package/src/version.ts +3 -1
  162. package/src/wakeup-driver.ts +270 -0
  163. package/src/wakeup-service.ts +504 -0
  164. package/src/wakeup-type.ts +90 -0
  165. package/src/wakeup.ts +139 -0
  166. package/src/worker.ts +176 -43
package/src/config.ts CHANGED
@@ -5,6 +5,7 @@ import { DBOS } from "@dbos-inc/dbos-sdk";
5
5
  import { type ApplyDeclared, applyWorkflow } from "./apply.ts";
6
6
  import { applySteps } from "./apply-steps.ts";
7
7
  import { callWorkflow } from "./call-workflow.ts";
8
+ import { changeDriver, changeOps, reservedFor } from "./change.ts";
8
9
  import {
9
10
  type Collection,
10
11
  collectionsConnector,
@@ -16,7 +17,7 @@ import { lazyPool, type LazyPool } from "./db.ts";
16
17
  import { callerFile, type Use, type WorkflowDefinition } from "./define.ts";
17
18
  import { DRIFT_WORKFLOW, driftWorkflow } from "./drift.ts";
18
19
  import { statusesOf, unconfigured } from "./env.ts";
19
- import type { Fake } from "./fake.ts";
20
+ import { type Fake, fakeSanomaWakeup } from "./fake.ts";
20
21
  import { peopleConnector } from "./person.ts";
21
22
  import type { LedgerStore } from "./ledger.ts";
22
23
  import {
@@ -31,14 +32,21 @@ import {
31
32
  checkConnector,
32
33
  setupOf,
33
34
  VENDOR,
35
+ withSetup,
34
36
  } from "./op.ts";
35
37
  import { type CedarPolicySource, policyAt } from "./cedar.ts";
36
38
  import type { Policy } from "./policy.ts";
37
- import { isChangeOp, type Resource, resourceTypesOf, withoutWriteOnly } from "./resource.ts";
39
+ import { type Resource, resourceTypesOf, withoutWriteOnly } from "./resource.ts";
38
40
  import { APPLY_WORKFLOW, BUILT_IN_WORKFLOWS, CALL_WORKFLOW, DATA_VENDOR } from "./shared.ts";
39
- import { sourceDriver, sourceOps } from "./source.ts";
41
+ import { type RemoteHalf, sourceDriver, sourceOps } from "./source.ts";
40
42
  import type { Steps } from "./steps.ts";
43
+ import { checkSchedule } from "./schedules.ts";
41
44
  import { computeVersion } from "./version.ts";
45
+ import type { DeclaredResource } from "./resources.ts";
46
+ import { checkWakeupOps, type DeclaredWakeup } from "./wakeup.ts";
47
+ import { sanomaWakeupDriver, wakeupSteps } from "./wakeup-driver.ts";
48
+ import { wakeupOps } from "./wakeup-type.ts";
49
+ import { checkDatabaseOps } from "./database.ts";
42
50
 
43
51
  /**
44
52
  * What a worker runs, on which connectors: the argument to `startWorker`.
@@ -65,7 +73,7 @@ export interface SanomaConfig {
65
73
  * only, for tests.
66
74
  */
67
75
  ledger?: LedgerStore;
68
- /** Postgres for the runtime. Defaults to `SANOMA_DATABASE_URL`, then the local docker compose database. */
76
+ /** Postgres for the runtime. Defaults to `SANOMA_DATABASE_URL`, then `DEFAULT_DATABASE_URL`. */
69
77
  databaseUrl?: string;
70
78
  /** The file that defined the config, absolute, when known. Set by `defineConfig` from its call site. */
71
79
  file?: string;
@@ -92,6 +100,12 @@ export interface SanomaConfig {
92
100
  * records in the config's database, and in memory for a sandbox run.
93
101
  */
94
102
  collections?: readonly Collection[];
103
+ /**
104
+ * Run the wakeup service inside this instance (`sanoma serve`), for other instances' wakeups
105
+ * to register with. An instance that runs it must not stop, so `startWorker` refuses a config
106
+ * that sets it and declares a wakeup of its own.
107
+ */
108
+ wakeup?: true;
95
109
  }
96
110
 
97
111
  /** A config, checked, with everything a worker, client or app derives from it. */
@@ -107,8 +121,9 @@ export interface ResolvedConfig {
107
121
  /** The DBOS queue runs are started on: `sanoma:<appName>`. */
108
122
  queueName: string;
109
123
  /**
110
- * The operations the connectors declare, by id, the built-in `sanoma.source.resolve` when one
111
- * declares a host type, and the collections' (`data.<name>.<op>`).
124
+ * The operations the connectors declare, by id, the built-in `sanoma.change.*` (`changeOps`, in
125
+ * every config), `sanoma.source.resolve` and `sanoma.wakeup`'s when one declares a host type,
126
+ * and the collections' (`data.<name>.<op>`).
112
127
  */
113
128
  ops: Map<string, Op>;
114
129
  /** The drivers' functions, by operation id, each guarded by its driver's `env` (`indexDrivers`). */
@@ -118,13 +133,27 @@ export interface ResolvedConfig {
118
133
  * `connectors` order: what `drivers` indexes and the credentials are read from (`credentialsOf`).
119
134
  */
120
135
  driversByVendor: Map<string, Driver>;
121
- /** The makers of the fakes the connectors attach, by vendor id in `connectors` order: each sandbox run makes its own. */
136
+ /**
137
+ * The makers of the fakes the connectors attach, by vendor id in `connectors` order: each
138
+ * sandbox run makes its own. Resolved with `fakes: "instance"`, each gives its vendor's
139
+ * instance fake (`instanceFakes`).
140
+ */
122
141
  fakes: Map<string, () => Fake<any, any>>;
142
+ /**
143
+ * Resolved with `fakes: "instance"` (a demo): each vendor's one fake, by vendor id, made once
144
+ * for this resolution. Each is its vendor's driver (`driversByVendor`), so live runs, sandbox
145
+ * runs and direct calls all call it, and the world's Givens seed it (`applyGivens`).
146
+ */
147
+ instanceFakes?: ReadonlyMap<string, Fake<any, any>>;
123
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. */
124
149
  logins: ReadonlyMap<string, NonNullable<ConnectorSetup["login"]>>;
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. */
151
+ models: ReadonlyMap<string, NonNullable<ConnectorSetup["model"]>>;
152
+ /** The remote halves the connectors attach, made once for this resolution, by vendor id in `connectors` order: `remoteFor` finds a URL's. */
153
+ remotes: ReadonlyMap<string, RemoteHalf>;
125
154
  /** `scenarios/` in `root`, as a `file:` URL, when that directory exists. */
126
155
  scenarios?: URL;
127
- /** The resource types the connectors declare, by `<vendor>.<type>`: what the drift workflow checks and scenarios seed. */
156
+ /** The resource types the connectors declare (and `sanoma.wakeup`, beside a host type), by `<vendor>.<type>`: what the drift workflow checks and scenarios seed. */
128
157
  types: ReadonlyMap<string, Resource>;
129
158
  /** The steps scenarios can use besides the generic ones: each connector's (`withSetup`), once each, then the config's own. */
130
159
  steps: readonly Steps[];
@@ -160,6 +189,11 @@ export interface ResolvedConfig {
160
189
  root?: string;
161
190
  }
162
191
 
192
+ /**
193
+ * The database when neither the config nor `SANOMA_DATABASE_URL` names one: the dev Postgres
194
+ * `pnpm db:up` starts in this checkout. The `sanoma` launcher never falls back to it: with no
195
+ * URL named it starts the company's own Postgres and sets `SANOMA_DATABASE_URL` to it.
196
+ */
163
197
  export const DEFAULT_DATABASE_URL = "postgresql://postgres:dbos@localhost:5433/sanoma";
164
198
 
165
199
  /** Returns the config, with the file it is called from as its `file`: its directory is the config's `root`. */
@@ -198,6 +232,17 @@ export function rootOf(config: Pick<SanomaConfig, "root" | "file">): string | un
198
232
  : undefined;
199
233
  }
200
234
 
235
+ /** What `resolveConfig` is given besides the config. */
236
+ export interface ResolveOptions {
237
+ /**
238
+ * `"instance"`: run the whole instance on the connectors' fakes, as a demo does: each vendor's
239
+ * driver is one fake made for this resolution (`instanceFakes`), which every run and call
240
+ * shares. The collections stay on the config's database. Refused for a vendor whose connector
241
+ * attaches a driver and no fake, which would otherwise run live.
242
+ */
243
+ fakes?: "instance";
244
+ }
245
+
201
246
  /**
202
247
  * Checks a config and derives what the runtime needs from it: the drivers and fakes from the
203
248
  * connectors' setups (`withSetup`), the collections' operations, driver and fake, and from
@@ -208,7 +253,7 @@ export function rootOf(config: Pick<SanomaConfig, "root" | "file">): string | un
208
253
  * `root`. Each entry point resolves once: the worker and the client when they start, the app at
209
254
  * `startApp`, the `sanoma` bin when it loads the config.
210
255
  */
211
- export function resolveConfig(config: SanomaConfig): ResolvedConfig {
256
+ export function resolveConfig(config: SanomaConfig, options: ResolveOptions = {}): ResolvedConfig {
212
257
  if (!Array.isArray(config.connectors)) {
213
258
  throw new Error("The config needs `connectors`: the connectors its workflows call, as their packages export them");
214
259
  }
@@ -230,28 +275,38 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
230
275
  const root = rootOf(config);
231
276
  const appName = appNameAt(root);
232
277
  const scenarios = scenariosAt(root);
233
- const types = resourceTypesOf(config.connectors);
278
+ // With the built-in `sanoma.wakeup` when they declare a host type.
279
+ const running = withBuiltIns(config.connectors);
280
+ const types = resourceTypesOf(running);
234
281
  // Built-in: `sanoma.source.resolve`, which apply and drift call for a host's revision.
235
282
  const hosts = [...types.values()].some((type) => type.host);
236
283
  // Built-in: the collections' operations, `data.<name>.<op>`, on the config's database.
237
284
  const databaseUrl = resolveDatabaseUrl(config);
238
285
  const declared = [...collections.values()];
239
286
  const dataStore = declared.length ? lazyPool(databaseUrl, "data") : undefined;
287
+ // Built-in, in every config: a change's operations, which the template's policy names (`sanoma.change.merge`).
240
288
  const ops = indexConnectors([
241
- ...config.connectors,
289
+ ...running,
290
+ changeOps,
242
291
  ...(hosts ? [sourceOps] : []),
243
292
  ...(dataStore ? [collectionsConnector(declared)] : []),
244
293
  ]);
245
- const driversByVendor = new Map([...makersOf(config.connectors, "driver")].map(([vendor, make]) => [vendor, make()]));
294
+ checkWakeupOps(types, ops);
295
+ checkDatabaseOps(types, ops);
296
+ const fakes = makersOf(running, "fake");
297
+ const instanceFakes = options.fakes === "instance" ? instanceFakesOf(running, fakes) : undefined;
298
+ const driversByVendor = instanceFakes
299
+ ? new Map([...instanceFakes].map(([vendor, fake]) => [vendor, fake.driver]))
300
+ : new Map([...makersOf(running, "driver")].map(([vendor, make]) => [vendor, make()]));
246
301
  const drivers = indexDrivers(
247
302
  [
248
303
  ...driversByVendor.values(),
304
+ changeDriver(),
249
305
  ...(hosts ? [sourceDriver] : []),
250
306
  ...(dataStore ? [collectionsDriver(declared, dataStore)] : []),
251
307
  ],
252
308
  ops,
253
309
  );
254
- const fakes = makersOf(config.connectors, "fake");
255
310
  if (dataStore) fakes.set(DATA_VENDOR, () => collectionsFake(declared));
256
311
  const names = new Map<string, WorkflowDefinition<any, any>>();
257
312
  for (const wf of config.workflows) {
@@ -259,6 +314,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
259
314
  throw new Error(`A workflow is named "${wf.name}", the name of a built-in workflow; name it otherwise`);
260
315
  }
261
316
  checkUses(wf, ops, drivers, types);
317
+ checkSchedule(wf);
262
318
  const other = names.get(wf.name);
263
319
  if (other && other !== wf) {
264
320
  throw new Error(
@@ -293,10 +349,13 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
293
349
  drivers,
294
350
  driversByVendor,
295
351
  fakes,
352
+ ...(instanceFakes && { instanceFakes }),
296
353
  logins: makersOf(config.connectors, "login"),
354
+ models: makersOf(config.connectors, "model"),
355
+ remotes: new Map([...makersOf(config.connectors, "remote")].map(([vendor, make]) => [vendor, make()])),
297
356
  ...(scenarios && { scenarios }),
298
357
  types,
299
- steps: [...new Set([...config.connectors.flatMap((c) => setupOf(c).steps ?? []), ...(config.steps ?? [])])],
358
+ steps: [...new Set([...running.flatMap((c) => setupOf(c).steps ?? []), ...(config.steps ?? [])])],
300
359
  collections,
301
360
  ...(dataStore && { dataStore }),
302
361
  workflows: names,
@@ -332,9 +391,32 @@ function collectionsOf(config: SanomaConfig): Map<string, Collection> {
332
391
  return collections;
333
392
  }
334
393
 
335
- /** The config's connectors and the runtime's people types' (`peopleConnector`): what its data files are read against. */
394
+ /**
395
+ * `sanoma.wakeup`, the runtime's own: the type's operations and `sanoma.wakeup.register` (effect
396
+ * `write`), which the worker registers each deadline through before it stops, with its live
397
+ * driver (`sanomaWakeupDriver`), its fake (`fakeSanomaWakeup`) and its step. `withBuiltIns`
398
+ * merges it into every config with a host type.
399
+ */
400
+ export const sanomaWakeup = withSetup(wakeupOps, {
401
+ driver: sanomaWakeupDriver,
402
+ fake: fakeSanomaWakeup,
403
+ steps: wakeupSteps,
404
+ });
405
+
406
+ /**
407
+ * The connectors and, when they declare a host type, the runtime's own wakeup (`sanoma.wakeup`),
408
+ * the fallback for a host with no clock of its own: what a config runs on. The same array when
409
+ * they declare none.
410
+ */
411
+ const withBuiltIns = (connectors: Connector<any, any>[]): Connector<any, any>[] =>
412
+ [...resourceTypesOf(connectors).values()].some((type) => type.host) ? [...connectors, sanomaWakeup] : connectors;
413
+
414
+ /**
415
+ * The config's connectors (with `sanoma.wakeup` when they declare a host type) and the runtime's
416
+ * people types' (`peopleConnector`): what its data files are read against.
417
+ */
336
418
  export const connectorsOf = (config: Pick<SanomaConfig, "connectors">): Connector<any, any>[] => [
337
- ...config.connectors,
419
+ ...withBuiltIns(config.connectors),
338
420
  peopleConnector,
339
421
  ];
340
422
 
@@ -360,6 +442,25 @@ function makersOf<K extends keyof ConnectorSetup>(
360
442
  return makers;
361
443
  }
362
444
 
445
+ /**
446
+ * Each vendor's one fake, made now, for `resolveConfig`'s `fakes: "instance"`; `fakes` (the
447
+ * makers) then give these. Refuses a connector that attaches a driver and no fake.
448
+ */
449
+ function instanceFakesOf(
450
+ connectors: readonly Connector<any, any>[],
451
+ fakes: Map<string, () => Fake<any, any>>,
452
+ ): Map<string, Fake<any, any>> {
453
+ const live = [...makersOf(connectors, "driver").keys()].filter((vendor) => !fakes.has(vendor));
454
+ if (live.length) {
455
+ throw new Error(
456
+ `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`,
457
+ );
458
+ }
459
+ const made = new Map([...fakes].map(([vendor, make]) => [vendor, make()]));
460
+ for (const [vendor, fake] of made) fakes.set(vendor, () => fake);
461
+ return made;
462
+ }
463
+
363
464
  /**
364
465
  * The `name` of `<root>/package.json`, kept whole (`@acme/ops`), when that file exists and names
365
466
  * one; else "sanoma", as for a config with no `root`. Never a `package.json` above the root: a
@@ -385,6 +486,33 @@ function scenariosAt(root: string | undefined): URL | undefined {
385
486
  return pathToFileURL(`${dir}/`);
386
487
  }
387
488
 
489
+ /**
490
+ * The one wakeup the config's data files declare, or undefined: with none, and with one the reader
491
+ * leaves out for a problem (its declaration's or its host's; several wakeups are each one).
492
+ * Undefined means the instance has no wakeup, so it must not stop. Server-side: it reads the data
493
+ * files and the lockfile, only when the config has a wakeup type (`sanoma.wakeup` beside any host type).
494
+ */
495
+ export async function declaredWakeup(config: SanomaConfig): Promise<DeclaredWakeup | undefined> {
496
+ const connectors = connectorsOf(config);
497
+ const types = resourceTypesOf(connectors);
498
+ if (![...types.values()].some((type) => type.wakeup)) return undefined;
499
+ // Loaded when it is needed, not with the worker: the reader parses with oxc-parser.
500
+ const { readDataFiles } = await import("./resources.ts");
501
+ const { resources } = readDataFiles(rootOf(config), connectors, types);
502
+ const typeOf = (r: DeclaredResource) => types.get(`${r.vendor}.${r.type}`)!;
503
+ const declared = resources.find((r) => typeOf(r).wakeup);
504
+ if (!declared) return undefined;
505
+ const type = typeOf(declared);
506
+ const host = resources.find((r) => r.id === declared.refs[type.wakeup!.inputs.host]);
507
+ return {
508
+ id: declared.id,
509
+ type,
510
+ declared,
511
+ ...(declared.binding !== undefined && { binding: declared.binding }),
512
+ ...(host && { host: { id: host.id, type: typeOf(host) } }),
513
+ };
514
+ }
515
+
388
516
  /**
389
517
  * The data files under `root` and the lockfile beside them, in one read, as a drift or apply run
390
518
  * records them: each resource with its binding, without its write-only values (a secret marker
@@ -478,7 +606,8 @@ function guarded(vendor: string, env: DriverEnv, fn: DriverFn): DriverFn {
478
606
  * Every operation a workflow uses must be one the config's connectors declare, with a driver.
479
607
  * The workflow normally imports the same connector object; a copy is accepted only if it
480
608
  * declares the same effect and retry setting, and the worker's declaration is used either way.
481
- * None may change a declared resource (`isChangeOp`): only the built-in apply workflow does.
609
+ * None may use an operation a built-in workflow alone calls (`reservedFor`): a declared resource's
610
+ * change, which only apply makes, or a change's step, which only the change workflow takes.
482
611
  */
483
612
  function checkUses(
484
613
  wf: WorkflowDefinition<any, any>,
@@ -489,11 +618,10 @@ function checkUses(
489
618
  const problems: string[] = [];
490
619
  for (const op of (wf.uses as readonly Use[]).filter(isOp)) {
491
620
  const known = ops.get(op.id);
621
+ const reserved = known && reservedFor(known, types);
492
622
  if (!known) problems.push(`${op.id} is not declared by any connector in \`connectors\``);
493
- else if (isChangeOp(known, types)) {
494
- problems.push(
495
- `${op.id} changes a declared resource, which only ${APPLY_WORKFLOW} does: change the data file instead`,
496
- );
623
+ else if (reserved) {
624
+ problems.push(`${op.id} ${reserved.does}, which only ${reserved.workflow} does: ${reserved.instead} instead`);
497
625
  } else if (known !== op && (known.effect !== op.effect || known.idempotent !== op.idempotent)) {
498
626
  problems.push(
499
627
  `${op.id} is declared with effect "${op.effect}"${op.idempotent ? " (idempotent)" : ""}, ` +
@@ -1,4 +1,10 @@
1
+ import { sanoma as people } from "./person.ts";
2
+ import { wakeup } from "./wakeup-type.ts";
3
+
1
4
  // `@sanoma-ai/workflows/resources`: what a data file imports from the runtime: `secret`, which
2
- // names a stored secret, and `sanoma`, the constructors of its people types (`sanoma.person`, …).
5
+ // names a stored secret, and `sanoma`, the constructors of its people types (`sanoma.person`, …)
6
+ // and of its wakeup, `sanoma.wakeup({ host })`.
3
7
  export { secret, type Secret } from "./secret.ts";
4
- export { sanoma } from "./person.ts";
8
+
9
+ /** The runtime's own constructors: the people types' and the wakeup's. */
10
+ export const sanoma = { ...people, wakeup };
@@ -0,0 +1,152 @@
1
+ import type { z } from "zod";
2
+ import { hasPath } from "./host.ts";
3
+ import { DriverError, type Op } from "./op.ts";
4
+ import type { AfterCreate, Resource } from "./resource.ts";
5
+ import type { Secrets } from "./secret.ts";
6
+ import { RESOURCE_OPS } from "./wakeup.ts";
7
+
8
+ // The `Database` contract: what a resource type that holds the company's database (a Neon
9
+ // project, a Supabase project) declares about itself, beside `Host` and `Wakeup`, so databases
10
+ // are swappable and one conformance kit decides whether one works. Going live chooses one by it.
11
+
12
+ /** What a database can do, for choosing one: declared, never checked at runtime. */
13
+ export interface DatabaseCapabilities {
14
+ /** Its compute scales to zero and wakes on the next connection (Neon's). */
15
+ pauses: boolean;
16
+ /** It makes copy-on-write branches that carry the data, each with its own URL. */
17
+ branch: boolean;
18
+ }
19
+
20
+ /** What a database's state says of it. */
21
+ export interface DatabaseOutputs {
22
+ healthy: boolean;
23
+ }
24
+
25
+ /**
26
+ * A resource type's `database`: the fields it takes going live's choices in, by dotted path into
27
+ * its declared fields, the operation that stores its URL, and how its state reads.
28
+ */
29
+ export interface DatabaseSpec {
30
+ capabilities: DatabaseCapabilities;
31
+ inputs: {
32
+ /** Where it runs, one of `regions` when the type lists them. */
33
+ region: string;
34
+ /** Its compute's size, when the vendor takes one: absent for its default. */
35
+ size?: string;
36
+ };
37
+ /**
38
+ * The id of the operation that stores the database's URL, `<vendor>.<group>.<op>`, an
39
+ * operation of the type's own vendor: in the type's own group beside `read` and `import`
40
+ * (`neon.project.storeUrl`), or in a group of its own (`supabase.database.storeUrl`, which
41
+ * predates the contract), never a resource type's own operation. It takes the new object's
42
+ * binding id as `ref` or `id` and the data file's export as `export`, and stores the URL that
43
+ * carries `LISTEN` (the direct one, or a session pooler's; never a transaction-mode pooler's)
44
+ * as the secret `SANOMA_DATABASE_URL_<EXPORT>`, never returning it. The type's `afterCreate`
45
+ * calls it, so apply stores the URL once it makes the database.
46
+ */
47
+ storeUrl: string;
48
+ /** The regions `inputs.region` takes, for a choice; absent when the vendor's list is not known here. */
49
+ regions?: readonly string[];
50
+ outputs: (state: Record<string, unknown>) => DatabaseOutputs;
51
+ }
52
+
53
+ /**
54
+ * The secret a database's `storeUrl` stores its URL in, for the data file's export that declares
55
+ * it: `store` → `SANOMA_DATABASE_URL_STORE`, `mainStore` → `SANOMA_DATABASE_URL_MAIN_STORE`.
56
+ */
57
+ export const databaseUrlSecret = (exportName: string): string =>
58
+ `SANOMA_DATABASE_URL_${exportName.replaceAll(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase()}`;
59
+
60
+ /**
61
+ * A `storeUrl`'s store: `url` as `databaseUrlSecret(exportName)`, whose name it returns. The store
62
+ * only adds: a secret that holds another URL (another database, or a password since changed) is
63
+ * refused, not kept, with a `DriverError` (`stale`) that begins `where` and names what else it
64
+ * could be (`others`).
65
+ */
66
+ export async function storeDatabaseUrl(
67
+ secrets: Secrets,
68
+ exportName: string,
69
+ url: string,
70
+ { where, others }: { where: string; others: string },
71
+ ): Promise<{ secret: string }> {
72
+ const secret = databaseUrlSecret(exportName);
73
+ if ((await secrets.ensure(secret, () => url)) !== url) {
74
+ throw new DriverError(`${where}: ${secret} holds another URL (${others}); delete it to store this one`, {
75
+ retryable: false,
76
+ vendorCode: "stale",
77
+ });
78
+ }
79
+ return { secret };
80
+ }
81
+
82
+ /**
83
+ * Throws what is wrong with a resource type's `database`: a type apply cannot change (not
84
+ * `writable`), one apply may recreate without asking (`stateless`: a database holds data), an
85
+ * input that names no field, a capability that is not a boolean, a `storeUrl` that is not an
86
+ * operation of its vendor or is one of the type's resource operations, or that `afterCreate`
87
+ * does not call, `regions` that is not a list of strings, `outputs` that is not a function.
88
+ * Whether the connector has that operation, and it takes `{ ref | id, export }`, is
89
+ * `checkDatabaseOps`'s, which `resolveConfig` runs.
90
+ */
91
+ export function checkDatabase(
92
+ what: string,
93
+ database: DatabaseSpec,
94
+ type: {
95
+ vendor: string;
96
+ type: string;
97
+ schema: z.ZodObject;
98
+ writable?: boolean;
99
+ stateless?: boolean;
100
+ afterCreate?: readonly AfterCreate[];
101
+ },
102
+ ): void {
103
+ const problems: string[] = [];
104
+ if (!type.writable) problems.push("it must be writable, so apply can create and update it");
105
+ if (type.stateless) problems.push("it must not be stateless: it holds data, so apply asks before it deletes one");
106
+ const capabilities: Partial<DatabaseCapabilities> = database.capabilities ?? {};
107
+ for (const name of ["pauses", "branch"] as const) {
108
+ if (typeof capabilities[name] !== "boolean") problems.push(`capabilities.${name} must be true or false`);
109
+ }
110
+ const { region, size } = database.inputs ?? {};
111
+ if (typeof region !== "string" || !hasPath(type.schema, region)) problems.push(`region names no field (${region})`);
112
+ if (size !== undefined && !hasPath(type.schema, size)) problems.push(`size names no field (${size})`);
113
+ const storeUrl: unknown = database.storeUrl;
114
+ const [vendor, group, name, ...more] = typeof storeUrl === "string" ? storeUrl.split(".") : [];
115
+ if (vendor !== type.vendor || !/^\w+$/.test(group ?? "") || !/^\w+$/.test(name ?? "") || more.length) {
116
+ problems.push(`storeUrl must name an operation of its vendor, ${type.vendor}.<group>.<op> (${String(storeUrl)})`);
117
+ } else if (group === type.type && RESOURCE_OPS.has(name!)) {
118
+ problems.push(`storeUrl must be an operation of its own, not the type's ${name}`);
119
+ } else if (!type.afterCreate?.some((step) => step.op === storeUrl)) {
120
+ problems.push(`afterCreate must call ${storeUrl}, so apply stores the URL once it makes one`);
121
+ }
122
+ if (
123
+ database.regions !== undefined &&
124
+ (!Array.isArray(database.regions) || database.regions.some((r) => typeof r !== "string" || !r))
125
+ ) {
126
+ problems.push("regions must be a list of region names");
127
+ }
128
+ if (typeof database.outputs !== "function") problems.push("outputs must be a function of the state");
129
+ if (problems.length) throw new Error(`${what}: its database: ${problems.join("; ")}`);
130
+ }
131
+
132
+ /**
133
+ * Throws when a database type's `storeUrl` is not among the config's operations, is one of a
134
+ * resource type's own, or does not take `{ ref | id, export }`. Here rather than in
135
+ * `defineResource`, which sees one type before its package merges in the operations it adds
136
+ * (Supabase's pattern), as `checkWakeupOps`.
137
+ */
138
+ export function checkDatabaseOps(types: ReadonlyMap<string, Resource>, ops: ReadonlyMap<string, Op>): void {
139
+ for (const [id, type] of types) {
140
+ if (!type.database) continue;
141
+ const op = ops.get(type.database.storeUrl);
142
+ if (!op)
143
+ throw new Error(`${id}: its database's storeUrl, ${type.database.storeUrl}, is no operation of its connector`);
144
+ if (types.has(`${op.vendor}.${op.resource}`) && RESOURCE_OPS.has(op.name)) {
145
+ throw new Error(`${id}: its database's storeUrl, ${op.id}, is a resource type's own operation`);
146
+ }
147
+ // By its fields, not a sample's parse: an id or export with a format of its own is still one.
148
+ const has = (field: string) => hasPath(op.input, field);
149
+ if (!(has("export") && (has("ref") || has("id"))))
150
+ throw new Error(`${id}: its database's storeUrl, ${op.id}, must take { ref or id, export }`);
151
+ }
152
+ }
package/src/db.ts CHANGED
@@ -5,9 +5,9 @@ import { warn } from "./log.ts";
5
5
  import { errorMessage } from "./shared.ts";
6
6
 
7
7
  // The runtime's own tables live in the config's database, beside DBOS's: the credentials, the
8
- // ledger and the collections' records. What they share is here: the one preparation of a
9
- // database, a pool that opens once it is prepared and survives a dropped connection, and a table
10
- // creation two processes may race.
8
+ // ledger, the collections' records, the wakeup's row and the operator agent's chats. What they
9
+ // share is here: the one preparation of a database, a pool that opens once it is prepared and
10
+ // survives a dropped connection, and a table creation two processes may race.
11
11
 
12
12
  /** A connection or a pool: what the queries take. */
13
13
  export type Db = Pick<Pool | Client, "query">;
@@ -18,6 +18,16 @@ export const CREDENTIALS_TABLE = "sanoma_credentials";
18
18
  export const LEDGER_TABLE = "sanoma_ledger";
19
19
  /** Where the collections keep their records (`collection.ts`): one row per collection and key, the record as JSON. */
20
20
  export const DATA_TABLE = "sanoma_data";
21
+ /**
22
+ * The instance's side of its wakeup: one row, made with the table. `at` is the deadline last
23
+ * registered, `stopped_at` when the worker last stopped itself (the schedules backfill from it),
24
+ * `last_error` why the last registration failed.
25
+ */
26
+ export const WAKEUP_TABLE = "sanoma_wakeup";
27
+ /** The operator agent's chats (`chats.ts`): who each is for, its engine and its draft. */
28
+ export const CHATS_TABLE = "sanoma_chats";
29
+ /** Each chat's messages, the AI SDK's UI messages as JSON, in `seq` order. */
30
+ export const CHAT_MESSAGES_TABLE = "sanoma_chat_messages";
21
31
 
22
32
  /** The runtime's tables, each with the word the messages name it by. */
23
33
  const TABLES: readonly [what: string, ddl: string][] = [
@@ -39,6 +49,22 @@ const TABLES: readonly [what: string, ddl: string][] = [
39
49
  `CREATE TABLE IF NOT EXISTS ${DATA_TABLE} (collection text NOT NULL, key text NOT NULL, record jsonb NOT NULL, ` +
40
50
  "updated_at timestamptz NOT NULL DEFAULT now(), updated_by text NOT NULL, PRIMARY KEY (collection, key))",
41
51
  ],
52
+ [
53
+ "wakeup",
54
+ `CREATE TABLE IF NOT EXISTS ${WAKEUP_TABLE} (id integer PRIMARY KEY CHECK (id = 1), at timestamptz, ` +
55
+ `stopped_at timestamptz, last_error text); INSERT INTO ${WAKEUP_TABLE} (id) VALUES (1) ON CONFLICT (id) DO NOTHING`,
56
+ ],
57
+ [
58
+ "chats",
59
+ `CREATE TABLE IF NOT EXISTS ${CHATS_TABLE} (id text PRIMARY KEY, person text NOT NULL, engine text NOT NULL, ` +
60
+ "draft_id text, title text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()); " +
61
+ `CREATE INDEX IF NOT EXISTS ${CHATS_TABLE}_person ON ${CHATS_TABLE} (person, created_at)`,
62
+ ],
63
+ [
64
+ "chat messages",
65
+ `CREATE TABLE IF NOT EXISTS ${CHAT_MESSAGES_TABLE} (chat_id text NOT NULL REFERENCES ${CHATS_TABLE} (id) ` +
66
+ "ON DELETE CASCADE, id text NOT NULL, seq integer NOT NULL, message jsonb NOT NULL, PRIMARY KEY (chat_id, id))",
67
+ ],
42
68
  ];
43
69
 
44
70
  /** The Postgres error code of `err`, or an empty string when it has none. */
@@ -142,7 +168,7 @@ const prepared = new Map<string, Promise<void>>();
142
168
  * Makes the database at `databaseUrl` ready for the runtime, once per process and URL: the
143
169
  * database itself and DBOS's tables when they are not there yet (as a worker's launch makes
144
170
  * them; credentials may be set, and a ledger read, before any worker has run), then the
145
- * credentials, ledger and data tables, and its idle-transaction timeout (`guardIdleTransactions`).
171
+ * credentials, ledger, data, wakeup and chat tables, and its idle-transaction timeout (`guardIdleTransactions`).
146
172
  * Two processes preparing one database both succeed: the second waits for the first
147
173
  * (`lockPreparation`). A preparation that fails is forgotten, so the next call tries again.
148
174
  */
@@ -188,11 +214,7 @@ async function prepare(databaseUrl: string): Promise<void> {
188
214
  * cannot see (the lock's holder is this connection, not the migration's).
189
215
  */
190
216
  async function lockPreparation(databaseUrl: string): Promise<Client> {
191
- const conn = await connect(databaseUrl).catch(async (err: unknown) => {
192
- if (pgCode(err) !== NO_DATABASE) throw err;
193
- await createDatabase(databaseUrl);
194
- return connect(databaseUrl);
195
- });
217
+ const conn = await connectMaking(databaseUrl);
196
218
  try {
197
219
  const ask = "SELECT pg_try_advisory_lock(hashtext('sanoma.prepareDatabase')) AS locked";
198
220
  while (!(await conn.query<{ locked: boolean }>(ask)).rows[0]!.locked) await delay(50);
@@ -203,6 +225,15 @@ async function lockPreparation(databaseUrl: string): Promise<Client> {
203
225
  }
204
226
  }
205
227
 
228
+ /** A connection to the database at `databaseUrl`, made first when it is not there (`createDatabase`). */
229
+ export async function connectMaking(databaseUrl: string): Promise<Client> {
230
+ return connect(databaseUrl).catch(async (err: unknown) => {
231
+ if (pgCode(err) !== NO_DATABASE) throw err;
232
+ await createDatabase(databaseUrl);
233
+ return connect(databaseUrl);
234
+ });
235
+ }
236
+
206
237
  async function connect(databaseUrl: string): Promise<Client> {
207
238
  const conn = new Client({ connectionString: databaseUrl, connectionTimeoutMillis: 10_000 });
208
239
  // An error nobody listens for ends the process; the query in flight fails with it.
@@ -263,3 +294,21 @@ export async function createTable(db: Db, ddl: string, what: string): Promise<vo
263
294
  throw new Error(`could not prepare the ${what} table: ${errorMessage(err)}`, { cause: err });
264
295
  }
265
296
  }
297
+
298
+ // The wakeup row's writes: one worker registers per instance (the supervisor runs one at a time,
299
+ // and a deploy stops the old container before the new one starts), so none names a worker.
300
+
301
+ /** Records that the wakeup took `at`. */
302
+ export async function recordWakeupRegistration(db: Db, at: string): Promise<void> {
303
+ await db.query(`UPDATE ${WAKEUP_TABLE} SET at = $1, last_error = NULL WHERE id = 1`, [at]);
304
+ }
305
+
306
+ /** Records why the last registration failed. */
307
+ export async function recordWakeupError(db: Db, error: string): Promise<void> {
308
+ await db.query(`UPDATE ${WAKEUP_TABLE} SET last_error = $1 WHERE id = 1`, [error]);
309
+ }
310
+
311
+ /** Records that the worker stops itself now. */
312
+ export async function recordWakeupStop(db: Db): Promise<void> {
313
+ await db.query(`UPDATE ${WAKEUP_TABLE} SET stopped_at = now() WHERE id = 1`);
314
+ }