@sanoma-ai/workflows 0.11.12 → 0.11.14
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/CLOUD-IDENTITY.md +12 -4
- package/KMS-SECRETS.md +3 -1
- package/README.md +4 -2
- package/dist/bundled.d.ts +2 -2
- package/dist/chats.d.ts +3 -3
- package/dist/chats.js +34 -20
- package/dist/chats.js.map +1 -1
- package/dist/client.d.ts +17 -4
- package/dist/client.js +74 -63
- package/dist/client.js.map +1 -1
- package/dist/cloud-identity.js +14 -12
- package/dist/cloud-identity.js.map +1 -1
- package/dist/collection.d.ts +2 -2
- package/dist/collection.js +53 -42
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +11 -6
- package/dist/config.js +15 -13
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts +2 -0
- package/dist/connection.js +112 -40
- package/dist/connection.js.map +1 -1
- package/dist/credentials.d.ts +1 -1
- package/dist/credentials.js +33 -29
- package/dist/credentials.js.map +1 -1
- package/dist/db/company.d.ts +1152 -0
- package/dist/db/company.js +118 -0
- package/dist/db/company.js.map +1 -0
- package/dist/db/dbos.d.ts +111 -0
- package/dist/db/dbos.js +15 -0
- package/dist/db/dbos.js.map +1 -0
- package/dist/db/identity.d.ts +367 -0
- package/dist/db/identity.js +48 -0
- package/dist/db/identity.js.map +1 -0
- package/dist/db/index.d.ts +7 -0
- package/dist/db/index.js +12 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/kms.d.ts +224 -0
- package/dist/db/kms.js +29 -0
- package/dist/db/kms.js.map +1 -0
- package/dist/db/migrate.d.ts +66 -0
- package/dist/db/migrate.js +39 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/wakeup.d.ts +271 -0
- package/dist/db/wakeup.js +34 -0
- package/dist/db/wakeup.js.map +1 -0
- package/dist/db.d.ts +42 -39
- package/dist/db.js +129 -169
- package/dist/db.js.map +1 -1
- package/dist/deadlines.js +8 -4
- package/dist/deadlines.js.map +1 -1
- package/dist/deployment-key.d.ts +4 -1
- package/dist/deployment-key.js +10 -3
- package/dist/deployment-key.js.map +1 -1
- package/dist/host-derive.d.ts +12 -3
- package/dist/host-derive.js +21 -8
- package/dist/host-derive.js.map +1 -1
- package/dist/identity-provisioning.d.ts +2 -2
- package/dist/identity-provisioning.js +75 -42
- package/dist/identity-provisioning.js.map +1 -1
- package/dist/identity-service.js +18 -15
- package/dist/identity-service.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/kms-secrets.d.ts +9 -0
- package/dist/kms-secrets.js +59 -27
- package/dist/kms-secrets.js.map +1 -1
- package/dist/ledger.d.ts +1 -1
- package/dist/ledger.js +22 -14
- package/dist/ledger.js.map +1 -1
- package/dist/op.d.ts +27 -6
- package/dist/op.js.map +1 -1
- package/dist/run-status.js +7 -3
- package/dist/run-status.js.map +1 -1
- package/dist/schedules.d.ts +1 -1
- package/dist/schedules.js +5 -7
- package/dist/schedules.js.map +1 -1
- package/dist/schema.d.ts +6 -5
- package/dist/schema.js +11 -7
- package/dist/schema.js.map +1 -1
- package/dist/shared.d.ts +6 -23
- package/dist/shared.js +6 -5
- package/dist/shared.js.map +1 -1
- package/dist/storage-provisioning.d.ts +2 -0
- package/dist/storage-provisioning.js +51 -50
- package/dist/storage-provisioning.js.map +1 -1
- package/dist/tokens.d.ts +2 -2
- package/dist/tokens.js +31 -20
- package/dist/tokens.js.map +1 -1
- package/dist/version.d.ts +4 -4
- package/dist/version.js +5 -5
- package/dist/version.js.map +1 -1
- package/dist/wakeup-service.d.ts +0 -4
- package/dist/wakeup-service.js +102 -93
- package/dist/wakeup-service.js.map +1 -1
- package/dist/worker.d.ts +7 -0
- package/dist/worker.js +26 -21
- package/dist/worker.js.map +1 -1
- package/drizzle/company/0000_tables.sql +104 -0
- package/drizzle/company/0001_wakeup_row.sql +2 -0
- package/drizzle/company/meta/0000_snapshot.json +652 -0
- package/drizzle/company/meta/0001_snapshot.json +652 -0
- package/drizzle/company/meta/_journal.json +20 -0
- package/drizzle/wakeup/0000_tables.sql +26 -0
- package/drizzle/wakeup/meta/0000_snapshot.json +200 -0
- package/drizzle/wakeup/meta/_journal.json +13 -0
- package/oxlint.json +2 -1
- package/package.json +11 -2
- package/src/bundled.ts +2 -2
- package/src/chats.ts +41 -42
- package/src/client.ts +81 -64
- package/src/cloud-identity.ts +23 -24
- package/src/collection.ts +58 -50
- package/src/config.ts +29 -19
- package/src/connection.ts +132 -102
- package/src/credentials.ts +37 -33
- package/src/db/company.ts +173 -0
- package/src/db/dbos.ts +17 -0
- package/src/db/identity.ts +61 -0
- package/src/db/index.ts +12 -0
- package/src/db/kms.ts +41 -0
- package/src/db/migrate.ts +71 -0
- package/src/db/wakeup.ts +41 -0
- package/src/db.ts +151 -182
- package/src/deadlines.ts +10 -4
- package/src/deployment-key.ts +11 -3
- package/src/host-derive.ts +26 -9
- package/src/identity-provisioning.ts +85 -57
- package/src/identity-service.ts +19 -19
- package/src/index.ts +3 -0
- package/src/kms-secrets.ts +64 -56
- package/src/ledger.ts +23 -18
- package/src/op.ts +28 -6
- package/src/run-status.ts +7 -6
- package/src/schedules.ts +6 -8
- package/src/schema.ts +11 -8
- package/src/shared.ts +6 -17
- package/src/storage-provisioning.ts +47 -52
- package/src/tokens.ts +43 -33
- package/src/version.ts +5 -5
- package/src/wakeup-service.ts +117 -131
- package/src/worker.ts +38 -21
package/src/db.ts
CHANGED
|
@@ -1,110 +1,63 @@
|
|
|
1
1
|
import { DBOS, DBOSClient } from "@dbos-inc/dbos-sdk";
|
|
2
2
|
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
-
import {
|
|
3
|
+
import { and, eq, inArray, lte, ne, sql } from "drizzle-orm";
|
|
4
|
+
import { DrizzleQueryError } from "drizzle-orm/errors";
|
|
5
|
+
import { drizzle, type NodePgClient, type NodePgQueryResultHKT } from "drizzle-orm/node-postgres";
|
|
6
|
+
import { type PgDatabase, PgPreparedQuery } from "drizzle-orm/pg-core";
|
|
7
|
+
import { Client, Pool } from "pg";
|
|
8
|
+
import { inviteRequests } from "./db/company.ts";
|
|
9
|
+
import { workflowStatus } from "./db/dbos.ts";
|
|
10
|
+
import { applyMigrations, COMPANY_MIGRATIONS, migrationsApplied } from "./db/migrate.ts";
|
|
4
11
|
import { warn } from "./log.ts";
|
|
5
12
|
import { errorMessage } from "./shared.ts";
|
|
6
13
|
|
|
7
|
-
// The runtime's own tables live in the config's database, beside DBOS's
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
14
|
+
// The runtime's own tables live in the config's database, beside DBOS's (`db/company.ts`). What
|
|
15
|
+
// the stores share is here: Drizzle over a `pg` pool or connection (`databaseOn`), the one
|
|
16
|
+
// preparation of a database, which applies the generated migrations under a lock, and a pool that
|
|
17
|
+
// opens once it is prepared and survives a dropped connection.
|
|
11
18
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
19
|
+
/**
|
|
20
|
+
* A database, or a transaction on one (`db.transaction`): what every store takes, so a store runs
|
|
21
|
+
* inside a caller's transaction as it runs on its own. Made by `databaseOn`.
|
|
22
|
+
*/
|
|
23
|
+
export type Db = PgDatabase<NodePgQueryResultHKT>;
|
|
14
24
|
|
|
15
|
-
/** Where the credentials set from the app live. */
|
|
16
|
-
export const CREDENTIALS_TABLE = "sanoma_credentials";
|
|
17
|
-
/** Where the Postgres ledger keeps its records. */
|
|
18
|
-
export const LEDGER_TABLE = "sanoma_ledger";
|
|
19
|
-
/** Where the collections keep their records (`collection.ts`): one row per collection and key, the record as JSON. */
|
|
20
|
-
export const DATA_TABLE = "sanoma_data";
|
|
21
25
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
26
|
+
* Drizzle over `client`, a `pg` pool or connection the caller owns and ends (`db.$client`). Column
|
|
27
|
+
* names are the schema's properties in snake_case (`casing`), as the generated migrations write them.
|
|
28
|
+
*
|
|
29
|
+
* A statement that fails throws the `pg` error itself, as `client.query` would, not Drizzle's
|
|
30
|
+
* `DrizzleQueryError`, whose message carries the statement and its parameters (a credential's
|
|
31
|
+
* sealed value, a person's email, a record): `pgCode` reads the code, and no message or log
|
|
32
|
+
* repeats what was sent. `db.test.ts` holds Drizzle to this with a sentinel parameter.
|
|
25
33
|
*/
|
|
26
|
-
export
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
export const CHAT_MESSAGES_TABLE = "sanoma_chat_messages";
|
|
31
|
-
/** The api tokens (`tokens.ts`): each one's SHA-256, never the token, whom it acts for, its groups, and when it was revoked. */
|
|
32
|
-
export const API_TOKENS_TABLE = "sanoma_api_tokens";
|
|
34
|
+
export function databaseOn<C extends NodePgClient>(client: C) {
|
|
35
|
+
rethrowCause();
|
|
36
|
+
return drizzle(client, { casing: "snake_case" });
|
|
37
|
+
}
|
|
33
38
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
],
|
|
57
|
-
[
|
|
58
|
-
"credentials",
|
|
59
|
-
// A table from before 0.10.0 (one row per name, each value sealed to its name alone) holds
|
|
60
|
-
// nothing 0.10.0 can read: it goes, and its credentials are set again.
|
|
61
|
-
// One table for the look and the drop: the one the name resolves to.
|
|
62
|
-
`DO $$ BEGIN
|
|
63
|
-
IF to_regclass('${CREDENTIALS_TABLE}') IS NOT NULL AND NOT EXISTS (
|
|
64
|
-
SELECT FROM pg_attribute
|
|
65
|
-
WHERE attrelid = to_regclass('${CREDENTIALS_TABLE}') AND attname = 'namespace' AND NOT attisdropped
|
|
66
|
-
) THEN
|
|
67
|
-
DROP TABLE ${CREDENTIALS_TABLE};
|
|
68
|
-
END IF;
|
|
69
|
-
END $$; ` +
|
|
70
|
-
`CREATE TABLE IF NOT EXISTS ${CREDENTIALS_TABLE} (namespace text NOT NULL, name text NOT NULL, value text NOT NULL, ` +
|
|
71
|
-
"version text NOT NULL, set_by text NOT NULL, set_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(namespace, name))",
|
|
72
|
-
],
|
|
73
|
-
[
|
|
74
|
-
"ledger",
|
|
75
|
-
// `n` is the write order, `id` the record's, which the first write of takes, and `seq` the
|
|
76
|
-
// record's place in its run: a run reads in `seq` order, then write order.
|
|
77
|
-
`CREATE TABLE IF NOT EXISTS ${LEDGER_TABLE} (n bigserial PRIMARY KEY, id text NOT NULL UNIQUE, ` +
|
|
78
|
-
`run_id text NOT NULL, seq integer NOT NULL, record jsonb NOT NULL); ` +
|
|
79
|
-
`CREATE INDEX IF NOT EXISTS ${LEDGER_TABLE}_run_seq ON ${LEDGER_TABLE} (run_id, seq, n)`,
|
|
80
|
-
],
|
|
81
|
-
[
|
|
82
|
-
"data",
|
|
83
|
-
`CREATE TABLE IF NOT EXISTS ${DATA_TABLE} (collection text NOT NULL, key text NOT NULL, record jsonb NOT NULL, ` +
|
|
84
|
-
"updated_at timestamptz NOT NULL DEFAULT now(), updated_by text NOT NULL, PRIMARY KEY (collection, key))",
|
|
85
|
-
],
|
|
86
|
-
[
|
|
87
|
-
"wakeup",
|
|
88
|
-
`CREATE TABLE IF NOT EXISTS ${WAKEUP_TABLE} (id integer PRIMARY KEY CHECK (id = 1), at timestamptz, ` +
|
|
89
|
-
`stopped_at timestamptz, last_error text); INSERT INTO ${WAKEUP_TABLE} (id) VALUES (1) ON CONFLICT (id) DO NOTHING`,
|
|
90
|
-
],
|
|
91
|
-
[
|
|
92
|
-
"chats",
|
|
93
|
-
`CREATE TABLE IF NOT EXISTS ${CHATS_TABLE} (id text PRIMARY KEY, person text NOT NULL, engine text NOT NULL, ` +
|
|
94
|
-
"draft_id text, title text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()); " +
|
|
95
|
-
`CREATE INDEX IF NOT EXISTS ${CHATS_TABLE}_person ON ${CHATS_TABLE} (person, created_at)`,
|
|
96
|
-
],
|
|
97
|
-
[
|
|
98
|
-
"chat messages",
|
|
99
|
-
`CREATE TABLE IF NOT EXISTS ${CHAT_MESSAGES_TABLE} (chat_id text NOT NULL REFERENCES ${CHATS_TABLE} (id) ` +
|
|
100
|
-
"ON DELETE CASCADE, id text NOT NULL, seq integer NOT NULL, message jsonb NOT NULL, PRIMARY KEY (chat_id, id))",
|
|
101
|
-
],
|
|
102
|
-
[
|
|
103
|
-
"api tokens",
|
|
104
|
-
`CREATE TABLE IF NOT EXISTS ${API_TOKENS_TABLE} (id text PRIMARY KEY, hash text NOT NULL UNIQUE, ` +
|
|
105
|
-
"subject text NOT NULL, groups jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), revoked_at timestamptz)",
|
|
106
|
-
],
|
|
107
|
-
];
|
|
39
|
+
let rethrowing = false;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Makes every prepared query of this copy of Drizzle throw the driver's error in place of
|
|
43
|
+
* `DrizzleQueryError` (drizzle-orm 0.45.4 wraps in `PgPreparedQuery.queryWithCache`, the one
|
|
44
|
+
* place every query passes). Once per copy of the module.
|
|
45
|
+
*/
|
|
46
|
+
function rethrowCause(): void {
|
|
47
|
+
if (rethrowing) return;
|
|
48
|
+
rethrowing = true;
|
|
49
|
+
const prototype = PgPreparedQuery.prototype as unknown as {
|
|
50
|
+
queryWithCache(this: unknown, ...args: unknown[]): Promise<unknown>;
|
|
51
|
+
};
|
|
52
|
+
const wrapped = prototype.queryWithCache;
|
|
53
|
+
prototype.queryWithCache = async function (...args) {
|
|
54
|
+
try {
|
|
55
|
+
return await wrapped.apply(this, args);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
throw err instanceof DrizzleQueryError && err.cause !== undefined ? err.cause : err;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
108
61
|
|
|
109
62
|
/** The Postgres error code of `err`, or an empty string when it has none. */
|
|
110
63
|
export const pgCode = (err: unknown): string => (err as { code?: unknown } | null)?.code?.toString() ?? "";
|
|
@@ -145,22 +98,27 @@ export const DBOS_DATABASE_OPTIONS = { systemDatabaseIdleTransactionTimeoutMs: 0
|
|
|
145
98
|
* `version`, so DBOS gives them to a worker of that version: those it stamped, each with its status
|
|
146
99
|
* (a PENDING one is the caller's to queue again). DBOS has no call for it (a run's version is set at
|
|
147
100
|
* its start, `forkWorkflow` makes a new id, `rewindWorkflow` takes only an ended run), so this is the
|
|
148
|
-
* runtime's one write to DBOS's own tables
|
|
149
|
-
*
|
|
101
|
+
* runtime's one write to DBOS's own tables (`db/dbos.ts`, dbos-sdk 5.2.11). One statement: of two
|
|
102
|
+
* workers stamping a run, one does.
|
|
150
103
|
*/
|
|
151
104
|
export async function stampVersion(
|
|
152
105
|
db: Db,
|
|
153
106
|
ids: readonly string[],
|
|
154
107
|
version: string,
|
|
155
108
|
): Promise<{ id: string; pending: boolean }[]> {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
109
|
+
const stamped = await db
|
|
110
|
+
.update(workflowStatus)
|
|
111
|
+
.set({ applicationVersion: version })
|
|
112
|
+
.where(
|
|
113
|
+
and(
|
|
114
|
+
inArray(workflowStatus.workflowUuid, [...ids]),
|
|
115
|
+
eq(workflowStatus.name, "change"),
|
|
116
|
+
inArray(workflowStatus.status, ["PENDING", "ENQUEUED"]),
|
|
117
|
+
ne(workflowStatus.applicationVersion, version),
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
.returning({ id: workflowStatus.workflowUuid, status: workflowStatus.status });
|
|
121
|
+
return stamped.map((r) => ({ id: r.id, pending: r.status === "PENDING" }));
|
|
164
122
|
}
|
|
165
123
|
|
|
166
124
|
/**
|
|
@@ -187,37 +145,45 @@ const INSUFFICIENT_PRIVILEGE = "42501";
|
|
|
187
145
|
/** Postgres's code for a database that is not there (`invalid_catalog_name`). */
|
|
188
146
|
const NO_DATABASE = "3D000";
|
|
189
147
|
|
|
190
|
-
/** A
|
|
191
|
-
export interface
|
|
192
|
-
db(): Promise<
|
|
148
|
+
/** A database opened on first use, once prepared, and ended by `close`. */
|
|
149
|
+
export interface LazyDb {
|
|
150
|
+
db(): Promise<Db>;
|
|
151
|
+
/** The pool `db()` runs on, once the database is prepared. */
|
|
152
|
+
pool(): Promise<Pool>;
|
|
193
153
|
close(): Promise<void>;
|
|
194
154
|
}
|
|
195
155
|
|
|
196
156
|
/**
|
|
197
|
-
* A
|
|
198
|
-
* database is prepared (`prepareDatabase`). A preparation that fails lets the pool go, so the
|
|
199
|
-
* next
|
|
157
|
+
* A database for `what` on a pool at `databaseUrl`, opened on the first `db()` (or `pool()`) once
|
|
158
|
+
* the database is prepared (`prepareDatabase`). A preparation that fails lets the pool go, so the
|
|
159
|
+
* next call tries again. `close` ends the pool, for good: a call after it is refused.
|
|
200
160
|
*/
|
|
201
|
-
export function
|
|
161
|
+
export function lazyDb(databaseUrl: string, what: string): LazyDb {
|
|
202
162
|
let pool: Pool | undefined;
|
|
163
|
+
let db: Db | undefined;
|
|
203
164
|
let closed = false;
|
|
165
|
+
async function opened(): Promise<Pool> {
|
|
166
|
+
if (closed) throw new Error(`the ${what} store is closed`);
|
|
167
|
+
const current = (pool ??= openPool(databaseUrl, what));
|
|
168
|
+
try {
|
|
169
|
+
await prepareDatabase(databaseUrl);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (pool === current) pool = db = undefined;
|
|
172
|
+
await current.end().catch(() => undefined);
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
return current;
|
|
176
|
+
}
|
|
204
177
|
return {
|
|
205
178
|
async db() {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
try {
|
|
209
|
-
await prepareDatabase(databaseUrl);
|
|
210
|
-
} catch (err) {
|
|
211
|
-
if (pool === current) pool = undefined;
|
|
212
|
-
await current.end().catch(() => undefined);
|
|
213
|
-
throw err;
|
|
214
|
-
}
|
|
215
|
-
return current;
|
|
179
|
+
const current = await opened();
|
|
180
|
+
return (db ??= databaseOn(current));
|
|
216
181
|
},
|
|
182
|
+
pool: opened,
|
|
217
183
|
async close() {
|
|
218
184
|
closed = true;
|
|
219
185
|
const current = pool;
|
|
220
|
-
pool = undefined;
|
|
186
|
+
pool = db = undefined;
|
|
221
187
|
await current?.end();
|
|
222
188
|
},
|
|
223
189
|
};
|
|
@@ -243,9 +209,10 @@ export function checkPreparedOnly(by: string): void {
|
|
|
243
209
|
* Makes the database at `databaseUrl` ready for the runtime, once per process and URL: the
|
|
244
210
|
* database itself and DBOS's tables when they are not there yet (as a worker's launch makes
|
|
245
211
|
* them; credentials may be set, and a ledger read, before any worker has run), then the
|
|
246
|
-
*
|
|
247
|
-
* Two processes preparing one database both
|
|
248
|
-
* (`lockPreparation`). A preparation that fails is
|
|
212
|
+
* runtime's tables, by the migrations the database has not had (`COMPANY_MIGRATIONS`), and its
|
|
213
|
+
* idle-transaction timeout (`guardIdleTransactions`). Two processes preparing one database both
|
|
214
|
+
* succeed: the second waits for the first (`lockPreparation`). A preparation that fails is
|
|
215
|
+
* forgotten, so the next call tries again.
|
|
249
216
|
*/
|
|
250
217
|
export function prepareDatabase(databaseUrl: string): Promise<void> {
|
|
251
218
|
let preparing = prepared.get(databaseUrl);
|
|
@@ -265,33 +232,48 @@ const unprepared = (err: unknown): Error =>
|
|
|
265
232
|
new Error(`could not prepare the database: ${errorMessage(err)}`, { cause: err });
|
|
266
233
|
|
|
267
234
|
async function prepare(databaseUrl: string): Promise<void> {
|
|
268
|
-
|
|
269
|
-
throw unprepared(err);
|
|
270
|
-
});
|
|
271
|
-
try {
|
|
235
|
+
await underPreparationLock(databaseUrl, async (db) => {
|
|
272
236
|
await DBOS.migrate(databaseUrl).catch((err: unknown) => {
|
|
273
237
|
// A DBOS table made meanwhile by a release from before the lock, mid-upgrade: it is there.
|
|
274
238
|
if (!madeAlready(err)) throw unprepared(err);
|
|
275
239
|
});
|
|
276
|
-
|
|
277
|
-
|
|
240
|
+
await applyMigrations(db, COMPANY_MIGRATIONS).catch((err: unknown) => {
|
|
241
|
+
throw unprepared(err);
|
|
242
|
+
});
|
|
243
|
+
await guardIdleTransactions(db);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Runs `make` on a connection holding the preparation's session lock of the database at
|
|
249
|
+
* `databaseUrl` (made first when missing), so a second process waits, then finds everything made.
|
|
250
|
+
* For what makes a database's tables: `prepareDatabase`, and the wakeup service's own.
|
|
251
|
+
*/
|
|
252
|
+
export async function underPreparationLock<T>(databaseUrl: string, make: (db: Db) => Promise<T>): Promise<T> {
|
|
253
|
+
const db = await lockPreparation(databaseUrl).catch((err: unknown) => {
|
|
254
|
+
throw unprepared(err);
|
|
255
|
+
});
|
|
256
|
+
try {
|
|
257
|
+
return await make(db);
|
|
278
258
|
} finally {
|
|
279
259
|
// Ending the session releases the lock; a failure to end it must not hide the preparation's.
|
|
280
|
-
await
|
|
260
|
+
await db.$client.end().catch(() => undefined);
|
|
281
261
|
}
|
|
282
262
|
}
|
|
283
263
|
|
|
284
|
-
/**
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
264
|
+
/**
|
|
265
|
+
* Refuses when the database at `databaseUrl` lacks DBOS's tables or the runtime's (every
|
|
266
|
+
* migration of `COMPANY_MIGRATIONS`, by its journal): `by` makes them, not this process.
|
|
267
|
+
*/
|
|
288
268
|
async function checkPrepared(databaseUrl: string, by: string): Promise<void> {
|
|
289
269
|
const conn = await connect(databaseUrl);
|
|
290
270
|
try {
|
|
291
|
-
const
|
|
292
|
-
|
|
271
|
+
const db = databaseOn(conn);
|
|
272
|
+
const { rows } = await db.execute<{ dbos: boolean }>(
|
|
273
|
+
sql`SELECT to_regclass('dbos.workflow_status') IS NOT NULL AS dbos`,
|
|
293
274
|
);
|
|
294
|
-
const
|
|
275
|
+
const dbos = rows[0]?.dbos === true;
|
|
276
|
+
const ours = await migrationsApplied(db, COMPANY_MIGRATIONS);
|
|
295
277
|
if (!dbos || !ours) {
|
|
296
278
|
const missing = [...(dbos ? [] : ["DBOS's"]), ...(ours ? [] : ["Sanoma's"])].join(" and ");
|
|
297
279
|
throw new Error(`the database has no ${missing} tables yet: ${by} makes them, and this process never does`);
|
|
@@ -304,19 +286,20 @@ async function checkPrepared(databaseUrl: string, by: string): Promise<void> {
|
|
|
304
286
|
/**
|
|
305
287
|
* A connection to the database at `databaseUrl` (made first when missing) holding the
|
|
306
288
|
* preparation's session lock until it ends, so a second process waits, then finds everything
|
|
307
|
-
* made. Racing `DBOS.migrate`s lose to each other in many ways (40P01, XX000),
|
|
308
|
-
*
|
|
309
|
-
* statement: `CREATE INDEX CONCURRENTLY` waits
|
|
310
|
-
* cannot see (the lock's holder is this
|
|
289
|
+
* made. Racing `DBOS.migrate`s lose to each other in many ways (40P01, XX000), and Drizzle's
|
|
290
|
+
* migrator takes no lock of its own, so the race is prevented rather than each loss forgiven.
|
|
291
|
+
* The lock is polled, never waited for by a blocked statement: `CREATE INDEX CONCURRENTLY` waits
|
|
292
|
+
* on that statement's snapshot, a deadlock Postgres cannot see (the lock's holder is this
|
|
293
|
+
* connection, not the migration's).
|
|
311
294
|
*/
|
|
312
|
-
async function lockPreparation(databaseUrl: string)
|
|
313
|
-
const
|
|
295
|
+
async function lockPreparation(databaseUrl: string) {
|
|
296
|
+
const db = databaseOn(await connectMaking(databaseUrl));
|
|
314
297
|
try {
|
|
315
|
-
const ask =
|
|
316
|
-
while (
|
|
317
|
-
return
|
|
298
|
+
const ask = sql`SELECT pg_try_advisory_lock(hashtext('sanoma.prepareDatabase')) AS locked`;
|
|
299
|
+
while ((await db.execute<{ locked: boolean }>(ask)).rows[0]?.locked !== true) await delay(50);
|
|
300
|
+
return db;
|
|
318
301
|
} catch (err) {
|
|
319
|
-
await
|
|
302
|
+
await db.$client.end().catch(() => undefined);
|
|
320
303
|
throw err;
|
|
321
304
|
}
|
|
322
305
|
}
|
|
@@ -348,7 +331,7 @@ async function createDatabase(databaseUrl: string): Promise<void> {
|
|
|
348
331
|
url.pathname = "/postgres";
|
|
349
332
|
const admin = await connect(url.toString());
|
|
350
333
|
try {
|
|
351
|
-
await admin.
|
|
334
|
+
await databaseOn(admin).execute(sql`CREATE DATABASE ${sql.identifier(name)}`);
|
|
352
335
|
} catch (err) {
|
|
353
336
|
if (!madeAlready(err)) throw err;
|
|
354
337
|
} finally {
|
|
@@ -364,13 +347,13 @@ async function createDatabase(databaseUrl: string): Promise<void> {
|
|
|
364
347
|
*/
|
|
365
348
|
async function guardIdleTransactions(db: Db): Promise<void> {
|
|
366
349
|
try {
|
|
367
|
-
await db.
|
|
350
|
+
await db.execute(sql`DO $$ BEGIN
|
|
368
351
|
IF current_setting('idle_in_transaction_session_timeout') = '0' AND NOT EXISTS (
|
|
369
352
|
SELECT FROM pg_db_role_setting s JOIN pg_database d ON d.oid = s.setdatabase, unnest(s.setconfig) c
|
|
370
353
|
WHERE d.datname = current_database() AND s.setrole = 0 AND c LIKE 'idle_in_transaction_session_timeout=%'
|
|
371
354
|
) THEN
|
|
372
355
|
EXECUTE format('ALTER DATABASE %I SET idle_in_transaction_session_timeout = %L', current_database(),
|
|
373
|
-
'${IDLE_IN_TRANSACTION_TIMEOUT}');
|
|
356
|
+
${sql.raw(`'${IDLE_IN_TRANSACTION_TIMEOUT}'`)});
|
|
374
357
|
END IF;
|
|
375
358
|
END $$`);
|
|
376
359
|
} catch (err) {
|
|
@@ -379,32 +362,18 @@ async function guardIdleTransactions(db: Db): Promise<void> {
|
|
|
379
362
|
}
|
|
380
363
|
|
|
381
364
|
/**
|
|
382
|
-
*
|
|
383
|
-
*
|
|
365
|
+
* Marks a sealed invite request's `nonce` spent until `expiresAt`, when the row goes: true the
|
|
366
|
+
* first time, false for one spent already, whichever process asks. A row, not memory: the app
|
|
367
|
+
* keeps nothing across requests.
|
|
384
368
|
*/
|
|
385
|
-
export async function
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
// and a deploy stops the old container before the new one starts), so none names a worker.
|
|
396
|
-
|
|
397
|
-
/** Records that the wakeup took `at`. */
|
|
398
|
-
export async function recordWakeupRegistration(db: Db, at: string): Promise<void> {
|
|
399
|
-
await db.query(`UPDATE ${WAKEUP_TABLE} SET at = $1, last_error = NULL WHERE id = 1`, [at]);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/** Records why the last registration failed. */
|
|
403
|
-
export async function recordWakeupError(db: Db, error: string): Promise<void> {
|
|
404
|
-
await db.query(`UPDATE ${WAKEUP_TABLE} SET last_error = $1 WHERE id = 1`, [error]);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/** Records that the worker stops itself now. */
|
|
408
|
-
export async function recordWakeupStop(db: Db): Promise<void> {
|
|
409
|
-
await db.query(`UPDATE ${WAKEUP_TABLE} SET stopped_at = now() WHERE id = 1`);
|
|
369
|
+
export async function spendInviteRequest(db: Db, nonce: string, expiresAt: Date): Promise<boolean> {
|
|
370
|
+
// Rows go an hour after they expire, not at it: the app's clock decides when a request is
|
|
371
|
+
// past, and a database clock ahead of it must not forget a nonce the app still accepts.
|
|
372
|
+
await db.delete(inviteRequests).where(lte(inviteRequests.expiresAt, sql`now() - interval '1 hour'`));
|
|
373
|
+
const spent = await db
|
|
374
|
+
.insert(inviteRequests)
|
|
375
|
+
.values({ nonce, expiresAt })
|
|
376
|
+
.onConflictDoNothing({ target: inviteRequests.nonce })
|
|
377
|
+
.returning({ nonce: inviteRequests.nonce });
|
|
378
|
+
return spent.length === 1;
|
|
410
379
|
}
|
package/src/deadlines.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
3
3
|
import { dbosStatusesOf } from "./client.ts";
|
|
4
|
+
import { eq, sql } from "drizzle-orm";
|
|
5
|
+
import type { PgUpdateSetSource } from "drizzle-orm/pg-core";
|
|
4
6
|
import type { Db } from "./db.ts";
|
|
5
|
-
import {
|
|
7
|
+
import { wakeup as wakeupRow } from "./db/company.ts";
|
|
6
8
|
import type { ResolvedConfig } from "./config.ts";
|
|
7
9
|
import { warn } from "./log.ts";
|
|
8
10
|
import type { CallContext, DriverFn, Op } from "./op.ts";
|
|
@@ -171,22 +173,26 @@ export function stopWhenIdle(rule: StopRule): { close(): void } {
|
|
|
171
173
|
if (!(await rule.idle())) return false;
|
|
172
174
|
const next = rule.deadlines.nextDeadline();
|
|
173
175
|
const at = next?.toISOString() ?? null;
|
|
176
|
+
// The wakeup row's writes: one worker registers per instance (the supervisor runs one at a
|
|
177
|
+
// time, and a deploy stops the old container before the new one starts), so none names a worker.
|
|
174
178
|
const db = await rule.db();
|
|
179
|
+
const record = (set: PgUpdateSetSource<typeof wakeupRow>) =>
|
|
180
|
+
db.update(wakeupRow).set(set).where(eq(wakeupRow.id, 1));
|
|
175
181
|
if (at !== null) {
|
|
176
182
|
try {
|
|
177
183
|
await rule.register({ at });
|
|
178
184
|
} catch (err) {
|
|
179
185
|
const message = errorMessage(err);
|
|
180
|
-
await
|
|
186
|
+
await record({ lastError: message });
|
|
181
187
|
problem(`could not register the wakeup at ${at}, so the worker keeps running: ${message}`);
|
|
182
188
|
return false;
|
|
183
189
|
}
|
|
184
|
-
await
|
|
190
|
+
await record({ at: new Date(at), lastError: null });
|
|
185
191
|
}
|
|
186
192
|
lastProblem = undefined;
|
|
187
193
|
if (closed || !(await rule.idle()) || rule.deadlines.nextDeadline()?.toISOString() !== next?.toISOString())
|
|
188
194
|
return false;
|
|
189
|
-
await
|
|
195
|
+
await record({ stoppedAt: sql`now()` });
|
|
190
196
|
await rule.stop().catch((err: unknown) => warn(`the worker could not stop cleanly: ${errorMessage(err)}`));
|
|
191
197
|
rule.onStopped?.({ at });
|
|
192
198
|
return true;
|
package/src/deployment-key.ts
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
import { hkdfSync } from "node:crypto";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
2
|
|
|
4
3
|
/** The deployment's own keys: bound only to the environment or bootstrap, never written by the runtime. */
|
|
5
4
|
export const DEPLOYMENT_KEYS: readonly string[] = ["SANOMA_SIGNING_KEY", "SANOMA_SECRETS_KEY"];
|
|
6
5
|
export type DeploymentKeyName = "SANOMA_SIGNING_KEY" | "SANOMA_SECRETS_KEY";
|
|
7
6
|
|
|
8
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Bootstrap only: never resolves through the secret store that the key unlocks. `<name>` from the
|
|
9
|
+
* environment, or the file `<name>_FILE` names where there is a file system.
|
|
10
|
+
*/
|
|
9
11
|
export function deploymentKey(name: DeploymentKeyName, env: NodeJS.ProcessEnv = process.env): Buffer {
|
|
10
12
|
const value = env[name];
|
|
11
13
|
const file = env[`${name}_FILE`];
|
|
12
14
|
if (value && file) throw new Error(`Set only one of ${name} and ${name}_FILE`);
|
|
13
15
|
let encoded = value;
|
|
14
16
|
if (file) {
|
|
17
|
+
// Node's `fs` where there is a file system (Node), found at the call rather than imported:
|
|
18
|
+
// workerd has no files to mount a key in, so there a `_FILE` input is refused.
|
|
19
|
+
const fs = (globalThis.process as { getBuiltinModule?: (id: string) => unknown } | undefined)?.getBuiltinModule?.(
|
|
20
|
+
"node:fs",
|
|
21
|
+
) as typeof import("node:fs") | undefined;
|
|
22
|
+
if (!fs) throw new Error(`${name}_FILE names a file, and this host has no file system: set ${name}`);
|
|
15
23
|
try {
|
|
16
|
-
encoded = readFileSync(file, "utf8").trim();
|
|
24
|
+
encoded = fs.readFileSync(file, "utf8").trim();
|
|
17
25
|
} catch {
|
|
18
26
|
throw new Error(`${name}_FILE could not be read`);
|
|
19
27
|
}
|
package/src/host-derive.ts
CHANGED
|
@@ -418,9 +418,28 @@ async function publicOnGithub(repository: string): Promise<boolean | undefined>
|
|
|
418
418
|
}
|
|
419
419
|
}
|
|
420
420
|
|
|
421
|
+
/** What `hostFacts` reads from the company's root: the installed `sanoma`, `origin` and the public sites' domains. */
|
|
422
|
+
export type HostRootFacts = Pick<HostFacts, "version" | "origin" | "domains">;
|
|
423
|
+
|
|
421
424
|
/**
|
|
422
|
-
*
|
|
423
|
-
*
|
|
425
|
+
* The root's part of the hosts' facts (`HostRootFacts`), read now: the `sanoma` the repository
|
|
426
|
+
* installs, `origin` (git, with no credentials) and the public sites' domains. Node only, where
|
|
427
|
+
* the root is; a bundle carries them as read (the app's snapshot).
|
|
428
|
+
*/
|
|
429
|
+
export async function hostRootFacts(root: string): Promise<HostRootFacts> {
|
|
430
|
+
const { siteDomains } = await import("./sites.ts");
|
|
431
|
+
const [version, origin] = await Promise.all([installedSanoma(root), originUrl(root)]);
|
|
432
|
+
return {
|
|
433
|
+
...(version !== undefined && { version }),
|
|
434
|
+
...(origin !== undefined && { origin }),
|
|
435
|
+
domains: siteDomains(root),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* What the declared hosts that derive (`deriveHosts`) are filled from: the root's facts
|
|
441
|
+
* (`rootFacts`, asked only when a declared host derives: `hostRootFacts`, or a snapshot's), and of
|
|
442
|
+
* `secrets` (none outside a run) which
|
|
424
443
|
* optional credentials, App keys and source key are stored, and which credentials the drivers
|
|
425
444
|
* read from this machine's environment alone (`unbound`). With `mint` (apply, never drift or a
|
|
426
445
|
* sandbox run), the source key (where the config has the deploy-key type) and, when a wakeup is
|
|
@@ -429,7 +448,7 @@ async function publicOnGithub(repository: string): Promise<boolean | undefined>
|
|
|
429
448
|
* Undefined when no declared host derives.
|
|
430
449
|
*/
|
|
431
450
|
export async function hostFacts(options: {
|
|
432
|
-
|
|
451
|
+
rootFacts: () => Promise<HostRootFacts>;
|
|
433
452
|
resources: readonly DeclaredResource[];
|
|
434
453
|
types: ReadonlyMap<string, Resource>;
|
|
435
454
|
drivers: ReadonlyMap<string, Driver>;
|
|
@@ -441,7 +460,7 @@ export async function hostFacts(options: {
|
|
|
441
460
|
sender?: string;
|
|
442
461
|
mint: boolean;
|
|
443
462
|
}): Promise<HostFacts | undefined> {
|
|
444
|
-
const {
|
|
463
|
+
const { rootFacts, resources, types, drivers, secrets, mint } = options;
|
|
445
464
|
const typeOf = (r: DeclaredResource) => types.get(`${r.vendor}.${r.type}`);
|
|
446
465
|
const deriving = resources.filter((r) => typeOf(r)?.host?.derives);
|
|
447
466
|
if (!deriving.length) return undefined;
|
|
@@ -466,10 +485,8 @@ export async function hostFacts(options: {
|
|
|
466
485
|
const githubApp = [...(await stored(Object.values(GITHUB_APP)))];
|
|
467
486
|
if (mint && secrets && hosts.some((h) => wokenBy(h, resources, types))) await mintWakeupToken(secrets);
|
|
468
487
|
const keyed = types.has(DEPLOY_KEY_TYPE);
|
|
469
|
-
const {
|
|
470
|
-
|
|
471
|
-
installedSanoma(root),
|
|
472
|
-
originUrl(root),
|
|
488
|
+
const [{ version, origin, domains }, sourceKey] = await Promise.all([
|
|
489
|
+
rootFacts(),
|
|
473
490
|
secrets && hosts.length && keyed ? sourceKeyOf(secrets, mint) : undefined,
|
|
474
491
|
]);
|
|
475
492
|
// What a host may clone: `origin`, else the `source` it declares (with no deploy-key type, no
|
|
@@ -489,7 +506,7 @@ export async function hostFacts(options: {
|
|
|
489
506
|
return {
|
|
490
507
|
...(version !== undefined && { version }),
|
|
491
508
|
...(origin !== undefined && { origin }),
|
|
492
|
-
domains
|
|
509
|
+
domains,
|
|
493
510
|
builtInStore: options.builtInStore,
|
|
494
511
|
carried,
|
|
495
512
|
...(unbound.size > 0 && { unbound }),
|