@alma-harness/postgres 0.1.0 → 0.3.0

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.
package/dist/index.js CHANGED
@@ -1,11 +1,10 @@
1
1
  // src/session-store.ts
2
2
  import { assertWellFormed, scopePath as scopePath2 } from "@alma-harness/core";
3
3
 
4
- // src/scoped.ts
5
- import { scopePath } from "@alma-harness/core";
6
-
7
4
  // src/schema.ts
5
+ import { assertIso8601 } from "@alma-harness/core";
8
6
  var DEFAULT_RLS_ROLE = "alma_app";
7
+ var DEFAULT_RETENTION_ROLE = "alma_retention";
9
8
  var IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
10
9
  function assertRoleIdentifier(role) {
11
10
  if (!IDENTIFIER.test(role)) {
@@ -30,6 +29,23 @@ create policy ${name} on ${table}
30
29
  );
31
30
  `;
32
31
  }
32
+ function retentionPolicySql(table, retentionRole, name) {
33
+ assertRoleIdentifier(table);
34
+ assertRoleIdentifier(retentionRole);
35
+ assertRoleIdentifier(name);
36
+ return `
37
+ drop policy if exists ${name}_read on ${table};
38
+ create policy ${name}_read on ${table}
39
+ for select
40
+ to ${retentionRole}
41
+ using (true);
42
+
43
+ drop policy if exists ${name} on ${table};
44
+ create policy ${name} on ${table}
45
+ for delete
46
+ to ${retentionRole}
47
+ using (true);`;
48
+ }
33
49
  function roleBootstrapSql(role) {
34
50
  assertRoleIdentifier(role);
35
51
  return `
@@ -38,16 +54,26 @@ begin
38
54
  if not exists (select from pg_roles where rolname = '${role}') then
39
55
  begin
40
56
  create role ${role} nologin;
41
- exception when duplicate_object then
57
+ exception when duplicate_object or unique_violation then
42
58
  -- Another instance won the race between the existence check and the
43
59
  -- create (pg_roles is cluster-wide and CREATE ROLE has no IF NOT
44
60
  -- EXISTS); losing it must not roll back the rest of the migration.
61
+ -- Two creates that overlap surface as unique_violation on
62
+ -- pg_authid, not duplicate_object (review of 056\u2013058).
45
63
  null;
46
64
  end;
47
65
  end if;
48
66
  end $$;
49
67
  `;
50
68
  }
69
+ function grantRoleSql(role, to) {
70
+ assertRoleIdentifier(role);
71
+ assertRoleIdentifier(to);
72
+ return `grant ${role} to ${to};`;
73
+ }
74
+ async function grantRole(pool, opts) {
75
+ await pool.query(grantRoleSql(opts.role ?? DEFAULT_RLS_ROLE, opts.to));
76
+ }
51
77
  function sessionStoreMigrationSql(role = DEFAULT_RLS_ROLE) {
52
78
  assertRoleIdentifier(role);
53
79
  return `
@@ -84,6 +110,7 @@ async function migrateSessionStore(pool, opts = {}) {
84
110
  }
85
111
 
86
112
  // src/scoped.ts
113
+ import { scopePath } from "@alma-harness/core";
87
114
  var DEFAULT_STATEMENT_TIMEOUT_MS = 3e4;
88
115
  function resolveRlsRole(opts) {
89
116
  const role = opts.role === void 0 ? DEFAULT_RLS_ROLE : opts.role;
@@ -97,19 +124,15 @@ function resolveStatementTimeout(opts) {
97
124
  }
98
125
  return ms;
99
126
  }
100
- async function inScope(pool, role, scope, fn, statementTimeoutMs = DEFAULT_STATEMENT_TIMEOUT_MS) {
101
- scopePath(scope);
127
+ async function inTransaction(pool, role, fn, statementTimeoutMs) {
102
128
  const client = await pool.connect();
103
129
  let rollbackFailed;
104
130
  try {
105
131
  await client.query("begin");
106
132
  if (role !== null) await client.query(`set local role ${role}`);
107
- await client.query(
108
- `select set_config('alma.org', $1, true),
109
- set_config('alma.uid', $2, true),
110
- set_config('statement_timeout', $3, true)`,
111
- [scope.org, scope.uid, statementTimeoutMs === null ? "0" : String(statementTimeoutMs)]
112
- );
133
+ if (statementTimeoutMs !== void 0) {
134
+ await client.query(`select set_config('statement_timeout', $1, true)`, [statementTimeoutMs === null ? "0" : String(statementTimeoutMs)]);
135
+ }
113
136
  const result = await fn(client);
114
137
  await client.query("commit");
115
138
  return result;
@@ -122,6 +145,43 @@ async function inScope(pool, role, scope, fn, statementTimeoutMs = DEFAULT_STATE
122
145
  client.release(rollbackFailed === void 0 ? void 0 : rollbackFailed);
123
146
  }
124
147
  }
148
+ async function sweepBefore(pool, opts) {
149
+ assertRoleIdentifier(opts.table);
150
+ assertRoleIdentifier(opts.column);
151
+ const batch = opts.batch ?? 5e3;
152
+ if (!Number.isInteger(batch) || batch < 1) throw new Error(`batch must be a positive integer, got ${batch}`);
153
+ let total = 0;
154
+ for (; ; ) {
155
+ const removed = await inTransaction(pool, opts.role, async (client) => {
156
+ const { rowCount } = await client.query(
157
+ `delete from ${opts.table}
158
+ where ctid = any(array(select ctid from ${opts.table} where ${opts.column} < $1::timestamptz order by ${opts.column} limit $2))`,
159
+ [opts.before, batch]
160
+ );
161
+ return rowCount ?? 0;
162
+ });
163
+ total += removed;
164
+ if (removed < batch) return total;
165
+ }
166
+ }
167
+ async function purgeBefore(pool, opts) {
168
+ const role = opts.retentionRole ?? DEFAULT_RETENTION_ROLE;
169
+ assertRoleIdentifier(role);
170
+ assertIso8601(opts.before);
171
+ return sweepBefore(pool, { role, table: opts.table, column: opts.column, before: opts.before, ...opts.batch !== void 0 ? { batch: opts.batch } : {} });
172
+ }
173
+ async function inScope(pool, role, scope, fn, statementTimeoutMs = DEFAULT_STATEMENT_TIMEOUT_MS) {
174
+ scopePath(scope);
175
+ return inTransaction(pool, role, async (client) => {
176
+ await client.query(
177
+ `select set_config('alma.org', $1, true),
178
+ set_config('alma.uid', $2, true),
179
+ set_config('statement_timeout', $3, true)`,
180
+ [scope.org, scope.uid, statementTimeoutMs === null ? "0" : String(statementTimeoutMs)]
181
+ );
182
+ return fn(client);
183
+ });
184
+ }
125
185
 
126
186
  // src/session-store.ts
127
187
  var PostgresSessionStore = class {
@@ -205,7 +265,7 @@ var PostgresSessionStore = class {
205
265
  const removals = [];
206
266
  for (const row of rows) {
207
267
  const survivors = row.msg.blocks.filter(
208
- (b) => b.type !== "tool_call" && b.type !== "tool_result"
268
+ (b) => b.type !== "tool_call" && b.type !== "tool_result" && b.type !== "reasoning"
209
269
  );
210
270
  if (survivors.length === row.msg.blocks.length) continue;
211
271
  blocks += row.msg.blocks.length - survivors.length;
@@ -249,11 +309,7 @@ var PostgresSessionStore = class {
249
309
  return inScope(this.#pool, this.#role, scope, fn, this.#timeoutMs);
250
310
  }
251
311
  };
252
- function instant(at) {
253
- const ms = Date.parse(at);
254
- if (Number.isNaN(ms)) throw new Error(`invalid ISO 8601 timestamp: ${JSON.stringify(at)}`);
255
- return ms;
256
- }
312
+ var instant = (at) => Date.parse(assertIso8601(at));
257
313
 
258
314
  // src/memory-stores.ts
259
315
  import {
@@ -1075,7 +1131,6 @@ var AUDIT_ROUTING_TABLE = "alma_audit_routing";
1075
1131
  var AUDIT_COST_TABLE = "alma_audit_cost";
1076
1132
  var AUDIT_RECALL_TABLE = "alma_audit_recall";
1077
1133
  var AUDIT_CONTEXT_TABLE = "alma_audit_context";
1078
- var DEFAULT_RETENTION_ROLE = "alma_retention";
1079
1134
  var AUDIT_TABLES = [
1080
1135
  AUDIT_ACCESS_TABLE,
1081
1136
  AUDIT_ROUTING_TABLE,
@@ -1109,6 +1164,9 @@ create index if not exists alma_audit_access_turn
1109
1164
  create index if not exists alma_audit_access_recent
1110
1165
  on ${AUDIT_ACCESS_TABLE} (org, uid, at desc);
1111
1166
 
1167
+ create index if not exists alma_audit_access_at
1168
+ on ${AUDIT_ACCESS_TABLE} (at);
1169
+
1112
1170
  create table if not exists ${AUDIT_ROUTING_TABLE} (
1113
1171
  id uuid not null default gen_random_uuid(),
1114
1172
  org text not null,
@@ -1128,6 +1186,9 @@ create table if not exists ${AUDIT_ROUTING_TABLE} (
1128
1186
  create index if not exists alma_audit_routing_turn
1129
1187
  on ${AUDIT_ROUTING_TABLE} (org, uid, turn_id);
1130
1188
 
1189
+ create index if not exists alma_audit_routing_at
1190
+ on ${AUDIT_ROUTING_TABLE} (at);
1191
+
1131
1192
  create table if not exists ${AUDIT_COST_TABLE} (
1132
1193
  id uuid not null default gen_random_uuid(),
1133
1194
  org text not null,
@@ -1155,6 +1216,9 @@ create index if not exists alma_audit_cost_turn
1155
1216
  create index if not exists alma_audit_cost_recent
1156
1217
  on ${AUDIT_COST_TABLE} (org, uid, at desc);
1157
1218
 
1219
+ create index if not exists alma_audit_cost_at
1220
+ on ${AUDIT_COST_TABLE} (at);
1221
+
1158
1222
  create table if not exists ${AUDIT_RECALL_TABLE} (
1159
1223
  id uuid not null default gen_random_uuid(),
1160
1224
  org text not null,
@@ -1177,6 +1241,9 @@ create table if not exists ${AUDIT_RECALL_TABLE} (
1177
1241
  create index if not exists alma_audit_recall_turn
1178
1242
  on ${AUDIT_RECALL_TABLE} (org, uid, turn_id);
1179
1243
 
1244
+ create index if not exists alma_audit_recall_at
1245
+ on ${AUDIT_RECALL_TABLE} (at);
1246
+
1180
1247
  create table if not exists ${AUDIT_CONTEXT_TABLE} (
1181
1248
  id uuid not null default gen_random_uuid(),
1182
1249
  org text not null,
@@ -1206,6 +1273,9 @@ create table if not exists ${AUDIT_CONTEXT_TABLE} (
1206
1273
 
1207
1274
  create index if not exists alma_audit_context_turn
1208
1275
  on ${AUDIT_CONTEXT_TABLE} (org, uid, turn_id);
1276
+
1277
+ create index if not exists alma_audit_context_at
1278
+ on ${AUDIT_CONTEXT_TABLE} (at);
1209
1279
  ${AUDIT_TABLES.map((t) => rlsPolicySql(t)).join("")}
1210
1280
  ${roleBootstrapSql(role)}
1211
1281
  ${roleBootstrapSql(retentionRole)}
@@ -1225,25 +1295,11 @@ grant select, insert
1225
1295
  -- 039 review caught: a retention mechanism that silently retains forever, in
1226
1296
  -- exactly the deployments careful enough not to connect as a superuser.
1227
1297
  --
1228
- -- TWO policies, not one. A FOR DELETE policy alone still deleted nothing,
1229
- -- because a DELETE with a WHERE clause must SCAN the rows to filter them, and
1230
- -- the scope-keyed policy is FOR ALL -- which governs SELECT too. That second
1231
- -- step surfaced only by running the statement; it does not follow from reading
1232
- -- the first fix.
1233
- ${AUDIT_TABLES.map(
1234
- (t) => `
1235
- drop policy if exists alma_audit_retention_read on ${t};
1236
- create policy alma_audit_retention_read on ${t}
1237
- for select
1238
- to ${retentionRole}
1239
- using (true);
1240
-
1241
- drop policy if exists alma_audit_retention on ${t};
1242
- create policy alma_audit_retention on ${t}
1243
- for delete
1244
- to ${retentionRole}
1245
- using (true);`
1246
- ).join("")}
1298
+ -- TWO policies, not one (see retentionPolicySql). A FOR DELETE policy alone
1299
+ -- still deleted nothing, because a DELETE with a WHERE clause must SCAN the
1300
+ -- rows to filter them, and the scope-keyed policy is FOR ALL -- which governs
1301
+ -- SELECT too. That second step surfaced only by running the statement.
1302
+ ${AUDIT_TABLES.map((t) => retentionPolicySql(t, retentionRole, "alma_audit_retention")).join("")}
1247
1303
 
1248
1304
  grant select, delete
1249
1305
  on ${AUDIT_TABLES.join(", ")}
@@ -1254,36 +1310,17 @@ async function migrateAuditLog(pool, opts = {}) {
1254
1310
  await pool.query(auditLogMigrationSql(opts.role, opts.retentionRole));
1255
1311
  }
1256
1312
  async function purgeAuditBefore(pool, windows, opts = {}) {
1257
- const retentionRole = opts.retentionRole ?? DEFAULT_RETENTION_ROLE;
1258
- assertRoleIdentifier(retentionRole);
1259
1313
  for (const table of AUDIT_TABLES) {
1260
1314
  const before = windows[table];
1261
- if (before !== void 0 && Number.isNaN(Date.parse(before))) {
1262
- throw new Error(`invalid ISO 8601 timestamp for ${table}: ${JSON.stringify(before)}`);
1263
- }
1315
+ if (before !== void 0) assertIso8601(before, `timestamp for ${table}`);
1264
1316
  }
1265
1317
  const purged = {};
1266
- const client = await pool.connect();
1267
- try {
1268
- await client.query("begin");
1269
- await client.query(`set local role ${retentionRole}`);
1270
- for (const table of AUDIT_TABLES) {
1271
- const before = windows[table];
1272
- if (before === void 0) continue;
1273
- const { rowCount } = await client.query(
1274
- `delete from ${table} where at < $1::timestamptz`,
1275
- [before]
1276
- );
1277
- purged[table] = rowCount ?? 0;
1278
- }
1279
- await client.query("commit");
1280
- return purged;
1281
- } catch (err) {
1282
- await client.query("rollback").catch(() => void 0);
1283
- throw err;
1284
- } finally {
1285
- client.release();
1318
+ for (const table of AUDIT_TABLES) {
1319
+ const before = windows[table];
1320
+ if (before === void 0) continue;
1321
+ purged[table] = await purgeBefore(pool, { table, column: "at", before, ...opts });
1286
1322
  }
1323
+ return purged;
1287
1324
  }
1288
1325
 
1289
1326
  // src/audit-store.ts
@@ -1439,12 +1476,7 @@ var PostgresAuditLog = class {
1439
1476
  }, this.#timeoutMs);
1440
1477
  }
1441
1478
  };
1442
- function instant2(at) {
1443
- if (Number.isNaN(Date.parse(at))) {
1444
- throw new Error(`invalid ISO 8601 timestamp: ${JSON.stringify(at)}`);
1445
- }
1446
- return at;
1447
- }
1479
+ var instant2 = (at) => assertIso8601(at);
1448
1480
 
1449
1481
  // src/turn-store.ts
1450
1482
  import {
@@ -1455,8 +1487,9 @@ import {
1455
1487
  // src/turn-schema.ts
1456
1488
  var TURN_LEASES_TABLE = "alma_turn_leases";
1457
1489
  var TURN_CLAIMS_TABLE = "alma_turn_claims";
1458
- function turnStoreMigrationSql(role = DEFAULT_RLS_ROLE) {
1490
+ function turnStoreMigrationSql(role = DEFAULT_RLS_ROLE, retentionRole = DEFAULT_RETENTION_ROLE) {
1459
1491
  assertRoleIdentifier(role);
1492
+ assertRoleIdentifier(retentionRole);
1460
1493
  return `
1461
1494
  create table if not exists ${TURN_LEASES_TABLE} (
1462
1495
  org text not null,
@@ -1476,15 +1509,31 @@ create table if not exists ${TURN_CLAIMS_TABLE} (
1476
1509
  created_at timestamptz not null default now(),
1477
1510
  primary key (org, uid, session_id, idempotency_key)
1478
1511
  );
1512
+
1513
+ create index if not exists alma_turn_claims_created
1514
+ on ${TURN_CLAIMS_TABLE} (created_at);
1479
1515
  ${rlsPolicySql(TURN_LEASES_TABLE)}${rlsPolicySql(TURN_CLAIMS_TABLE)}
1480
1516
  ${roleBootstrapSql(role)}
1517
+ ${roleBootstrapSql(retentionRole)}
1481
1518
  grant select, insert, update, delete
1482
1519
  on ${TURN_LEASES_TABLE}, ${TURN_CLAIMS_TABLE}
1483
1520
  to ${role};
1521
+ ${retentionPolicySql(TURN_CLAIMS_TABLE, retentionRole, "alma_turn_claims_retention")}
1522
+ ${retentionPolicySql(TURN_LEASES_TABLE, retentionRole, "alma_turn_leases_retention")}
1523
+
1524
+ grant select, delete
1525
+ on ${TURN_CLAIMS_TABLE}, ${TURN_LEASES_TABLE}
1526
+ to ${retentionRole};
1484
1527
  `;
1485
1528
  }
1486
1529
  async function migrateTurnStore(pool, opts = {}) {
1487
- await pool.query(turnStoreMigrationSql(opts.role));
1530
+ await pool.query(turnStoreMigrationSql(opts.role, opts.retentionRole));
1531
+ }
1532
+ async function purgeTurnClaimsBefore(pool, before, opts = {}) {
1533
+ return purgeBefore(pool, { table: TURN_CLAIMS_TABLE, column: "created_at", before, ...opts });
1534
+ }
1535
+ async function purgeExpiredLeases(pool, before, opts = {}) {
1536
+ return purgeBefore(pool, { table: TURN_LEASES_TABLE, column: "expires_at", before, ...opts });
1488
1537
  }
1489
1538
 
1490
1539
  // src/turn-store.ts
@@ -1607,12 +1656,10 @@ var PostgresTurnStore = class {
1607
1656
  await this.#inScope(scope, async (client) => {
1608
1657
  const params = sessionId === void 0 ? [scope.org, scope.uid] : [scope.org, scope.uid, sessionId];
1609
1658
  const bySession = sessionId === void 0 ? "" : " and session_id = $3";
1610
- for (const table of [TURN_CLAIMS_TABLE, TURN_LEASES_TABLE]) {
1611
- await client.query(
1612
- `delete from ${table} where org = $1 and uid = $2${bySession}`,
1613
- params
1614
- );
1615
- }
1659
+ await client.query(
1660
+ `delete from ${TURN_CLAIMS_TABLE} where org = $1 and uid = $2${bySession}`,
1661
+ params
1662
+ );
1616
1663
  });
1617
1664
  }
1618
1665
  /** Shared RLS binding — see `scoped.ts`. */
@@ -1630,6 +1677,262 @@ function assertLeaseOpts(opts) {
1630
1677
  }
1631
1678
  }
1632
1679
  }
1680
+
1681
+ // src/routine-store-schema.ts
1682
+ var ROUTINES_TABLE = "alma_routines";
1683
+ var DEFAULT_SCHEDULER_ROLE = "alma_scheduler";
1684
+ function routineStoreMigrationSql(role = DEFAULT_RLS_ROLE, schedulerRole = DEFAULT_SCHEDULER_ROLE) {
1685
+ assertRoleIdentifier(role);
1686
+ assertRoleIdentifier(schedulerRole);
1687
+ return `
1688
+ create table if not exists ${ROUTINES_TABLE} (
1689
+ org text not null,
1690
+ uid text not null,
1691
+ routine_id text not null,
1692
+ routine jsonb not null,
1693
+ registered_at timestamptz not null default now(),
1694
+ primary key (org, uid, routine_id)
1695
+ );
1696
+ ${rlsPolicySql(ROUTINES_TABLE)}
1697
+ ${roleBootstrapSql(role)}
1698
+ ${roleBootstrapSql(schedulerRole)}
1699
+ grant select, insert, update, delete
1700
+ on ${ROUTINES_TABLE}
1701
+ to ${role};
1702
+
1703
+ drop policy if exists alma_routines_schedule on ${ROUTINES_TABLE};
1704
+ create policy alma_routines_schedule on ${ROUTINES_TABLE}
1705
+ for select
1706
+ to ${schedulerRole}
1707
+ using (true);
1708
+
1709
+ grant select
1710
+ on ${ROUTINES_TABLE}
1711
+ to ${schedulerRole};
1712
+ `;
1713
+ }
1714
+ async function migrateRoutineStore(pool, opts = {}) {
1715
+ await pool.query(routineStoreMigrationSql(opts.role, opts.schedulerRole));
1716
+ }
1717
+
1718
+ // src/routine-store.ts
1719
+ var PostgresRoutineStore = class {
1720
+ #pool;
1721
+ #role;
1722
+ #schedulerRole;
1723
+ #timeoutMs;
1724
+ constructor(pool, opts = {}) {
1725
+ this.#pool = pool;
1726
+ this.#role = resolveRlsRole(opts);
1727
+ this.#schedulerRole = opts.schedulerRole ?? DEFAULT_SCHEDULER_ROLE;
1728
+ assertRoleIdentifier(this.#schedulerRole);
1729
+ this.#timeoutMs = resolveStatementTimeout(opts);
1730
+ }
1731
+ async register(routine) {
1732
+ const { registeredAt, ...plain } = routine;
1733
+ if (registeredAt !== void 0) assertIso8601(registeredAt, "registeredAt");
1734
+ await this.#inScope(routine.scope, async (client) => {
1735
+ await client.query(
1736
+ `insert into ${ROUTINES_TABLE} (org, uid, routine_id, routine, registered_at)
1737
+ values ($1, $2, $3, $4::jsonb, coalesce($5::timestamptz, now()))
1738
+ on conflict (org, uid, routine_id) do update set routine = excluded.routine`,
1739
+ [routine.scope.org, routine.scope.uid, routine.id, JSON.stringify(plain), registeredAt ?? null]
1740
+ );
1741
+ });
1742
+ }
1743
+ async cancel(scope, routineId) {
1744
+ await this.#inScope(scope, async (client) => {
1745
+ await client.query(
1746
+ `delete from ${ROUTINES_TABLE} where org = $1 and uid = $2 and routine_id = $3`,
1747
+ [scope.org, scope.uid, routineId]
1748
+ );
1749
+ });
1750
+ }
1751
+ async get(scope, routineId) {
1752
+ return this.#inScope(scope, async (client) => {
1753
+ const { rows } = await client.query(
1754
+ `select routine, registered_at from ${ROUTINES_TABLE}
1755
+ where org = $1 and uid = $2 and routine_id = $3`,
1756
+ [scope.org, scope.uid, routineId]
1757
+ );
1758
+ return rows[0] === void 0 ? null : toStored(rows[0]);
1759
+ });
1760
+ }
1761
+ async list() {
1762
+ return inTransaction(this.#pool, this.#schedulerRole, async (client) => {
1763
+ const { rows } = await client.query(
1764
+ `select routine, registered_at from ${ROUTINES_TABLE} order by org, uid, routine_id`
1765
+ );
1766
+ return rows.map(toStored);
1767
+ }, this.#timeoutMs);
1768
+ }
1769
+ /** Shared RLS binding — see `scoped.ts`. */
1770
+ async #inScope(scope, fn) {
1771
+ return inScope(this.#pool, this.#role, scope, fn, this.#timeoutMs);
1772
+ }
1773
+ };
1774
+ function toStored(row) {
1775
+ return { ...row.routine, registeredAt: row.registered_at.toISOString() };
1776
+ }
1777
+
1778
+ // src/routine-run-store.ts
1779
+ import { scopePath as scopePath5 } from "@alma-harness/core";
1780
+
1781
+ // src/routine-schema.ts
1782
+ var ROUTINE_RUNS_TABLE = "alma_routine_runs";
1783
+ function routineRunStoreMigrationSql(role = DEFAULT_RLS_ROLE, retentionRole = DEFAULT_RETENTION_ROLE) {
1784
+ assertRoleIdentifier(role);
1785
+ assertRoleIdentifier(retentionRole);
1786
+ return `
1787
+ create table if not exists ${ROUTINE_RUNS_TABLE} (
1788
+ org text not null,
1789
+ uid text not null,
1790
+ routine_id text not null,
1791
+ run_id text not null,
1792
+ started_at timestamptz not null,
1793
+ finished_at timestamptz,
1794
+ outcome text not null,
1795
+ reason text,
1796
+ cost_usd double precision not null,
1797
+ session_id text,
1798
+ turn_id text,
1799
+ handle jsonb,
1800
+ delivery_hash text,
1801
+ primary key (org, uid, routine_id, run_id),
1802
+ constraint alma_routine_runs_outcome_check
1803
+ check (outcome in ('delivered', 'duplicate', 'submitted', 'waiting', 'refused', 'failed'))
1804
+ );
1805
+
1806
+ create index if not exists alma_routine_runs_recent
1807
+ on ${ROUTINE_RUNS_TABLE} (org, uid, routine_id, started_at desc);
1808
+
1809
+ create index if not exists alma_routine_runs_started
1810
+ on ${ROUTINE_RUNS_TABLE} (started_at);
1811
+ ${rlsPolicySql(ROUTINE_RUNS_TABLE)}
1812
+ ${roleBootstrapSql(role)}
1813
+ ${roleBootstrapSql(retentionRole)}
1814
+ grant select, insert, update
1815
+ on ${ROUTINE_RUNS_TABLE}
1816
+ to ${role};
1817
+ ${retentionPolicySql(ROUTINE_RUNS_TABLE, retentionRole, "alma_routine_runs_retention")}
1818
+
1819
+ grant select, delete
1820
+ on ${ROUTINE_RUNS_TABLE}
1821
+ to ${retentionRole};
1822
+ `;
1823
+ }
1824
+ async function migrateRoutineRunStore(pool, opts = {}) {
1825
+ await pool.query(routineRunStoreMigrationSql(opts.role, opts.retentionRole));
1826
+ }
1827
+ async function purgeRoutineRunsBefore(pool, before, opts = {}) {
1828
+ return purgeBefore(pool, { table: ROUTINE_RUNS_TABLE, column: "started_at", before, ...opts });
1829
+ }
1830
+
1831
+ // src/routine-run-store.ts
1832
+ var COLUMNS = "routine_id, run_id, started_at, finished_at, outcome, reason, cost_usd, session_id, turn_id, handle, delivery_hash";
1833
+ var PostgresRoutineRunStore = class {
1834
+ #pool;
1835
+ #role;
1836
+ #timeoutMs;
1837
+ constructor(pool, opts = {}) {
1838
+ this.#pool = pool;
1839
+ this.#role = resolveRlsRole(opts);
1840
+ this.#timeoutMs = resolveStatementTimeout(opts);
1841
+ }
1842
+ async record(run) {
1843
+ scopePath5(run.scope);
1844
+ assertIso8601(run.startedAt, "startedAt");
1845
+ if (run.finishedAt !== void 0) assertIso8601(run.finishedAt, "finishedAt");
1846
+ await this.#inScope(run.scope, async (client) => {
1847
+ await client.query(
1848
+ `insert into ${ROUTINE_RUNS_TABLE}
1849
+ (org, uid, routine_id, run_id, started_at, finished_at, outcome, reason, cost_usd,
1850
+ session_id, turn_id, handle, delivery_hash)
1851
+ values ($1, $2, $3, $4, $5::timestamptz, $6::timestamptz, $7, $8, $9, $10, $11, $12::jsonb, $13)
1852
+ on conflict (org, uid, routine_id, run_id) do update set
1853
+ started_at = excluded.started_at, finished_at = excluded.finished_at,
1854
+ outcome = excluded.outcome, reason = excluded.reason, cost_usd = excluded.cost_usd,
1855
+ session_id = excluded.session_id, turn_id = excluded.turn_id,
1856
+ handle = excluded.handle, delivery_hash = excluded.delivery_hash`,
1857
+ [
1858
+ run.scope.org,
1859
+ run.scope.uid,
1860
+ run.routineId,
1861
+ run.id,
1862
+ run.startedAt,
1863
+ run.finishedAt ?? null,
1864
+ run.outcome,
1865
+ run.reason ?? null,
1866
+ run.costUsd,
1867
+ run.sessionId ?? null,
1868
+ run.turnId ?? null,
1869
+ run.handle === void 0 ? null : JSON.stringify(run.handle),
1870
+ run.deliveryHash ?? null
1871
+ ]
1872
+ );
1873
+ });
1874
+ }
1875
+ async get(scope, routineId, runId) {
1876
+ return this.#inScope(scope, async (client) => {
1877
+ const { rows } = await client.query(
1878
+ `select ${COLUMNS} from ${ROUTINE_RUNS_TABLE}
1879
+ where org = $1 and uid = $2 and routine_id = $3 and run_id = $4`,
1880
+ [scope.org, scope.uid, routineId, runId]
1881
+ );
1882
+ return rows[0] === void 0 ? null : toRun(scope, rows[0]);
1883
+ });
1884
+ }
1885
+ async list(scope, routineId, opts = {}) {
1886
+ scopePath5(scope);
1887
+ if (opts.since !== void 0) assertIso8601(opts.since, "since");
1888
+ if (opts.limit !== void 0 && opts.limit <= 0) return [];
1889
+ return this.#inScope(scope, async (client) => {
1890
+ const params = [scope.org, scope.uid, routineId];
1891
+ const where = ["org = $1", "uid = $2", "routine_id = $3"];
1892
+ if (opts.since !== void 0) {
1893
+ params.push(opts.since);
1894
+ where.push(`started_at >= $${params.length}::timestamptz`);
1895
+ }
1896
+ if (opts.outcome !== void 0) {
1897
+ params.push(opts.outcome);
1898
+ where.push(`outcome = $${params.length}`);
1899
+ }
1900
+ let limit = "";
1901
+ if (opts.limit !== void 0) {
1902
+ params.push(opts.limit);
1903
+ limit = ` limit $${params.length}`;
1904
+ }
1905
+ const { rows } = await client.query(
1906
+ `select ${COLUMNS} from ${ROUTINE_RUNS_TABLE}
1907
+ where ${where.join(" and ")}
1908
+ order by started_at desc, run_id asc${limit}`,
1909
+ params
1910
+ );
1911
+ return rows.map((row) => toRun(scope, row));
1912
+ });
1913
+ }
1914
+ /** Shared RLS binding — see `scoped.ts`. */
1915
+ async #inScope(scope, fn) {
1916
+ return inScope(this.#pool, this.#role, scope, fn, this.#timeoutMs);
1917
+ }
1918
+ };
1919
+ function toRun(scope, row) {
1920
+ const run = {
1921
+ id: row.run_id,
1922
+ routineId: row.routine_id,
1923
+ scope: { org: scope.org, uid: scope.uid },
1924
+ startedAt: row.started_at.toISOString(),
1925
+ outcome: row.outcome,
1926
+ costUsd: row.cost_usd
1927
+ };
1928
+ if (row.finished_at !== null) run.finishedAt = row.finished_at.toISOString();
1929
+ if (row.reason !== null) run.reason = row.reason;
1930
+ if (row.session_id !== null) run.sessionId = row.session_id;
1931
+ if (row.turn_id !== null) run.turnId = row.turn_id;
1932
+ if (row.handle !== null) run.handle = row.handle;
1933
+ if (row.delivery_hash !== null) run.deliveryHash = row.delivery_hash;
1934
+ return run;
1935
+ }
1633
1936
  export {
1634
1937
  AUDIT_ACCESS_TABLE,
1635
1938
  AUDIT_CONTEXT_TABLE,
@@ -1639,33 +1942,52 @@ export {
1639
1942
  AUDIT_TABLES,
1640
1943
  DEFAULT_RETENTION_ROLE,
1641
1944
  DEFAULT_RLS_ROLE,
1945
+ DEFAULT_SCHEDULER_ROLE,
1642
1946
  EPISODES_TABLE,
1643
1947
  FACTS_TABLE,
1644
1948
  PostgresAuditLog,
1645
1949
  PostgresEpisodeStore,
1646
1950
  PostgresErasureWatermarks,
1647
1951
  PostgresProfileStore,
1952
+ PostgresRoutineRunStore,
1953
+ PostgresRoutineStore,
1648
1954
  PostgresSessionStore,
1649
1955
  PostgresSpendStore,
1650
1956
  PostgresTurnStore,
1957
+ ROUTINES_TABLE,
1958
+ ROUTINE_RUNS_TABLE,
1651
1959
  SCOPE_STATE_TABLE,
1652
1960
  SPEND_SESSIONS_TABLE,
1653
1961
  SPEND_TENANT_DAYS_TABLE,
1654
1962
  TURN_CLAIMS_TABLE,
1655
1963
  TURN_LEASES_TABLE,
1964
+ assertIso8601,
1656
1965
  assertRoleIdentifier,
1657
1966
  auditLogMigrationSql,
1967
+ grantRole,
1968
+ grantRoleSql,
1969
+ inTransaction,
1658
1970
  memoryStoreMigrationSql,
1659
1971
  migrateAuditLog,
1660
1972
  migrateMemoryStores,
1973
+ migrateRoutineRunStore,
1974
+ migrateRoutineStore,
1661
1975
  migrateSessionStore,
1662
1976
  migrateSpendStore,
1663
1977
  migrateTurnStore,
1664
1978
  purgeAuditBefore,
1979
+ purgeBefore,
1980
+ purgeExpiredLeases,
1981
+ purgeRoutineRunsBefore,
1982
+ purgeTurnClaimsBefore,
1983
+ retentionPolicySql,
1665
1984
  rlsPolicySql,
1666
1985
  roleBootstrapSql,
1986
+ routineRunStoreMigrationSql,
1987
+ routineStoreMigrationSql,
1667
1988
  sessionStoreMigrationSql,
1668
1989
  spendStoreMigrationSql,
1990
+ sweepBefore,
1669
1991
  turnStoreMigrationSql
1670
1992
  };
1671
1993
  //# sourceMappingURL=index.js.map