@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.
Files changed (142) hide show
  1. package/CLOUD-IDENTITY.md +12 -4
  2. package/KMS-SECRETS.md +3 -1
  3. package/README.md +4 -2
  4. package/dist/bundled.d.ts +2 -2
  5. package/dist/chats.d.ts +3 -3
  6. package/dist/chats.js +34 -20
  7. package/dist/chats.js.map +1 -1
  8. package/dist/client.d.ts +17 -4
  9. package/dist/client.js +74 -63
  10. package/dist/client.js.map +1 -1
  11. package/dist/cloud-identity.js +14 -12
  12. package/dist/cloud-identity.js.map +1 -1
  13. package/dist/collection.d.ts +2 -2
  14. package/dist/collection.js +53 -42
  15. package/dist/collection.js.map +1 -1
  16. package/dist/config.d.ts +11 -6
  17. package/dist/config.js +15 -13
  18. package/dist/config.js.map +1 -1
  19. package/dist/connection.d.ts +2 -0
  20. package/dist/connection.js +112 -40
  21. package/dist/connection.js.map +1 -1
  22. package/dist/credentials.d.ts +1 -1
  23. package/dist/credentials.js +33 -29
  24. package/dist/credentials.js.map +1 -1
  25. package/dist/db/company.d.ts +1152 -0
  26. package/dist/db/company.js +118 -0
  27. package/dist/db/company.js.map +1 -0
  28. package/dist/db/dbos.d.ts +111 -0
  29. package/dist/db/dbos.js +15 -0
  30. package/dist/db/dbos.js.map +1 -0
  31. package/dist/db/identity.d.ts +367 -0
  32. package/dist/db/identity.js +48 -0
  33. package/dist/db/identity.js.map +1 -0
  34. package/dist/db/index.d.ts +7 -0
  35. package/dist/db/index.js +12 -0
  36. package/dist/db/index.js.map +1 -0
  37. package/dist/db/kms.d.ts +224 -0
  38. package/dist/db/kms.js +29 -0
  39. package/dist/db/kms.js.map +1 -0
  40. package/dist/db/migrate.d.ts +66 -0
  41. package/dist/db/migrate.js +39 -0
  42. package/dist/db/migrate.js.map +1 -0
  43. package/dist/db/wakeup.d.ts +271 -0
  44. package/dist/db/wakeup.js +34 -0
  45. package/dist/db/wakeup.js.map +1 -0
  46. package/dist/db.d.ts +42 -39
  47. package/dist/db.js +129 -169
  48. package/dist/db.js.map +1 -1
  49. package/dist/deadlines.js +8 -4
  50. package/dist/deadlines.js.map +1 -1
  51. package/dist/deployment-key.d.ts +4 -1
  52. package/dist/deployment-key.js +10 -3
  53. package/dist/deployment-key.js.map +1 -1
  54. package/dist/host-derive.d.ts +12 -3
  55. package/dist/host-derive.js +21 -8
  56. package/dist/host-derive.js.map +1 -1
  57. package/dist/identity-provisioning.d.ts +2 -2
  58. package/dist/identity-provisioning.js +75 -42
  59. package/dist/identity-provisioning.js.map +1 -1
  60. package/dist/identity-service.js +18 -15
  61. package/dist/identity-service.js.map +1 -1
  62. package/dist/index.d.ts +2 -2
  63. package/dist/index.js +1 -1
  64. package/dist/index.js.map +1 -1
  65. package/dist/kms-secrets.d.ts +9 -0
  66. package/dist/kms-secrets.js +59 -27
  67. package/dist/kms-secrets.js.map +1 -1
  68. package/dist/ledger.d.ts +1 -1
  69. package/dist/ledger.js +22 -14
  70. package/dist/ledger.js.map +1 -1
  71. package/dist/op.d.ts +27 -6
  72. package/dist/op.js.map +1 -1
  73. package/dist/run-status.js +7 -3
  74. package/dist/run-status.js.map +1 -1
  75. package/dist/schedules.d.ts +1 -1
  76. package/dist/schedules.js +5 -7
  77. package/dist/schedules.js.map +1 -1
  78. package/dist/schema.d.ts +6 -5
  79. package/dist/schema.js +11 -7
  80. package/dist/schema.js.map +1 -1
  81. package/dist/shared.d.ts +6 -23
  82. package/dist/shared.js +6 -5
  83. package/dist/shared.js.map +1 -1
  84. package/dist/storage-provisioning.d.ts +2 -0
  85. package/dist/storage-provisioning.js +51 -50
  86. package/dist/storage-provisioning.js.map +1 -1
  87. package/dist/tokens.d.ts +2 -2
  88. package/dist/tokens.js +31 -20
  89. package/dist/tokens.js.map +1 -1
  90. package/dist/version.d.ts +4 -4
  91. package/dist/version.js +5 -5
  92. package/dist/version.js.map +1 -1
  93. package/dist/wakeup-service.d.ts +0 -4
  94. package/dist/wakeup-service.js +102 -93
  95. package/dist/wakeup-service.js.map +1 -1
  96. package/dist/worker.d.ts +7 -0
  97. package/dist/worker.js +26 -21
  98. package/dist/worker.js.map +1 -1
  99. package/drizzle/company/0000_tables.sql +104 -0
  100. package/drizzle/company/0001_wakeup_row.sql +2 -0
  101. package/drizzle/company/meta/0000_snapshot.json +652 -0
  102. package/drizzle/company/meta/0001_snapshot.json +652 -0
  103. package/drizzle/company/meta/_journal.json +20 -0
  104. package/drizzle/wakeup/0000_tables.sql +26 -0
  105. package/drizzle/wakeup/meta/0000_snapshot.json +200 -0
  106. package/drizzle/wakeup/meta/_journal.json +13 -0
  107. package/oxlint.json +2 -1
  108. package/package.json +11 -2
  109. package/src/bundled.ts +2 -2
  110. package/src/chats.ts +41 -42
  111. package/src/client.ts +81 -64
  112. package/src/cloud-identity.ts +23 -24
  113. package/src/collection.ts +58 -50
  114. package/src/config.ts +29 -19
  115. package/src/connection.ts +132 -102
  116. package/src/credentials.ts +37 -33
  117. package/src/db/company.ts +173 -0
  118. package/src/db/dbos.ts +17 -0
  119. package/src/db/identity.ts +61 -0
  120. package/src/db/index.ts +12 -0
  121. package/src/db/kms.ts +41 -0
  122. package/src/db/migrate.ts +71 -0
  123. package/src/db/wakeup.ts +41 -0
  124. package/src/db.ts +151 -182
  125. package/src/deadlines.ts +10 -4
  126. package/src/deployment-key.ts +11 -3
  127. package/src/host-derive.ts +26 -9
  128. package/src/identity-provisioning.ts +85 -57
  129. package/src/identity-service.ts +19 -19
  130. package/src/index.ts +3 -0
  131. package/src/kms-secrets.ts +64 -56
  132. package/src/ledger.ts +23 -18
  133. package/src/op.ts +28 -6
  134. package/src/run-status.ts +7 -6
  135. package/src/schedules.ts +6 -8
  136. package/src/schema.ts +11 -8
  137. package/src/shared.ts +6 -17
  138. package/src/storage-provisioning.ts +47 -52
  139. package/src/tokens.ts +43 -33
  140. package/src/version.ts +5 -5
  141. package/src/wakeup-service.ts +117 -131
  142. 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 { Client, escapeIdentifier, Pool } from "pg";
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: the credentials, the
8
- // ledger, the collections' records, the wakeup's row, the operator agent's chats and the api tokens. 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.
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
- /** A connection or a pool: what the queries take. */
13
- export type Db = Pick<Pool | Client, "query">;
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
- * 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.
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 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";
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
- /** The runtime's tables, each with the word the messages name it by. */
35
- const TABLES: readonly [what: string, ddl: string][] = [
36
- [
37
- "connection health",
38
- `CREATE TABLE IF NOT EXISTS sanoma_connection_health (
39
- connection_id text PRIMARY KEY, revision text NOT NULL, health text NOT NULL,
40
- credential_version text, externally_renewed boolean NOT NULL, checked_at timestamptz NOT NULL DEFAULT now())`,
41
- ],
42
- [
43
- "connection setup receipts",
44
- `CREATE TABLE IF NOT EXISTS sanoma_connection_setups (
45
- connection_id text NOT NULL, revision text NOT NULL, credential_version text NOT NULL,
46
- PRIMARY KEY(connection_id, revision))`,
47
- ],
48
- [
49
- "connections",
50
- `CREATE TABLE IF NOT EXISTS sanoma_connections (
51
- id text PRIMARY KEY, vendor text NOT NULL, method text NOT NULL, owner text, rule text,
52
- account text NOT NULL, scopes jsonb NOT NULL, revision text NOT NULL, definition text NOT NULL,
53
- credential text, location jsonb, status text NOT NULL DEFAULT 'connected', updated_by text NOT NULL,
54
- updated_at timestamptz NOT NULL DEFAULT now());
55
- ALTER TABLE sanoma_connections ADD COLUMN IF NOT EXISTS location jsonb`,
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, in its schema `dbos` (dbos-sdk 5.2.11). One statement:
149
- * of two workers stamping a run, one does.
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 { rows } = await db.query<{ workflow_uuid: string; status: string }>(
157
- `UPDATE dbos.workflow_status SET application_version = $1
158
- WHERE workflow_uuid = ANY($2) AND name = 'change' AND status IN ('PENDING', 'ENQUEUED')
159
- AND application_version <> $1
160
- RETURNING workflow_uuid, status`,
161
- [version, ids],
162
- );
163
- return rows.map((r) => ({ id: r.workflow_uuid, pending: r.status === "PENDING" }));
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 pool opened on first use, once its database is prepared, and ended by `close`. */
191
- export interface LazyPool {
192
- db(): Promise<Pool>;
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 pool for `what` on the database at `databaseUrl`, opened on the first `db()` once the
198
- * database is prepared (`prepareDatabase`). A preparation that fails lets the pool go, so the
199
- * next `db()` tries again. `close` ends the pool, for good: a `db()` after it is refused.
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 lazyPool(databaseUrl: string, what: string): LazyPool {
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
- if (closed) throw new Error(`the ${what} store is closed`);
207
- const current = (pool ??= openPool(databaseUrl, what));
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
- * credentials, ledger, data, wakeup and chat tables, and its idle-transaction timeout (`guardIdleTransactions`).
247
- * Two processes preparing one database both succeed: the second waits for the first
248
- * (`lockPreparation`). A preparation that fails is forgotten, so the next call tries again.
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
- const conn = await lockPreparation(databaseUrl).catch((err: unknown) => {
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
- for (const [what, ddl] of TABLES) await createTable(conn, ddl, what);
277
- await guardIdleTransactions(conn);
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 conn.end().catch(() => undefined);
260
+ await db.$client.end().catch(() => undefined);
281
261
  }
282
262
  }
283
263
 
284
- /** The last of the runtime's tables a preparation makes (`TABLES`), so its being there says they all are. */
285
- const LAST_TABLE = API_TOKENS_TABLE;
286
-
287
- /** Refuses when the database at `databaseUrl` lacks DBOS's tables or the runtime's: `by` makes them, not this process. */
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 { rows } = await conn.query<{ dbos: boolean; ours: boolean }>(
292
- `SELECT to_regclass('dbos.workflow_status') IS NOT NULL AS dbos, to_regclass('${LAST_TABLE}') IS NOT NULL AS ours`,
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 { dbos, ours } = rows[0]!;
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), so the race is
308
- * prevented rather than each loss forgiven. The lock is polled, never waited for by a blocked
309
- * statement: `CREATE INDEX CONCURRENTLY` waits on that statement's snapshot, a deadlock Postgres
310
- * cannot see (the lock's holder is this connection, not the migration's).
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): Promise<Client> {
313
- const conn = await connectMaking(databaseUrl);
295
+ async function lockPreparation(databaseUrl: string) {
296
+ const db = databaseOn(await connectMaking(databaseUrl));
314
297
  try {
315
- const ask = "SELECT pg_try_advisory_lock(hashtext('sanoma.prepareDatabase')) AS locked";
316
- while (!(await conn.query<{ locked: boolean }>(ask)).rows[0]!.locked) await delay(50);
317
- return conn;
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 conn.end().catch(() => undefined);
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.query(`CREATE DATABASE ${escapeIdentifier(name)}`);
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.query(`DO $$ BEGIN
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
- * Runs `ddl` (`CREATE TABLE IF NOT EXISTS`, and the like) on `db`. Two processes racing to create
383
- * the same table both succeed; any other failure names the `what` table, with its cause.
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 createTable(db: Db, ddl: string, what: string): Promise<void> {
386
- try {
387
- await db.query(ddl);
388
- } catch (err) {
389
- if (madeAlready(err)) return;
390
- throw new Error(`could not prepare the ${what} table: ${errorMessage(err)}`, { cause: err });
391
- }
392
- }
393
-
394
- // The wakeup row's writes: one worker registers per instance (the supervisor runs one at a time,
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 { recordWakeupError, recordWakeupRegistration, recordWakeupStop } from "./db.ts";
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 recordWakeupError(db, message);
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 recordWakeupRegistration(db, at);
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 recordWakeupStop(db);
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;
@@ -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
- /** Bootstrap only: never resolves through the secret store that the key unlocks. */
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
  }
@@ -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
- * What the declared hosts that derive (`deriveHosts`) are filled from, read now: the installed
423
- * `sanoma`, `origin`, the public sites' domains, and of `secrets` (none outside a run) which
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
- root: string;
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 { root, resources, types, drivers, secrets, mint } = options;
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 { siteDomains } = await import("./sites.ts");
470
- const [version, origin, sourceKey] = await Promise.all([
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: siteDomains(root),
509
+ domains,
493
510
  builtInStore: options.builtInStore,
494
511
  carried,
495
512
  ...(unbound.size > 0 && { unbound }),