@sanoma-ai/workflows 0.11.12 → 0.11.13

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 +6 -0
  35. package/dist/db/index.js +10 -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 +63 -0
  41. package/dist/db/migrate.js +41 -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 +10 -0
  121. package/src/db/kms.ts +41 -0
  122. package/src/db/migrate.ts +70 -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
@@ -1,5 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { connectMaking, createTable, type Db, openPool } from "./db.ts";
2
+ import { and, eq, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm";
3
+ import { type Db, databaseOn, openPool, underPreparationLock } from "./db.ts";
4
+ import { applyMigrations, WAKEUP_MIGRATIONS } from "./db/migrate.ts";
5
+ import { wakeupAccounts, wakeupInstances } from "./db/wakeup.ts";
3
6
  import { info, warn } from "./log.ts";
4
7
  import { generateSecret, tokenHash as hashOf } from "./secret.ts";
5
8
  import { errorMessage } from "./shared.ts";
@@ -11,39 +14,9 @@ import { errorMessage } from "./shared.ts";
11
14
  * before it stops, `PUT`s with that token when it wants waking, replacing what it asked before;
12
15
  * the service then calls `GET {url}/wake`, retrying until an answer is a 2xx. It makes its two
13
16
  * tables itself, in the database it is given (sanoma-ops's own, when it hosts it): never among
14
- * `db.ts`'s, which every company's database gets.
17
+ * `db/company.ts`'s, which every company's database gets.
15
18
  */
16
19
 
17
- /** Where the accounts live: one per party that registers instances, its token's hash alone. */
18
- export const WAKEUP_ACCOUNTS_TABLE = "sanoma_wakeup_accounts";
19
- /** Where the instances live: each one's URL, its token's hash and its registered wake. */
20
- export const WAKEUP_INSTANCES_TABLE = "sanoma_wakeup_instances";
21
-
22
- const TABLES: readonly [what: string, ddl: string][] = [
23
- [
24
- "wakeup accounts",
25
- `CREATE TABLE IF NOT EXISTS ${WAKEUP_ACCOUNTS_TABLE} (id uuid PRIMARY KEY, token_hash text UNIQUE NOT NULL, ` +
26
- "label text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), revoked_at timestamptz)",
27
- ],
28
- [
29
- "wakeup instances",
30
- `CREATE TABLE IF NOT EXISTS ${WAKEUP_INSTANCES_TABLE} (id uuid PRIMARY KEY, ` +
31
- `account_id uuid NOT NULL REFERENCES ${WAKEUP_ACCOUNTS_TABLE} (id), url text NOT NULL, ` +
32
- "token_hash text UNIQUE NOT NULL, at timestamptz, " +
33
- "called_at timestamptz, attempts integer NOT NULL DEFAULT 0, next_attempt_at timestamptz, " +
34
- "last_error text, created_at timestamptz NOT NULL DEFAULT now())",
35
- ],
36
- // Each on its own, so one another process made first does not skip the next.
37
- [
38
- "wakeup instances",
39
- `CREATE INDEX IF NOT EXISTS ${WAKEUP_INSTANCES_TABLE}_at ON ${WAKEUP_INSTANCES_TABLE} (at) WHERE at IS NOT NULL`,
40
- ],
41
- [
42
- "wakeup instances",
43
- `CREATE INDEX IF NOT EXISTS ${WAKEUP_INSTANCES_TABLE}_account ON ${WAKEUP_INSTANCES_TABLE} (account_id)`,
44
- ],
45
- ];
46
-
47
20
  /** How long a call to an instance's `/wake` may take. */
48
21
  const CALL_TIMEOUT_MS = 10_000;
49
22
  /** The longest the loop sleeps without looking again, whatever is due. */
@@ -173,54 +146,45 @@ export function instanceUrl(value: unknown): { url: string } | { error: string }
173
146
  return { url: url.href.replace(/\/+$/, "") };
174
147
  }
175
148
 
176
- interface InstanceRow {
177
- id: string;
178
- account_id: string;
179
- url: string;
180
- at: Date | null;
181
- called_at: Date | null;
182
- attempts: number;
183
- last_error: string | null;
184
- }
149
+ type InstanceRow = typeof wakeupInstances.$inferSelect;
185
150
 
186
151
  /** An instance as `GET` shows it to its account. */
187
152
  const shownInstance = (row: InstanceRow) => ({
188
153
  id: row.id,
189
154
  url: row.url,
190
155
  at: row.at?.toISOString() ?? null,
191
- calledAt: row.called_at?.toISOString() ?? null,
156
+ calledAt: row.calledAt?.toISOString() ?? null,
192
157
  attempts: row.attempts,
193
- lastError: row.last_error,
158
+ lastError: row.lastError,
194
159
  });
195
160
 
196
161
  /** A row waiting for its call: registered, not called since its `at`. */
197
- const WAITING = "i.at IS NOT NULL AND (i.called_at IS NULL OR i.called_at < i.at)";
198
- /** Of an account not revoked. */
199
- const LIVE = `JOIN ${WAKEUP_ACCOUNTS_TABLE} a ON a.id = i.account_id AND a.revoked_at IS NULL`;
162
+ const waiting = and(
163
+ isNotNull(wakeupInstances.at),
164
+ or(isNull(wakeupInstances.calledAt), lt(wakeupInstances.calledAt, wakeupInstances.at)),
165
+ );
166
+ /** The join to an account not revoked. */
167
+ const live = and(eq(wakeupAccounts.id, wakeupInstances.accountId), isNull(wakeupAccounts.revokedAt));
200
168
 
201
169
  /**
202
- * Runs `use` on a connection to `databaseUrl` once the service's two tables are there: the
203
- * database and the tables made when missing.
170
+ * Runs `use` on a connection to `databaseUrl` holding the preparation's lock, the database and the
171
+ * service's two tables made when missing: the service's own migrations folder, with its own journal
172
+ * table, since the database may be a company's too.
204
173
  */
205
- async function withTables<T>(databaseUrl: string, use: (db: Db) => Promise<T>): Promise<T> {
206
- const conn = await connectMaking(databaseUrl);
207
- try {
208
- for (const [what, ddl] of TABLES) await createTable(conn, ddl, what);
209
- return await use(conn);
210
- } finally {
211
- await conn.end().catch(() => undefined);
212
- }
174
+ function withTables<T>(databaseUrl: string, use: (db: Db) => Promise<T>): Promise<T> {
175
+ return underPreparationLock(databaseUrl, async (db) => {
176
+ await applyMigrations(db, WAKEUP_MIGRATIONS).catch((err: unknown) => {
177
+ throw new Error(`could not prepare the wakeup service tables: ${errorMessage(err)}`, { cause: err });
178
+ });
179
+ return use(db);
180
+ });
213
181
  }
214
182
 
215
183
  async function insertAccount(db: Db, label: string): Promise<{ id: string; token: string }> {
216
184
  if (!label.trim()) throw new Error("An account's label must not be empty");
217
185
  const id = randomUUID();
218
186
  const token = newToken("swa_");
219
- await db.query(`INSERT INTO ${WAKEUP_ACCOUNTS_TABLE} (id, token_hash, label) VALUES ($1, $2, $3)`, [
220
- id,
221
- hashOf(token),
222
- label,
223
- ]);
187
+ await db.insert(wakeupAccounts).values({ id, tokenHash: hashOf(token), label });
224
188
  return { id, token };
225
189
  }
226
190
 
@@ -257,26 +221,26 @@ export async function startWakeupService({
257
221
  }: WakeupServiceOptions): Promise<WakeupService> {
258
222
  await withTables(databaseUrl, async () => {});
259
223
  const pool = openPool(databaseUrl, "wakeup service");
260
- const loop = startLoop({ pool, now, fetch, log });
224
+ const db = databaseOn(pool);
225
+ const loop = startLoop({ db, now, fetch, log });
261
226
 
262
227
  /** The live account the request's token is, or a 401. */
263
228
  async function account(request: Request): Promise<string | Response> {
264
229
  const token = bearer(request);
265
230
  if (!token?.startsWith("swa_")) return problem(401, "an account's token is needed");
266
- const { rows } = await pool.query<{ id: string }>(
267
- `SELECT id FROM ${WAKEUP_ACCOUNTS_TABLE} WHERE token_hash = $1 AND revoked_at IS NULL`,
268
- [hashOf(token)],
269
- );
270
- return rows[0]?.id ?? problem(401, "the token is no account's");
231
+ const [row] = await db
232
+ .select({ id: wakeupAccounts.id })
233
+ .from(wakeupAccounts)
234
+ .where(and(eq(wakeupAccounts.tokenHash, hashOf(token)), isNull(wakeupAccounts.revokedAt)));
235
+ return row?.id ?? problem(401, "the token is no account's");
271
236
  }
272
237
 
273
238
  /** The account's instance `id`, or a 404, or a 403 when it is another account's. */
274
239
  async function owned(accountId: string, id: string): Promise<InstanceRow | Response> {
275
240
  if (!UUID.test(id)) return problem(404, "no such instance");
276
- const { rows } = await pool.query<InstanceRow>(`SELECT * FROM ${WAKEUP_INSTANCES_TABLE} WHERE id = $1`, [id]);
277
- const row = rows[0];
241
+ const [row] = await db.select().from(wakeupInstances).where(eq(wakeupInstances.id, id));
278
242
  if (!row) return problem(404, "no such instance");
279
- return row.account_id === accountId ? row : problem(403, "the instance is another account's");
243
+ return row.accountId === accountId ? row : problem(403, "the instance is another account's");
280
244
  }
281
245
 
282
246
  async function register(request: Request): Promise<Response> {
@@ -291,32 +255,39 @@ export async function startWakeupService({
291
255
  return problem(400, "token must be an instance's token: swi_, then 43 or more base64url characters");
292
256
  // The token names the instance: a new one is a new row, one of this account's is moved to the
293
257
  // URL given, and another account's is refused (no row returned).
294
- const { rows } = await pool.query<{ id: string; made: boolean }>(
295
- `INSERT INTO ${WAKEUP_INSTANCES_TABLE} AS i (id, account_id, url, token_hash) VALUES ($1, $2, $3, $4) ` +
296
- "ON CONFLICT (token_hash) DO UPDATE SET url = EXCLUDED.url, " +
297
- "at = CASE WHEN i.url = EXCLUDED.url THEN i.at END, " +
298
- "called_at = CASE WHEN i.url = EXCLUDED.url THEN i.called_at END, " +
299
- "attempts = CASE WHEN i.url = EXCLUDED.url THEN i.attempts ELSE 0 END, " +
300
- "next_attempt_at = CASE WHEN i.url = EXCLUDED.url THEN i.next_attempt_at END, " +
301
- "last_error = CASE WHEN i.url = EXCLUDED.url THEN i.last_error END " +
302
- "WHERE i.account_id = EXCLUDED.account_id RETURNING i.id, i.id = $1 AS made",
303
- [randomUUID(), accountId, checked.url, hashOf(token)],
304
- );
305
- const row = rows[0];
258
+ const id = randomUUID();
259
+ const sameUrl = eq(wakeupInstances.url, checked.url);
260
+ const [row] = await db
261
+ .insert(wakeupInstances)
262
+ .values({ id, accountId, url: checked.url, tokenHash: hashOf(token) })
263
+ .onConflictDoUpdate({
264
+ target: wakeupInstances.tokenHash,
265
+ set: {
266
+ url: checked.url,
267
+ at: sql`CASE WHEN ${sameUrl} THEN ${wakeupInstances.at} END`,
268
+ calledAt: sql`CASE WHEN ${sameUrl} THEN ${wakeupInstances.calledAt} END`,
269
+ attempts: sql`CASE WHEN ${sameUrl} THEN ${wakeupInstances.attempts} ELSE 0 END`,
270
+ nextAttemptAt: sql`CASE WHEN ${sameUrl} THEN ${wakeupInstances.nextAttemptAt} END`,
271
+ lastError: sql`CASE WHEN ${sameUrl} THEN ${wakeupInstances.lastError} END`,
272
+ },
273
+ setWhere: eq(wakeupInstances.accountId, accountId),
274
+ })
275
+ .returning({ id: wakeupInstances.id });
306
276
  if (!row) {
307
277
  return problem(409, "the token is another account's instance's");
308
278
  }
309
- return json(row.made ? 201 : 200, { id: row.id });
279
+ return json(row.id === id ? 201 : 200, { id: row.id });
310
280
  }
311
281
 
312
282
  async function wake(request: Request): Promise<Response> {
313
283
  const token = bearer(request);
314
284
  if (!token?.startsWith("swi_")) return problem(401, "an instance's token is needed");
315
- const { rows } = await pool.query<{ id: string }>(
316
- `SELECT i.id FROM ${WAKEUP_INSTANCES_TABLE} i ${LIVE} WHERE i.token_hash = $1`,
317
- [hashOf(token)],
318
- );
319
- const id = rows[0]?.id;
285
+ const [found] = await db
286
+ .select({ id: wakeupInstances.id })
287
+ .from(wakeupInstances)
288
+ .innerJoin(wakeupAccounts, live)
289
+ .where(eq(wakeupInstances.tokenHash, hashOf(token)));
290
+ const id = found?.id;
320
291
  if (!id) return problem(401, "the token is no instance's");
321
292
  const given = await jsonBody(request);
322
293
  if (given instanceof Response) return given;
@@ -324,11 +295,10 @@ export async function startWakeupService({
324
295
  if (typeof at !== "string" || !ISO_DATE_TIME.test(at) || Number.isNaN(Date.parse(at)))
325
296
  return problem(400, "at must be an ISO 8601 date-time");
326
297
  // Whatever it had is replaced: a wake that arrives for an older one starts a worker with nothing to do.
327
- await pool.query(
328
- `UPDATE ${WAKEUP_INSTANCES_TABLE} SET at = $2, called_at = NULL, attempts = 0, ` +
329
- "next_attempt_at = NULL, last_error = NULL WHERE id = $1",
330
- [id, new Date(at)],
331
- );
298
+ await db
299
+ .update(wakeupInstances)
300
+ .set({ at: new Date(at), calledAt: null, attempts: 0, nextAttemptAt: null, lastError: null })
301
+ .where(eq(wakeupInstances.id, id));
332
302
  loop.poke();
333
303
  return noContent();
334
304
  }
@@ -350,19 +320,19 @@ export async function startWakeupService({
350
320
  const row = await owned(accountId, id);
351
321
  if (row instanceof Response) return row;
352
322
  if (method === "GET") return json(200, shownInstance(row));
353
- await pool.query(`DELETE FROM ${WAKEUP_INSTANCES_TABLE} WHERE id = $1`, [id]);
323
+ await db.delete(wakeupInstances).where(eq(wakeupInstances.id, id));
354
324
  return noContent();
355
325
  }
356
326
 
357
327
  return {
358
328
  routes: [{ path: "/wakeup", prefix: true, handle }],
359
- mintAccount: (label) => insertAccount(pool, label),
329
+ mintAccount: (label) => insertAccount(db, label),
360
330
  async revokeAccount(id) {
361
331
  if (!UUID.test(id)) return;
362
- await pool.query(`UPDATE ${WAKEUP_ACCOUNTS_TABLE} SET revoked_at = $2 WHERE id = $1 AND revoked_at IS NULL`, [
363
- id,
364
- now(),
365
- ]);
332
+ await db
333
+ .update(wakeupAccounts)
334
+ .set({ revokedAt: now() })
335
+ .where(and(eq(wakeupAccounts.id, id), isNull(wakeupAccounts.revokedAt)));
366
336
  },
367
337
  async stop() {
368
338
  await loop.stop();
@@ -371,11 +341,8 @@ export async function startWakeupService({
371
341
  };
372
342
  }
373
343
 
374
- interface Due {
375
- id: string;
376
- url: string;
377
- at: Date;
378
- }
344
+ /** A row due for its call: one waiting, so its `at` is set. */
345
+ type Due = Pick<InstanceRow, "id" | "url"> & { at: Date };
379
346
 
380
347
  /**
381
348
  * The one loop: calls every row that is due, at most `CONCURRENCY` at once, then sleeps until the
@@ -383,12 +350,12 @@ interface Due {
383
350
  * `min(2^attempts s, MAX_BACKOFF_S)`, for as long as it takes: no row is ever dropped.
384
351
  */
385
352
  function startLoop({
386
- pool,
353
+ db,
387
354
  now,
388
355
  fetch,
389
356
  log,
390
357
  }: {
391
- pool: ReturnType<typeof openPool>;
358
+ db: Db;
392
359
  now: () => Date;
393
360
  fetch: typeof globalThis.fetch;
394
361
  log: { info(line: string): void; warn(line: string): void };
@@ -414,21 +381,22 @@ function startLoop({
414
381
  if (stopping.signal.aborted) return;
415
382
  // Recorded only on the registration called: one replaced (another `at`), or moved to another
416
383
  // URL, is called again.
417
- const same = "WHERE id = $1 AND at = $2 AND url = $3";
384
+ const same = and(eq(wakeupInstances.id, row.id), eq(wakeupInstances.at, row.at), eq(wakeupInstances.url, row.url));
418
385
  if (error === undefined) {
419
- await pool.query(
420
- `UPDATE ${WAKEUP_INSTANCES_TABLE} SET called_at = $4, attempts = 0, next_attempt_at = NULL, ` +
421
- `last_error = NULL ${same}`,
422
- [row.id, row.at, row.url, now()],
423
- );
386
+ await db
387
+ .update(wakeupInstances)
388
+ .set({ calledAt: now(), attempts: 0, nextAttemptAt: null, lastError: null })
389
+ .where(same);
424
390
  log.info(`wakeup: called ${target}`);
425
391
  } else {
426
- await pool.query(
427
- `UPDATE ${WAKEUP_INSTANCES_TABLE} SET attempts = attempts + 1, last_error = $4, ` +
428
- `next_attempt_at = $5::timestamptz + least(power(2, attempts), ${MAX_BACKOFF_S}) * interval '1 second' ` +
429
- same,
430
- [row.id, row.at, row.url, error, now()],
431
- );
392
+ await db
393
+ .update(wakeupInstances)
394
+ .set({
395
+ attempts: sql`${wakeupInstances.attempts} + 1`,
396
+ lastError: error,
397
+ nextAttemptAt: sql`${now().toISOString()}::timestamptz + least(power(2, ${wakeupInstances.attempts}), ${sql.raw(String(MAX_BACKOFF_S))}) * interval '1 second'`,
398
+ })
399
+ .where(same);
432
400
  log.warn(`wakeup: calling ${target} failed (${error}); it is tried again`);
433
401
  }
434
402
  }
@@ -436,25 +404,43 @@ function startLoop({
436
404
  /** Calls every due row; returns when the next one is due, or undefined with none waiting. */
437
405
  async function round(): Promise<Date | undefined> {
438
406
  // Taken for `CLAIM_S`, rows another service holds skipped: each due row is called by one service.
439
- const { rows } = await pool.query<Due>(
440
- `UPDATE ${WAKEUP_INSTANCES_TABLE} SET next_attempt_at = $1::timestamptz + interval '${CLAIM_S} seconds' ` +
441
- `WHERE id IN (SELECT i.id FROM ${WAKEUP_INSTANCES_TABLE} i ${LIVE} WHERE ${WAITING} ` +
442
- "AND i.at <= $1 AND (i.next_attempt_at IS NULL OR i.next_attempt_at <= $1) " +
443
- `ORDER BY i.at LIMIT ${ROUND_ROWS} FOR UPDATE OF i SKIP LOCKED) RETURNING id, url, at`,
444
- [now()],
445
- );
446
- const queue = [...rows];
407
+ const at = now();
408
+ const due = db
409
+ .select({ id: wakeupInstances.id })
410
+ .from(wakeupInstances)
411
+ .innerJoin(wakeupAccounts, live)
412
+ .where(
413
+ and(
414
+ waiting,
415
+ lte(wakeupInstances.at, at),
416
+ or(isNull(wakeupInstances.nextAttemptAt), lte(wakeupInstances.nextAttemptAt, at)),
417
+ ),
418
+ )
419
+ .orderBy(wakeupInstances.at)
420
+ .limit(ROUND_ROWS)
421
+ .for("update", { of: wakeupInstances, skipLocked: true });
422
+ const rows = await db
423
+ .update(wakeupInstances)
424
+ .set({ nextAttemptAt: new Date(at.getTime() + CLAIM_S * 1000) })
425
+ .where(inArray(wakeupInstances.id, due))
426
+ .returning({ id: wakeupInstances.id, url: wakeupInstances.url, at: wakeupInstances.at });
427
+ const queue = rows.flatMap((r): Due[] => (r.at ? [{ ...r, at: r.at }] : []));
447
428
  const worker = async () => {
448
429
  for (let row = queue.shift(); row && !stopping.signal.aborted; row = queue.shift()) {
449
430
  await call(row).catch((err: unknown) => log.warn(`wakeup: could not record a call: ${errorMessage(err)}`));
450
431
  }
451
432
  };
452
433
  await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker));
453
- const next = await pool.query<{ next: Date | null }>(
454
- `SELECT min(greatest(i.at, coalesce(i.next_attempt_at, i.at))) AS next FROM ${WAKEUP_INSTANCES_TABLE} i ` +
455
- `${LIVE} WHERE ${WAITING}`,
456
- );
457
- return next.rows[0]?.next ?? undefined;
434
+ const [next] = await db
435
+ .select({
436
+ next: sql<Date | null>`min(greatest(${wakeupInstances.at}, coalesce(${wakeupInstances.nextAttemptAt}, ${wakeupInstances.at})))`.mapWith(
437
+ wakeupInstances.at,
438
+ ),
439
+ })
440
+ .from(wakeupInstances)
441
+ .innerJoin(wakeupAccounts, live)
442
+ .where(waiting);
443
+ return next?.next ?? undefined;
458
444
  }
459
445
 
460
446
  const sleep = (ms: number) =>
package/src/worker.ts CHANGED
@@ -4,7 +4,7 @@ import { buildCtx, decideStart, isInfrastructureError, startHoldOf } from "./cal
4
4
  import { dbosStatusesOf } from "./client.ts";
5
5
  import { declaredWakeup, envWakeup, type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
6
6
  import { configuredSecrets } from "./credentials.ts";
7
- import { type Db, DBOS_DATABASE_OPTIONS, lazyPool, prepareDatabase, stampVersion } from "./db.ts";
7
+ import { type Db, DBOS_DATABASE_OPTIONS, lazyDb, prepareDatabase, stampVersion } from "./db.ts";
8
8
  import {
9
9
  deadlineRegistry,
10
10
  type Deadlines,
@@ -23,9 +23,10 @@ import { dbosLogger, info, quietIfSandbox, setLogLevel, warn } from "./log.ts";
23
23
  import { lineStartsOf, readSource } from "./ast.ts";
24
24
  import { callsOf, outlineWithSource } from "./outline.ts";
25
25
  import { currentRun, type Run, type RunArgs, type WorkerState, type WorkflowOutline } from "./run.ts";
26
+ import type { DatabasePreparation } from "./op.ts";
26
27
  import { applyScheduled, nextScheduledFire, type ScheduledStarter, scheduledStarter } from "./schedules.ts";
27
28
  import type { Source } from "./source.ts";
28
- import { approverLabel, CHANGE_WORKFLOW, errorMessage, WORKFLOW_ACTION } from "./shared.ts";
29
+ import { approverLabel, errorMessage, WORKFLOW_ACTION } from "./shared.ts";
29
30
 
30
31
  export interface Worker {
31
32
  /**
@@ -102,6 +103,12 @@ export interface WorkerOptions {
102
103
  * it off, on a database of its own, so no schedule is applied there to start a live run.
103
104
  */
104
105
  schedules?: boolean;
106
+ /**
107
+ * What else the worker prepares the database for at its start, after the runtime's own tables
108
+ * and the connectors' (`ConnectorSetup.prepare`): the launcher passes the staff login's, which
109
+ * no config names. Each gets the database's direct URL.
110
+ */
111
+ prepare?: readonly DatabasePreparation[];
105
112
  }
106
113
 
107
114
  /**
@@ -129,7 +136,7 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
129
136
  }
130
137
  // The config's own, or a Postgres store made for this worker, which `stop` then closes.
131
138
  const ledger = ownedLedger(resolved);
132
- const secrets = lazyPool(resolved.databaseUrl, "secrets");
139
+ const secrets = lazyDb(resolved.databaseUrl, "secrets");
133
140
  const storedSecrets = configuredSecrets(resolved.secrets, () => secrets.db());
134
141
  let stopRule: { close(): void } | undefined;
135
142
  let stopping: Promise<void> | undefined;
@@ -182,7 +189,7 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
182
189
  () => resolved.connections.close(),
183
190
  () => resolved.identityProvisioning.close(),
184
191
  () => secrets.close(),
185
- () => resolved.connectionPool.close(),
192
+ () => resolved.connectionStore.close(),
186
193
  () => ledger.close(),
187
194
  async () => resolved.dataStore?.close(),
188
195
  ]) {
@@ -209,6 +216,9 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
209
216
  // Before DBOS's pool connects, so its connections start with the database's idle-transaction
210
217
  // timeout (`DBOS_DATABASE_OPTIONS`); once per process and database.
211
218
  await prepareDatabase(resolved.databaseUrl);
219
+ // Then what the connectors and the launcher keep there (a login's tables): the app only reads them.
220
+ for (const prepare of [...resolved.preparations.values(), ...(options.prepare ?? [])])
221
+ await prepare(resolved.databaseUrl);
212
222
  DBOS.setConfig({
213
223
  name: resolved.appName,
214
224
  systemDatabaseUrl: resolved.databaseUrl,
@@ -233,8 +243,8 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
233
243
  );
234
244
  }
235
245
  }
236
- // Change runs move to the latest version only: a rollback's worker, unless promoted, leaves them be.
237
- if (isLatest) await carryChangeRuns(resolved, () => secrets.db());
246
+ // Built-in runs move to the latest version only: a rollback's worker, unless promoted, leaves them be.
247
+ if (isLatest) await carryBuiltInRuns(resolved, () => secrets.db());
238
248
  await DBOS.registerQueue(resolved.queueName);
239
249
  // The schedules are the latest version's: an older worker leaves them as the newer set them.
240
250
  if (isLatest && options.schedules !== false) {
@@ -282,7 +292,7 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
282
292
  secrets.close(),
283
293
  ledger.close(),
284
294
  resolved.dataStore?.close(),
285
- resolved.connectionPool.close(),
295
+ resolved.connectionStore.close(),
286
296
  ]);
287
297
  }
288
298
  current = previous;
@@ -313,37 +323,44 @@ function outlineOf(wf: WorkflowDefinition<any, any>): WorkflowOutline {
313
323
  }
314
324
 
315
325
  /**
316
- * Takes over the unfinished change runs of other versions whose change code is this worker's,
317
- * keeping each run's id, history and unread messages: a change's own deploy restarts the worker on
318
- * the merged code, a new version, which DBOS would never give the run to. Each is stamped with this
319
- * version (`stampVersion`), and a PENDING one queued again on the app's queue (`resumeWorkflows`);
320
- * one stopped between the two is PENDING on this version, which DBOS's recovery takes. Not
321
- * `rewindWorkflow`, which takes only a cancelled run and deletes its unread messages. It assumes one
322
- * live worker per database, as a deploy does: an older one still running such a run runs it on
323
- * until it stops. A failure is warned about, and the worker starts.
326
+ * Takes over the unfinished runs of the built-in workflows (apply, change, …) started on other
327
+ * versions whose code is this worker's (`builtInCodes`), keeping each run's id, history and unread
328
+ * messages: a deploy restarts the worker on the merged code, a new version, which DBOS would never
329
+ * give the run to, and the apply or change run that made the deploy is one of them. Each is stamped
330
+ * with this version (`stampVersion`), and a PENDING one queued again on the app's queue
331
+ * (`resumeWorkflows`); one stopped between the two is PENDING on this version, which DBOS's
332
+ * recovery takes. Not `rewindWorkflow`, which takes only a cancelled run and deletes its unread
333
+ * messages. It assumes one live worker per database, as a deploy does: an older one still running
334
+ * such a run runs it on until it stops. A failure is warned about, and the worker starts.
324
335
  */
325
- async function carryChangeRuns({ appName, version, changeCode, queueName }: ResolvedConfig, db: () => Promise<Db>) {
336
+ async function carryBuiltInRuns({ appName, version, builtInCodes, queueName }: ResolvedConfig, db: () => Promise<Db>) {
326
337
  try {
327
338
  const runs = await DBOS.listWorkflows({
328
- workflowName: CHANGE_WORKFLOW,
339
+ workflowName: Object.keys(builtInCodes),
329
340
  status: ["PENDING", "ENQUEUED"],
330
341
  applicationName: appName,
331
- attributes: { changeCode },
332
342
  loadInput: false,
333
343
  loadOutput: false,
334
344
  });
335
345
  const theirs = runs
336
- .filter((r) => r.applicationVersion && r.applicationVersion !== version)
346
+ .filter(
347
+ (r) =>
348
+ r.applicationVersion &&
349
+ r.applicationVersion !== version &&
350
+ r.attributes?.builtInCode === builtInCodes[r.workflowName],
351
+ )
337
352
  .map((r) => r.workflowID);
338
353
  if (!theirs.length) return;
339
354
  const carried = await stampVersion(await db(), theirs, version);
340
355
  const pending = carried.filter((r) => r.pending).map((r) => r.id);
341
356
  if (pending.length) await DBOS.resumeWorkflows(pending, { queueName });
342
357
  if (carried.length) {
343
- info(`carried ${carried.length} change run(s) onto version ${version}: ${carried.map((r) => r.id).join(", ")}`);
358
+ info(`carried ${carried.length} built-in run(s) onto version ${version}: ${carried.map((r) => r.id).join(", ")}`);
344
359
  }
345
360
  } catch (err) {
346
- warn(`carrying the change runs of other versions onto ${version} failed, so they stay there: ${errorMessage(err)}`);
361
+ warn(
362
+ `carrying the built-in runs of other versions onto ${version} failed, so they stay there: ${errorMessage(err)}`,
363
+ );
347
364
  }
348
365
  }
349
366