@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
package/src/connection.ts CHANGED
@@ -16,7 +16,9 @@ import { type Connector, setupOf, VENDOR, defineConnector, defineDriver, type Ca
16
16
  import { isSecret, type Secret } from "./secret.ts";
17
17
  import { SanomaError } from "./errors.ts";
18
18
  import { SecretStoreError, type SecretAccess, type SecretLocation } from "./secret-store.ts";
19
+ import { and, eq, getTableColumns, isNull, or, sql } from "drizzle-orm";
19
20
  import type { Db } from "./db.ts";
21
+ import { connectionHealth, connections, connectionSetups } from "./db/company.ts";
20
22
 
21
23
  /** Serializable account reference; vendor literal prevents cross-connector selection in typed calls. */
22
24
  export interface ConnectionRef<V extends string = string> {
@@ -255,25 +257,10 @@ export function connectionCatalog(
255
257
  return { definitions: defined, rules: dynamic, methods };
256
258
  }
257
259
 
258
- type ConnectionRow = {
259
- id: string;
260
- vendor: string;
261
- method: string;
262
- owner: string | null;
263
- rule: string | null;
264
- account: string;
265
- scopes: string[];
266
- revision: string;
267
- definition: string;
268
- credential: string | null;
269
- location?: SecretLocation | null;
270
- status: "connected" | "disconnected" | "pending" | "uncertain";
271
- updated_by?: string;
272
- updated_at?: Date;
273
- health?: ConnectionHealth["state"];
274
- checked_at?: Date;
275
- externally_renewed?: boolean;
276
- };
260
+ /** What a connection's row says of it, as `db/company.ts` declares it. */
261
+ export type ConnectionStatus = "connected" | "disconnected" | "pending" | "uncertain";
262
+ /** A connection's metadata, what `save` writes: its row less what the save sets. */
263
+ type ConnectionRow = Omit<typeof connections.$inferSelect, "location" | "updatedBy" | "updatedAt">;
277
264
  const snapshot = (r: ConnectionRow): ConnectionSnapshot => ({
278
265
  id: r.id,
279
266
  vendor: r.vendor,
@@ -312,14 +299,21 @@ export class ConnectionRegistry {
312
299
 
313
300
  /** Safe card data only. Listing does not read credential values or contact a vendor. */
314
301
  async cards(actor: string): Promise<ConnectionCard[]> {
315
- const rows = (
316
- await (
317
- await this.db()
318
- ).query<ConnectionRow>(
319
- "SELECT c.*, h.health, h.checked_at, h.externally_renewed FROM sanoma_connections c LEFT JOIN sanoma_connection_health h ON c.id=h.connection_id AND c.revision=h.revision WHERE c.owner IS NULL OR c.owner=$1",
320
- [actor],
302
+ const rows = await (
303
+ await this.db()
304
+ )
305
+ .select({
306
+ ...getTableColumns(connections),
307
+ health: connectionHealth.health,
308
+ checkedAt: connectionHealth.checkedAt,
309
+ externallyRenewed: connectionHealth.externallyRenewed,
310
+ })
311
+ .from(connections)
312
+ .leftJoin(
313
+ connectionHealth,
314
+ and(eq(connections.id, connectionHealth.connectionId), eq(connections.revision, connectionHealth.revision)),
321
315
  )
322
- ).rows;
316
+ .where(or(isNull(connections.owner), eq(connections.owner, actor)));
323
317
  const found = new Map(rows.map((r) => [r.id, r]));
324
318
  const ids = new Set([...this.catalog.definitions.keys(), ...rows.filter((r) => r.rule).map((r) => r.id)]);
325
319
  const cards: ConnectionCard[] = [];
@@ -352,14 +346,14 @@ export class ConnectionRegistry {
352
346
  : row.status,
353
347
  ...(credential && this.secrets && { location: this.secrets.location(credential) }),
354
348
  ...(row?.location && { boundLocation: row.location }),
355
- ...(row?.updated_by && { updatedBy: row.updated_by }),
356
- ...(row?.updated_at && { updatedAt: row.updated_at.toISOString() }),
349
+ ...(row?.updatedBy && { updatedBy: row.updatedBy }),
350
+ ...(row?.updatedAt && { updatedAt: row.updatedAt.toISOString() }),
357
351
  ...(row?.health &&
358
- row.checked_at && {
352
+ row.checkedAt && {
359
353
  health: {
360
354
  state: row.health,
361
- checkedAt: row.checked_at.toISOString(),
362
- externallyRenewed: row.externally_renewed ?? false,
355
+ checkedAt: row.checkedAt.toISOString(),
356
+ externallyRenewed: row.externallyRenewed ?? false,
363
357
  },
364
358
  }),
365
359
  });
@@ -371,7 +365,7 @@ export class ConnectionRegistry {
371
365
  * stable across preview, approval and retry; account allocation is scoped to its principal. */
372
366
  async setup(target: ConnectionTarget, actor: Principal, requestId: string): Promise<ConnectionSetup> {
373
367
  let declaration: ConnectionDefinition | ConnectionRule | undefined;
374
- let row: ConnectionRow | undefined;
368
+ let row: typeof connections.$inferSelect | undefined;
375
369
  let id: string;
376
370
  let credential: string | undefined;
377
371
  if ("rule" in target) {
@@ -453,30 +447,39 @@ export class ConnectionRegistry {
453
447
  async uncertain(ref: ConnectionRef, revision: string, actor: string): Promise<void> {
454
448
  await (
455
449
  await this.db()
456
- ).query(
457
- "UPDATE sanoma_connections SET status='uncertain',updated_by=$3,updated_at=now() WHERE id=$1 AND revision=$2 AND status='pending'",
458
- [ref.$connection, revision, actor],
459
- );
450
+ )
451
+ .update(connections)
452
+ .set({ status: "uncertain", updatedBy: actor })
453
+ .where(
454
+ and(eq(connections.id, ref.$connection), eq(connections.revision, revision), eq(connections.status, "pending")),
455
+ );
460
456
  }
461
457
 
462
458
  async storedVersion(id: string, revision: string): Promise<string | undefined> {
463
- return (
464
- await (
465
- await this.db()
466
- ).query<{ credential_version: string }>(
467
- "SELECT credential_version FROM sanoma_connection_setups WHERE connection_id=$1 AND revision=$2",
468
- [id, revision],
469
- )
470
- ).rows[0]?.credential_version;
459
+ const [row] = await (
460
+ await this.db()
461
+ )
462
+ .select({ credentialVersion: connectionSetups.credentialVersion })
463
+ .from(connectionSetups)
464
+ .where(and(eq(connectionSetups.connectionId, id), eq(connectionSetups.revision, revision)));
465
+ return row?.credentialVersion;
471
466
  }
472
467
 
473
468
  async recordStoredVersion(id: string, revision: string, version: string): Promise<void> {
474
- await (
475
- await this.db()
476
- ).query(
477
- "INSERT INTO sanoma_connection_setups (connection_id,revision,credential_version) SELECT id,revision,$3 FROM sanoma_connections WHERE id=$1 AND revision=$2 AND status='pending' ON CONFLICT DO NOTHING",
478
- [id, revision, version],
479
- );
469
+ const db = await this.db();
470
+ await db
471
+ .insert(connectionSetups)
472
+ .select(
473
+ db
474
+ .select({
475
+ connectionId: connections.id,
476
+ revision: connections.revision,
477
+ credentialVersion: sql<string>`${version}`.as("credential_version"),
478
+ })
479
+ .from(connections)
480
+ .where(and(eq(connections.id, id), eq(connections.revision, revision), eq(connections.status, "pending"))),
481
+ )
482
+ .onConflictDoNothing();
480
483
  if ((await this.storedVersion(id, revision)) !== version) throw new SecretStoreError("conflict");
481
484
  }
482
485
 
@@ -487,28 +490,45 @@ export class ConnectionRegistry {
487
490
  version?: string,
488
491
  external = false,
489
492
  ): Promise<ConnectionHealth> {
490
- const previous = (
491
- await (
492
- await this.db()
493
- ).query<{ credential_version: string | null }>(
494
- "SELECT credential_version FROM sanoma_connection_health WHERE connection_id=$1 AND revision=$2",
495
- [id, revision],
496
- )
497
- ).rows[0];
493
+ const db = await this.db();
494
+ const [previous] = await db
495
+ .select({ credentialVersion: connectionHealth.credentialVersion })
496
+ .from(connectionHealth)
497
+ .where(and(eq(connectionHealth.connectionId, id), eq(connectionHealth.revision, revision)));
498
498
  const renewed =
499
499
  external &&
500
- previous?.credential_version != null &&
500
+ previous?.credentialVersion != null &&
501
501
  version !== undefined &&
502
- previous.credential_version !== version;
503
- const result = await (
504
- await this.db()
505
- ).query<{ checked_at: Date }>(
506
- "INSERT INTO sanoma_connection_health (connection_id,revision,health,credential_version,externally_renewed) SELECT id,revision,$3,$4,$5 FROM sanoma_connections WHERE id=$1 AND revision=$2 ON CONFLICT(connection_id) DO UPDATE SET revision=excluded.revision,health=excluded.health,credential_version=excluded.credential_version,externally_renewed=excluded.externally_renewed,checked_at=now() RETURNING checked_at",
507
- [id, revision, state, version ?? previous?.credential_version ?? null, renewed],
508
- );
509
- const checked = result.rows[0];
502
+ previous.credentialVersion !== version;
503
+ const credentialVersion = version ?? previous?.credentialVersion ?? null;
504
+ const [checked] = await db
505
+ .insert(connectionHealth)
506
+ .select(
507
+ db
508
+ .select({
509
+ connectionId: connections.id,
510
+ revision: connections.revision,
511
+ health: sql<string>`${state}`.as("health"),
512
+ credentialVersion: sql<string | null>`${credentialVersion}`.as("credential_version"),
513
+ externallyRenewed: sql<boolean>`${renewed}`.as("externally_renewed"),
514
+ checkedAt: sql<Date>`now()`.as("checked_at"),
515
+ })
516
+ .from(connections)
517
+ .where(and(eq(connections.id, id), eq(connections.revision, revision))),
518
+ )
519
+ .onConflictDoUpdate({
520
+ target: connectionHealth.connectionId,
521
+ set: {
522
+ revision,
523
+ health: state,
524
+ credentialVersion,
525
+ externallyRenewed: renewed,
526
+ checkedAt: sql`now()`,
527
+ },
528
+ })
529
+ .returning({ checkedAt: connectionHealth.checkedAt });
510
530
  if (!checked) refuse("The connection changed during the test; reload before testing it again");
511
- return { state, checkedAt: checked.checked_at.toISOString(), externallyRenewed: renewed };
531
+ return { state, checkedAt: checked.checkedAt.toISOString(), externallyRenewed: renewed };
512
532
  }
513
533
 
514
534
  async test(ref: ConnectionRef, actor: Principal): Promise<ConnectionHealth> {
@@ -578,13 +598,14 @@ export class ConnectionRegistry {
578
598
  if (JSON.stringify(identity.scopes) !== JSON.stringify([...new Set(row.scopes)].toSorted()))
579
599
  throw new AuthenticationError("scope_changed");
580
600
  const next = randomUUID();
581
- const result = await (
601
+ const [activated] = await (
582
602
  await this.db()
583
- ).query(
584
- "UPDATE sanoma_connections SET status='connected',revision=$3,updated_by=$4,updated_at=now() WHERE id=$1 AND revision=$2 RETURNING id",
585
- [row.id, revision, next, actor.id],
586
- );
587
- if (!result.rowCount) refuse("The connection changed; review it again before activating");
603
+ )
604
+ .update(connections)
605
+ .set({ status: "connected", revision: next, updatedBy: actor.id })
606
+ .where(and(eq(connections.id, row.id), eq(connections.revision, revision)))
607
+ .returning({ id: connections.id });
608
+ if (!activated) refuse("The connection changed; review it again before activating");
588
609
  await this.health(row.id, next, "connected", bundle?.version);
589
610
  return snapshot({ ...row, revision: next, status: "connected" });
590
611
  }
@@ -617,8 +638,9 @@ export class ConnectionRegistry {
617
638
  checkAuthenticationStore(method, caps, location.management);
618
639
  }
619
640
 
620
- private async row(id: string): Promise<ConnectionRow | undefined> {
621
- return (await (await this.db()).query<ConnectionRow>("SELECT * FROM sanoma_connections WHERE id=$1", [id])).rows[0];
641
+ private async row(id: string) {
642
+ const [row] = await (await this.db()).select().from(connections).where(eq(connections.id, id));
643
+ return row;
622
644
  }
623
645
 
624
646
  async select(vendor: string, ref: ConnectionRef | undefined, actor: string): Promise<ConnectionSnapshot | undefined> {
@@ -772,38 +794,46 @@ export class ConnectionRegistry {
772
794
  if (row.rule && row.owner !== actor)
773
795
  throw new SanomaError("policy_denied", "The connection belongs to another principal");
774
796
  const revision = randomUUID();
775
- const changed = await (
797
+ const [changed] = await (
776
798
  await this.db()
777
- ).query(
778
- "UPDATE sanoma_connections SET status='disconnected',revision=$2,updated_by=$3,updated_at=now() WHERE id=$1 AND revision=$4 RETURNING id",
779
- [row.id, revision, actor, expectedRevision],
780
- );
781
- if (!changed.rowCount) refuse("The connection changed; reload before disconnecting it");
799
+ )
800
+ .update(connections)
801
+ .set({ status: "disconnected", revision, updatedBy: actor })
802
+ .where(and(eq(connections.id, row.id), eq(connections.revision, expectedRevision)))
803
+ .returning({ id: connections.id });
804
+ if (!changed) refuse("The connection changed; reload before disconnecting it");
782
805
  return { id: row.id, revision, status: "disconnected" };
783
806
  }
784
807
 
785
808
  private async save(row: ConnectionRow, expected: string | null, actor: string): Promise<void> {
786
- const values = [
787
- row.id,
788
- row.vendor,
789
- row.method,
790
- row.owner,
791
- row.rule,
792
- row.account,
793
- JSON.stringify(row.scopes),
794
- row.revision,
795
- row.definition,
796
- row.credential,
797
- actor,
798
- row.status,
799
- row.credential && this.secrets ? JSON.stringify(this.secrets.location(row.credential)) : null,
800
- ];
801
- const query =
809
+ const db = await this.db();
810
+ const values = {
811
+ vendor: row.vendor,
812
+ method: row.method,
813
+ owner: row.owner,
814
+ rule: row.rule,
815
+ account: row.account,
816
+ scopes: row.scopes,
817
+ revision: row.revision,
818
+ definition: row.definition,
819
+ credential: row.credential,
820
+ updatedBy: actor,
821
+ status: row.status,
822
+ location: row.credential && this.secrets ? this.secrets.location(row.credential) : null,
823
+ };
824
+ const [saved] =
802
825
  expected === null
803
- ? "INSERT INTO sanoma_connections (id,vendor,method,owner,rule,account,scopes,revision,definition,credential,updated_by,status,location) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT(id) DO NOTHING RETURNING id"
804
- : "UPDATE sanoma_connections SET vendor=$2,method=$3,owner=$4,rule=$5,account=$6,scopes=$7,revision=$8,definition=$9,credential=$10,updated_by=$11,updated_at=now(),status=$12,location=$13 WHERE id=$1 AND revision=$14 RETURNING id";
805
- const result = await (await this.db()).query(query, [...values, ...(expected === null ? [] : [expected])]);
806
- if (!result.rowCount) refuse("The connection changed; reload before binding it");
826
+ ? await db
827
+ .insert(connections)
828
+ .values({ id: row.id, ...values })
829
+ .onConflictDoNothing()
830
+ .returning({ id: connections.id })
831
+ : await db
832
+ .update(connections)
833
+ .set(values)
834
+ .where(and(eq(connections.id, row.id), eq(connections.revision, expected)))
835
+ .returning({ id: connections.id });
836
+ if (!saved) refuse("The connection changed; reload before binding it");
807
837
  }
808
838
  }
809
839
 
@@ -1,5 +1,7 @@
1
1
  import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:crypto";
2
- import { CREDENTIALS_TABLE as TABLE, type Db } from "./db.ts";
2
+ import { and, eq } from "drizzle-orm";
3
+ import type { Db } from "./db.ts";
4
+ import { credentials } from "./db/company.ts";
3
5
  import {
4
6
  SecretAccess,
5
7
  SecretStoreError,
@@ -40,13 +42,21 @@ export function decrypt(identity: string, stored: string): string {
40
42
  }
41
43
  }
42
44
 
43
- type Row = { value: string; version: string; set_by: string; set_at: Date };
45
+ const returned = {
46
+ value: credentials.value,
47
+ version: credentials.version,
48
+ setBy: credentials.setBy,
49
+ setAt: credentials.setAt,
50
+ };
51
+ type Row = Pick<typeof credentials.$inferSelect, keyof typeof returned>;
52
+ const at = ({ namespace, path }: SecretAddress) =>
53
+ and(eq(credentials.namespace, namespace), eq(credentials.name, path));
44
54
  const identity = ({ namespace, path }: SecretAddress) => JSON.stringify([namespace, path]);
45
55
  const decoded = (address: SecretAddress, row: Row): SecretValue => ({
46
56
  value: decrypt(identity(address), row.value),
47
57
  version: row.version,
48
- updatedBy: row.set_by,
49
- updatedAt: row.set_at.toISOString(),
58
+ updatedBy: row.setBy,
59
+ updatedAt: row.setAt.toISOString(),
50
60
  });
51
61
 
52
62
  /** One company's database connection supplies authority; namespace separates deployments within it. */
@@ -63,46 +73,40 @@ export function postgresSecretStore(db: () => Promise<Db>): SecretStore {
63
73
  async validate(namespace) {
64
74
  if (!namespace) throw new SecretStoreError("invalid_configuration");
65
75
  deploymentKey(SECRETS_KEY);
66
- await (await db()).query(`SELECT namespace FROM ${TABLE} LIMIT 0`);
76
+ await (await db()).select({ namespace: credentials.namespace }).from(credentials).limit(0);
67
77
  },
68
78
  async read(address) {
69
- const { rows } = await (
70
- await db()
71
- ).query<Row>(`SELECT value, version, set_by, set_at FROM ${TABLE} WHERE namespace = $1 AND name = $2`, [
72
- address.namespace,
73
- address.path,
74
- ]);
75
- return rows[0] && decoded(address, rows[0]);
79
+ const [row] = await (await db()).select(returned).from(credentials).where(at(address));
80
+ return row && decoded(address, row);
76
81
  },
77
82
  async write(address, value, { expectedVersion, by }) {
78
83
  if (Buffer.byteLength(value) > 65_536) throw new SecretStoreError("too_large");
79
84
  const version = randomUUID();
80
85
  const sealed = encrypt(identity(address), value);
81
- const query =
86
+ const database = await db();
87
+ const [row] =
82
88
  expectedVersion === null
83
- ? `INSERT INTO ${TABLE} (namespace,name,value,version,set_by) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (namespace,name) DO NOTHING RETURNING value,version,set_by,set_at`
84
- : `UPDATE ${TABLE} SET value=$3,version=$4,set_by=$5,set_at=now() WHERE namespace=$1 AND name=$2 AND version=$6 RETURNING value,version,set_by,set_at`;
85
- const params = [
86
- address.namespace,
87
- address.path,
88
- sealed,
89
- version,
90
- by,
91
- ...(expectedVersion === null ? [] : [expectedVersion]),
92
- ];
93
- const { rows } = await (await db()).query<Row>(query, params);
94
- if (!rows[0]) throw new SecretStoreError("conflict");
95
- return decoded(address, rows[0]);
89
+ ? await database
90
+ .insert(credentials)
91
+ .values({ namespace: address.namespace, name: address.path, value: sealed, version, setBy: by })
92
+ .onConflictDoNothing()
93
+ .returning(returned)
94
+ : await database
95
+ .update(credentials)
96
+ .set({ value: sealed, version, setBy: by })
97
+ .where(and(at(address), eq(credentials.version, expectedVersion)))
98
+ .returning(returned);
99
+ if (!row) throw new SecretStoreError("conflict");
100
+ return decoded(address, row);
96
101
  },
97
102
  async delete(address, { expectedVersion }) {
98
- const { rowCount } = await (
103
+ const deleted = await (
99
104
  await db()
100
- ).query(`DELETE FROM ${TABLE} WHERE namespace=$1 AND name=$2 AND version=$3`, [
101
- address.namespace,
102
- address.path,
103
- expectedVersion,
104
- ]);
105
- if (!rowCount) throw new SecretStoreError("conflict");
105
+ )
106
+ .delete(credentials)
107
+ .where(and(at(address), eq(credentials.version, expectedVersion)))
108
+ .returning({ name: credentials.name });
109
+ if (!deleted.length) throw new SecretStoreError("conflict");
106
110
  },
107
111
  };
108
112
  }
@@ -0,0 +1,173 @@
1
+ import type { UIMessage } from "ai";
2
+ import { sql } from "drizzle-orm";
3
+ import {
4
+ bigserial,
5
+ boolean,
6
+ check,
7
+ index,
8
+ integer,
9
+ jsonb,
10
+ pgTable,
11
+ primaryKey,
12
+ text,
13
+ timestamp,
14
+ } from "drizzle-orm/pg-core";
15
+ import type { ConnectionHealth, ConnectionStatus } from "../connection.ts";
16
+ import type { LedgerRecord } from "../ledger.ts";
17
+ import type { SecretLocation } from "../secret-store.ts";
18
+
19
+ // The runtime's tables in a company's database, beside DBOS's: what `prepareDatabase` makes and
20
+ // every store reads. Column names are the property names in snake_case (`casing` in `db.ts` and
21
+ // in `drizzle/company.config.ts`). A change here is a new migration: `pnpm --filter
22
+ // @sanoma-ai/workflows generate` writes it under `drizzle/company/`, and CI refuses a schema
23
+ // whose migration was not generated.
24
+
25
+ const when = () => timestamp({ withTimezone: true });
26
+
27
+ /** The credentials set from the app, each sealed under its namespace and name. */
28
+ export const credentials = pgTable(
29
+ "sanoma_credentials",
30
+ {
31
+ namespace: text().notNull(),
32
+ name: text().notNull(),
33
+ value: text().notNull(),
34
+ version: text().notNull(),
35
+ setBy: text().notNull(),
36
+ setAt: when()
37
+ .notNull()
38
+ .defaultNow()
39
+ .$onUpdate(() => sql`now()`),
40
+ },
41
+ (t) => [primaryKey({ columns: [t.namespace, t.name] })],
42
+ );
43
+
44
+ /**
45
+ * The Postgres ledger's records. `n` is the write order, `id` the record's, which the first write
46
+ * of takes, and `seq` the record's place in its run: a run reads in `seq` order, then write order.
47
+ */
48
+ export const ledger = pgTable(
49
+ "sanoma_ledger",
50
+ {
51
+ n: bigserial({ mode: "number" }).primaryKey(),
52
+ id: text().notNull().unique(),
53
+ runId: text().notNull(),
54
+ seq: integer().notNull(),
55
+ record: jsonb().$type<LedgerRecord>().notNull(),
56
+ },
57
+ (t) => [index("sanoma_ledger_run_seq").on(t.runId, t.seq, t.n)],
58
+ );
59
+
60
+ /** The collections' records (`collection.ts`): one row per collection and key, the record as JSON. */
61
+ export const data = pgTable(
62
+ "sanoma_data",
63
+ {
64
+ collection: text().notNull(),
65
+ key: text().notNull(),
66
+ record: jsonb().$type<Record<string, unknown>>().notNull(),
67
+ updatedAt: when().notNull().defaultNow(),
68
+ updatedBy: text().notNull(),
69
+ },
70
+ (t) => [primaryKey({ columns: [t.collection, t.key] })],
71
+ );
72
+
73
+ /**
74
+ * The instance's side of its wakeup: one row, made with the table (`0001_wakeup_row.sql`). `at`
75
+ * is the deadline last registered, `stopped_at` when the worker last stopped itself (the
76
+ * schedules backfill from it), `last_error` why the last registration failed.
77
+ */
78
+ export const wakeup = pgTable(
79
+ "sanoma_wakeup",
80
+ {
81
+ id: integer().primaryKey(),
82
+ at: when(),
83
+ stoppedAt: when(),
84
+ lastError: text(),
85
+ },
86
+ (t) => [check("sanoma_wakeup_one_row", sql`${t.id} = 1`)],
87
+ );
88
+
89
+ /** The operator agent's chats (`chats.ts`): who each is for, its engine and its draft. */
90
+ export const chats = pgTable(
91
+ "sanoma_chats",
92
+ {
93
+ id: text().primaryKey(),
94
+ person: text().notNull(),
95
+ engine: text().notNull(),
96
+ draftId: text(),
97
+ title: text().notNull(),
98
+ createdAt: when().notNull().defaultNow(),
99
+ },
100
+ (t) => [index("sanoma_chats_person").on(t.person, t.createdAt)],
101
+ );
102
+
103
+ /** Each chat's messages, the AI SDK's UI messages as JSON, in `seq` order. */
104
+ export const chatMessages = pgTable(
105
+ "sanoma_chat_messages",
106
+ {
107
+ chatId: text()
108
+ .notNull()
109
+ .references(() => chats.id, { onDelete: "cascade" }),
110
+ id: text().notNull(),
111
+ seq: integer().notNull(),
112
+ message: jsonb().$type<UIMessage>().notNull(),
113
+ },
114
+ (t) => [primaryKey({ columns: [t.chatId, t.id] })],
115
+ );
116
+
117
+ /** The api tokens (`tokens.ts`): each one's SHA-256, never the token, whom it acts for, its groups, and when it was revoked. */
118
+ export const apiTokens = pgTable("sanoma_api_tokens", {
119
+ id: text().primaryKey(),
120
+ hash: text().notNull().unique(),
121
+ subject: text().notNull(),
122
+ groups: jsonb().$type<string[]>().notNull(),
123
+ createdAt: when().notNull().defaultNow(),
124
+ revokedAt: when(),
125
+ });
126
+
127
+ /** The nonces of sealed invite requests spent (`spendInviteRequest`), each until it expires. */
128
+ export const inviteRequests = pgTable("sanoma_invite_requests", {
129
+ nonce: text().primaryKey(),
130
+ expiresAt: when().notNull(),
131
+ });
132
+
133
+ /** The connected accounts (`connection.ts`): metadata only, never an authentication value. */
134
+ export const connections = pgTable("sanoma_connections", {
135
+ id: text().primaryKey(),
136
+ vendor: text().notNull(),
137
+ method: text().notNull(),
138
+ owner: text(),
139
+ rule: text(),
140
+ account: text().notNull(),
141
+ scopes: jsonb().$type<string[]>().notNull(),
142
+ revision: text().notNull(),
143
+ definition: text().notNull(),
144
+ credential: text(),
145
+ location: jsonb().$type<SecretLocation>(),
146
+ status: text().$type<ConnectionStatus>().notNull().default("connected"),
147
+ updatedBy: text().notNull(),
148
+ updatedAt: when()
149
+ .notNull()
150
+ .defaultNow()
151
+ .$onUpdate(() => sql`now()`),
152
+ });
153
+
154
+ /** Each connection's last health check. */
155
+ export const connectionHealth = pgTable("sanoma_connection_health", {
156
+ connectionId: text().primaryKey(),
157
+ revision: text().notNull(),
158
+ health: text().$type<ConnectionHealth["state"]>().notNull(),
159
+ credentialVersion: text(),
160
+ externallyRenewed: boolean().notNull(),
161
+ checkedAt: when().notNull().defaultNow(),
162
+ });
163
+
164
+ /** The receipts of a connection's setup, one per revision. */
165
+ export const connectionSetups = pgTable(
166
+ "sanoma_connection_setups",
167
+ {
168
+ connectionId: text().notNull(),
169
+ revision: text().notNull(),
170
+ credentialVersion: text().notNull(),
171
+ },
172
+ (t) => [primaryKey({ columns: [t.connectionId, t.revision] })],
173
+ );
package/src/db/dbos.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { pgSchema, text } from "drizzle-orm/pg-core";
2
+
3
+ // What the runtime reads of DBOS's own tables (dbos-sdk 5.2.11), in its schema `dbos`: the
4
+ // columns named here only. DBOS makes and migrates these tables (`DBOS.migrate`); no migration is
5
+ // generated from this model, and the runtime's one write to them is `stampVersion` (`db.ts`).
6
+
7
+ export const dbos = pgSchema("dbos");
8
+
9
+ /** Each run's status row. */
10
+ export const workflowStatus = dbos.table("workflow_status", {
11
+ workflowUuid: text().primaryKey(),
12
+ status: text().notNull(),
13
+ name: text(),
14
+ applicationName: text(),
15
+ applicationVersion: text(),
16
+ queueName: text(),
17
+ });