@sanoma-ai/workflows 0.11.11 → 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 (145) hide show
  1. package/CLOUD-IDENTITY.md +12 -4
  2. package/KMS-SECRETS.md +3 -1
  3. package/README.md +6 -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/database.js +4 -1
  26. package/dist/database.js.map +1 -1
  27. package/dist/db/company.d.ts +1152 -0
  28. package/dist/db/company.js +118 -0
  29. package/dist/db/company.js.map +1 -0
  30. package/dist/db/dbos.d.ts +111 -0
  31. package/dist/db/dbos.js +15 -0
  32. package/dist/db/dbos.js.map +1 -0
  33. package/dist/db/identity.d.ts +367 -0
  34. package/dist/db/identity.js +48 -0
  35. package/dist/db/identity.js.map +1 -0
  36. package/dist/db/index.d.ts +6 -0
  37. package/dist/db/index.js +10 -0
  38. package/dist/db/index.js.map +1 -0
  39. package/dist/db/kms.d.ts +224 -0
  40. package/dist/db/kms.js +29 -0
  41. package/dist/db/kms.js.map +1 -0
  42. package/dist/db/migrate.d.ts +63 -0
  43. package/dist/db/migrate.js +41 -0
  44. package/dist/db/migrate.js.map +1 -0
  45. package/dist/db/wakeup.d.ts +271 -0
  46. package/dist/db/wakeup.js +34 -0
  47. package/dist/db/wakeup.js.map +1 -0
  48. package/dist/db.d.ts +42 -39
  49. package/dist/db.js +129 -169
  50. package/dist/db.js.map +1 -1
  51. package/dist/deadlines.js +8 -4
  52. package/dist/deadlines.js.map +1 -1
  53. package/dist/deployment-key.d.ts +4 -1
  54. package/dist/deployment-key.js +10 -3
  55. package/dist/deployment-key.js.map +1 -1
  56. package/dist/host-derive.d.ts +12 -3
  57. package/dist/host-derive.js +21 -8
  58. package/dist/host-derive.js.map +1 -1
  59. package/dist/identity-provisioning.d.ts +2 -2
  60. package/dist/identity-provisioning.js +75 -42
  61. package/dist/identity-provisioning.js.map +1 -1
  62. package/dist/identity-service.js +18 -15
  63. package/dist/identity-service.js.map +1 -1
  64. package/dist/index.d.ts +2 -2
  65. package/dist/index.js +1 -1
  66. package/dist/index.js.map +1 -1
  67. package/dist/kms-secrets.d.ts +9 -0
  68. package/dist/kms-secrets.js +59 -27
  69. package/dist/kms-secrets.js.map +1 -1
  70. package/dist/ledger.d.ts +1 -1
  71. package/dist/ledger.js +22 -14
  72. package/dist/ledger.js.map +1 -1
  73. package/dist/op.d.ts +27 -6
  74. package/dist/op.js.map +1 -1
  75. package/dist/run-status.js +7 -3
  76. package/dist/run-status.js.map +1 -1
  77. package/dist/schedules.d.ts +1 -1
  78. package/dist/schedules.js +5 -7
  79. package/dist/schedules.js.map +1 -1
  80. package/dist/schema.d.ts +6 -5
  81. package/dist/schema.js +11 -7
  82. package/dist/schema.js.map +1 -1
  83. package/dist/shared.d.ts +6 -23
  84. package/dist/shared.js +6 -5
  85. package/dist/shared.js.map +1 -1
  86. package/dist/storage-provisioning.d.ts +2 -0
  87. package/dist/storage-provisioning.js +51 -50
  88. package/dist/storage-provisioning.js.map +1 -1
  89. package/dist/tokens.d.ts +2 -2
  90. package/dist/tokens.js +31 -20
  91. package/dist/tokens.js.map +1 -1
  92. package/dist/version.d.ts +4 -4
  93. package/dist/version.js +5 -5
  94. package/dist/version.js.map +1 -1
  95. package/dist/wakeup-service.d.ts +0 -4
  96. package/dist/wakeup-service.js +102 -93
  97. package/dist/wakeup-service.js.map +1 -1
  98. package/dist/worker.d.ts +7 -0
  99. package/dist/worker.js +26 -21
  100. package/dist/worker.js.map +1 -1
  101. package/drizzle/company/0000_tables.sql +104 -0
  102. package/drizzle/company/0001_wakeup_row.sql +2 -0
  103. package/drizzle/company/meta/0000_snapshot.json +652 -0
  104. package/drizzle/company/meta/0001_snapshot.json +652 -0
  105. package/drizzle/company/meta/_journal.json +20 -0
  106. package/drizzle/wakeup/0000_tables.sql +26 -0
  107. package/drizzle/wakeup/meta/0000_snapshot.json +200 -0
  108. package/drizzle/wakeup/meta/_journal.json +13 -0
  109. package/oxlint.json +2 -1
  110. package/package.json +11 -2
  111. package/src/bundled.ts +2 -2
  112. package/src/chats.ts +41 -42
  113. package/src/client.ts +81 -64
  114. package/src/cloud-identity.ts +23 -24
  115. package/src/collection.ts +58 -50
  116. package/src/config.ts +29 -19
  117. package/src/connection.ts +132 -102
  118. package/src/credentials.ts +37 -33
  119. package/src/database.ts +4 -2
  120. package/src/db/company.ts +173 -0
  121. package/src/db/dbos.ts +17 -0
  122. package/src/db/identity.ts +61 -0
  123. package/src/db/index.ts +10 -0
  124. package/src/db/kms.ts +41 -0
  125. package/src/db/migrate.ts +70 -0
  126. package/src/db/wakeup.ts +41 -0
  127. package/src/db.ts +151 -182
  128. package/src/deadlines.ts +10 -4
  129. package/src/deployment-key.ts +11 -3
  130. package/src/host-derive.ts +26 -9
  131. package/src/identity-provisioning.ts +85 -57
  132. package/src/identity-service.ts +19 -19
  133. package/src/index.ts +3 -0
  134. package/src/kms-secrets.ts +64 -56
  135. package/src/ledger.ts +23 -18
  136. package/src/op.ts +28 -6
  137. package/src/run-status.ts +7 -6
  138. package/src/schedules.ts +6 -8
  139. package/src/schema.ts +11 -8
  140. package/src/shared.ts +6 -17
  141. package/src/storage-provisioning.ts +47 -52
  142. package/src/tokens.ts +43 -33
  143. package/src/version.ts +5 -5
  144. package/src/wakeup-service.ts +117 -131
  145. package/src/worker.ts +38 -21
@@ -1,6 +1,8 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { Compute, OAuth2Client } from "google-auth-library";
3
+ import { and, eq, gt, lte, sql } from "drizzle-orm";
3
4
  import type { Db } from "./db.ts";
5
+ import { identityCredentials, identityDeployments } from "./db/identity.ts";
4
6
  import { enrollmentBroker } from "./identity-broker.ts";
5
7
  export { provisioningIdentityBroker, attachedGoogleIdTokenIssuer } from "./provisioning-identity.ts";
6
8
  export type { ProvisioningIdentity, ProvisioningIdToken } from "./provisioning-identity.ts";
@@ -64,31 +66,28 @@ REVOKE ALL ON ALL TABLES IN SCHEMA sanoma_identity FROM PUBLIC;
64
66
  export function postgresIdentityEnrollments(database: () => Promise<Db>): IdentityEnrollments {
65
67
  return {
66
68
  async active(hash) {
67
- const { rows } = await (
69
+ const [row] = await (
68
70
  await database()
69
- ).query<{
70
- deployment: string;
71
- company: string;
72
- environment: string;
73
- service_account: string;
74
- generation: string;
75
- }>(
76
- `SELECT d.id AS deployment, d.company, d.environment, d.service_account, c.generation
77
- FROM sanoma_identity.credentials c JOIN sanoma_identity.deployments d ON d.id = c.deployment
78
- WHERE c.hash = $1 AND NOT c.revoked AND d.enabled
79
- AND c.valid_from <= clock_timestamp() AND c.valid_until > clock_timestamp()`,
80
- [hash],
81
- );
82
- const row = rows[0];
83
- return (
84
- row && {
85
- deployment: row.deployment,
86
- company: row.company,
87
- environment: row.environment,
88
- serviceAccount: row.service_account,
89
- generation: Number(row.generation),
90
- }
91
- );
71
+ )
72
+ .select({
73
+ deployment: identityDeployments.id,
74
+ company: identityDeployments.company,
75
+ environment: identityDeployments.environment,
76
+ serviceAccount: identityDeployments.serviceAccount,
77
+ generation: identityCredentials.generation,
78
+ })
79
+ .from(identityCredentials)
80
+ .innerJoin(identityDeployments, eq(identityDeployments.id, identityCredentials.deployment))
81
+ .where(
82
+ and(
83
+ eq(identityCredentials.hash, hash),
84
+ eq(identityCredentials.revoked, false),
85
+ eq(identityDeployments.enabled, true),
86
+ lte(identityCredentials.validFrom, sql`clock_timestamp()`),
87
+ gt(identityCredentials.validUntil, sql`clock_timestamp()`),
88
+ ),
89
+ );
90
+ return row;
92
91
  },
93
92
  };
94
93
  }
package/src/collection.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
2
  import { z } from "zod";
3
- import { DATA_TABLE, type Db } from "./db.ts";
3
+ import * as drizzle from "drizzle-orm";
4
+ import { and, eq, or, sql, type SQL } from "drizzle-orm";
5
+ import type { Db } from "./db.ts";
6
+ import { data } from "./db/company.ts";
4
7
  import { defineFake, type Fake } from "./fake.ts";
5
8
  import { isObject } from "./resource.ts";
6
9
  import {
@@ -29,7 +32,7 @@ import {
29
32
  // A company's own records: a collection is declared with plain values (a name, a key and typed
30
33
  // fields), and its `get`, `list`, `save` and `delete` are operations of the vendor `data`, under
31
34
  // the policy and in the ledger like any connector's. The records are JSON rows of one table the
32
- // runtime owns (`DATA_TABLE`), in the config's database; a sandbox run keeps them in memory.
35
+ // runtime owns (`data`), in the config's database; a sandbox run keeps them in memory.
33
36
 
34
37
  export type FieldType = (typeof FIELD_TYPES)[number];
35
38
 
@@ -266,7 +269,7 @@ export function collectionsConnector(list: readonly Collection[]): Connector<typ
266
269
  return { ...defineConnector(DATA_VENDOR, {}), ...Object.fromEntries(ops) } as Connector<typeof DATA_VENDOR, DataOps>;
267
270
  }
268
271
 
269
- const COLUMNS = "key, record, updated_at, updated_by";
272
+ const COLUMNS = { key: data.key, record: data.record, updatedAt: data.updatedAt, updatedBy: data.updatedBy };
270
273
  const recordExists = () =>
271
274
  new DriverError("Collection record already exists", { retryable: false, status: 409, vendorCode: "record_exists" });
272
275
  const recordChanged = () =>
@@ -278,14 +281,10 @@ const recordChanged = () =>
278
281
  const sameJson = (a: Record<string, unknown>, b: Record<string, unknown>) =>
279
282
  isDeepStrictEqual(JSON.parse(JSON.stringify(a)), JSON.parse(JSON.stringify(b)));
280
283
  type SaveInput = { record: Record<string, unknown>; ifAbsent?: boolean; ifRecord?: Record<string, unknown> };
281
- const RANGE = { gt: ">", gte: ">=", lt: "<", lte: "<=" } as const;
284
+ // By namespace: the fake names a range's bounds `gt`, `gte`, `lt` and `lte`.
285
+ const RANGE = { gt: drizzle.gt, gte: drizzle.gte, lt: drizzle.lt, lte: drizzle.lte };
282
286
 
283
- interface Row {
284
- key: string;
285
- record: Record<string, unknown>;
286
- updated_at: Date;
287
- updated_by: string;
288
- }
287
+ type Row = Pick<typeof data.$inferSelect, keyof typeof COLUMNS>;
289
288
 
290
289
  /** A `list` input as its schema parses it. */
291
290
  interface Listing {
@@ -295,58 +294,70 @@ interface Listing {
295
294
  }
296
295
 
297
296
  /**
298
- * The collections' driver: their records in `DATA_TABLE` of the database `store` is on, through
297
+ * The collections' driver: their records in the `data` table of the database `store` is on, through
299
298
  * the pool a run's calls share, or one connection in a transaction (a direct write, with its
300
299
  * ledger record). Every value reaches Postgres as a parameter, field names included; the field
301
300
  * names and operators are the ones the input's schema accepts.
302
301
  */
303
302
  export function collectionsDriver(list: readonly Collection[], store: { db(): Promise<Db> }): Driver {
304
- const query = async (sql: string, params: unknown[]) => (await store.db()).query<Row>(sql, params);
305
303
  const impl = list.map((c) => {
306
304
  const saved = (row: Row) =>
307
305
  answer(c, {
308
306
  key: row.key,
309
307
  record: row.record,
310
- updatedAt: row.updated_at.toISOString(),
311
- updatedBy: row.updated_by,
308
+ updatedAt: row.updatedAt.toISOString(),
309
+ updatedBy: row.updatedBy,
312
310
  });
311
+ const here = (key: string) => and(eq(data.collection, c.name), eq(data.key, key));
313
312
  const ops: Record<string, (input: any, call: CallContext) => Promise<unknown>> = {
314
313
  get: async ({ key }: { key: string }) => {
315
- const sql = `SELECT ${COLUMNS} FROM ${DATA_TABLE} WHERE collection = $1 AND key = $2`;
316
- const [row] = (await query(sql, [c.name, key])).rows;
314
+ const [row] = await (await store.db()).select(COLUMNS).from(data).where(here(key));
317
315
  return row ? saved(row) : null;
318
316
  },
319
- list: async (listing: Listing) => (await query(...listSql(c, listing))).rows.map(saved),
317
+ list: async (listing: Listing) => {
318
+ const { where, order } = listClauses(c, listing);
319
+ const rows = await (
320
+ await store.db()
321
+ )
322
+ .select(COLUMNS)
323
+ .from(data)
324
+ .where(where)
325
+ .orderBy(...order)
326
+ .limit(listing.limit);
327
+ return rows.map(saved);
328
+ },
320
329
  save: async ({ record, ifAbsent, ifRecord }: SaveInput, call) => {
330
+ const db = await store.db();
331
+ const key = record[c.key] as string;
332
+ const by = updatedBy(call);
321
333
  if (ifRecord !== undefined) {
322
334
  // PostgreSQL locks and rechecks the predicate on a concurrent update. A lost-response
323
335
  // retry may recover its own identical result, but never overwrite another actor's state.
324
- const retry = "record = $3::jsonb AND updated_by = $4";
325
- const sql = `UPDATE ${DATA_TABLE} SET record = $3::jsonb,
326
- updated_at = CASE WHEN ${retry} THEN updated_at ELSE now() END, updated_by = $4
327
- WHERE collection = $1 AND key = $2 AND (record = $5::jsonb OR (${retry}))
328
- RETURNING ${COLUMNS}`;
329
- const params = [c.name, record[c.key], JSON.stringify(record), updatedBy(call), JSON.stringify(ifRecord)];
330
- const row = (await query(sql, params)).rows[0];
336
+ const retry = and(eq(data.record, record), eq(data.updatedBy, by))!;
337
+ const [row] = await db
338
+ .update(data)
339
+ .set({ record, updatedAt: sql`CASE WHEN ${retry} THEN ${data.updatedAt} ELSE now() END`, updatedBy: by })
340
+ .where(and(here(key), or(eq(data.record, ifRecord), retry)))
341
+ .returning(COLUMNS);
331
342
  if (!row) throw recordChanged();
332
343
  return saved(row);
333
344
  }
334
345
  // The conflict row is locked by Postgres: never implement an insert-only claim as get + save.
335
346
  // An identical retry by the same actor keeps the first receipt and never overwrites later progress.
336
347
  const conflict = ifAbsent
337
- ? `record = ${DATA_TABLE}.record WHERE ${DATA_TABLE}.record = EXCLUDED.record AND ${DATA_TABLE}.updated_by = EXCLUDED.updated_by`
338
- : "record = EXCLUDED.record, updated_at = now(), updated_by = EXCLUDED.updated_by";
339
- const sql =
340
- `INSERT INTO ${DATA_TABLE} (collection, key, record, updated_by) VALUES ($1, $2, $3, $4) ` +
341
- `ON CONFLICT (collection, key) DO UPDATE SET ${conflict} RETURNING ${COLUMNS}`;
342
- const params = [c.name, record[c.key], JSON.stringify(record), updatedBy(call)];
343
- const row = (await query(sql, params)).rows[0];
348
+ ? { set: { record: sql`${data.record}` }, setWhere: and(eq(data.record, record), eq(data.updatedBy, by)) }
349
+ : { set: { record, updatedAt: sql`now()`, updatedBy: by } };
350
+ const [row] = await db
351
+ .insert(data)
352
+ .values({ collection: c.name, key, record, updatedBy: by })
353
+ .onConflictDoUpdate({ target: [data.collection, data.key], ...conflict })
354
+ .returning(COLUMNS);
344
355
  if (!row) throw recordExists();
345
356
  return saved(row);
346
357
  },
347
358
  delete: async ({ key }: { key: string }) => {
348
- const sql = `DELETE FROM ${DATA_TABLE} WHERE collection = $1 AND key = $2`;
349
- return { deleted: ((await query(sql, [c.name, key])).rowCount ?? 0) > 0 };
359
+ const gone = await (await store.db()).delete(data).where(here(key)).returning({ key: data.key });
360
+ return { deleted: gone.length > 0 };
350
361
  },
351
362
  };
352
363
  return [c.name, ops];
@@ -369,40 +380,37 @@ const jsonTypesOf = (c: Collection): Record<string, JsonType> =>
369
380
  const SQL_TYPE = { string: "text", number: "numeric", boolean: "boolean" } as const;
370
381
 
371
382
  /**
372
- * The `list` query on Postgres, and its parameters. A field's value is compared as its declared
383
+ * The `list` query's filter and order on Postgres. A field's value is compared as its declared
373
384
  * type, text byte by byte (`COLLATE "C"`, whatever the database's collation, as the fake
374
385
  * compares it); a missing value, or one of another type, is SQL's null: it matches no filter and
375
386
  * sorts last.
376
387
  */
377
- function listSql(c: Collection, { where = {}, order, limit }: Listing): [string, unknown[]] {
388
+ function listClauses(c: Collection, { where = {}, order }: Listing): { where: SQL | undefined; order: SQL[] } {
378
389
  const types = jsonTypesOf(c);
379
- const params: unknown[] = [c.name];
380
- const param = (value: unknown) => `$${params.push(value)}`;
381
390
  const field = (name: string) => {
382
391
  const type = types[name]!;
383
- const json = `record -> ${param(name)}::text`;
384
- const typed = `CASE WHEN jsonb_typeof(${json}) = '${type}' THEN (${json} #>> '{}')::${SQL_TYPE[type]} END`;
385
- return type === "string" ? `(${typed}) COLLATE "C"` : typed;
392
+ const sqlType = sql.raw(SQL_TYPE[type]);
393
+ const json = sql`${data.record} -> ${name}::text`;
394
+ const typed = sql`CASE WHEN jsonb_typeof(${json}) = ${type} THEN (${json} #>> '{}')::${sqlType} END`;
395
+ return type === "string" ? sql`(${typed}) COLLATE "C"` : typed;
386
396
  };
387
- const value = (name: string, v: unknown) => `${param(v)}::${SQL_TYPE[types[name]!]}`;
388
- const conditions = ["collection = $1"];
397
+ const value = (name: string, v: unknown) => sql`${v}::${sql.raw(SQL_TYPE[types[name]!])}`;
398
+ const conditions: SQL[] = [eq(data.collection, c.name)];
389
399
  for (const [name, condition] of Object.entries(where)) {
390
400
  if (condition === undefined) continue;
391
- if (!isObject(condition)) conditions.push(`${field(name)} = ${value(name, condition)}`);
401
+ if (!isObject(condition)) conditions.push(eq(field(name), value(name, condition)));
392
402
  else {
393
403
  for (const [op, v] of Object.entries(condition)) {
394
- const sign = RANGE[op as keyof typeof RANGE];
395
- if (v !== undefined) conditions.push(`${field(name)} ${sign} ${value(name, v)}`);
404
+ if (v !== undefined) conditions.push(RANGE[op as keyof typeof RANGE](field(name), value(name, v)));
396
405
  }
397
406
  }
398
407
  }
399
- const byKey = `key COLLATE "C"`;
408
+ const byKey = sql`${data.key} COLLATE "C"`;
400
409
  const by =
401
410
  order === undefined
402
- ? byKey
403
- : `${field(order.replace(/^-/, ""))} ${order.startsWith("-") ? "DESC" : "ASC"} NULLS LAST, ${byKey}`;
404
- const sql = `SELECT ${COLUMNS} FROM ${DATA_TABLE} WHERE ${conditions.join(" AND ")} ORDER BY ${by} LIMIT ${param(limit)}`;
405
- return [sql, params];
411
+ ? [byKey]
412
+ : [sql`${field(order.replace(/^-/, ""))} ${sql.raw(order.startsWith("-") ? "DESC" : "ASC")} NULLS LAST`, byKey];
413
+ return { where: and(...conditions), order: by };
406
414
  }
407
415
 
408
416
  /** Orders two values of one type as Postgres does under `COLLATE "C"`: strings by their UTF-8 bytes. */
package/src/config.ts CHANGED
@@ -35,7 +35,7 @@ import {
35
35
  collectionsFake,
36
36
  isCollection,
37
37
  } from "./collection.ts";
38
- import { lazyPool, type LazyPool } from "./db.ts";
38
+ import { lazyDb, type LazyDb } from "./db.ts";
39
39
  import { isDirectory, kindDirs, SCENARIOS_DIR } from "./paths.ts";
40
40
  import { callerFile, type Use, type WorkflowDefinition } from "./define.ts";
41
41
  import { DRIFT_WORKFLOW, driftWorkflow } from "./drift.ts";
@@ -77,7 +77,7 @@ import { runtimeDriver, sandboxRuntimeDriver, sanoma } from "./runtime-ops.ts";
77
77
  import { tokenStore } from "./tokens.ts";
78
78
  import type { Steps } from "./steps.ts";
79
79
  import { checkSchedule } from "./schedules.ts";
80
- import { changeCodeOf, computeVersion } from "./version.ts";
80
+ import { builtInCodeOf, computeVersion } from "./version.ts";
81
81
  import { type DeclaredResource, readPeople, type ResourceProblem } from "./resources.ts";
82
82
  import { checkWakeupOps, type DeclaredWakeup, type EnvWakeup, HOST_URL } from "./wakeup.ts";
83
83
  import { instanceUrl, WAKEUP_TOKEN } from "./wakeup-service.ts";
@@ -174,7 +174,7 @@ export interface Login {
174
174
  export interface ResolvedConfig {
175
175
  secrets: SecretConfiguration;
176
176
  connections: ConnectionRegistry;
177
- connectionPool: LazyPool;
177
+ connectionStore: LazyDb;
178
178
  identityProvisioning: ReturnType<typeof configuredIdentityProvisioning>;
179
179
  /**
180
180
  * Scopes workflows, queues and versions in the system database: the `name` of
@@ -184,8 +184,11 @@ export interface ResolvedConfig {
184
184
  databaseUrl: string;
185
185
  /** The DBOS application version: `<appName>@<DBOS__APPVERSION, or a hash of the workflows>`. */
186
186
  version: string;
187
- /** The hash of the built-in change workflow's own code (`changeCodeOf`), which each change run carries. */
188
- changeCode: string;
187
+ /**
188
+ * Each built-in workflow's code hash (`builtInCodeOf`), by its name, which each of its runs carries:
189
+ * a worker on another version of the app takes over a run whose code is its own (`carryBuiltInRuns`).
190
+ */
191
+ builtInCodes: Readonly<Record<string, string>>;
189
192
  /** The DBOS queue runs are started on: `sanoma:<appName>`. */
190
193
  queueName: string;
191
194
  /** The config's `login`, checked (`checkLogin`); the built-in `login-code` workflow sends through its `sender`, from its `from`. */
@@ -206,6 +209,8 @@ export interface ResolvedConfig {
206
209
  driversByVendor: Map<string, Driver>;
207
210
  /** The makers of the fakes the connectors attach, by vendor id in `connectors` order: each sandbox run makes its own. */
208
211
  fakes: Map<string, () => Fake<any, any>>;
212
+ /** The preparations the connectors attach (`ConnectorSetup.prepare`), by vendor id in `connectors` order: the worker runs each at its start. */
213
+ preparations: ReadonlyMap<string, NonNullable<ConnectorSetup["prepare"]>>;
209
214
  /** The makers of the login halves the connectors attach, by vendor id in `connectors` order: the app makes one per public site that names it. */
210
215
  logins: ReadonlyMap<string, NonNullable<ConnectorSetup["login"]>>;
211
216
  /** The makers of the model halves the connectors attach, by vendor id in `connectors` order: the app makes each once for its model registry. */
@@ -235,11 +240,11 @@ export interface ResolvedConfig {
235
240
  /** The config's collections, by name: their operations are in `ops` and `drivers`, their fake in `fakes`, under `DATA_VENDOR`. */
236
241
  collections: ReadonlyMap<string, Collection>;
237
242
  /**
238
- * The pool the collections' driver keeps their records through, when there are any: opened on
243
+ * The database the collections' driver keeps their records in, when there are any: opened on
239
244
  * its first call, and closed by whoever calls the driver (the worker, and the client, which
240
245
  * calls it directly, when each stops).
241
246
  */
242
- dataStore?: LazyPool;
247
+ dataStore?: LazyDb;
243
248
  /**
244
249
  * By name; each checked against `ops` and `drivers`. The config's, the built-ins `drift`
245
250
  * (`DRIFT_WORKFLOW`) and `apply` (`APPLY_WORKFLOW`) when its connectors declare resource types,
@@ -347,15 +352,15 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
347
352
  const hostTyped = [...types.values()].some((type) => type.host);
348
353
  // Built-in: the collections' operations, `data.<name>.<op>`, on the config's database.
349
354
  const databaseUrl = resolveDatabaseUrl(config);
350
- const connectionPool = lazyPool(databaseUrl, "connections");
355
+ const connectionStore = lazyDb(databaseUrl, "connections");
351
356
  const identityProvisioning = configuredIdentityProvisioning(config.identityProvisioning);
352
357
  const connections = new ConnectionRegistry(
353
358
  connectionCatalog(config.connectors, config.connections, config.connectionRules),
354
- () => connectionPool.db(),
355
- configuredSecrets(config.secrets ?? {}, () => connectionPool.db()),
359
+ () => connectionStore.db(),
360
+ configuredSecrets(config.secrets ?? {}, () => connectionStore.db()),
356
361
  );
357
362
  const declared = [...collections.values()];
358
- const dataStore = declared.length ? lazyPool(databaseUrl, "data") : undefined;
363
+ const dataStore = declared.length ? lazyDb(databaseUrl, "data") : undefined;
359
364
  // Built-in, in every config: a change's operations, which the template's policy names
360
365
  // (`sanoma.change.merge`), the listing of apps a page may call (`sanoma.apps.list`), and what a
361
366
  // workflow asks the runtime to do for it (`sanoma`, runtime-ops.ts).
@@ -380,9 +385,9 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
380
385
  connectionDriver(connections),
381
386
  appsListDriver,
382
387
  runtimeDriver({
383
- tokens: tokenStore(() => connectionPool.db()),
388
+ tokens: tokenStore(() => connectionStore.db()),
384
389
  identities: identityProvisioning.store,
385
- inspect: inspectRun(() => connectionPool.db(), appName),
390
+ inspect: inspectRun(() => connectionStore.db(), appName),
386
391
  }),
387
392
  ...(hostTyped ? [sourceDriver] : []),
388
393
  ...(dataStore ? [collectionsDriver(declared, dataStore)] : []),
@@ -452,20 +457,25 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
452
457
  return {
453
458
  secrets: config.secrets ?? {},
454
459
  connections,
455
- connectionPool,
460
+ connectionStore,
456
461
  identityProvisioning,
457
462
  appName,
458
463
  databaseUrl,
459
464
  // The built-in's code and operations are part of the app's version, like the config's own. A
460
465
  // bundle's code is not the source's: it carries the hashes of the root it was read from.
461
466
  version: bundled?.version ?? computeVersion({ appName, workflows: [...names.values()] }),
462
- changeCode: bundled?.changeCode ?? changeCodeOf(names.get(CHANGE_WORKFLOW)!),
467
+ builtInCodes:
468
+ bundled?.builtInCodes ??
469
+ Object.fromEntries(
470
+ [...names.values()].filter((wf) => BUILT_IN_WORKFLOWS.has(wf.name)).map((wf) => [wf.name, builtInCodeOf(wf)]),
471
+ ),
463
472
  queueName: `sanoma:${appName}`,
464
473
  ...(login && { login }),
465
474
  ops,
466
475
  drivers,
467
476
  driversByVendor,
468
477
  fakes,
478
+ preparations: makersOf(config.connectors, "prepare"),
469
479
  logins: makersOf(config.connectors, "login"),
470
480
  models: makersOf(config.connectors, "model"),
471
481
  remotes,
@@ -626,8 +636,8 @@ function makersOf<K extends keyof ConnectorSetup>(
626
636
  export function readRootSnapshot(config: SanomaConfig, resolved: ResolvedConfig = resolveConfig(config)): RootSnapshot {
627
637
  const root = rootOf(config);
628
638
  if (root === undefined) throw new Error("A config with no root has no files to carry");
629
- const { appName, version, changeCode } = resolved;
630
- return { appName, version, changeCode, policies: readPolicyFiles(root), people: readPeople(root) };
639
+ const { appName, version, builtInCodes } = resolved;
640
+ return { appName, version, builtInCodes, policies: readPolicyFiles(root), people: readPeople(root) };
631
641
  }
632
642
 
633
643
  /**
@@ -762,9 +772,9 @@ async function readDeclared(
762
772
  const keep = new Set<string>();
763
773
  const leftOut = new Set<string>();
764
774
  if (root !== undefined) {
765
- const { deriveHosts, hostFacts } = await import("./host-derive.ts");
775
+ const { deriveHosts, hostFacts, hostRootFacts } = await import("./host-derive.ts");
766
776
  const facts = await hostFacts({
767
- root,
777
+ rootFacts: () => hostRootFacts(root),
768
778
  resources,
769
779
  types,
770
780
  drivers: options.drivers,