@alma-harness/postgres 0.2.0 → 0.4.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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Reference storage adapters for [Alma](https://github.com/FabioFernandesCarneiro/alma),
4
4
  with row-level security as defense in depth.
5
5
 
6
- > **Status: 0.2.0 on npm, pre-1.0.** The API is still moving; see the
6
+ > **Status: 0.3.0 on npm, pre-1.0.** The API is still moving; see the
7
7
  > [roadmap](../../docs/architecture.md#12-adoption-roadmap) for where it stands.
8
8
 
9
9
  ## What it owns
@@ -23,6 +23,9 @@ with row-level security as defense in depth.
23
23
  `purgeTurnClaimsBefore` is the claims' time-based retention (spec:
24
24
  erasure-reaches-the-claims): it runs as the retention role, like the audit
25
25
  sweep, since the app role's delete is scoped and serves erasure only.
26
+ `purgeExpiredLeases` sweeps leases that expired before a cutoff — the
27
+ orphan a crashed holder leaves, since erasure keeps leases (spec:
28
+ close-review-part-two). Every swept column carries an index.
26
29
  - `PostgresAuditLog` — the five audit trails (spec 038), written synchronously
27
30
  and with no buffering wrapper: a buffered sink that loses its buffer on a
28
31
  crash stops writing silently, which is the failure `AuditSinkError` was typed
@@ -39,7 +42,8 @@ with row-level security as defense in depth.
39
42
  deployment not connected as a superuser — FORCE ROW LEVEL SECURITY subjects
40
43
  even the table owner to the predicate.
41
44
  - `migrateSessionStore`, `migrateMemoryStores`, `migrateSpendStore`,
42
- `migrateTurnStore`, `migrateRoutineRunStore`, `migrateAuditLog` —
45
+ `migrateTurnStore`, `migrateRoutineRunStore`, `migrateRoutineStore`,
46
+ `migrateAuditLog` —
43
47
  idempotent migrations; running them is the product's choice, typically at
44
48
  startup.
45
49
 
@@ -48,6 +52,10 @@ with row-level security as defense in depth.
48
52
  the last delivery's hash for the dedupe, durable across processes. The app
49
53
  role gets no delete — a run record is retained like a trail — and
50
54
  `purgeRoutineRunsBefore` sweeps by age as the retention role.
55
+ - `PostgresRoutineStore` — the routines themselves (spec: clock-tick), as
56
+ `jsonb` under the shared policy; `list` runs as `alma_scheduler`, the
57
+ role the tick assumes to read every scope, created by the migration.
58
+ Grant it to the login the tick connects as, like the other two roles.
51
59
 
52
60
  Every operation runs inside a transaction that assumes a dedicated
53
61
  non-superuser role and binds `alma.org` / `alma.uid` as transaction-local
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { SessionStore, Scope, Msg, LoadOpts, ToolTrafficExpiry, EpisodeStore, CopySurface, EpisodeInput, Episode, EpisodeQuery, EpisodeQueryResult, ErasureSelector, TombstoneResult, ErasureWatermarkStore, ProfileStore, ProfileReadOpts, Profile, FactObservation, ObserveResult, InvalidateResult, SpendStore, SpendKey, SpendTotals, AuditLog, AccessEvent, RoutingEvent, CostEvent, RecallEvent, ContextEvent, TurnStore, LeaseOpts, TurnLease, TurnKey, TurnClaim, CompletedTurn, RoutineRunStore, RoutineRun, RoutineRunOutcome } from '@alma-harness/core';
2
- export { AuditLog, EpisodeStore, ProfileStore, RoutineRunStore, SessionStore, SpendStore, TurnStore } from '@alma-harness/core';
1
+ import { SessionStore, Scope, Msg, LoadOpts, ToolTrafficExpiry, EpisodeStore, CopySurface, EpisodeInput, Episode, EpisodeQuery, EpisodeQueryResult, ErasureSelector, TombstoneResult, ErasureWatermarkStore, ProfileStore, ProfileReadOpts, Profile, FactObservation, ObserveResult, InvalidateResult, SpendStore, SpendKey, SpendTotals, AuditLog, AccessEvent, RoutingEvent, CostEvent, RecallEvent, ContextEvent, TurnStore, LeaseOpts, TurnLease, TurnKey, TurnClaim, CompletedTurn, RoutineStore, Routine, StoredRoutine, RoutineRunStore, RoutineRun, RoutineRunOutcome } from '@alma-harness/core';
2
+ export { AuditLog, EpisodeStore, ProfileStore, RoutineRunStore, RoutineStore, SessionStore, SpendStore, TurnStore, assertIso8601 } from '@alma-harness/core';
3
3
  import { Pool, PoolClient } from 'pg';
4
4
 
5
5
  /**
@@ -35,7 +35,9 @@ interface ScopedStoreOptions {
35
35
  * inside an aborted transaction; handing it back to the pool would give the
36
36
  * next borrower cascading errors, so it is destroyed instead of reused.
37
37
  */
38
- declare function inTransaction<T>(pool: Pool, role: string | null, fn: (client: PoolClient) => Promise<T>): Promise<T>;
38
+ declare function inTransaction<T>(pool: Pool, role: string | null, fn: (client: PoolClient) => Promise<T>,
39
+ /** A per-transaction `statement_timeout`; the scoped path always sets one, the deployment reads may (spec: close-060-064-findings). */
40
+ statementTimeoutMs?: number | null): Promise<T>;
39
41
  /**
40
42
  * A retention sweep in BATCHES — spec: close-review-part-two. One unbounded
41
43
  * `delete … where stamp < cutoff` on a backlog is one long transaction and a
@@ -50,6 +52,14 @@ declare function sweepBefore(pool: Pool, opts: {
50
52
  before: string;
51
53
  batch?: number;
52
54
  }): Promise<number>;
55
+ /** The three sweeps' one preamble (spec: close-060-064-findings): the role, the cutoff, the batch. */
56
+ declare function purgeBefore(pool: Pool, opts: {
57
+ table: string;
58
+ column: string;
59
+ before: string;
60
+ retentionRole?: string;
61
+ batch?: number;
62
+ }): Promise<number>;
53
63
 
54
64
  type PostgresSessionStoreOptions = ScopedStoreOptions;
55
65
  /**
@@ -154,8 +164,6 @@ declare function rlsPolicySql(table: string, keys?: readonly ("org" | "uid")[]):
154
164
  * names, so a migration on a populated database drops and recreates its own.
155
165
  */
156
166
  declare function retentionPolicySql(table: string, retentionRole: string, name: string): string;
157
- /** Rejects an unparseable ISO 8601 timestamp with the value in the message, before it reaches SQL. */
158
- declare function assertIso8601(at: string, label?: string): string;
159
167
  /** Creates the RLS role if it does not exist, tolerating a concurrent race. */
160
168
  declare function roleBootstrapSql(role: string): string;
161
169
  /**
@@ -422,6 +430,45 @@ declare class PostgresTurnStore implements TurnStore {
422
430
  erase(scope: Scope, sessionId?: string): Promise<void>;
423
431
  }
424
432
 
433
+ interface PostgresRoutineStoreOptions extends ScopedStoreOptions {
434
+ /** The role `list` assumes — the deployment actor. Default `alma_scheduler`. */
435
+ schedulerRole?: string;
436
+ }
437
+ /**
438
+ * Reference `RoutineStore` adapter — spec: clock-tick. Scoped writes and
439
+ * reads as the app role, like every store here; `list` as the scheduler
440
+ * role, the one cross-scope read, through the same transaction helper the
441
+ * sweeps use.
442
+ */
443
+ declare class PostgresRoutineStore implements RoutineStore {
444
+ #private;
445
+ constructor(pool: Pool, opts?: PostgresRoutineStoreOptions);
446
+ register(routine: Routine & {
447
+ registeredAt?: string;
448
+ }): Promise<void>;
449
+ cancel(scope: Scope, routineId: string): Promise<void>;
450
+ get(scope: Scope, routineId: string): Promise<StoredRoutine | null>;
451
+ list(): Promise<StoredRoutine[]>;
452
+ }
453
+
454
+ /**
455
+ * Schema and migration for the routine store — spec: clock-tick. One
456
+ * scope-stamped table; the routine as `jsonb`, the registration stamp as a
457
+ * column. The app role reads and writes its own scope's rows; the tick
458
+ * lists EVERY scope, so it runs as a role of its own with a policy that
459
+ * admits all rows — the retention role's shape, for the same reason: FORCE
460
+ * ROW LEVEL SECURITY binds the owner too.
461
+ */
462
+ declare const ROUTINES_TABLE = "alma_routines";
463
+ /** Role the tick assumes to list every scope's routines — spec: clock-tick. */
464
+ declare const DEFAULT_SCHEDULER_ROLE = "alma_scheduler";
465
+ declare function routineStoreMigrationSql(role?: string, schedulerRole?: string): string;
466
+ /** Idempotent; running it is the product's choice (typically at startup). */
467
+ declare function migrateRoutineStore(pool: Pool, opts?: {
468
+ role?: string;
469
+ schedulerRole?: string;
470
+ }): Promise<void>;
471
+
425
472
  type PostgresRoutineRunStoreOptions = ScopedStoreOptions;
426
473
  /**
427
474
  * Reference `RoutineRunStore` adapter — spec: postgres-routine-runs. Same RLS
@@ -519,6 +566,15 @@ declare function purgeTurnClaimsBefore(pool: Pool, before: string, opts?: {
519
566
  retentionRole?: string;
520
567
  batch?: number;
521
568
  }): Promise<number>;
569
+ /**
570
+ * Deletes leases that expired before the cutoff — spec: close-060-064-findings.
571
+ * Erasure leaves leases alone (spec: close-review-part-two), and a holder that
572
+ * crashed never releases; this bounds the orphans, as the retention role.
573
+ */
574
+ declare function purgeExpiredLeases(pool: Pool, before: string, opts?: {
575
+ retentionRole?: string;
576
+ batch?: number;
577
+ }): Promise<number>;
522
578
 
523
579
  /**
524
580
  * Schema and migration for the spend store — spec: spend-store.
@@ -550,4 +606,4 @@ declare function migrateSpendStore(pool: Pool, opts?: {
550
606
  role?: string;
551
607
  }): Promise<void>;
552
608
 
553
- export { AUDIT_ACCESS_TABLE, AUDIT_CONTEXT_TABLE, AUDIT_COST_TABLE, AUDIT_RECALL_TABLE, AUDIT_ROUTING_TABLE, AUDIT_TABLES, type AuditTable, DEFAULT_RETENTION_ROLE, DEFAULT_RLS_ROLE, EPISODES_TABLE, FACTS_TABLE, PostgresAuditLog, type PostgresAuditLogOptions, PostgresEpisodeStore, PostgresErasureWatermarks, type PostgresMemoryStoreOptions, PostgresProfileStore, PostgresRoutineRunStore, type PostgresRoutineRunStoreOptions, PostgresSessionStore, type PostgresSessionStoreOptions, PostgresSpendStore, type PostgresSpendStoreOptions, PostgresTurnStore, type PostgresTurnStoreOptions, ROUTINE_RUNS_TABLE, SCOPE_STATE_TABLE, SPEND_SESSIONS_TABLE, SPEND_TENANT_DAYS_TABLE, type ScopedStoreOptions, TURN_CLAIMS_TABLE, TURN_LEASES_TABLE, assertIso8601, assertRoleIdentifier, auditLogMigrationSql, grantRole, grantRoleSql, inTransaction, memoryStoreMigrationSql, migrateAuditLog, migrateMemoryStores, migrateRoutineRunStore, migrateSessionStore, migrateSpendStore, migrateTurnStore, purgeAuditBefore, purgeRoutineRunsBefore, purgeTurnClaimsBefore, retentionPolicySql, rlsPolicySql, roleBootstrapSql, routineRunStoreMigrationSql, sessionStoreMigrationSql, spendStoreMigrationSql, sweepBefore, turnStoreMigrationSql };
609
+ export { AUDIT_ACCESS_TABLE, AUDIT_CONTEXT_TABLE, AUDIT_COST_TABLE, AUDIT_RECALL_TABLE, AUDIT_ROUTING_TABLE, AUDIT_TABLES, type AuditTable, DEFAULT_RETENTION_ROLE, DEFAULT_RLS_ROLE, DEFAULT_SCHEDULER_ROLE, EPISODES_TABLE, FACTS_TABLE, PostgresAuditLog, type PostgresAuditLogOptions, PostgresEpisodeStore, PostgresErasureWatermarks, type PostgresMemoryStoreOptions, PostgresProfileStore, PostgresRoutineRunStore, type PostgresRoutineRunStoreOptions, PostgresRoutineStore, type PostgresRoutineStoreOptions, PostgresSessionStore, type PostgresSessionStoreOptions, PostgresSpendStore, type PostgresSpendStoreOptions, PostgresTurnStore, type PostgresTurnStoreOptions, ROUTINES_TABLE, ROUTINE_RUNS_TABLE, SCOPE_STATE_TABLE, SPEND_SESSIONS_TABLE, SPEND_TENANT_DAYS_TABLE, type ScopedStoreOptions, TURN_CLAIMS_TABLE, TURN_LEASES_TABLE, assertRoleIdentifier, auditLogMigrationSql, grantRole, grantRoleSql, inTransaction, memoryStoreMigrationSql, migrateAuditLog, migrateMemoryStores, migrateRoutineRunStore, migrateRoutineStore, migrateSessionStore, migrateSpendStore, migrateTurnStore, purgeAuditBefore, purgeBefore, purgeExpiredLeases, purgeRoutineRunsBefore, purgeTurnClaimsBefore, retentionPolicySql, rlsPolicySql, roleBootstrapSql, routineRunStoreMigrationSql, routineStoreMigrationSql, sessionStoreMigrationSql, spendStoreMigrationSql, sweepBefore, turnStoreMigrationSql };
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { assertWellFormed, scopePath as scopePath2 } from "@alma-harness/core";
3
3
 
4
4
  // src/schema.ts
5
+ import { assertIso8601 } from "@alma-harness/core";
5
6
  var DEFAULT_RLS_ROLE = "alma_app";
6
7
  var DEFAULT_RETENTION_ROLE = "alma_retention";
7
8
  var IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
@@ -45,12 +46,6 @@ create policy ${name} on ${table}
45
46
  to ${retentionRole}
46
47
  using (true);`;
47
48
  }
48
- function assertIso8601(at, label = "timestamp") {
49
- if (Number.isNaN(Date.parse(at))) {
50
- throw new Error(`invalid ISO 8601 ${label}: ${JSON.stringify(at)}`);
51
- }
52
- return at;
53
- }
54
49
  function roleBootstrapSql(role) {
55
50
  assertRoleIdentifier(role);
56
51
  return `
@@ -129,12 +124,15 @@ function resolveStatementTimeout(opts) {
129
124
  }
130
125
  return ms;
131
126
  }
132
- async function inTransaction(pool, role, fn) {
127
+ async function inTransaction(pool, role, fn, statementTimeoutMs) {
133
128
  const client = await pool.connect();
134
129
  let rollbackFailed;
135
130
  try {
136
131
  await client.query("begin");
137
132
  if (role !== null) await client.query(`set local role ${role}`);
133
+ if (statementTimeoutMs !== void 0) {
134
+ await client.query(`select set_config('statement_timeout', $1, true)`, [statementTimeoutMs === null ? "0" : String(statementTimeoutMs)]);
135
+ }
138
136
  const result = await fn(client);
139
137
  await client.query("commit");
140
138
  return result;
@@ -157,7 +155,7 @@ async function sweepBefore(pool, opts) {
157
155
  const removed = await inTransaction(pool, opts.role, async (client) => {
158
156
  const { rowCount } = await client.query(
159
157
  `delete from ${opts.table}
160
- where ctid = any(array(select ctid from ${opts.table} where ${opts.column} < $1::timestamptz limit $2))`,
158
+ where ctid = any(array(select ctid from ${opts.table} where ${opts.column} < $1::timestamptz order by ${opts.column} limit $2))`,
161
159
  [opts.before, batch]
162
160
  );
163
161
  return rowCount ?? 0;
@@ -166,6 +164,12 @@ async function sweepBefore(pool, opts) {
166
164
  if (removed < batch) return total;
167
165
  }
168
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
+ }
169
173
  async function inScope(pool, role, scope, fn, statementTimeoutMs = DEFAULT_STATEMENT_TIMEOUT_MS) {
170
174
  scopePath(scope);
171
175
  return inTransaction(pool, role, async (client) => {
@@ -1160,6 +1164,9 @@ create index if not exists alma_audit_access_turn
1160
1164
  create index if not exists alma_audit_access_recent
1161
1165
  on ${AUDIT_ACCESS_TABLE} (org, uid, at desc);
1162
1166
 
1167
+ create index if not exists alma_audit_access_at
1168
+ on ${AUDIT_ACCESS_TABLE} (at);
1169
+
1163
1170
  create table if not exists ${AUDIT_ROUTING_TABLE} (
1164
1171
  id uuid not null default gen_random_uuid(),
1165
1172
  org text not null,
@@ -1179,6 +1186,9 @@ create table if not exists ${AUDIT_ROUTING_TABLE} (
1179
1186
  create index if not exists alma_audit_routing_turn
1180
1187
  on ${AUDIT_ROUTING_TABLE} (org, uid, turn_id);
1181
1188
 
1189
+ create index if not exists alma_audit_routing_at
1190
+ on ${AUDIT_ROUTING_TABLE} (at);
1191
+
1182
1192
  create table if not exists ${AUDIT_COST_TABLE} (
1183
1193
  id uuid not null default gen_random_uuid(),
1184
1194
  org text not null,
@@ -1206,6 +1216,9 @@ create index if not exists alma_audit_cost_turn
1206
1216
  create index if not exists alma_audit_cost_recent
1207
1217
  on ${AUDIT_COST_TABLE} (org, uid, at desc);
1208
1218
 
1219
+ create index if not exists alma_audit_cost_at
1220
+ on ${AUDIT_COST_TABLE} (at);
1221
+
1209
1222
  create table if not exists ${AUDIT_RECALL_TABLE} (
1210
1223
  id uuid not null default gen_random_uuid(),
1211
1224
  org text not null,
@@ -1228,6 +1241,9 @@ create table if not exists ${AUDIT_RECALL_TABLE} (
1228
1241
  create index if not exists alma_audit_recall_turn
1229
1242
  on ${AUDIT_RECALL_TABLE} (org, uid, turn_id);
1230
1243
 
1244
+ create index if not exists alma_audit_recall_at
1245
+ on ${AUDIT_RECALL_TABLE} (at);
1246
+
1231
1247
  create table if not exists ${AUDIT_CONTEXT_TABLE} (
1232
1248
  id uuid not null default gen_random_uuid(),
1233
1249
  org text not null,
@@ -1257,6 +1273,9 @@ create table if not exists ${AUDIT_CONTEXT_TABLE} (
1257
1273
 
1258
1274
  create index if not exists alma_audit_context_turn
1259
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);
1260
1279
  ${AUDIT_TABLES.map((t) => rlsPolicySql(t)).join("")}
1261
1280
  ${roleBootstrapSql(role)}
1262
1281
  ${roleBootstrapSql(retentionRole)}
@@ -1291,8 +1310,6 @@ async function migrateAuditLog(pool, opts = {}) {
1291
1310
  await pool.query(auditLogMigrationSql(opts.role, opts.retentionRole));
1292
1311
  }
1293
1312
  async function purgeAuditBefore(pool, windows, opts = {}) {
1294
- const retentionRole = opts.retentionRole ?? DEFAULT_RETENTION_ROLE;
1295
- assertRoleIdentifier(retentionRole);
1296
1313
  for (const table of AUDIT_TABLES) {
1297
1314
  const before = windows[table];
1298
1315
  if (before !== void 0) assertIso8601(before, `timestamp for ${table}`);
@@ -1301,7 +1318,7 @@ async function purgeAuditBefore(pool, windows, opts = {}) {
1301
1318
  for (const table of AUDIT_TABLES) {
1302
1319
  const before = windows[table];
1303
1320
  if (before === void 0) continue;
1304
- purged[table] = await sweepBefore(pool, { role: retentionRole, table, column: "at", before, ...opts.batch !== void 0 ? { batch: opts.batch } : {} });
1321
+ purged[table] = await purgeBefore(pool, { table, column: "at", before, ...opts });
1305
1322
  }
1306
1323
  return purged;
1307
1324
  }
@@ -1502,9 +1519,10 @@ grant select, insert, update, delete
1502
1519
  on ${TURN_LEASES_TABLE}, ${TURN_CLAIMS_TABLE}
1503
1520
  to ${role};
1504
1521
  ${retentionPolicySql(TURN_CLAIMS_TABLE, retentionRole, "alma_turn_claims_retention")}
1522
+ ${retentionPolicySql(TURN_LEASES_TABLE, retentionRole, "alma_turn_leases_retention")}
1505
1523
 
1506
1524
  grant select, delete
1507
- on ${TURN_CLAIMS_TABLE}
1525
+ on ${TURN_CLAIMS_TABLE}, ${TURN_LEASES_TABLE}
1508
1526
  to ${retentionRole};
1509
1527
  `;
1510
1528
  }
@@ -1512,10 +1530,10 @@ async function migrateTurnStore(pool, opts = {}) {
1512
1530
  await pool.query(turnStoreMigrationSql(opts.role, opts.retentionRole));
1513
1531
  }
1514
1532
  async function purgeTurnClaimsBefore(pool, before, opts = {}) {
1515
- const role = opts.retentionRole ?? DEFAULT_RETENTION_ROLE;
1516
- assertRoleIdentifier(role);
1517
- assertIso8601(before);
1518
- return sweepBefore(pool, { role, table: TURN_CLAIMS_TABLE, column: "created_at", before, ...opts.batch !== void 0 ? { batch: opts.batch } : {} });
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 });
1519
1537
  }
1520
1538
 
1521
1539
  // src/turn-store.ts
@@ -1660,6 +1678,103 @@ function assertLeaseOpts(opts) {
1660
1678
  }
1661
1679
  }
1662
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
+
1663
1778
  // src/routine-run-store.ts
1664
1779
  import { scopePath as scopePath5 } from "@alma-harness/core";
1665
1780
 
@@ -1690,6 +1805,9 @@ create table if not exists ${ROUTINE_RUNS_TABLE} (
1690
1805
 
1691
1806
  create index if not exists alma_routine_runs_recent
1692
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);
1693
1811
  ${rlsPolicySql(ROUTINE_RUNS_TABLE)}
1694
1812
  ${roleBootstrapSql(role)}
1695
1813
  ${roleBootstrapSql(retentionRole)}
@@ -1707,10 +1825,7 @@ async function migrateRoutineRunStore(pool, opts = {}) {
1707
1825
  await pool.query(routineRunStoreMigrationSql(opts.role, opts.retentionRole));
1708
1826
  }
1709
1827
  async function purgeRoutineRunsBefore(pool, before, opts = {}) {
1710
- const role = opts.retentionRole ?? DEFAULT_RETENTION_ROLE;
1711
- assertRoleIdentifier(role);
1712
- assertIso8601(before);
1713
- return sweepBefore(pool, { role, table: ROUTINE_RUNS_TABLE, column: "started_at", before, ...opts.batch !== void 0 ? { batch: opts.batch } : {} });
1828
+ return purgeBefore(pool, { table: ROUTINE_RUNS_TABLE, column: "started_at", before, ...opts });
1714
1829
  }
1715
1830
 
1716
1831
  // src/routine-run-store.ts
@@ -1827,6 +1942,7 @@ export {
1827
1942
  AUDIT_TABLES,
1828
1943
  DEFAULT_RETENTION_ROLE,
1829
1944
  DEFAULT_RLS_ROLE,
1945
+ DEFAULT_SCHEDULER_ROLE,
1830
1946
  EPISODES_TABLE,
1831
1947
  FACTS_TABLE,
1832
1948
  PostgresAuditLog,
@@ -1834,9 +1950,11 @@ export {
1834
1950
  PostgresErasureWatermarks,
1835
1951
  PostgresProfileStore,
1836
1952
  PostgresRoutineRunStore,
1953
+ PostgresRoutineStore,
1837
1954
  PostgresSessionStore,
1838
1955
  PostgresSpendStore,
1839
1956
  PostgresTurnStore,
1957
+ ROUTINES_TABLE,
1840
1958
  ROUTINE_RUNS_TABLE,
1841
1959
  SCOPE_STATE_TABLE,
1842
1960
  SPEND_SESSIONS_TABLE,
@@ -1853,16 +1971,20 @@ export {
1853
1971
  migrateAuditLog,
1854
1972
  migrateMemoryStores,
1855
1973
  migrateRoutineRunStore,
1974
+ migrateRoutineStore,
1856
1975
  migrateSessionStore,
1857
1976
  migrateSpendStore,
1858
1977
  migrateTurnStore,
1859
1978
  purgeAuditBefore,
1979
+ purgeBefore,
1980
+ purgeExpiredLeases,
1860
1981
  purgeRoutineRunsBefore,
1861
1982
  purgeTurnClaimsBefore,
1862
1983
  retentionPolicySql,
1863
1984
  rlsPolicySql,
1864
1985
  roleBootstrapSql,
1865
1986
  routineRunStoreMigrationSql,
1987
+ routineStoreMigrationSql,
1866
1988
  sessionStoreMigrationSql,
1867
1989
  spendStoreMigrationSql,
1868
1990
  sweepBefore,