@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
package/CLOUD-IDENTITY.md CHANGED
@@ -75,8 +75,10 @@ Trusted composition that already owns a writer pool may instead supply the low-l
75
75
 
76
76
  ```ts
77
77
  import { postgresIdentityProvisioning } from "@sanoma-ai/workflows/cloud-identity";
78
+ import { databaseOn } from "@sanoma-ai/workflows/db";
78
79
 
79
- const identityProvisioning = postgresIdentityProvisioning(async () => enrollmentWriterPool, {
80
+ const enrollmentWriter = databaseOn(enrollmentWriterPool);
81
+ const identityProvisioning = postgresIdentityProvisioning(async () => enrollmentWriter, {
80
82
  maxValidityMs: 24 * 60 * 60 * 1000,
81
83
  });
82
84
  // Supply this adapter as defineConfig's identityProvisioning value.
@@ -111,9 +113,11 @@ import {
111
113
  postgresIdentityEnrollments,
112
114
  attachedGoogleTokenIssuer,
113
115
  } from "@sanoma-ai/workflows/cloud-identity";
116
+ import { databaseOn } from "@sanoma-ai/workflows/db";
114
117
 
118
+ const brokerReader = databaseOn(brokerReadOnlyPool);
115
119
  const handle = identityBroker({
116
- enrollments: postgresIdentityEnrollments(async () => brokerReadOnlyPool),
120
+ enrollments: postgresIdentityEnrollments(async () => brokerReader),
117
121
  issue: attachedGoogleTokenIssuer(),
118
122
  audit: writeDurableIdentityAudit,
119
123
  });
@@ -131,8 +135,10 @@ The following composition joins D1 to the separate [#470 KMS adapter](https://gi
131
135
  import { readFile } from "node:fs/promises";
132
136
  import { KeyManagementServiceClient } from "@google-cloud/kms";
133
137
  import { cloudIdentityAuth } from "@sanoma-ai/workflows/cloud-identity";
138
+ import { databaseOn } from "@sanoma-ai/workflows/db";
134
139
  import { kmsPostgresStore } from "@sanoma-ai/workflows/kms-secrets";
135
140
 
141
+ const companyDatabase = databaseOn(companyScopedPool);
136
142
  const authClient = cloudIdentityAuth({
137
143
  endpoint: "https://identity.example.test/token",
138
144
  credential: async () => (await readFile("/run/sanoma/deployment-credential", "utf8")).trim(),
@@ -142,7 +148,7 @@ const store = kmsPostgresStore({
142
148
  company: "example-company", // Fixed by trusted bootstrap.
143
149
  keyName: "projects/example-project/locations/us-central1/keyRings/production/cryptoKeys/example-company",
144
150
  kms,
145
- database: async () => companyScopedPool,
151
+ database: async () => companyDatabase,
146
152
  });
147
153
  // Supply store to defineConfig's secrets configuration.
148
154
  // The deployment owns kms.close() and the database pool lifecycle.
@@ -208,7 +214,9 @@ import {
208
214
  attachedGoogleIdTokenIssuer,
209
215
  postgresIdentityEnrollments,
210
216
  } from "@sanoma-ai/workflows/cloud-identity";
217
+ import { databaseOn } from "@sanoma-ai/workflows/db";
211
218
 
219
+ const provisioningReader = databaseOn(provisioningReadOnlyPool);
212
220
  const identity = {
213
221
  serviceAccount: "provision-source@example-project.iam.gserviceaccount.com",
214
222
  subject: "123456789012345678901",
@@ -217,7 +225,7 @@ const identity = {
217
225
  };
218
226
  const handle = provisioningIdentityBroker({
219
227
  identity,
220
- enrollments: postgresIdentityEnrollments(async () => provisioningReadOnlyPool),
228
+ enrollments: postgresIdentityEnrollments(async () => provisioningReader),
221
229
  issue: attachedGoogleIdTokenIssuer(identity),
222
230
  audit: writeDurableProvisioningAudit,
223
231
  });
package/KMS-SECRETS.md CHANGED
@@ -48,8 +48,10 @@ For standalone use or an explicitly supplied KMS client, use the lower-level `km
48
48
 
49
49
  ```ts
50
50
  import { KeyManagementServiceClient } from "@google-cloud/kms";
51
+ import { databaseOn } from "@sanoma-ai/workflows/db";
51
52
  import { kmsPostgresStore } from "@sanoma-ai/workflows/kms-secrets";
52
53
 
54
+ const companyDatabase = databaseOn(companyPool);
53
55
  const kms = new KeyManagementServiceClient({ authClient: companyAuthClient });
54
56
  const store = kmsPostgresStore({
55
57
  company: companyIdFromTrustedBootstrap,
@@ -60,7 +62,7 @@ const store = kmsPostgresStore({
60
62
  await store.validate("production");
61
63
  ```
62
64
 
63
- An explicit `database` returns a `SecretStore`; omitting it returns a factory that uses the runtime's database. In both forms of `kmsPostgresStore`, the caller owns the supplied KMS client, including `kms.close()`. An explicit database connection is also caller-owned. `managedKmsStore` supplies both the runtime binding and SDK lifecycle instead.
65
+ An explicit `database` returns a `SecretStore`; omitting it returns a factory that uses the runtime's database. In both forms of `kmsPostgresStore`, the caller owns the supplied KMS client, including `kms.close()`. An explicit database (`databaseOn` over the caller's `pg` pool) is also caller-owned, its pool included. `managedKmsStore` supplies both the runtime binding and SDK lifecycle instead.
64
66
 
65
67
  `SANOMA_SIGNING_KEY` stays independent. This store does not use `SANOMA_SECRETS_KEY`. Workflow code cannot import either backend entry. Secret intake must remain outside durable workflow inputs/outputs. Runtime binding does not establish deployed Cloud readiness; provisioning, bootstrap delivery and live company isolation remain #532 verification work.
66
68
 
package/README.md CHANGED
@@ -53,7 +53,9 @@ export const shop = withSetup(defineConnector("shop", specs, info), {
53
53
 
54
54
  `withSetup` lays what it is given over what the connector already carries, and keeps it on the connector's vendor record, so a spread copy of the connector keeps it too. A config that needs a driver made otherwise (another bridge, other options) swaps the driver and keeps the fake: `withSetup(github, { driver: () => githubDriver({ bridge }) })`. It is for connector packages and configs, not for workflows, which the lint does not let import it. Both are optional: a connector without a driver declares its operations and implements none, so a workflow using one is refused; a connector without a fake has none for sandbox runs, which the app's connector page says.
55
55
 
56
- A connector that signs visitors in to a public site attaches a login half too, as `login`: `withSetup(connector, { login: (context) => half })`. The app calls it once per public site whose `site.ts` names the connector (`defineSite({ public: true, login: "<vendor>" })`), when it starts, with a `LoginContext` (the database, a key of its own derived from `SANOMA_SIGNING_KEY` to sign its cookies with, a vendor's secrets, the site's origins, `sendCode(email, code)` when the config names a `login.sender`, and `social`, the company's GitHub App's OAuth client when the app is given it); a login that keeps tables in the database migrates them there, serialized and idempotent. The `LoginHalf` it makes (see its type) answers the provider's stable subject for a request, with the email and the GitHub login when it verified them, never a principal: the app makes the visitor's principal from it, so a connector cannot set a visitor's groups. A visitor's address, for rate limits, is the `CLIENT_IP_HEADER` header the app sets, never `X-Forwarded-For`. One vendor's connectors attach one `login`, as for a driver. [`@sanoma-ai/connector-better-auth`](https://www.npmjs.com/package/@sanoma-ai/connector-better-auth) is one, email and password (and an email code, with a sender) on the company's database; it also exports `betterAuthStaffLogin`, the staff sign-in the app uses on a host (`LoginContext.staff`: who is declared, the GitHub client, and where a sign-in is recorded). The app makes it as it starts and sends it every request to the site's own host ([public sites](https://www.npmjs.com/package/@sanoma-ai/app#public-sites)).
56
+ A connector that keeps tables on the company's database (a login's) attaches how to make them, as `prepare`: `withSetup(connector, { prepare: async (databaseUrl) => … })`, a `DatabasePreparation` the worker runs at its start, after the runtime's own tables, with the database's direct URL (never a pooler's: an advisory lock is one connection's); idempotent, and safe for two workers at once. The app never makes a connector's tables, since on a host it may run where it must not migrate: a login opened before the worker prepared them refuses. `startWorker`'s `prepare` option adds preparations no config names, as the `sanoma` bin adds the staff login's.
57
+
58
+ A connector that signs visitors in to a public site attaches a login half too, as `login`: `withSetup(connector, { login: (context) => half })`. The app calls it once per public site whose `site.ts` names the connector (`defineSite({ public: true, login: "<vendor>" })`), when it starts, with a `LoginContext` (`database()`, the client's pool, the app's one on Node and the request's own on a Worker, so a half keeps an instance per pool and nothing else across requests; a key of its own derived from `SANOMA_SIGNING_KEY` to sign its cookies with, a vendor's secrets, the site's origins, `sendCode(email, code)` when the config names a `login.sender`, and `social`, the company's GitHub App's OAuth client when the app is given it), on the tables its `prepare` made. The `LoginHalf` it makes (see its type) answers the provider's stable subject for a request, with the email and the GitHub login when it verified them, never a principal: the app makes the visitor's principal from it, so a connector cannot set a visitor's groups. A visitor's address, for rate limits, is the `CLIENT_IP_HEADER` header the app sets, never `X-Forwarded-For`. One vendor's connectors attach one `login`, as for a driver. [`@sanoma-ai/connector-better-auth`](https://www.npmjs.com/package/@sanoma-ai/connector-better-auth) is one, email and password (and an email code, with a sender) on the company's database; it also exports `betterAuthStaffLogin`, the staff sign-in the app uses on a host (`LoginContext.staff`: who is declared, the GitHub client, and where a sign-in is recorded). The app makes it as it starts and sends it every request to the site's own host ([public sites](https://www.npmjs.com/package/@sanoma-ai/app#public-sites)).
57
59
 
58
60
  A model connector attaches a model half, as `model`: `withSetup(connector, { model: (context) => provider })`, an AI SDK provider (`ProviderV4` from `@ai-sdk/provider`) with the vendor's adapter as the transport. The app makes it for its model registry, once per resolved config and client (again after one that throws), where its models are `<vendor>/<model-id>`; it answers `languageModel("default")` with the connector's own default. Its `ModelContext` gives it the secrets as a login's does (the selected store and explicit bindings), which it reads when a model is called; its driver declares the key in `env`, with no operations if it needs none. One vendor's connectors attach one `model`. [`@sanoma-ai/connector-anthropic`](https://www.npmjs.com/package/@sanoma-ai/connector-anthropic) is one.
59
61
 
@@ -669,6 +671,8 @@ A stopped worker cannot launch again in its process, so whoever supervises it st
669
671
 
670
672
  A database is a resource type that holds the company's database (a Neon project, a Supabase project): `defineResource({ …, writable: true, afterCreate, database })`, where `database` (`DatabaseSpec`) says what it can do (`capabilities: { pauses, branch }`: its compute scales to zero and wakes on a connection; it makes copy-on-write branches that carry the data), where it takes going live's choices, by dotted path into its fields (`inputs: { region, size? }`), the regions it takes when known (`regions`), the operation that stores its URL (`storeUrl`) and how its state reads (`outputs(state) → { healthy }`). `storeUrl` is an operation of the type's own vendor, in the type's own group (`neon.project.storeUrl`) or another (`supabase.database.storeUrl`), never one of a resource type's own: it takes `{ ref | id, export }` and stores the URL that carries `LISTEN` as `SANOMA_DATABASE_URL_<EXPORT>` (`databaseUrlSecret(export)`; `storeDatabaseUrl` stores it so, refusing a secret that holds another), never returning it, and the type's `afterCreate` calls it. A database type is not `stateless`: apply asks before it deletes one. `defineResource` refuses a `database` that breaks any of that; `resolveConfig` refuses one whose `storeUrl` is not among the config's operations, or does not take a binding id and an export. Its conformance kit is [`@sanoma-ai/testing/database`](../testing/README.md#a-databases-conformance). After retiring a database, a workflow can remove its exported URL with `sanoma.secret.delete` under the [runtime operation contract](#the-runtimes-own-operations).
671
673
 
674
+ `databaseUrlSecret` converts camel-case exports to uppercase words (`mainStore` → `SANOMA_DATABASE_URL_MAIN_STORE`) and preserves exports that are already uppercase, including digits and underscores (`CLOUD_ACME_97F9` → `SANOMA_DATABASE_URL_CLOUD_ACME_97F9`). Use the same export when storing, binding and retiring its URL.
675
+
672
676
  ### Secrets
673
677
 
674
678
  Never put a secret in a workflow's input or an operation's input or output. They are persisted verbatim: DBOS keeps every step's input and output in Postgres, the ledger records each call's input and output, an approval a policy asks for carries the held call's input, and the app shows all of it to anyone who can reach it. A driver reads its credentials when it is called (from the environment or a secret store), and an operation that creates a secret (an API key, a password reset link) returns a reference to where it is stored, not the secret.
@@ -679,7 +683,7 @@ A secret a resource needs is named, never written. A data file gives `secret("NA
679
683
 
680
684
  Every run is stamped with the application version of the worker that runs it, `<appName>@<version>`, and only a worker on that version recovers it after a restart. Versioning is automatic: the version is a hash of each workflow's name, the source of its `run` function, its operations and its input schema, which is DBOS's own scheme applied to the workflow code rather than to the runtime's registration wrapper (DBOS would otherwise see one identical function for every workflow and never change). The hash cannot see functions `run` calls that live elsewhere, op schemas, drivers or the policy: edit those with no run in flight, or restart on the old code first. To name a version instead (a git commit, say), set DBOS's `DBOS__APPVERSION` environment variable; it is prefixed with the app name the same way. A new version becomes the app's latest when it first starts, and runs queued without a version go to the latest; a worker started on an older version (a rollback) warns instead, unless started with `{ promote: true }`. The hash is of the code as it runs, so TypeScript source and the compiled JavaScript of the same workflow have different versions.
681
685
 
682
- Change runs are the exception: a change's own deploy restarts the worker on the merged code, a new version, so the run has to outlive its version. Each change run carries a hash of the change workflow's own code (its `changeCode` attribute: what the version hashes of the change workflow, with the runtime's and DBOS's versions, never the company's workflows), and a worker that starts as the app's latest version takes over the unfinished change runs of other versions whose hash is its own, keeping each run's id, its completed steps and the messages it has not read yet (an approval decided, an event told while no worker ran). It says so in one line at `info`. Runs of the company's workflows stay on their version, a change's `apply` and `drift` runs among them; so does a change run whose hash differs, after an upgrade of the runtime.
686
+ Built-in runs are the exception: a deploy restarts the worker on the merged code, a new version, and the `apply` or `change` run that made the deploy has to outlive its version. Each run of a built-in workflow (`apply`, `change`, `drift`, `call`, `login-code`) carries a hash of that workflow's own code (its `builtInCode` attribute: what the version hashes of the workflow, with the runtime's and DBOS's versions, never the company's workflows), and a worker that starts as the app's latest version takes over the unfinished built-in runs of other versions whose hash is its own, keeping each run's id, its completed steps and the messages it has not read yet (an approval decided, an event told while no worker ran). It says so in one line at `info`. Runs of the company's workflows stay on their version; so does a built-in run whose hash differs, after an upgrade of the runtime (or, for `apply`, a change of the resource types it may call).
683
687
 
684
688
  When a worker starts, it warns about unfinished runs it will not pick up, naming them: runs started on another version, and runs queued on another queue. Run the version that started them, or fork each onto the current version with `DBOSClient.forkWorkflow(id, step, { applicationVersion, queueName })` and cancel the original.
685
689
 
package/dist/bundled.d.ts CHANGED
@@ -5,8 +5,8 @@ export interface RootSnapshot {
5
5
  appName: string;
6
6
  /** `ResolvedConfig.version`: a hash of the workflows' source, which a bundle's code is not, so a run it queues is its worker's. */
7
7
  version: string;
8
- /** `ResolvedConfig.changeCode`, for the same reason. */
9
- changeCode: string;
8
+ /** `ResolvedConfig.builtInCodes`, for the same reason. */
9
+ builtInCodes: Readonly<Record<string, string>>;
10
10
  /** `readPolicyFiles`: each `.cedar` file under `policies/` (and an app's), by path from the root. */
11
11
  policies: {
12
12
  file: string;
package/dist/chats.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { UIMessage } from "ai";
2
- import type { Pool } from "pg";
2
+ import type { Db } from "./db.ts";
3
3
  /** A chat as `SanomaClient.chats.list` reads it. */
4
4
  export interface Chat {
5
5
  id: string;
@@ -39,5 +39,5 @@ export interface Chats {
39
39
  /** The person's chats, the newest first: the latest 50. */
40
40
  list(person: string): Promise<Chat[]>;
41
41
  }
42
- /** The chats' store on the pool `db` gives, the config's database once it is prepared. */
43
- export declare function chatStore(db: () => Promise<Pool>): Chats;
42
+ /** The chats' store on the database `db` gives, the config's database once it is prepared. */
43
+ export declare function chatStore(db: () => Promise<Db>): Chats;
package/dist/chats.js CHANGED
@@ -1,21 +1,25 @@
1
- import { CHAT_MESSAGES_TABLE as MESSAGES, CHATS_TABLE as CHATS } from "./db.js";
1
+ import { asc, desc, eq, sql } from "drizzle-orm";
2
+ import { chatMessages, chats } from "./db/company.js";
2
3
  import { SanomaError } from "./errors.js";
3
- const COLUMNS = "id, person, engine, draft_id, title, created_at";
4
- const chatOf = ({ draft_id, created_at, ...row }) => ({
4
+ const chatOf = ({ draftId, createdAt, ...row }) => ({
5
5
  ...row,
6
- ...(draft_id === null ? {} : { draftId: draft_id }),
7
- createdAt: created_at.getTime(),
6
+ ...(draftId === null ? {} : { draftId }),
7
+ createdAt: createdAt.getTime(),
8
8
  });
9
- /** The chats' store on the pool `db` gives, the config's database once it is prepared. */
9
+ /** The chats' store on the database `db` gives, the config's database once it is prepared. */
10
10
  export function chatStore(db) {
11
11
  return {
12
12
  async open({ id, person, engine, title }) {
13
- const { rows } = await (await db()).query(`INSERT INTO ${CHATS} (id, person, engine, title) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING RETURNING id`, [id, person, engine, title]);
14
- if (!rows.length)
13
+ const opened = await (await db())
14
+ .insert(chats)
15
+ .values({ id, person, engine, title })
16
+ .onConflictDoNothing()
17
+ .returning({ id: chats.id });
18
+ if (!opened.length)
15
19
  throw new SanomaError("invalid_input", `Chat ${id} exists already; open it with another id`);
16
20
  },
17
21
  async attach(id, draftId) {
18
- await (await db()).query(`UPDATE ${CHATS} SET draft_id = $2 WHERE id = $1`, [id, draftId]);
22
+ await (await db()).update(chats).set({ draftId }).where(eq(chats.id, id));
19
23
  },
20
24
  async upsert(id, messages) {
21
25
  if (!messages.length)
@@ -23,24 +27,34 @@ export function chatStore(db) {
23
27
  if (messages.some((m) => !m.id))
24
28
  throw new SanomaError("invalid_input", "A chat message needs an id");
25
29
  // A new message's place is after the chat's last, in the order given.
26
- await (await db()).query(`INSERT INTO ${MESSAGES} (chat_id, id, seq, message) ` +
27
- `SELECT $1, m.value->>'id', last.seq + m.n, m.value ` +
28
- `FROM jsonb_array_elements($2::jsonb) WITH ORDINALITY AS m (value, n), ` +
29
- `(SELECT coalesce(max(seq), 0) AS seq FROM ${MESSAGES} WHERE chat_id = $1) AS last ` +
30
- "ON CONFLICT (chat_id, id) DO UPDATE SET message = EXCLUDED.message", [id, JSON.stringify(messages)]);
30
+ const last = sql `(SELECT coalesce(max(${chatMessages.seq}), 0) FROM ${chatMessages} WHERE ${eq(chatMessages.chatId, id)})`;
31
+ await (await db())
32
+ .insert(chatMessages)
33
+ .values(messages.map((message, n) => ({ chatId: id, id: message.id, seq: sql `${last} + ${n + 1}`, message })))
34
+ .onConflictDoUpdate({
35
+ target: [chatMessages.chatId, chatMessages.id],
36
+ set: { message: sql `excluded.message` },
37
+ });
31
38
  },
32
39
  async load(id) {
33
- const { rows } = await (await db()).query(`SELECT message FROM ${MESSAGES} WHERE chat_id = $1 ORDER BY seq, id`, [id]);
40
+ const rows = await (await db())
41
+ .select({ message: chatMessages.message })
42
+ .from(chatMessages)
43
+ .where(eq(chatMessages.chatId, id))
44
+ .orderBy(asc(chatMessages.seq), asc(chatMessages.id));
34
45
  return rows.map((row) => row.message);
35
46
  },
36
47
  async get(id) {
37
- const { rows } = await (await db()).query(`SELECT ${COLUMNS} FROM ${CHATS} WHERE id = $1`, [id]);
38
- return rows[0] && chatOf(rows[0]);
48
+ const [row] = await (await db()).select().from(chats).where(eq(chats.id, id));
49
+ return row && chatOf(row);
39
50
  },
40
51
  async list(person) {
41
- const { rows } = await (await db()).query(`SELECT ${COLUMNS} FROM ${CHATS} WHERE person = $1 ORDER BY created_at DESC, id LIMIT 50`, [
42
- person,
43
- ]);
52
+ const rows = await (await db())
53
+ .select()
54
+ .from(chats)
55
+ .where(eq(chats.person, person))
56
+ .orderBy(desc(chats.createdAt), asc(chats.id))
57
+ .limit(50);
44
58
  return rows.map(chatOf);
45
59
  },
46
60
  };
package/dist/chats.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"chats.js","sourceRoot":"","sources":["../src/chats.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"chats.js","sourceRoot":"","sources":["../src/chats.ts"],"names":[],"mappings":"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"}
package/dist/client.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Pool } from "pg";
1
2
  import { type ConnectionRef, type ConnectionSnapshot, type ConnectionTarget, type ConnectionSetup, type ConnectionCard, type ConnectionRule } from "./connection.ts";
2
3
  import { type WorkflowStatusString } from "@dbos-inc/dbos-sdk";
3
4
  import { z } from "zod";
@@ -126,6 +127,12 @@ export interface ClientOptions {
126
127
  * `origin`) and fetches a draft's new commits into. Without one, `remoteEvent` does nothing.
127
128
  */
128
129
  source?: Source;
130
+ /**
131
+ * The version each run it starts is queued for, so only a worker of that code runs it: the app
132
+ * bundle's (its snapshot's), which is live beside a worker of another version while a deploy
133
+ * rolls. Without it a run is queued with none, which DBOS gives the app's latest version.
134
+ */
135
+ appVersion?: string;
129
136
  }
130
137
  /** Talks to the runtime from another process (the app, a script), through the shared Postgres. */
131
138
  export declare class SanomaClient {
@@ -135,7 +142,7 @@ export declare class SanomaClient {
135
142
  /** The config's own ledger store, or a Postgres one made for this client, which `close` then closes. */
136
143
  private readonly audit;
137
144
  /** On the config's database, for the runtime's tables (the credentials, the chats): opened on first use. */
138
- private readonly pool;
145
+ private readonly database;
139
146
  /** What a direct call's driver reads secrets from, as a worker's live driver does. */
140
147
  private readonly storedSecrets;
141
148
  /** The operator agent's chats: their rows and their transcripts, in the config's database. */
@@ -154,7 +161,8 @@ export declare class SanomaClient {
154
161
  * Checks the input against the workflow's schema, then queues a run for the worker. The run
155
162
  * gets the input as sent, not as the schema parsed it: the worker parses it once, so a schema
156
163
  * with a `.transform` or a default sees the caller's value, and `run.started` records it.
157
- * `workflow` is a definition, or the name of one in the config's `workflows`.
164
+ * `workflow` is a definition, or the name of one in the config's `workflows`. The run is queued
165
+ * for the client's `appVersion` when it has one.
158
166
  */
159
167
  start<S extends z.ZodType>(workflow: WorkflowDefinition<any, S> | string, input: z.input<S>, options: StartOptions): Promise<string>;
160
168
  /**
@@ -213,6 +221,11 @@ export declare class SanomaClient {
213
221
  by: string;
214
222
  expiresAt: Date;
215
223
  }): Promise<void>;
224
+ /**
225
+ * Spends a sealed invite request's `nonce` (`openInvite`), good until `expiresAt`: true the first
226
+ * time, false when it was spent already, in this process or another.
227
+ */
228
+ spendInviteRequest(nonce: string, expiresAt: Date): Promise<boolean>;
216
229
  /** When each of `people` last signed in to the staff login, and how, by id (`recordSignIn`); one who never did is left out. */
217
230
  lastSignIns(people: readonly string[]): Promise<Record<string, {
218
231
  at: number;
@@ -451,8 +464,8 @@ export declare class SanomaClient {
451
464
  * `invalid_input` when none does.
452
465
  */
453
466
  private declared;
454
- /** The pool the runtime's tables are read and written with, on the database once it is prepared. */
455
- private db;
467
+ /** The pool the client reads and writes with, once the database is prepared: what a login runs on (`LoginContext.database`). */
468
+ pool(): Promise<Pool>;
456
469
  /**
457
470
  * The principal as the data files declare it (`admit`): its id alone is taken, never the groups
458
471
  * a caller gives, and an id nobody declares is `not_admitted`.
package/dist/client.js CHANGED
@@ -3,6 +3,8 @@ import { encodeCredentials, decodeCredentials, verifyAuthentication, Authenticat
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { isDeepStrictEqual } from "node:util";
5
5
  import { Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
6
+ import { is } from "drizzle-orm";
7
+ import { TransactionRollbackError } from "drizzle-orm/errors";
6
8
  import { z } from "zod";
7
9
  import { APPROVALS_EVENT, ApprovalMessage, ApprovalWithdrawal, decisionEventOf, messageKeyOf, topicOf, withdrawalRefusal, } from "./approvals.js";
8
10
  import { beforeOf, decideCall, decideStart, recorded, startHoldOf } from "./call.js";
@@ -11,7 +13,7 @@ import { tokenStore } from "./tokens.js";
11
13
  import { collectionsDriver } from "./collection.js";
12
14
  import { resolveConfig } from "./config.js";
13
15
  import { configuredSecrets } from "./credentials.js";
14
- import { dbosClient, lazyPool } from "./db.js";
16
+ import { dbosClient, lazyDb, spendInviteRequest } from "./db.js";
15
17
  import { credentialsOf } from "./env.js";
16
18
  import { Principal } from "./define.js";
17
19
  import { APPLY_WORKFLOW } from "./apply.js";
@@ -79,7 +81,7 @@ export class SanomaClient {
79
81
  /** The config's own ledger store, or a Postgres one made for this client, which `close` then closes. */
80
82
  audit;
81
83
  /** On the config's database, for the runtime's tables (the credentials, the chats): opened on first use. */
82
- pool;
84
+ database;
83
85
  /** What a direct call's driver reads secrets from, as a worker's live driver does. */
84
86
  storedSecrets;
85
87
  /** The operator agent's chats: their rows and their transcripts, in the config's database. */
@@ -93,10 +95,10 @@ export class SanomaClient {
93
95
  this.drivers = drivers;
94
96
  this.options = options;
95
97
  this.audit = ownedLedger(config);
96
- this.pool = lazyPool(config.databaseUrl, "client");
97
- this.storedSecrets = configuredSecrets(config.secrets, () => this.db());
98
- this.chats = chatStore(() => this.db());
99
- this.tokens = tokenStore(() => this.db());
98
+ this.database = lazyDb(config.databaseUrl, "client");
99
+ this.storedSecrets = configuredSecrets(config.secrets, () => this.database.db());
100
+ this.chats = chatStore(() => this.database.db());
101
+ this.tokens = tokenStore(() => this.database.db());
100
102
  }
101
103
  /**
102
104
  * Connects to the runtime's Postgres for the config's app. Checks the config first and throws
@@ -120,7 +122,8 @@ export class SanomaClient {
120
122
  * Checks the input against the workflow's schema, then queues a run for the worker. The run
121
123
  * gets the input as sent, not as the schema parsed it: the worker parses it once, so a schema
122
124
  * with a `.transform` or a default sees the caller's value, and `run.started` records it.
123
- * `workflow` is a definition, or the name of one in the config's `workflows`.
125
+ * `workflow` is a definition, or the name of one in the config's `workflows`. The run is queued
126
+ * for the client's `appVersion` when it has one.
124
127
  */
125
128
  async start(workflow, input, options) {
126
129
  const startedBy = await this.withSubject(await this.admitted(parseOrThrow(PrincipalOrId, options?.startedBy, '`startedBy` must be a principal, such as "alice" or { id: "alice" }')));
@@ -146,11 +149,11 @@ export class SanomaClient {
146
149
  const args = { input, startedBy, ...(sandbox === undefined ? {} : { sandbox }), ...(hold && { hold }) };
147
150
  // Kept on the run's status row too, so a listing tells sandbox runs apart without its input,
148
151
  // and says whom an agent's run is for (DBOS keeps the principal's id and groups itself); a
149
- // change run's code, so a worker on another version with the same takes it over.
152
+ // built-in run's code, so a worker on another version with the same takes it over.
150
153
  const attributes = {
151
154
  sandbox,
152
155
  onBehalfOf: startedBy.onBehalfOf,
153
- changeCode: workflow.name === CHANGE_WORKFLOW ? this.config.changeCode : undefined,
156
+ builtInCode: this.config.builtInCodes[workflow.name],
154
157
  };
155
158
  this.options.onWrite?.();
156
159
  // An id DBOS has already returns that run, unless it is another workflow's, which DBOS refuses.
@@ -160,6 +163,7 @@ export class SanomaClient {
160
163
  workflowName: workflow.name,
161
164
  workflowID: runId,
162
165
  applicationName: this.config.appName,
166
+ ...(this.options.appVersion !== undefined && { appVersion: this.options.appVersion }),
163
167
  authenticatedUser: startedBy.id,
164
168
  authenticatedRoles: startedBy.groups ?? [],
165
169
  ...(Object.values(attributes).some((v) => v !== undefined) && { attributes }),
@@ -281,66 +285,66 @@ export class SanomaClient {
281
285
  const { dataStore, collections, ledger } = this.config;
282
286
  const connectionMutation = op.id.startsWith("sanoma.connection.");
283
287
  const transactionStore = connectionMutation
284
- ? this.config.connectionPool
288
+ ? this.config.connectionStore
285
289
  : op.vendor === DATA_VENDOR
286
290
  ? dataStore
287
291
  : undefined;
288
- const conn = !ledger && transactionStore ? await (await transactionStore.db()).connect() : undefined;
289
- let broken;
290
- try {
291
- await conn?.query("BEGIN");
292
- // The metadata or collection driver shares the ledger transaction; neither has an env guard.
293
- const fn = conn
294
- ? (connectionMutation
295
- ? connectionDriver(this.config.connections.onDatabase(conn))
296
- : collectionsDriver([collections.get(op.resource)], { db: async () => conn })).ops[`${op.resource}.${op.name}`]
297
- : call.fn;
298
- const at = Date.now();
299
- const record = (outcome) => ({
300
- v: 1,
301
- app: this.config.appName,
302
- id: `${runId}:op.called:0`,
303
- runId,
304
- seq: 0,
305
- at,
306
- actor,
307
- workflow: CALL_WORKFLOW,
308
- type: "op.called",
309
- op: op.id,
310
- effect: op.effect,
311
- input: logged,
312
- decision,
313
- ...(context.connection && { connection: context.connection }),
314
- ...outcome,
315
- durationMs: Date.now() - at,
316
- });
317
- let output;
292
+ const at = Date.now();
293
+ const record = (outcome) => ({
294
+ v: 1,
295
+ app: this.config.appName,
296
+ id: `${runId}:op.called:0`,
297
+ runId,
298
+ seq: 0,
299
+ at,
300
+ actor,
301
+ workflow: CALL_WORKFLOW,
302
+ type: "op.called",
303
+ op: op.id,
304
+ effect: op.effect,
305
+ input: logged,
306
+ decision,
307
+ ...(context.connection && { connection: context.connection }),
308
+ ...outcome,
309
+ durationMs: Date.now() - at,
310
+ });
311
+ /** Runs the call through `fn`; a failure is recorded on the store's own connection, outside any transaction. */
312
+ const output = async (fn) => {
318
313
  try {
319
- output = await outputOf(op, () => fn(parsed, context));
314
+ return await outputOf(op, () => fn(parsed, context));
320
315
  }
321
316
  catch (err) {
322
- // Recorded on the store's own connection; the transaction is rolled back below.
323
317
  await this.audit.store.append(record({ error: errorInfo(err) }));
324
318
  throw err;
325
319
  }
326
- const done = record({ output: recorded(op, output) });
327
- if (!conn) {
328
- await this.audit.store.append(done);
329
- return output;
330
- }
331
- if (await insertRecord(conn, done)) {
332
- await conn.query("COMMIT");
333
- return output;
334
- }
335
- await conn.query("ROLLBACK");
336
- return (await this.recordedCall(runId, op.id, logged, context.connection)).output;
320
+ };
321
+ if (ledger || !transactionStore) {
322
+ const result = await output(call.fn);
323
+ await this.audit.store.append(record({ output: recorded(op, result) }));
324
+ return result;
337
325
  }
338
- catch (err) {
339
- await conn?.query("ROLLBACK").catch((e) => (broken = e));
340
- throw err;
326
+ // The write and its record in one transaction: the metadata or collection driver runs on the
327
+ // transaction's connection (neither has an env guard), and a record another write of the id
328
+ // was first with rolls the write back, for the recorded output to stand.
329
+ const store = await transactionStore.db();
330
+ try {
331
+ return await store.transaction(async (tx) => {
332
+ const fn = (connectionMutation
333
+ ? connectionDriver(this.config.connections.onDatabase(tx))
334
+ : collectionsDriver([collections.get(op.resource)], { db: async () => tx })).ops[`${op.resource}.${op.name}`];
335
+ const result = await output(fn);
336
+ if (await insertRecord(tx, record({ output: recorded(op, result) })))
337
+ return result;
338
+ tx.rollback();
339
+ });
341
340
  }
342
- finally {
343
- conn?.release(broken);
341
+ catch (err) {
342
+ if (!is(err, TransactionRollbackError))
343
+ throw err;
344
+ const first = await this.recordedCall(runId, op.id, logged, context.connection);
345
+ if (!first)
346
+ throw err;
347
+ return first.output;
344
348
  }
345
349
  }
346
350
  /**
@@ -413,6 +417,13 @@ export class SanomaClient {
413
417
  expiresAt: options.expiresAt.toISOString(),
414
418
  }));
415
419
  }
420
+ /**
421
+ * Spends a sealed invite request's `nonce` (`openInvite`), good until `expiresAt`: true the first
422
+ * time, false when it was spent already, in this process or another.
423
+ */
424
+ async spendInviteRequest(nonce, expiresAt) {
425
+ return spendInviteRequest(await this.database.db(), nonce, expiresAt);
426
+ }
416
427
  /** When each of `people` last signed in to the staff login, and how, by id (`recordSignIn`); one who never did is left out. */
417
428
  lastSignIns(people) {
418
429
  return lastSignIns(this.audit.store, people);
@@ -1116,10 +1127,10 @@ export class SanomaClient {
1116
1127
  this.config.connections.close(),
1117
1128
  this.config.identityProvisioning.close(),
1118
1129
  this.dbos.destroy(),
1119
- this.pool.close(),
1130
+ this.database.close(),
1120
1131
  this.audit.close(),
1121
1132
  this.config.dataStore?.close(),
1122
- this.config.connectionPool.close(),
1133
+ this.config.connectionStore.close(),
1123
1134
  ]);
1124
1135
  }
1125
1136
  /**
@@ -1140,9 +1151,9 @@ export class SanomaClient {
1140
1151
  issues: [{ path: ["name"], message, code: "custom" }],
1141
1152
  });
1142
1153
  }
1143
- /** The pool the runtime's tables are read and written with, on the database once it is prepared. */
1144
- db() {
1145
- return this.pool.db();
1154
+ /** The pool the client reads and writes with, once the database is prepared: what a login runs on (`LoginContext.database`). */
1155
+ pool() {
1156
+ return this.database.pool();
1146
1157
  }
1147
1158
  /**
1148
1159
  * The principal as the data files declare it (`admit`): its id alone is taken, never the groups