@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.
- package/README.md +52 -14
- package/dist/apply.d.ts +2 -1
- package/dist/apply.js +29 -7
- package/dist/apply.js.map +1 -1
- package/dist/approvals.d.ts +27 -2
- package/dist/approvals.js +73 -9
- package/dist/approvals.js.map +1 -1
- package/dist/call.d.ts +3 -2
- package/dist/call.js +38 -30
- package/dist/call.js.map +1 -1
- package/dist/cedar.js +24 -9
- package/dist/cedar.js.map +1 -1
- package/dist/change.d.ts +219 -0
- package/dist/change.js +150 -0
- package/dist/change.js.map +1 -0
- package/dist/chats.d.ts +43 -0
- package/dist/chats.js +48 -0
- package/dist/chats.js.map +1 -0
- package/dist/client.d.ts +52 -6
- package/dist/client.js +88 -47
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +75 -8
- package/dist/config.js +103 -13
- package/dist/config.js.map +1 -1
- package/dist/constructors.d.ts +37 -1
- package/dist/constructors.js +6 -2
- package/dist/constructors.js.map +1 -1
- package/dist/database.d.ts +83 -0
- package/dist/database.js +92 -0
- package/dist/database.js.map +1 -0
- package/dist/db.d.ts +19 -1
- package/dist/db.js +51 -7
- package/dist/db.js.map +1 -1
- package/dist/deadlines.d.ts +91 -0
- package/dist/deadlines.js +165 -0
- package/dist/deadlines.js.map +1 -0
- package/dist/define.d.ts +25 -4
- package/dist/define.js +40 -0
- package/dist/define.js.map +1 -1
- package/dist/demo.d.ts +28 -0
- package/dist/demo.js +72 -0
- package/dist/demo.js.map +1 -0
- package/dist/describe.d.ts +5 -2
- package/dist/describe.js +10 -6
- package/dist/describe.js.map +1 -1
- package/dist/errors.d.ts +10 -1
- package/dist/errors.js +13 -0
- package/dist/errors.js.map +1 -1
- package/dist/fake.d.ts +34 -0
- package/dist/fake.js +134 -1
- package/dist/fake.js.map +1 -1
- package/dist/grid.d.ts +34 -0
- package/dist/grid.js +62 -0
- package/dist/grid.js.map +1 -0
- package/dist/host.d.ts +12 -2
- package/dist/host.js +2 -2
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +15 -8
- package/dist/index.js +10 -5
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +11 -2
- package/dist/ledger.js.map +1 -1
- package/dist/lint.js +2 -2
- package/dist/lint.js.map +1 -1
- package/dist/op.d.ts +21 -2
- package/dist/op.js.map +1 -1
- package/dist/person.d.ts +5 -0
- package/dist/person.js +3 -1
- package/dist/person.js.map +1 -1
- package/dist/policy.d.ts +7 -1
- package/dist/policy.js +2 -1
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +13 -1
- package/dist/resource.js +9 -1
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +2 -0
- package/dist/resources.js +15 -4
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +5 -0
- package/dist/run.js.map +1 -1
- package/dist/sandbox.d.ts +38 -10
- package/dist/sandbox.js +66 -35
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +18 -1
- package/dist/scenario.js +77 -27
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +10 -1
- package/dist/scenarios.js +19 -4
- package/dist/scenarios.js.map +1 -1
- package/dist/schedules.d.ts +66 -0
- package/dist/schedules.js +158 -0
- package/dist/schedules.js.map +1 -0
- package/dist/schema.d.ts +6 -0
- package/dist/schema.js +13 -3
- package/dist/schema.js.map +1 -1
- package/dist/shared.d.ts +30 -2
- package/dist/shared.js +42 -4
- package/dist/shared.js.map +1 -1
- package/dist/source.d.ts +185 -2
- package/dist/source.js +9 -0
- package/dist/source.js.map +1 -1
- package/dist/supervisor.d.ts +39 -0
- package/dist/supervisor.js +68 -0
- package/dist/supervisor.js.map +1 -0
- package/dist/version.d.ts +3 -1
- package/dist/version.js +3 -1
- package/dist/version.js.map +1 -1
- package/dist/wakeup-driver.d.ts +128 -0
- package/dist/wakeup-driver.js +216 -0
- package/dist/wakeup-driver.js.map +1 -0
- package/dist/wakeup-service.d.ts +83 -0
- package/dist/wakeup-service.js +415 -0
- package/dist/wakeup-service.js.map +1 -0
- package/dist/wakeup-type.d.ts +43 -0
- package/dist/wakeup-type.js +80 -0
- package/dist/wakeup-type.js.map +1 -0
- package/dist/wakeup.d.ts +77 -0
- package/dist/wakeup.js +78 -0
- package/dist/wakeup.js.map +1 -0
- package/dist/worker.d.ts +39 -1
- package/dist/worker.js +125 -38
- package/dist/worker.js.map +1 -1
- package/oxlint.json +3 -1
- package/package.json +10 -2
- package/src/apply.ts +25 -6
- package/src/approvals.ts +89 -16
- package/src/call.ts +40 -29
- package/src/cedar.ts +24 -6
- package/src/change.ts +222 -0
- package/src/chats.ts +114 -0
- package/src/client.ts +145 -55
- package/src/config.ts +149 -21
- package/src/constructors.ts +8 -2
- package/src/database.ts +152 -0
- package/src/db.ts +58 -9
- package/src/deadlines.ts +228 -0
- package/src/define.ts +71 -4
- package/src/demo.ts +87 -0
- package/src/describe.ts +18 -6
- package/src/errors.ts +14 -0
- package/src/fake.ts +149 -0
- package/src/grid.ts +100 -0
- package/src/host.ts +12 -4
- package/src/index.ts +66 -3
- package/src/ledger.ts +15 -3
- package/src/lint.ts +2 -2
- package/src/op.ts +22 -2
- package/src/person.ts +4 -1
- package/src/policy.ts +7 -1
- package/src/resource.ts +22 -3
- package/src/resources.ts +17 -4
- package/src/run.ts +5 -0
- package/src/sandbox.ts +97 -37
- package/src/scenario.ts +97 -28
- package/src/scenarios.ts +21 -5
- package/src/schedules.ts +206 -0
- package/src/schema.ts +16 -3
- package/src/shared.ts +51 -4
- package/src/source.ts +176 -3
- package/src/supervisor.ts +102 -0
- package/src/version.ts +3 -1
- package/src/wakeup-driver.ts +270 -0
- package/src/wakeup-service.ts +504 -0
- package/src/wakeup-type.ts +90 -0
- package/src/wakeup.ts +139 -0
- 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
|
|
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 {
|
|
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
|
|
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.
|
|
111
|
-
* declares a host type,
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
...
|
|
289
|
+
...running,
|
|
290
|
+
changeOps,
|
|
242
291
|
...(hosts ? [sourceOps] : []),
|
|
243
292
|
...(dataStore ? [collectionsConnector(declared)] : []),
|
|
244
293
|
]);
|
|
245
|
-
|
|
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([...
|
|
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
|
-
/**
|
|
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
|
|
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 (
|
|
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)" : ""}, ` +
|
package/src/constructors.ts
CHANGED
|
@@ -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
|
-
|
|
8
|
+
|
|
9
|
+
/** The runtime's own constructors: the people types' and the wakeup's. */
|
|
10
|
+
export const sanoma = { ...people, wakeup };
|
package/src/database.ts
ADDED
|
@@ -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
|
|
9
|
-
// database, a pool that opens once it is prepared and
|
|
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
|
|
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
|
|
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
|
+
}
|