@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
@@ -0,0 +1,200 @@
1
+ {
2
+ "id": "8ab4b417-ea98-494f-a151-5ac42f186eb5",
3
+ "prevId": "00000000-0000-0000-0000-000000000000",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.sanoma_wakeup_accounts": {
8
+ "name": "sanoma_wakeup_accounts",
9
+ "schema": "",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "uuid",
14
+ "primaryKey": true,
15
+ "notNull": true
16
+ },
17
+ "token_hash": {
18
+ "name": "token_hash",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true
22
+ },
23
+ "label": {
24
+ "name": "label",
25
+ "type": "text",
26
+ "primaryKey": false,
27
+ "notNull": true
28
+ },
29
+ "created_at": {
30
+ "name": "created_at",
31
+ "type": "timestamp with time zone",
32
+ "primaryKey": false,
33
+ "notNull": true,
34
+ "default": "now()"
35
+ },
36
+ "revoked_at": {
37
+ "name": "revoked_at",
38
+ "type": "timestamp with time zone",
39
+ "primaryKey": false,
40
+ "notNull": false
41
+ }
42
+ },
43
+ "indexes": {},
44
+ "foreignKeys": {},
45
+ "compositePrimaryKeys": {},
46
+ "uniqueConstraints": {
47
+ "sanoma_wakeup_accounts_tokenHash_unique": {
48
+ "name": "sanoma_wakeup_accounts_tokenHash_unique",
49
+ "nullsNotDistinct": false,
50
+ "columns": [
51
+ "token_hash"
52
+ ]
53
+ }
54
+ },
55
+ "policies": {},
56
+ "checkConstraints": {},
57
+ "isRLSEnabled": false
58
+ },
59
+ "public.sanoma_wakeup_instances": {
60
+ "name": "sanoma_wakeup_instances",
61
+ "schema": "",
62
+ "columns": {
63
+ "id": {
64
+ "name": "id",
65
+ "type": "uuid",
66
+ "primaryKey": true,
67
+ "notNull": true
68
+ },
69
+ "account_id": {
70
+ "name": "account_id",
71
+ "type": "uuid",
72
+ "primaryKey": false,
73
+ "notNull": true
74
+ },
75
+ "url": {
76
+ "name": "url",
77
+ "type": "text",
78
+ "primaryKey": false,
79
+ "notNull": true
80
+ },
81
+ "token_hash": {
82
+ "name": "token_hash",
83
+ "type": "text",
84
+ "primaryKey": false,
85
+ "notNull": true
86
+ },
87
+ "at": {
88
+ "name": "at",
89
+ "type": "timestamp with time zone",
90
+ "primaryKey": false,
91
+ "notNull": false
92
+ },
93
+ "called_at": {
94
+ "name": "called_at",
95
+ "type": "timestamp with time zone",
96
+ "primaryKey": false,
97
+ "notNull": false
98
+ },
99
+ "attempts": {
100
+ "name": "attempts",
101
+ "type": "integer",
102
+ "primaryKey": false,
103
+ "notNull": true,
104
+ "default": 0
105
+ },
106
+ "next_attempt_at": {
107
+ "name": "next_attempt_at",
108
+ "type": "timestamp with time zone",
109
+ "primaryKey": false,
110
+ "notNull": false
111
+ },
112
+ "last_error": {
113
+ "name": "last_error",
114
+ "type": "text",
115
+ "primaryKey": false,
116
+ "notNull": false
117
+ },
118
+ "created_at": {
119
+ "name": "created_at",
120
+ "type": "timestamp with time zone",
121
+ "primaryKey": false,
122
+ "notNull": true,
123
+ "default": "now()"
124
+ }
125
+ },
126
+ "indexes": {
127
+ "sanoma_wakeup_instances_at": {
128
+ "name": "sanoma_wakeup_instances_at",
129
+ "columns": [
130
+ {
131
+ "expression": "at",
132
+ "isExpression": false,
133
+ "asc": true,
134
+ "nulls": "last"
135
+ }
136
+ ],
137
+ "isUnique": false,
138
+ "where": "\"sanoma_wakeup_instances\".\"at\" IS NOT NULL",
139
+ "concurrently": false,
140
+ "method": "btree",
141
+ "with": {}
142
+ },
143
+ "sanoma_wakeup_instances_account": {
144
+ "name": "sanoma_wakeup_instances_account",
145
+ "columns": [
146
+ {
147
+ "expression": "account_id",
148
+ "isExpression": false,
149
+ "asc": true,
150
+ "nulls": "last"
151
+ }
152
+ ],
153
+ "isUnique": false,
154
+ "concurrently": false,
155
+ "method": "btree",
156
+ "with": {}
157
+ }
158
+ },
159
+ "foreignKeys": {
160
+ "sanoma_wakeup_instances_account_id_sanoma_wakeup_accounts_id_fk": {
161
+ "name": "sanoma_wakeup_instances_account_id_sanoma_wakeup_accounts_id_fk",
162
+ "tableFrom": "sanoma_wakeup_instances",
163
+ "tableTo": "sanoma_wakeup_accounts",
164
+ "columnsFrom": [
165
+ "account_id"
166
+ ],
167
+ "columnsTo": [
168
+ "id"
169
+ ],
170
+ "onDelete": "no action",
171
+ "onUpdate": "no action"
172
+ }
173
+ },
174
+ "compositePrimaryKeys": {},
175
+ "uniqueConstraints": {
176
+ "sanoma_wakeup_instances_tokenHash_unique": {
177
+ "name": "sanoma_wakeup_instances_tokenHash_unique",
178
+ "nullsNotDistinct": false,
179
+ "columns": [
180
+ "token_hash"
181
+ ]
182
+ }
183
+ },
184
+ "policies": {},
185
+ "checkConstraints": {},
186
+ "isRLSEnabled": false
187
+ }
188
+ },
189
+ "enums": {},
190
+ "schemas": {},
191
+ "sequences": {},
192
+ "roles": {},
193
+ "policies": {},
194
+ "views": {},
195
+ "_meta": {
196
+ "columns": {},
197
+ "schemas": {},
198
+ "tables": {}
199
+ }
200
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1793827391027,
9
+ "tag": "0000_tables",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
package/oxlint.json CHANGED
@@ -103,7 +103,8 @@
103
103
  "@sanoma-ai/workflows/cloud-identity",
104
104
  "@sanoma-ai/workflows/identity-service",
105
105
  "@sanoma-ai/workflows/kms-secrets",
106
- "@sanoma-ai/workflows/wakeup-service"
106
+ "@sanoma-ai/workflows/wakeup-service",
107
+ "@sanoma-ai/workflows/db"
107
108
  ],
108
109
  "message": "A workflow must call vendors through ctx, so the policy sees every call: import the connector itself, `@sanoma-ai/connector-<vendor>`."
109
110
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanoma-ai/workflows",
3
- "version": "0.11.12",
3
+ "version": "0.11.13",
4
4
  "description": "Business processes as TypeScript functions, run durably on DBOS",
5
5
  "keywords": [
6
6
  "approvals",
@@ -22,6 +22,8 @@
22
22
  "dist",
23
23
  "src",
24
24
  "!src/AGENTS.md",
25
+ "drizzle",
26
+ "!drizzle/*.config.ts",
25
27
  "oxlint.json",
26
28
  "README.md",
27
29
  "LICENSE",
@@ -86,6 +88,10 @@
86
88
  "types": "./dist/kms-secrets.d.ts",
87
89
  "default": "./dist/kms-secrets.js"
88
90
  },
91
+ "./db": {
92
+ "types": "./dist/db/index.d.ts",
93
+ "default": "./dist/db/index.js"
94
+ },
89
95
  "./identity-service": {
90
96
  "types": "./dist/identity-service.d.ts",
91
97
  "default": "./dist/identity-service.js"
@@ -106,6 +112,7 @@
106
112
  "@google-cloud/kms": "^6.3.0",
107
113
  "ai": "7.0.153",
108
114
  "crc-32": "^1.2.2",
115
+ "drizzle-orm": "0.45.4",
109
116
  "google-auth-library": "^11.3.2",
110
117
  "oxc-parser": "^0.153.0",
111
118
  "oxfmt": "^0.72.0",
@@ -115,6 +122,7 @@
115
122
  },
116
123
  "devDependencies": {
117
124
  "@types/pg": "^8.23.1",
125
+ "drizzle-kit": "0.31.11",
118
126
  "msw": "^3.0.3",
119
127
  "srvx": "^0.11.22",
120
128
  "zod": "^4.6.5"
@@ -129,6 +137,7 @@
129
137
  "scripts": {
130
138
  "build": "tsc -p tsconfig.build.json",
131
139
  "clean": "rm -rf dist",
132
- "watch": "tsc -p tsconfig.build.json --watch --preserveWatchOutput"
140
+ "watch": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
141
+ "generate": "drizzle-kit generate --config drizzle/company.config.ts && drizzle-kit generate --config drizzle/wakeup.config.ts"
133
142
  }
134
143
  }
package/src/bundled.ts CHANGED
@@ -12,8 +12,8 @@ export interface RootSnapshot {
12
12
  appName: string;
13
13
  /** `ResolvedConfig.version`: a hash of the workflows' source, which a bundle's code is not, so a run it queues is its worker's. */
14
14
  version: string;
15
- /** `ResolvedConfig.changeCode`, for the same reason. */
16
- changeCode: string;
15
+ /** `ResolvedConfig.builtInCodes`, for the same reason. */
16
+ builtInCodes: Readonly<Record<string, string>>;
17
17
  /** `readPolicyFiles`: each `.cedar` file under `policies/` (and an app's), by path from the root. */
18
18
  policies: { file: string; text: string }[];
19
19
  /** `readPeople`: the people, agents and groups the data files declare, with their problems. */
package/src/chats.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { UIMessage } from "ai";
2
- import type { Pool } from "pg";
3
- import { CHAT_MESSAGES_TABLE as MESSAGES, CHATS_TABLE as CHATS } from "./db.ts";
2
+ import { asc, desc, eq, sql } from "drizzle-orm";
3
+ import type { Db } from "./db.ts";
4
+ import { chatMessages, chats } from "./db/company.ts";
4
5
  import { SanomaError } from "./errors.ts";
5
6
 
6
7
  // The operator agent's chats, in the config's database beside the runtime's other tables: one row
@@ -44,70 +45,68 @@ export interface Chats {
44
45
  list(person: string): Promise<Chat[]>;
45
46
  }
46
47
 
47
- /** A row of the chats' table. */
48
- interface ChatRow {
49
- id: string;
50
- person: string;
51
- engine: string;
52
- draft_id: string | null;
53
- title: string;
54
- created_at: Date;
55
- }
56
-
57
- const COLUMNS = "id, person, engine, draft_id, title, created_at";
58
-
59
- const chatOf = ({ draft_id, created_at, ...row }: ChatRow): Chat => ({
48
+ const chatOf = ({ draftId, createdAt, ...row }: typeof chats.$inferSelect): Chat => ({
60
49
  ...row,
61
- ...(draft_id === null ? {} : { draftId: draft_id }),
62
- createdAt: created_at.getTime(),
50
+ ...(draftId === null ? {} : { draftId }),
51
+ createdAt: createdAt.getTime(),
63
52
  });
64
53
 
65
- /** The chats' store on the pool `db` gives, the config's database once it is prepared. */
66
- export function chatStore(db: () => Promise<Pool>): Chats {
54
+ /** The chats' store on the database `db` gives, the config's database once it is prepared. */
55
+ export function chatStore(db: () => Promise<Db>): Chats {
67
56
  return {
68
57
  async open({ id, person, engine, title }) {
69
- const { rows } = await (
58
+ const opened = await (
70
59
  await db()
71
- ).query(
72
- `INSERT INTO ${CHATS} (id, person, engine, title) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING RETURNING id`,
73
- [id, person, engine, title],
74
- );
75
- if (!rows.length) throw new SanomaError("invalid_input", `Chat ${id} exists already; open it with another id`);
60
+ )
61
+ .insert(chats)
62
+ .values({ id, person, engine, title })
63
+ .onConflictDoNothing()
64
+ .returning({ id: chats.id });
65
+ if (!opened.length) throw new SanomaError("invalid_input", `Chat ${id} exists already; open it with another id`);
76
66
  },
77
67
  async attach(id, draftId) {
78
- await (await db()).query(`UPDATE ${CHATS} SET draft_id = $2 WHERE id = $1`, [id, draftId]);
68
+ await (await db()).update(chats).set({ draftId }).where(eq(chats.id, id));
79
69
  },
80
70
  async upsert(id, messages) {
81
71
  if (!messages.length) return;
82
72
  if (messages.some((m) => !m.id)) throw new SanomaError("invalid_input", "A chat message needs an id");
83
73
  // A new message's place is after the chat's last, in the order given.
74
+ const last = sql`(SELECT coalesce(max(${chatMessages.seq}), 0) FROM ${chatMessages} WHERE ${eq(chatMessages.chatId, id)})`;
84
75
  await (
85
76
  await db()
86
- ).query(
87
- `INSERT INTO ${MESSAGES} (chat_id, id, seq, message) ` +
88
- `SELECT $1, m.value->>'id', last.seq + m.n, m.value ` +
89
- `FROM jsonb_array_elements($2::jsonb) WITH ORDINALITY AS m (value, n), ` +
90
- `(SELECT coalesce(max(seq), 0) AS seq FROM ${MESSAGES} WHERE chat_id = $1) AS last ` +
91
- "ON CONFLICT (chat_id, id) DO UPDATE SET message = EXCLUDED.message",
92
- [id, JSON.stringify(messages)],
93
- );
77
+ )
78
+ .insert(chatMessages)
79
+ .values(
80
+ messages.map((message, n) => ({ chatId: id, id: message.id, seq: sql<number>`${last} + ${n + 1}`, message })),
81
+ )
82
+ .onConflictDoUpdate({
83
+ target: [chatMessages.chatId, chatMessages.id],
84
+ set: { message: sql`excluded.message` },
85
+ });
94
86
  },
95
87
  async load(id) {
96
- const { rows } = await (
88
+ const rows = await (
97
89
  await db()
98
- ).query<{ message: UIMessage }>(`SELECT message FROM ${MESSAGES} WHERE chat_id = $1 ORDER BY seq, id`, [id]);
90
+ )
91
+ .select({ message: chatMessages.message })
92
+ .from(chatMessages)
93
+ .where(eq(chatMessages.chatId, id))
94
+ .orderBy(asc(chatMessages.seq), asc(chatMessages.id));
99
95
  return rows.map((row) => row.message);
100
96
  },
101
97
  async get(id) {
102
- const { rows } = await (await db()).query<ChatRow>(`SELECT ${COLUMNS} FROM ${CHATS} WHERE id = $1`, [id]);
103
- return rows[0] && chatOf(rows[0]);
98
+ const [row] = await (await db()).select().from(chats).where(eq(chats.id, id));
99
+ return row && chatOf(row);
104
100
  },
105
101
  async list(person) {
106
- const { rows } = await (
102
+ const rows = await (
107
103
  await db()
108
- ).query<ChatRow>(`SELECT ${COLUMNS} FROM ${CHATS} WHERE person = $1 ORDER BY created_at DESC, id LIMIT 50`, [
109
- person,
110
- ]);
104
+ )
105
+ .select()
106
+ .from(chats)
107
+ .where(eq(chats.person, person))
108
+ .orderBy(desc(chats.createdAt), asc(chats.id))
109
+ .limit(50);
111
110
  return rows.map(chatOf);
112
111
  },
113
112
  };
package/src/client.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Pool } from "pg";
1
2
  import {
2
3
  connectionDriver,
3
4
  connectionTargetSchema,
@@ -13,7 +14,8 @@ import { encodeCredentials, decodeCredentials, verifyAuthentication, Authenticat
13
14
  import { createHash, randomUUID } from "node:crypto";
14
15
  import { isDeepStrictEqual } from "node:util";
15
16
  import { type DBOSClient, Error as DBOSErrors, type WorkflowStatusString } from "@dbos-inc/dbos-sdk";
16
- import type { Pool } from "pg";
17
+ import { is } from "drizzle-orm";
18
+ import { TransactionRollbackError } from "drizzle-orm/errors";
17
19
  import { z } from "zod";
18
20
  import {
19
21
  APPROVALS_EVENT,
@@ -30,7 +32,7 @@ import { type Tokens, tokenStore } from "./tokens.ts";
30
32
  import { collectionsDriver } from "./collection.ts";
31
33
  import { type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
32
34
  import { configuredSecrets } from "./credentials.ts";
33
- import { dbosClient, lazyPool, type LazyPool } from "./db.ts";
35
+ import { dbosClient, lazyDb, type LazyDb, spendInviteRequest } from "./db.ts";
34
36
  import { type CredentialStatus, credentialsOf } from "./env.ts";
35
37
  import { type ApprovalState, Principal, type WorkflowDefinition } from "./define.ts";
36
38
  import { APPLY_WORKFLOW, type ApplyInput, type ApplyReport } from "./apply.ts";
@@ -263,6 +265,12 @@ export interface ClientOptions {
263
265
  * `origin`) and fetches a draft's new commits into. Without one, `remoteEvent` does nothing.
264
266
  */
265
267
  source?: Source;
268
+ /**
269
+ * The version each run it starts is queued for, so only a worker of that code runs it: the app
270
+ * bundle's (its snapshot's), which is live beside a worker of another version while a deploy
271
+ * rolls. Without it a run is queued with none, which DBOS gives the app's latest version.
272
+ */
273
+ appVersion?: string;
266
274
  }
267
275
 
268
276
  /** Talks to the runtime from another process (the app, a script), through the shared Postgres. */
@@ -273,7 +281,7 @@ export class SanomaClient {
273
281
  /** The config's own ledger store, or a Postgres one made for this client, which `close` then closes. */
274
282
  private readonly audit: OwnedLedger;
275
283
  /** On the config's database, for the runtime's tables (the credentials, the chats): opened on first use. */
276
- private readonly pool: LazyPool;
284
+ private readonly database: LazyDb;
277
285
  /** What a direct call's driver reads secrets from, as a worker's live driver does. */
278
286
  private readonly storedSecrets: SecretAccess;
279
287
  /** The operator agent's chats: their rows and their transcripts, in the config's database. */
@@ -293,10 +301,10 @@ export class SanomaClient {
293
301
  this.drivers = drivers;
294
302
  this.options = options;
295
303
  this.audit = ownedLedger(config);
296
- this.pool = lazyPool(config.databaseUrl, "client");
297
- this.storedSecrets = configuredSecrets(config.secrets, () => this.db());
298
- this.chats = chatStore(() => this.db());
299
- this.tokens = tokenStore(() => this.db());
304
+ this.database = lazyDb(config.databaseUrl, "client");
305
+ this.storedSecrets = configuredSecrets(config.secrets, () => this.database.db());
306
+ this.chats = chatStore(() => this.database.db());
307
+ this.tokens = tokenStore(() => this.database.db());
300
308
  }
301
309
 
302
310
  /**
@@ -321,7 +329,8 @@ export class SanomaClient {
321
329
  * Checks the input against the workflow's schema, then queues a run for the worker. The run
322
330
  * gets the input as sent, not as the schema parsed it: the worker parses it once, so a schema
323
331
  * with a `.transform` or a default sees the caller's value, and `run.started` records it.
324
- * `workflow` is a definition, or the name of one in the config's `workflows`.
332
+ * `workflow` is a definition, or the name of one in the config's `workflows`. The run is queued
333
+ * for the client's `appVersion` when it has one.
325
334
  */
326
335
  async start<S extends z.ZodType>(
327
336
  workflow: WorkflowDefinition<any, S> | string,
@@ -363,11 +372,11 @@ export class SanomaClient {
363
372
  const args: RunArgs = { input, startedBy, ...(sandbox === undefined ? {} : { sandbox }), ...(hold && { hold }) };
364
373
  // Kept on the run's status row too, so a listing tells sandbox runs apart without its input,
365
374
  // and says whom an agent's run is for (DBOS keeps the principal's id and groups itself); a
366
- // change run's code, so a worker on another version with the same takes it over.
375
+ // built-in run's code, so a worker on another version with the same takes it over.
367
376
  const attributes = {
368
377
  sandbox,
369
378
  onBehalfOf: startedBy.onBehalfOf,
370
- changeCode: workflow.name === CHANGE_WORKFLOW ? this.config.changeCode : undefined,
379
+ builtInCode: this.config.builtInCodes[workflow.name],
371
380
  };
372
381
  this.options.onWrite?.();
373
382
  // An id DBOS has already returns that run, unless it is another workflow's, which DBOS refuses.
@@ -378,6 +387,7 @@ export class SanomaClient {
378
387
  workflowName: workflow.name,
379
388
  workflowID: runId,
380
389
  applicationName: this.config.appName,
390
+ ...(this.options.appVersion !== undefined && { appVersion: this.options.appVersion }),
381
391
  authenticatedUser: startedBy.id,
382
392
  authenticatedRoles: startedBy.groups ?? [],
383
393
  ...(Object.values(attributes).some((v) => v !== undefined) && { attributes }),
@@ -525,64 +535,63 @@ export class SanomaClient {
525
535
  const { dataStore, collections, ledger } = this.config;
526
536
  const connectionMutation = op.id.startsWith("sanoma.connection.");
527
537
  const transactionStore = connectionMutation
528
- ? this.config.connectionPool
538
+ ? this.config.connectionStore
529
539
  : op.vendor === DATA_VENDOR
530
540
  ? dataStore
531
541
  : undefined;
532
- const conn = !ledger && transactionStore ? await (await transactionStore.db()).connect() : undefined;
533
- let broken: Error | undefined;
534
- try {
535
- await conn?.query("BEGIN");
536
- // The metadata or collection driver shares the ledger transaction; neither has an env guard.
537
- const fn = conn
538
- ? (connectionMutation
539
- ? connectionDriver(this.config.connections.onDatabase(conn))
540
- : collectionsDriver([collections.get(op.resource)!], { db: async () => conn })
541
- ).ops[`${op.resource}.${op.name}`]!
542
- : call.fn;
543
- const at = Date.now();
544
- const record = (outcome: { output: unknown } | { error: ErrorInfo }): LedgerRecord => ({
545
- v: 1,
546
- app: this.config.appName,
547
- id: `${runId}:op.called:0`,
548
- runId,
549
- seq: 0,
550
- at,
551
- actor,
552
- workflow: CALL_WORKFLOW,
553
- type: "op.called",
554
- op: op.id,
555
- effect: op.effect,
556
- input: logged,
557
- decision,
558
- ...(context.connection && { connection: context.connection }),
559
- ...outcome,
560
- durationMs: Date.now() - at,
561
- });
562
- let output: unknown;
542
+ const at = Date.now();
543
+ const record = (outcome: { output: unknown } | { error: ErrorInfo }): LedgerRecord => ({
544
+ v: 1,
545
+ app: this.config.appName,
546
+ id: `${runId}:op.called:0`,
547
+ runId,
548
+ seq: 0,
549
+ at,
550
+ actor,
551
+ workflow: CALL_WORKFLOW,
552
+ type: "op.called",
553
+ op: op.id,
554
+ effect: op.effect,
555
+ input: logged,
556
+ decision,
557
+ ...(context.connection && { connection: context.connection }),
558
+ ...outcome,
559
+ durationMs: Date.now() - at,
560
+ });
561
+ /** Runs the call through `fn`; a failure is recorded on the store's own connection, outside any transaction. */
562
+ const output = async (fn: typeof call.fn): Promise<unknown> => {
563
563
  try {
564
- output = await outputOf(op, () => fn(parsed, context));
564
+ return await outputOf(op, () => fn(parsed, context));
565
565
  } catch (err) {
566
- // Recorded on the store's own connection; the transaction is rolled back below.
567
566
  await this.audit.store.append(record({ error: errorInfo(err) }));
568
567
  throw err;
569
568
  }
570
- const done = record({ output: recorded(op, output) });
571
- if (!conn) {
572
- await this.audit.store.append(done);
573
- return output;
574
- }
575
- if (await insertRecord(conn, done)) {
576
- await conn.query("COMMIT");
577
- return output;
578
- }
579
- await conn.query("ROLLBACK");
580
- return (await this.recordedCall(runId, op.id, logged, context.connection))!.output;
569
+ };
570
+ if (ledger || !transactionStore) {
571
+ const result = await output(call.fn);
572
+ await this.audit.store.append(record({ output: recorded(op, result) }));
573
+ return result;
574
+ }
575
+ // The write and its record in one transaction: the metadata or collection driver runs on the
576
+ // transaction's connection (neither has an env guard), and a record another write of the id
577
+ // was first with rolls the write back, for the recorded output to stand.
578
+ const store = await transactionStore.db();
579
+ try {
580
+ return await store.transaction(async (tx) => {
581
+ const fn = (
582
+ connectionMutation
583
+ ? connectionDriver(this.config.connections.onDatabase(tx))
584
+ : collectionsDriver([collections.get(op.resource)!], { db: async () => tx })
585
+ ).ops[`${op.resource}.${op.name}`]!;
586
+ const result = await output(fn);
587
+ if (await insertRecord(tx, record({ output: recorded(op, result) }))) return result;
588
+ tx.rollback();
589
+ });
581
590
  } catch (err) {
582
- await conn?.query("ROLLBACK").catch((e: Error) => (broken = e));
583
- throw err;
584
- } finally {
585
- conn?.release(broken);
591
+ if (!is(err, TransactionRollbackError)) throw err;
592
+ const first = await this.recordedCall(runId, op.id, logged, context.connection);
593
+ if (!first) throw err;
594
+ return first.output;
586
595
  }
587
596
  }
588
597
 
@@ -675,6 +684,14 @@ export class SanomaClient {
675
684
  );
676
685
  }
677
686
 
687
+ /**
688
+ * Spends a sealed invite request's `nonce` (`openInvite`), good until `expiresAt`: true the first
689
+ * time, false when it was spent already, in this process or another.
690
+ */
691
+ async spendInviteRequest(nonce: string, expiresAt: Date): Promise<boolean> {
692
+ return spendInviteRequest(await this.database.db(), nonce, expiresAt);
693
+ }
694
+
678
695
  /** When each of `people` last signed in to the staff login, and how, by id (`recordSignIn`); one who never did is left out. */
679
696
  lastSignIns(people: readonly string[]): Promise<Record<string, { at: number; method: SignInMethod }>> {
680
697
  return lastSignIns(this.audit.store, people);
@@ -1479,10 +1496,10 @@ export class SanomaClient {
1479
1496
  this.config.connections.close(),
1480
1497
  this.config.identityProvisioning.close(),
1481
1498
  this.dbos.destroy(),
1482
- this.pool.close(),
1499
+ this.database.close(),
1483
1500
  this.audit.close(),
1484
1501
  this.config.dataStore?.close(),
1485
- this.config.connectionPool.close(),
1502
+ this.config.connectionStore.close(),
1486
1503
  ]);
1487
1504
  }
1488
1505
 
@@ -1506,9 +1523,9 @@ export class SanomaClient {
1506
1523
  });
1507
1524
  }
1508
1525
 
1509
- /** The pool the runtime's tables are read and written with, on the database once it is prepared. */
1510
- private db(): Promise<Pool> {
1511
- return this.pool.db();
1526
+ /** The pool the client reads and writes with, once the database is prepared: what a login runs on (`LoginContext.database`). */
1527
+ pool(): Promise<Pool> {
1528
+ return this.database.pool();
1512
1529
  }
1513
1530
 
1514
1531
  /**