@voltro/database 0.33.0 → 0.35.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.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ConfigError } from 'effect';
2
+ import { Context } from 'effect';
2
3
  import { Effect } from 'effect';
3
4
  import { Exit } from 'effect';
4
5
  import { Layer } from 'effect';
@@ -8,6 +9,7 @@ import { SqlClient } from '@effect/sql';
8
9
  import { SqlError } from '@effect/sql';
9
10
  import { Statement } from '@effect/sql';
10
11
  import { Stream } from 'effect';
12
+ import { TransactionConnection } from '@effect/sql/SqlClient';
11
13
  import { VoidIfEmpty } from 'effect/Types';
12
14
  import { YieldableError } from 'effect/Cause';
13
15
 
@@ -182,6 +184,7 @@ export declare const attributionFields: (explicit?: WriteAttribution | undefined
182
184
  traceId?: string;
183
185
  subjectId?: string | null;
184
186
  procedure?: string;
187
+ via?: "agent";
185
188
  };
186
189
 
187
190
  /** The write's identity, as both sides can compute it. NOT the row image: the
@@ -316,6 +319,13 @@ export declare interface BranchExecutor {
316
319
  readonly createTable: (namespace: string, table: string) => Promise<void>;
317
320
  /** Copy one table's rows from the parent namespace into the branch (`INSERT … SELECT`). */
318
321
  readonly copyTable: (fromNamespace: string, toNamespace: string, table: string) => Promise<void>;
322
+ /** Replay one of the parent's foreign keys into the branch, re-pointed at the
323
+ * branch's own copy of the target table. `LIKE … INCLUDING ALL` does not copy
324
+ * foreign keys — see {@link BranchForeignKey} for why that is not optional. */
325
+ readonly addForeignKey: (namespace: string, fk: BranchForeignKey) => Promise<void>;
326
+ /** Restore an index's NAME, which the `LIKE` copy re-derived. Idempotent + a
327
+ * no-op when the name already matches — see {@link BranchIndexName}. */
328
+ readonly restoreIndexName: (namespace: string, index: BranchIndexName) => Promise<void>;
319
329
  /** Drop the whole branch namespace (teardown). */
320
330
  readonly dropNamespace: (namespace: string) => Promise<void>;
321
331
  /** Create a Neon copy-on-write branch from `parentBranch`; RESOLVE to the new
@@ -325,6 +335,69 @@ export declare interface BranchExecutor {
325
335
  readonly neonBranchDelete: (branchId: string) => Promise<void>;
326
336
  }
327
337
 
338
+ /**
339
+ * One foreign key to REPLAY into the branch namespace, re-pointed at the
340
+ * branch's own copy of the target table.
341
+ *
342
+ * This exists because of a silent fidelity hole in the namespace mechanism, and
343
+ * the hole is a property of Postgres rather than of our emission:
344
+ * `CREATE TABLE … (LIKE parent INCLUDING ALL)` copies columns, defaults, CHECK
345
+ * constraints, identity and indexes — and does NOT copy FOREIGN KEYS. There is
346
+ * no `INCLUDING` clause that does. So a namespace branch of a schema with
347
+ * referential integrity came up WITHOUT any of it: a PR preview bound to that
348
+ * branch accepts writes production rejects, and a migration rehearsed on it is
349
+ * rehearsing a different schema from the one it claims to.
350
+ *
351
+ * The caller supplies them (introspection already carries them per column, as
352
+ * `ColumnSnapshot.references`), because the branch primitive deliberately does
353
+ * no I/O of its own.
354
+ */
355
+ export declare interface BranchForeignKey {
356
+ /** Table (in the branch) that carries the FK column. */
357
+ readonly table: string;
358
+ readonly column: string;
359
+ /** Referenced table — resolved to the BRANCH's copy, never the parent's. */
360
+ readonly targetTable: string;
361
+ readonly targetColumn: string;
362
+ readonly onDelete?: 'cascade' | 'restrict' | 'setNull' | 'noAction';
363
+ readonly onUpdate?: 'cascade' | 'restrict' | 'setNull' | 'noAction';
364
+ }
365
+
366
+ /**
367
+ * One index whose NAME must be restored on the branch.
368
+ *
369
+ * The second thing `LIKE … INCLUDING ALL` does not carry, found the same way as
370
+ * the foreign keys — by branching a real converged schema and watching the
371
+ * differ propose work. Postgres copies the index and **re-derives its name from
372
+ * the table and columns**; the source name survives only when it happened to
373
+ * equal that derivation. Measured on pg 17:
374
+ *
375
+ * probeparent._voltro_api_keys byApiKeyTenant
376
+ * br_pr9_probe._voltro_api_keys _voltro_api_keys_tenantId_idx
377
+ * probeparent._voltro_idempotency _voltro_idempotency_scope_key_uq
378
+ * br_pr9_probe._voltro_idempotency _voltro_idempotency_scope_key_idx
379
+ *
380
+ * The planner compares indexes BY NAME, so every custom-named index on the
381
+ * parent reads as a different index on the branch: a rehearsal proposed
382
+ * `rename-index` plus a spurious `drop-unique-composite`/`add-unique-composite`
383
+ * pair for a schema that was already converged. Which is worse than noise — the
384
+ * rehearsal is supposed to be the thing that tells you what your migration does.
385
+ *
386
+ * PRIMARY KEYS are excluded: `<table>_pkey` is postgres's own derivation AND
387
+ * what four of the five introspectors fabricate, so the copy is already right.
388
+ * Expression indexes are excluded too — their key list carries no column to
389
+ * match on.
390
+ */
391
+ export declare interface BranchIndexName {
392
+ readonly table: string;
393
+ /** Key columns IN INDEX ORDER — how the branch's copy is identified, since its
394
+ * re-derived name is exactly what we do not know. */
395
+ readonly columns: ReadonlyArray<string>;
396
+ readonly unique: boolean;
397
+ /** The name the PARENT's index carries, and the one to restore. */
398
+ readonly name: string;
399
+ }
400
+
328
401
  export declare interface BranchLimitDecision {
329
402
  /** Namespaces to tear down now (past TTL). */
330
403
  readonly evict: ReadonlyArray<string>;
@@ -375,6 +448,14 @@ export declare interface BranchPlanInput {
375
448
  readonly seed: BranchSeed;
376
449
  /** namespace only: the source schema to snapshot from (required for `'copy'`). */
377
450
  readonly parentNamespace?: string;
451
+ /** namespace only: the parent's foreign keys, replayed into the branch —
452
+ * `LIKE … INCLUDING ALL` does not copy them. Ignored by `neon-cow`, whose
453
+ * copy-on-write branch carries the parent's catalog verbatim. */
454
+ readonly foreignKeys?: ReadonlyArray<BranchForeignKey>;
455
+ /** namespace only: composite UNIQUE constraints whose NAME the `LIKE` copy
456
+ * re-derived by the `LIKE` copy. Same reason as `foreignKeys` — a branch that
457
+ * differs from the parent is not a copy of it. See {@link BranchIndexName}. */
458
+ readonly indexNames?: ReadonlyArray<BranchIndexName>;
378
459
  /** neon-cow only: the base Neon branch to copy-on-write from (default `'main'`). */
379
460
  readonly neonParentBranch?: string;
380
461
  }
@@ -393,6 +474,14 @@ export declare interface BranchProvisionRequest {
393
474
  readonly tableNames: ReadonlyArray<string>;
394
475
  /** namespace mechanism, `seed: 'copy'`: the source schema to snapshot from. */
395
476
  readonly parentNamespace?: string;
477
+ /** namespace mechanism: the parent's foreign keys, replayed into the branch
478
+ * after the tables exist (and after the rows, for a `copy` seed). Omitting
479
+ * them yields a branch with NO referential integrity — see
480
+ * {@link BranchForeignKey}. */
481
+ readonly foreignKeys?: ReadonlyArray<BranchForeignKey>;
482
+ /** namespace mechanism: the parent's index names, restored on the branch after
483
+ * the `LIKE` copy re-derived them — see {@link BranchIndexName}. */
484
+ readonly indexNames?: ReadonlyArray<BranchIndexName>;
396
485
  /** The owned-DB connection string (Neon detection drives the fast-path). */
397
486
  readonly dbUrl?: string;
398
487
  /** neon mechanism: the base branch to copy-on-write from (default `'main'`). */
@@ -431,7 +520,7 @@ export declare interface BranchSqlRunner {
431
520
  export declare type BranchState = 'requested' | 'provisioning' | 'ready' | 'destroying' | 'destroyed' | 'failed';
432
521
 
433
522
  export declare interface BranchStep {
434
- readonly kind: 'create-namespace' | 'create-table' | 'copy-table' | 'seed' | 'drop-namespace' | 'neon-branch-create' | 'neon-branch-delete';
523
+ readonly kind: 'create-namespace' | 'create-table' | 'copy-table' | 'copy-foreign-key' | 'restore-index-name' | 'seed' | 'drop-namespace' | 'neon-branch-create' | 'neon-branch-delete';
435
524
  readonly detail: string;
436
525
  readonly table?: string;
437
526
  }
@@ -443,6 +532,37 @@ export declare class BranchTransitionInvalid extends Error {
443
532
  constructor(from: BranchState, event: BranchEvent);
444
533
  }
445
534
 
535
+ /**
536
+ * Per-dialect ceilings, keyed by the same ids `DialectId` uses.
537
+ *
538
+ * Deliberately NOT a `Record<DialectId, …>` typed against the dialect union:
539
+ * `@voltro/database` owns both, so the union would compile-check the map — but
540
+ * the stores look their entry up by a string they hold anyway (mysql's store
541
+ * carries `variant: 'mysql' | 'mariadb'`), and an unknown key must degrade to
542
+ * "no chunking" rather than to `undefined.maxBindParameters` at runtime. The
543
+ * parity test asserts every shipped dialect has an entry, which is the check
544
+ * that actually matters.
545
+ */
546
+ export declare const BULK_INSERT_LIMITS: Readonly<Record<string, BulkInsertLimits>>;
547
+
548
+ /**
549
+ * What one `INSERT … VALUES` statement may carry on a dialect.
550
+ *
551
+ * Both bounds are real and independent; see the header for the mssql case where
552
+ * the row bound binds first.
553
+ */
554
+ export declare interface BulkInsertLimits {
555
+ /** Bind parameters (`?` / `$n`) the engine accepts in ONE statement. */
556
+ readonly maxBindParameters: number;
557
+ /** Row constructors the engine accepts in one `VALUES` clause, when the
558
+ * engine caps that separately. */
559
+ readonly maxRowsPerStatement?: number;
560
+ }
561
+
562
+ /** The ceilings for `dialectId`, or `undefined` when we do not know them —
563
+ * which must read as "emit one statement, exactly as before". */
564
+ export declare const bulkInsertLimitsFor: (dialectId: string) => BulkInsertLimits | undefined;
565
+
446
566
  /** Raw binary column — `BYTEA` (postgres) / `BLOB` (sqlite) / `LONGBLOB`
447
567
  * (mysql/mariadb) / `VARBINARY(MAX)` (mssql). Values round-trip as
448
568
  * `Uint8Array`. Use for storing bytes IN the database (e.g. the
@@ -500,6 +620,33 @@ export declare type ChangeEvent = {
500
620
  readonly op: 'insert' | 'update' | 'delete';
501
621
  readonly old: Row | null;
502
622
  readonly new: Row | null;
623
+ /**
624
+ * Set when the TRANSPORT could not carry this change's row images and they
625
+ * had to be RECONSTRUCTED. Absent on every ordinary event, which is the only
626
+ * case where `old`/`new` are the images as they were at commit.
627
+ *
628
+ * Postgres caps a `pg_notify` payload at 8000 bytes, and under
629
+ * `changeStrategy: 'cdc'` the NOTIFY trigger is the SOLE emitter — so a row
630
+ * that does not fit used to arrive with both images null and every tap
631
+ * (search index, analytics mirror, cdc-out, row history) dropped it as a
632
+ * non-event, permanently, with nothing logged. The CDC consumer re-reads the
633
+ * row by primary key before the event reaches anyone; this field says what
634
+ * that recovered:
635
+ *
636
+ * - `'rehydrated'` — insert/update. `new` is the row RE-READ from the
637
+ * database, so it is the row as it is NOW, not necessarily the image the
638
+ * write that fired this event produced (a later write to the same row can
639
+ * already have landed). `old` is null — postgres keeps no pre-image once
640
+ * the transaction is gone.
641
+ * - `'tombstone'` — delete. `old` carries the PRIMARY KEY AND NOTHING ELSE:
642
+ * the row is gone, so the pre-image is unrecoverable and the key is the
643
+ * whole truth. Enough to REMOVE the row downstream; not a snapshot of what
644
+ * was deleted, and anything recording history must not treat it as one.
645
+ * - `'unrecovered'` — both images are null. The change is known to have
646
+ * happened and its content is not available (no key in the payload, the
647
+ * re-read failed, or the row was already gone). Re-read or resync.
648
+ */
649
+ readonly oversized?: 'rehydrated' | 'tombstone' | 'unrecovered';
503
650
  /**
504
651
  * How the event reached this process. Absent/'inline' = emitted by this
505
652
  * process's own write path; 'injected' = delivered over a cross-instance
@@ -549,10 +696,16 @@ export declare type ChangeEvent = {
549
696
  * an absent `traceId`.
550
697
  */
551
698
  readonly procedure?: string;
699
+ /**
700
+ * The write was made BY AN AGENT acting as `subjectId`, not by that subject
701
+ * directly. Mirrors `WriteAttribution.via` — see it for why the identity is
702
+ * NOT changed instead. Absent = a direct call, which is a fact, not a gap.
703
+ */
704
+ readonly via?: 'agent';
552
705
  };
553
706
 
554
707
  /** Attribution and origin, identical on every constructor. */
555
- export declare type ChangeEventMeta = Pick<ChangeEvent, 'origin' | 'traceId' | 'subjectId' | 'procedure'>;
708
+ export declare type ChangeEventMeta = Pick<ChangeEvent, 'origin' | 'traceId' | 'subjectId' | 'procedure' | 'via'>;
556
709
 
557
710
  /** A row was created. `old` is null, always. */
558
711
  export declare const changeInsert: (table: string, row: Row, meta?: ChangeEventMeta) => ChangeEvent;
@@ -589,6 +742,19 @@ export declare type ChangeStrategy = 'inline' | 'cdc';
589
742
  /** A row changed. Both sides are present, because an update has both. */
590
743
  export declare const changeUpdate: (table: string, before: Row, after: Row, meta?: ChangeEventMeta) => ChangeEvent;
591
744
 
745
+ /**
746
+ * Split `rows` into groups that each fit in one statement on this dialect.
747
+ *
748
+ * Returns a single group whenever the whole array already fits — which is the
749
+ * overwhelmingly common case, and it must stay allocation-cheap and, more
750
+ * importantly, must keep emitting the SAME single statement it did before. A
751
+ * chunker that split a 3-row insert into 3 would be a correct implementation of
752
+ * the wrong thing.
753
+ *
754
+ * `undefined` limits (an unknown dialect) → one group, unchanged behaviour.
755
+ */
756
+ export declare const chunkRowsForInsert: <T extends Record<string, unknown>>(rows: ReadonlyArray<T>, limits: BulkInsertLimits | undefined) => ReadonlyArray<ReadonlyArray<T>>;
757
+
592
758
  /**
593
759
  * Claim the attribution for an echo, or `undefined` if this write did not
594
760
  * originate here.
@@ -1468,6 +1634,18 @@ declare type ColumnOptionalForInsert<K extends PropertyKey, D> = K extends AutoF
1468
1634
  */
1469
1635
  export declare const columnSchema: <TsType, C extends ColumnType>(def: ColumnDefinition<TsType, C>) => Schema.Schema.Any;
1470
1636
 
1637
+ /**
1638
+ * How many bind parameters one row costs.
1639
+ *
1640
+ * The MAX across rows, not the first row's count: `sql.insert` builds its
1641
+ * column list from the union of the rows' keys and binds a value (a NULL where
1642
+ * a row omits the column) in every position, so a set whose first row is narrow
1643
+ * and whose tenth is wide costs the WIDE row's count for every row. Counting
1644
+ * the first row would under-count exactly the shape most likely to be near the
1645
+ * ceiling.
1646
+ */
1647
+ export declare const columnsPerRow: (rows: ReadonlyArray<Record<string, unknown>>) => number;
1648
+
1471
1649
  export declare type ColumnType = 'id' | 'text' | 'integer' | 'real' | 'decimal' | 'bigint' | 'boolean' | 'timestamp' | 'date' | 'json' | 'bytes' | 'reference' | 'vector' | 'enum' | 'array' | 'interval' | 'raw';
1472
1650
 
1473
1651
  /**
@@ -1564,6 +1742,75 @@ export declare interface ConnectionConfig {
1564
1742
  */
1565
1743
  readonly ssl?: boolean;
1566
1744
  readonly maxConnections?: number;
1745
+ /**
1746
+ * How long a caller waits for a free pooled connection before the acquire
1747
+ * FAILS (ms). Default {@link DEFAULT_ACQUIRE_TIMEOUT_MS}; `0` restores the
1748
+ * driver's own (unbounded) behaviour. Env `DB_ACQUIRE_TIMEOUT_MS`.
1749
+ *
1750
+ * This is the OTHER half of `statementTimeoutMs`. That one bounds a query the
1751
+ * SERVER is running; nothing bounded a query the client had not sent yet — a
1752
+ * request arriving when every pooled connection is busy did not fail, did not
1753
+ * retry and logged nothing, it simply sat in the driver's queue. A bounded
1754
+ * failure names the pool as the cause at the moment it IS the cause, instead
1755
+ * of surfacing as an unexplained latency spike somewhere with no connection
1756
+ * information in it.
1757
+ *
1758
+ * **What each dialect can actually enforce** — the drivers differ, and the
1759
+ * difference is not papered over:
1760
+ * - **postgres** — exact. Maps to node-postgres' `connectionTimeoutMillis`,
1761
+ * which bounds BOTH establishing a new connection and waiting in the
1762
+ * pool's pending queue for a busy one (`pg-pool`'s `_pendingQueue`
1763
+ * timer). This is the full guarantee.
1764
+ * - **mysql/mariadb** — establishment only, and that is the whole of it.
1765
+ * mysql2's pool has NO time-based acquire bound at all
1766
+ * (`lib/base/pool.js` pushes the callback onto `_connectionQueue` with no
1767
+ * timer). The waiting half is UNBOUNDED by default and deliberately so —
1768
+ * see {@link ConnectionConfig.acquireQueueLimit} for the length bound
1769
+ * that exists, why it is opt-in, and what it costs.
1770
+ * - **mssql** — establishment only. Maps to `@effect/sql-mssql`'s
1771
+ * `connectTimeout` (tedious connect + the boot `SELECT 1` probe); its
1772
+ * pool is an Effect `Pool` whose `get` has no timeout knob.
1773
+ * - **sqlite** — ignored. One in-process connection, no pool to exhaust.
1774
+ */
1775
+ readonly acquireTimeoutMs?: number;
1776
+ /**
1777
+ * **mysql/mariadb only, OPT-IN, and a hazard** — how many callers may be
1778
+ * QUEUED waiting for a free pooled connection before the next one fails
1779
+ * immediately (mysql2's `queueLimit`). Unset by default (mysql2's own `0` =
1780
+ * unbounded queue). Env `DB_ACQUIRE_QUEUE_LIMIT`.
1781
+ *
1782
+ * It looks like the mysql substitute for `acquireTimeoutMs`, which mysql2
1783
+ * cannot express for the waiting phase. **It is not, and a default of 100
1784
+ * shipped for exactly one change set before it wedged a process.** A LENGTH
1785
+ * bound and a TIME bound are not interchangeable, and the difference is the
1786
+ * reason this is opt-in:
1787
+ *
1788
+ * - A time bound self-throttles. The acquire fails only after the wait has
1789
+ * elapsed, so a caller that retries on failure retries no faster than the
1790
+ * timeout — a busy loop is impossible.
1791
+ * - A length bound is free. Past the limit mysql2 rejects the acquire
1792
+ * **synchronously** (`lib/base/pool.js` — the queue-limit branch is the one
1793
+ * error return that does not go through `process.nextTick`), so a caller
1794
+ * that retries on failure retries at CPU speed, in the same tick, forever.
1795
+ * The event loop is never reached again: no timer fires, nothing drains the
1796
+ * queue that caused the rejection, and nothing is logged.
1797
+ *
1798
+ * That caller is not hypothetical and not ours to fix: `@effect/cluster`
1799
+ * releases shards one statement per shard (300 by default) and wraps each in
1800
+ * `Effect.eventually` — retry until success, no schedule, no delay, logged at
1801
+ * debug. On a `shardLockDisableAdvisory` (Galera / PXC) runner those releases
1802
+ * are pooled `DELETE FROM cluster_locks` statements, so every graceful
1803
+ * shutdown queued ~290 acquires against a 10-connection pool, crossed the
1804
+ * limit, and spun the process at 100% CPU with no output.
1805
+ *
1806
+ * So: set it only where you know nothing retries an acquire failure without a
1807
+ * delay, and set it well above the fan-out of anything that might.
1808
+ *
1809
+ * Other dialects ignore it: postgres bounds the wait by TIME (which is
1810
+ * strictly better — it does not care how many are ahead of you), and mssql's
1811
+ * Effect `Pool` exposes neither.
1812
+ */
1813
+ readonly acquireQueueLimit?: number;
1567
1814
  /**
1568
1815
  * Per-statement timeout in ms — a runaway query (missing index, cartesian
1569
1816
  * join) is aborted instead of holding a pooled connection forever, which under
@@ -1602,6 +1849,22 @@ export declare interface ConnectionConfig {
1602
1849
  readonly schema?: string;
1603
1850
  }
1604
1851
 
1852
+ /**
1853
+ * A stable, non-disclosing id for the database a connection points at.
1854
+ *
1855
+ * `undefined` when the inputs do not identify one — which a caller must treat as
1856
+ * "cannot tell", never as "different".
1857
+ */
1858
+ export declare const connectionIdentity: (input: ConnectionIdentityInput) => string | undefined;
1859
+
1860
+ export declare interface ConnectionIdentityInput {
1861
+ readonly dialect?: string | undefined;
1862
+ readonly url?: string | undefined;
1863
+ readonly host?: string | undefined;
1864
+ readonly port?: number | undefined;
1865
+ readonly database?: string | undefined;
1866
+ }
1867
+
1605
1868
  /** Case-insensitive substring match on a text column. Non-indexable —
1606
1869
  * always evaluated as an unindexed leaf (the matcher's pre-filter only
1607
1870
  * recognizes eq/in/range). Lowers to `ILIKE '%…%'` on Postgres. */
@@ -2012,6 +2275,29 @@ export declare interface DbEnumHandle<Values extends ReadonlyArray<string>> {
2012
2275
  readonly column: () => ColumnBuilder<Values[number], 'enum'>;
2013
2276
  }
2014
2277
 
2278
+ export declare const dbMetricTagCacheSizeForTests: () => number;
2279
+
2280
+ /**
2281
+ * What kind of database work is being timed.
2282
+ *
2283
+ * A CLOSED union on purpose — it is a metric label, and a label whose value set
2284
+ * is open is a cardinality incident waiting for the first caller who passes a
2285
+ * table name. Adding a member here is a deliberate act with a matching entry in
2286
+ * the docs' metrics table.
2287
+ */
2288
+ export declare type DbOp = 'select' | 'insert' | 'update' | 'delete' | 'upsert' | 'raw' | 'transaction' | 'ddl';
2289
+
2290
+ /** Current in-flight count for `dialect` — the gauge's value, for tests. */
2291
+ export declare const dbOperationsInFlight: (dialect: string) => number;
2292
+
2293
+ /** One completed database operation. */
2294
+ export declare interface DbOpSample {
2295
+ readonly dialect: DialectId | string;
2296
+ readonly op: DbOp;
2297
+ readonly durationMs: number;
2298
+ readonly status: 'ok' | 'error';
2299
+ }
2300
+
2015
2301
  /**
2016
2302
  * Fixed-point exact-decimal column — money, tax rates, anything where
2017
2303
  * floating-point rounding is unacceptable. Emits `NUMERIC(precision, scale)`
@@ -2089,6 +2375,34 @@ export declare const decryptFieldsOnRead: (rows: ReadonlyArray<Row>, table: Tabl
2089
2375
  }) => void;
2090
2376
  }) => ReadonlyArray<Row>;
2091
2377
 
2378
+ /**
2379
+ * 10 s — the framework's default `ConnectionConfig.acquireTimeoutMs`.
2380
+ *
2381
+ * Long enough that a healthy pool under a burst never trips it (a connection is
2382
+ * normally handed over in single-digit milliseconds) and short enough that "the
2383
+ * pool is exhausted" reaches a log line while it is still the answer to a
2384
+ * question somebody is asking. Every dialect that can bound an acquire reads
2385
+ * THIS constant, so the number is one decision rather than four.
2386
+ */
2387
+ export declare const DEFAULT_ACQUIRE_TIMEOUT_MS = 10000;
2388
+
2389
+ /**
2390
+ * How often a persistent fallback is allowed to say so.
2391
+ *
2392
+ * A once-per-table warning (the shape `makeOversizedReporter` uses) is right for
2393
+ * a condition that either recovers or does not recur. This one is different: it
2394
+ * is a PERMANENT per-query cliff, and after the single line scrolls out of the
2395
+ * log there is nothing left to find. Re-warning on an interval keeps a
2396
+ * still-degraded query present in a log an operator greps a week later, without
2397
+ * turning a hot path into a log flood.
2398
+ *
2399
+ * Env: `VOLTRO_DB_EAGER_FALLBACK_WARN_INTERVAL_MS`. `0` warns once per
2400
+ * (table, reason) and never again.
2401
+ */
2402
+ export declare const DEFAULT_EAGER_FALLBACK_WARN_INTERVAL_MS = 300000;
2403
+
2404
+ export declare const DEFAULT_SUBJECT_GRAPH_DEPTH = 4;
2405
+
2092
2406
  export declare const defineMigration: (input: MigrationDefinitionInput) => MigrationDefinition;
2093
2407
 
2094
2408
  /**
@@ -2111,6 +2425,42 @@ export declare const defineSeed: (input: SeedDefinitionInput) => SeedDefinition;
2111
2425
 
2112
2426
  export declare const denseRank: (alias?: string) => WindowBuilder;
2113
2427
 
2428
+ /**
2429
+ * Walk the schema outward from `subjectTable` and return every route to a table
2430
+ * holding that subject's rows.
2431
+ *
2432
+ * Breadth-first with a visited set keyed on (table, foreignKey), so a cyclic
2433
+ * schema terminates and a table reachable two ways contributes both routes ONCE
2434
+ * each rather than looping. Two tables can legitimately be reached by different
2435
+ * columns (`createdBy` and `updatedBy` both pointing at `actors`) and both are
2436
+ * kept — dropping one would silently narrow an erasure.
2437
+ */
2438
+ export declare const deriveSubjectGraph: (input: DeriveSubjectGraphInput) => SubjectGraph;
2439
+
2440
+ export declare interface DeriveSubjectGraphInput {
2441
+ /** The table whose primary key IS the subject id (`users`, `actors`, …). */
2442
+ readonly subjectTable: string;
2443
+ /** Every registered table. Defaults to the live table registry at call time —
2444
+ * a default rather than a fixed read, so a test can hand in a schema without
2445
+ * writing to a process-global other suites also use. */
2446
+ readonly tables?: ReadonlyArray<TableLike>;
2447
+ /** Every registered relation, as `allRegisteredRelations()` returns them.
2448
+ * Defaults to the live relations registry. */
2449
+ readonly relations?: ReadonlyArray<{
2450
+ readonly source: string;
2451
+ readonly name: string;
2452
+ readonly relation: Relation;
2453
+ }>;
2454
+ /**
2455
+ * Hop ceiling. 4 is deep enough for `user → org-membership → project → task`
2456
+ * and shallow enough that a densely-linked schema does not fan out into every
2457
+ * table it has. Anything cut is REPORTED as `depth-truncated`.
2458
+ */
2459
+ readonly maxDepth?: number;
2460
+ /** Tables never to walk INTO. */
2461
+ readonly exclude?: ReadonlyArray<string>;
2462
+ }
2463
+
2114
2464
  /**
2115
2465
  * Lower-cases + naively singularizes a table name into a typeid prefix.
2116
2466
  *
@@ -2167,6 +2517,57 @@ export declare interface DialectReplicationAdapter {
2167
2517
  compare(primaryPosition: string, replicaPosition: string): CaughtUpVerdict;
2168
2518
  }
2169
2519
 
2520
+ /**
2521
+ * The parts of a transaction that are genuinely the DIALECT's. Anything not in
2522
+ * here is owned by `runRetryingTransaction` / `runStoreTransaction` and must not
2523
+ * be re-decided per store — see the header.
2524
+ */
2525
+ export declare interface DialectTransactionSpec {
2526
+ /** `<Store>.transactional` / `<Store>.runInNamespace` — prefixes the
2527
+ * missing-connection error so a failure names the entry point it came from. */
2528
+ readonly label: string;
2529
+ /**
2530
+ * Which dialect this transaction belongs to — the `dialect` label on
2531
+ * `voltro_db_queries_total{op="transaction"}` and its duration histogram.
2532
+ *
2533
+ * REQUIRED rather than derived from `span.attributes['db.system']`: that
2534
+ * field carries OpenTelemetry's spelling (`postgresql`, not `postgres`) and
2535
+ * mysql and mariadb share one value there, so a metric derived from it would
2536
+ * silently merge two dialects. Required also means a new dialect store cannot
2537
+ * be written without deciding what its transactions are called.
2538
+ */
2539
+ readonly dialect: DialectId;
2540
+ /** The store's `sql.withTransaction` — issues BEGIN/COMMIT/ROLLBACK and
2541
+ * provides `TransactionConnection`. */
2542
+ readonly withTransaction: <A>(effect: Effect.Effect<A, unknown, never>) => Effect.Effect<A, unknown, never>;
2543
+ /** The store's managed runtime, ALWAYS as `runPromiseExit` — `runPromise` is
2544
+ * the bug this file exists to prevent, so the shared bracket never sees one. */
2545
+ readonly runPromiseExit: <A>(effect: Effect.Effect<A, unknown, never>) => Promise<Exit.Exit<A, unknown>>;
2546
+ /** Transient-contention predicate: pg `40001`/`40P01`, mysql deadlock/lock-wait,
2547
+ * sqlite `SQLITE_BUSY`, turso's MVCC write-write conflict, mssql `1205`. Drives
2548
+ * BOTH the retry schedule and the commit-defect promotion. */
2549
+ readonly isRetryable: (e: unknown) => boolean;
2550
+ /** OpenTelemetry span for the whole boundary (retries included). */
2551
+ readonly span: {
2552
+ readonly name: string;
2553
+ readonly attributes: Readonly<Record<string, string>>;
2554
+ };
2555
+ /**
2556
+ * Runs on the transaction's connection BEFORE the caller's work, once per
2557
+ * attempt. Postgres' namespace binding (`SET LOCAL search_path TO "<ns>"`)
2558
+ * is the only current use; it must be inside the transaction, on this
2559
+ * connection, or a pooled handoff leaks the tenant's schema into the next
2560
+ * request.
2561
+ */
2562
+ readonly prepare?: (conn: TransactionConnectionContext) => Effect.Effect<unknown, unknown, never>;
2563
+ /**
2564
+ * Wraps the finished program (retry + span already applied) immediately
2565
+ * before it runs. Turso sets the fiber flag that upgrades `BEGIN` to
2566
+ * `BEGIN CONCURRENT` here; every other dialect omits it.
2567
+ */
2568
+ readonly wrapProgram?: <A>(effect: Effect.Effect<A, unknown, never>) => Effect.Effect<A, unknown, never>;
2569
+ }
2570
+
2170
2571
  /**
2171
2572
  * Drain accumulated WARN-level identifier messages. Called once by
2172
2573
  * `voltro dev` (and `voltro start`) after schema discovery so the boot
@@ -2216,6 +2617,26 @@ export declare class EagerCardinalityError extends Error {
2216
2617
  constructor(message: string);
2217
2618
  }
2218
2619
 
2620
+ export declare interface EagerFallbackEvent {
2621
+ readonly dialect: DialectId | string;
2622
+ readonly table: string;
2623
+ readonly reason: EagerFallbackReason;
2624
+ /** The error the JSON-agg statement threw. Absent for `not-compilable`. */
2625
+ readonly error?: unknown;
2626
+ }
2627
+
2628
+ /** Why an eager-load query is running on the walker instead of JSON-agg. */
2629
+ export declare type EagerFallbackReason =
2630
+ /** `compileEagerJson` returned `null` — the shape is not compilable (an
2631
+ * unregistered relation, an ambiguous inferred FK, or a namespaced read on a
2632
+ * dialect whose JSON-agg compiler does not qualify identifiers). Steady
2633
+ * state for a given query: it will never take the fast path. */
2634
+ 'not-compilable'
2635
+ /** The compiled JSON-agg statement THREW and the walker re-ran the work. This
2636
+ * is the one to alert on: the fast path exists, is being attempted, and is
2637
+ * failing — so every such query pays BOTH paths. */
2638
+ | 'execute-failed';
2639
+
2219
2640
  /**
2220
2641
  * Compiled JSON-aggregation plan: a `Statement.Statement` ready to
2221
2642
  * execute via `SqlClient.SqlClient`, plus a `decode()` that restores
@@ -2447,6 +2868,46 @@ export declare const expires: () => MixinDefinition<{
2447
2868
  readonly expiresAt: ColumnDefinition<Date | null, "timestamp", boolean>;
2448
2869
  }>;
2449
2870
 
2871
+ /**
2872
+ * The ONE place `injectExternalChange`'s origin stamp is decided.
2873
+ *
2874
+ * Every store — memory + all four dialects — used to carry its own
2875
+ * `{ ...event, origin: 'injected' }`, five hand copies of one subtle decision,
2876
+ * which is precisely the shape this repo has twice watched drift (see the
2877
+ * `settleTransactionExit` note in `voltro/CLAUDE.md`). Extracted so the rule has
2878
+ * a single definition and `injectOriginParity.test.ts` can assert no store has
2879
+ * grown a sixth.
2880
+ *
2881
+ * **The rule, and why it is `??` rather than an unconditional stamp.** The seam
2882
+ * means "emit to local subscribers without persisting", and its overwhelming
2883
+ * caller IS a transport (the postgres NOTIFY consumer, the mysql binlog reader,
2884
+ * plugin-broadcast's bus) — so an event that states no origin is stamped
2885
+ * `'injected'`. But one framework caller emits through this seam and is NOT a
2886
+ * transport: `publishReactivity` wakes a reactivity CHANNEL, which has no row,
2887
+ * no database and no peer behind it. That emission happened HERE, and calling it
2888
+ * injected cost two things:
2889
+ *
2890
+ * - a subscriber could not tell its OWN channel publish from a peer's, since
2891
+ * both arrived `'injected'`;
2892
+ * - plugin-broadcast's re-entrancy bracket suppresses re-publishes while it is
2893
+ * injecting, so a channel published synchronously from inside a change
2894
+ * listener never left the replica — silently.
2895
+ *
2896
+ * So a caller that KNOWS the emission is local says so, and that statement wins.
2897
+ *
2898
+ * `attribution` is merged UNDER the event on purpose: an event that already
2899
+ * carries identity came from the originating replica and that identity wins over
2900
+ * anything this replica has pending.
2901
+ */
2902
+ export declare const externalChangeEvent: (event: ChangeEvent, attribution?: Partial<ChangeEventMeta>) => ChangeEvent;
2903
+
2904
+ /** The subset of `@voltro/logger`'s logger this module needs. Structural so
2905
+ * this package keeps its browser-safe import surface. */
2906
+ export declare interface FallbackLogger {
2907
+ readonly warn: (message: string, fields?: Record<string, unknown>) => void;
2908
+ readonly debug: (message: string, fields?: Record<string, unknown>) => void;
2909
+ }
2910
+
2450
2911
  /** The cipher the store middleware injects. Operates on opaque strings. */
2451
2912
  export declare interface FieldCipher {
2452
2913
  readonly encrypt: (plaintext: string) => string;
@@ -2543,6 +3004,26 @@ export declare interface FileMigrationContext {
2543
3004
  readonly appliedAt: string;
2544
3005
  }
2545
3006
 
3007
+ /**
3008
+ * Fire the `onSchemaChange` seeds for the tables an apply just changed.
3009
+ *
3010
+ * Unlike tenant-create this does NOT propagate: a schema apply that succeeded
3011
+ * must not be reported as failed because a data fixture threw. The runner logs
3012
+ * + records the failure in `_voltro_seeds`.
3013
+ */
3014
+ export declare const fireSchemaChangeSeeds: (event: SchemaChangeSeedEvent) => Promise<void>;
3015
+
3016
+ /**
3017
+ * Fire the `onTenantCreate` seeds for a freshly provisioned namespace.
3018
+ *
3019
+ * Awaited, and a rejection PROPAGATES to the provisioning caller on purpose:
3020
+ * a tenant whose namespace exists but whose seed data does not is a broken
3021
+ * tenant that reports success, which is the exact failure class this seam was
3022
+ * built to remove. `provisionTenantNamespace` is idempotent, so the caller's
3023
+ * retry is safe.
3024
+ */
3025
+ export declare const fireTenantCreateSeeds: (event: TenantCreateSeedEvent) => Promise<void>;
3026
+
2546
3027
  /** Flatten an intersection into a single object type for readable errors/hovers. */
2547
3028
  declare type Flatten<O> = {
2548
3029
  [K in keyof O]: O[K];
@@ -2565,6 +3046,10 @@ declare interface FormatIssueOptions {
2565
3046
  readonly indent?: string;
2566
3047
  }
2567
3048
 
3049
+ /** Human-readable rendering of a derived graph — what `voltro privacy scope`
3050
+ * prints, and what a DPO reads before signing off on a DSAR process. */
3051
+ export declare const formatSubjectGraph: (graph: SubjectGraph) => string;
3052
+
2568
3053
  /**
2569
3054
  * Live tables the framework's own runtime creates under a name that does NOT
2570
3055
  * start with a reserved prefix, so the prefix rule below cannot recognise them.
@@ -3140,6 +3625,9 @@ export declare const lead: (column: string, offset?: number, alias?: string) =>
3140
3625
 
3141
3626
  export declare type LeafOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'notIn' | 'contains' | 'startsWith' | 'arrayContains' | 'arrayOverlaps' | 'arrayHas' | 'isNull' | 'isNotNull' | 'spatial';
3142
3627
 
3628
+ /** Every registration conflict since the last {@link clearRetentions}. */
3629
+ export declare const listRetentionConflicts: () => ReadonlyArray<RetentionConflict>;
3630
+
3143
3631
  /** Every currently-registered retention spec. The boot sweep reads this each tick. */
3144
3632
  export declare const listRetentions: () => ReadonlyArray<RetentionSpec>;
3145
3633
 
@@ -3179,14 +3667,40 @@ export declare const lt: <RowOf = Record<string, unknown>, K extends keyof RowOf
3179
3667
 
3180
3668
  export declare const lte: <RowOf = Record<string, unknown>, K extends keyof RowOf & string = keyof RowOf & string>(column: K, value: RowOf[K]) => PredicateLeaf;
3181
3669
 
3670
+ /**
3671
+ * Build the store-scoped fallback reporter: counts EVERY occurrence, logs a
3672
+ * rate-limited line.
3673
+ *
3674
+ * Instance-scoped rather than a module global, for the reason
3675
+ * `makeOversizedReporter` states: the rate limit must be the STORE's, so a
3676
+ * second store in the same process (a read replica, a test harness) cannot
3677
+ * silence the first one's first warning.
3678
+ *
3679
+ * The COUNTER is never rate-limited. That separation is the whole point of
3680
+ * PERF-23 — the log line is for a human reading it at the moment it happens,
3681
+ * and the counter is for the dashboard that has to show a cliff which started
3682
+ * three weeks ago.
3683
+ */
3684
+ export declare const makeEagerFallbackReporter: (log: FallbackLogger, options?: {
3685
+ readonly warnIntervalMs?: number;
3686
+ readonly now?: () => number;
3687
+ }) => ((event: EagerFallbackEvent) => void);
3688
+
3182
3689
  /**
3183
3690
  * A concrete {@link BranchExecutor} for the NAMESPACE mechanism on Postgres — it
3184
3691
  * emits schema DDL + table-copy DML through an injected {@link BranchSqlRunner}.
3185
3692
  * `createTable` clones the parent table's STRUCTURE (`LIKE … INCLUDING ALL`);
3186
- * `copyTable` snapshots its rows (`INSERT … SELECT`); teardown drops the schema.
3693
+ * `copyTable` snapshots its rows (`INSERT … SELECT`); `addForeignKey` replays the
3694
+ * referential integrity `LIKE` does not carry; teardown drops the schema.
3187
3695
  * The Neon ops throw — a copy-on-write branch needs a Neon connection (which
3188
3696
  * resolves to the `neon-cow` mechanism + its own executor).
3189
3697
  *
3698
+ * POSTGRES ONLY, and the name is the whole warning: every statement here is
3699
+ * postgres syntax (`CREATE SCHEMA`, `LIKE … INCLUDING ALL`, `"` quoting). MySQL /
3700
+ * MariaDB spell the clone `CREATE TABLE x LIKE y` and quote with backticks;
3701
+ * sqlite has no schema namespace to create. A caller on another dialect needs
3702
+ * its own executor — the PLAN is dialect-agnostic, this executor is not.
3703
+ *
3190
3704
  * Pure SQL emission: unit-tested with a recording runner (no live DB to BUILD or
3191
3705
  * TEST — only to RUN, like any SQL). The cloud wires `run` to its owned store.
3192
3706
  */
@@ -3482,6 +3996,15 @@ export declare const neq: <RowOf = Record<string, unknown>, K extends keyof RowO
3482
3996
  * double-destroy or a provision-after-destroyed can't corrupt state). */
3483
3997
  export declare const nextBranchState: (current: BranchState, event: BranchEvent) => BranchState;
3484
3998
 
3999
+ /** The normalised `(host, port, database)` a connection points at, before
4000
+ * hashing. Exported for the tests — the digest is not readable, and a
4001
+ * normalisation bug in it would be invisible otherwise. */
4002
+ export declare const normaliseTarget: (input: ConnectionIdentityInput) => {
4003
+ readonly host: string;
4004
+ readonly port: number;
4005
+ readonly database: string;
4006
+ } | undefined;
4007
+
3485
4008
  /** Negate a sub-predicate: `not(eq('archived', true))` → `NOT (...)`.
3486
4009
  * Wraps any subtree, including `and(...)`/`or(...)`. */
3487
4010
  export declare const not: (predicate: Predicate) => NotPredicate;
@@ -3506,6 +4029,16 @@ export declare interface NotPredicate {
3506
4029
  */
3507
4030
  export declare const numeric: (precision: number, scale?: number) => ColumnBuilder<string, "decimal">;
3508
4031
 
4032
+ /**
4033
+ * Time `run`, count it, and hold the in-flight gauge for its duration.
4034
+ *
4035
+ * The `finally` is load-bearing in two directions: a throw must still be TIMED
4036
+ * (a statement that fails after 30 seconds is the interesting one) and must
4037
+ * still release the gauge, or a single failing query leaks a permanent +1 and
4038
+ * the saturation signal drifts upward forever.
4039
+ */
4040
+ export declare const observeDbOp: <T>(dialect: DialectId | string, op: DbOp, run: () => Promise<T>) => Promise<T>;
4041
+
3509
4042
  /**
3510
4043
  * What a decrypt failure does. `'throw'` (default, and the ONLY safe production
3511
4044
  * behaviour) surfaces a typed `FieldDecryptionError`. `'null'` degrades the one
@@ -3650,10 +4183,18 @@ export declare const planBranch: (input: BranchPlanInput) => ReadonlyArray<Branc
3650
4183
  /**
3651
4184
  * The ordered steps to stand a branch up — what the cloud executor runs
3652
4185
  * (create-namespace + create-table via `provisionTenantNamespace`, copy-table
3653
- * via cross-namespace `INSERT … SELECT`, seed via the boot seeds). Pure plan, so
4186
+ * via cross-namespace `INSERT … SELECT`, copy-foreign-key via
4187
+ * `ALTER TABLE … ADD CONSTRAINT`, seed via the boot seeds). Pure plan, so
3654
4188
  * it's unit-testable; the executor is the cloud side.
4189
+ *
4190
+ * The FK replay is sequenced AFTER the row copy on purpose: `INSERT … SELECT`
4191
+ * per table has no topological order, so a child table copied before its parent
4192
+ * would violate a constraint that is only correct once every table is populated.
4193
+ * It is sequenced BEFORE the `fresh` seed for the mirror-image reason — seeds
4194
+ * write through the app's own paths and should meet the same integrity rules
4195
+ * production has.
3655
4196
  */
3656
- export declare const planBranchProvision: (branchNamespace: string, tableNames: ReadonlyArray<string>, seed: BranchSeed, parentNamespace?: string) => ReadonlyArray<BranchStep>;
4197
+ export declare const planBranchProvision: (branchNamespace: string, tableNames: ReadonlyArray<string>, seed: BranchSeed, parentNamespace?: string, foreignKeys?: ReadonlyArray<BranchForeignKey>, indexNames?: ReadonlyArray<BranchIndexName>) => ReadonlyArray<BranchStep>;
3657
4198
 
3658
4199
  /** The teardown plan — drop the whole branch namespace (idempotent on the
3659
4200
  * executor side). */
@@ -4583,11 +5124,22 @@ declare interface RawSqlFragment {
4583
5124
  readonly strings: ReadonlyArray<string>;
4584
5125
  readonly values: ReadonlyArray<unknown>;
4585
5126
  /**
4586
- * Tables this raw read depends on, for reactive invalidation. Raw
4587
- * reads are otherwise untracked — the query planner can't infer which
4588
- * tables an arbitrary SQL string touches. Declare them here (or via
4589
- * `store.raw(fragment, { dependsOn })`) to opt the read into the
4590
- * reactive layer's change-driven recomputation.
5127
+ * Tables this raw read depends on, for reactive invalidation. Raw reads are
5128
+ * otherwise untracked — the query planner cannot infer which tables an
5129
+ * arbitrary SQL string touches. Declare them here (or via
5130
+ * `store.raw(fragment, { dependsOn })`).
5131
+ *
5132
+ * WHAT IT DRIVES, stated narrowly because the previous wording promised more
5133
+ * than any code delivered. For a live query whose handler returns a COMPUTED
5134
+ * VALUE — the shape whose handler is genuinely re-run on a change — these
5135
+ * tables JOIN the query's declared `source:`, so a write to one recomputes the
5136
+ * subscription. That is the only shape where re-running can refresh a raw
5137
+ * result. A handler that returns a query DESCRIPTOR re-runs the DESCRIPTOR on
5138
+ * a change, not the handler, so declaring tables cannot refresh the raw read
5139
+ * there; the runtime warns about that case at subscribe time instead.
5140
+ *
5141
+ * Best-effort in both directions: the tables are recorded as declared and
5142
+ * never validated against the SQL.
4591
5143
  */
4592
5144
  readonly dependsOn?: ReadonlyArray<string>;
4593
5145
  }
@@ -4617,6 +5169,15 @@ export declare const readableByColumns: (table: TableLike) => ReadonlyArray<Read
4617
5169
  * would truncate. */
4618
5170
  export declare const real: () => ColumnBuilder<number, "real", boolean>;
4619
5171
 
5172
+ /**
5173
+ * Record one completed operation: the counter, the duration histogram, and — on
5174
+ * a throw — the error counter.
5175
+ *
5176
+ * Prefer {@link observeDbOp}, which also moves the in-flight gauge and cannot
5177
+ * forget the `error` arm on a throw.
5178
+ */
5179
+ export declare const recordDbOp: (s: DbOpSample) => void;
5180
+
4620
5181
  /** One write, as the store sees it at the moment it happens. */
4621
5182
  export declare interface RecordedWrite {
4622
5183
  readonly table: string;
@@ -4733,7 +5294,33 @@ export declare const registerPendingAttribution: (key: string, attribution: Writ
4733
5294
  */
4734
5295
  export declare const registerRelations: (spec: RelationsSpec) => void;
4735
5296
 
4736
- /** Register (or replace) the retention bound for a table. Idempotent per table. */
5297
+ /**
5298
+ * Register the retention bound for a table.
5299
+ *
5300
+ * ── Two registrations for one table used to be a silent last-write-wins ─────
5301
+ *
5302
+ * A consumer registered `_voltro_schedule_claims` at 1 hour from a startup, and
5303
+ * one second later the framework registered its own default for the same table.
5304
+ * Ours won, nothing said so, and their startup went on logging `bounded to 1h`
5305
+ * at every boot while the table kept everything younger than the framework's
5306
+ * TTL. They found it by counting rows, not by reading a log.
5307
+ *
5308
+ * `registry.set(table, spec)` is what did that, and the comment above it —
5309
+ * "so a double-registration (e.g. plugin re-init) is idempotent" — describes a
5310
+ * case that really exists (a plugin re-registering ITS OWN spec) and silently
5311
+ * covered a different one.
5312
+ *
5313
+ * **Precedence now decides, and a real conflict is always reported.** An app's
5314
+ * registration outranks a plugin's, which outranks a framework default; a tie
5315
+ * keeps the later one, as before. `source` defaults to `'app'`, so an
5316
+ * application does not have to know the field exists to win with it.
5317
+ *
5318
+ * The direction was not obvious and is worth stating: the loser here is chosen
5319
+ * by WHO registered, not by which TTL is narrower. A narrower TTL deletes more,
5320
+ * and picking "narrower wins" would let a framework default we tighten in some
5321
+ * future release silently start deleting an app's data faster than the app
5322
+ * asked for. Whoever owns the data decides; we are the fallback.
5323
+ */
4737
5324
  export declare const registerRetention: (spec: RetentionSpec) => void;
4738
5325
 
4739
5326
  /**
@@ -4808,6 +5395,9 @@ export declare const requireTable: (tableName: string) => TableLike;
4808
5395
 
4809
5396
  export declare const requireTenants: () => (() => TableLike);
4810
5397
 
5398
+ /** Test seams for the cardinality guard — the caches are module-private otherwise. */
5399
+ export declare const resetDbMetricTagCacheForTests: () => void;
5400
+
4811
5401
  /**
4812
5402
  * Re-read SNOWFLAKE_MACHINE_ID from the environment. Use in tests or
4813
5403
  * after a runtime reconfiguration. Production code should set the env
@@ -4874,6 +5464,13 @@ export declare const resolveActorSnapshot: (store: ActorLookupStore, subjectId:
4874
5464
  */
4875
5465
  export declare const resolveBranchMechanism: (opts: BranchMechanismOptions) => BranchMechanism;
4876
5466
 
5467
+ /** Resolve the re-warn interval. Env overrides the caller, like every other
5468
+ * tunable here; an unparseable or negative value falls through to the default
5469
+ * rather than being honoured. */
5470
+ export declare const resolveEagerFallbackWarnIntervalMs: (configured?: number | undefined, env?: {
5471
+ readonly VOLTRO_DB_EAGER_FALLBACK_WARN_INTERVAL_MS?: string;
5472
+ }) => number;
5473
+
4877
5474
  /**
4878
5475
  * Answer a transport echo with the attribution of the local write that produced
4879
5476
  * it, or with `undefined` when the write came from another replica.
@@ -4943,6 +5540,22 @@ export declare const resolveTenantHome: (tenantId: string | null | undefined, co
4943
5540
  */
4944
5541
  export declare const resolveTenantNamespace: (tenantId: string | null | undefined) => string;
4945
5542
 
5543
+ export declare interface RetentionConflict {
5544
+ readonly table: string;
5545
+ readonly kept: {
5546
+ readonly source: RetentionSource;
5547
+ readonly ttlMs: number;
5548
+ };
5549
+ readonly dropped: {
5550
+ readonly source: RetentionSource;
5551
+ readonly ttlMs: number;
5552
+ };
5553
+ }
5554
+
5555
+ /** Who registered a bound. Higher precedence first: an app's deliberate policy
5556
+ * outranks a plugin's, which outranks a framework default. */
5557
+ export declare type RetentionSource = 'app' | 'plugin' | 'framework';
5558
+
4946
5559
  /** A registered bound on one append-only table. */
4947
5560
  export declare interface RetentionSpec {
4948
5561
  /** Physical table name, e.g. `_voltro_schedule_runs`. */
@@ -4961,6 +5574,16 @@ export declare interface RetentionSpec {
4961
5574
  * plain time-only purge.
4962
5575
  */
4963
5576
  readonly where?: Predicate;
5577
+ /**
5578
+ * Who registered this. Decides which registration wins when two arrive for
5579
+ * one table — see {@link registerRetention}.
5580
+ *
5581
+ * DEFAULTS TO `'app'`, and that default is the feature: an application
5582
+ * registering a bound has said something deliberate about ITS data, so it
5583
+ * outranks a framework default without having to know this field exists. The
5584
+ * framework and plugins pass their own value explicitly.
5585
+ */
5586
+ readonly source?: RetentionSource;
4964
5587
  /** Optional human label for logs (defaults to the table name). */
4965
5588
  readonly label?: string;
4966
5589
  /**
@@ -5048,6 +5671,57 @@ export declare interface RuleViolationDetail {
5048
5671
  readonly message?: string;
5049
5672
  }
5050
5673
 
5674
+ /**
5675
+ * Run `body` inside one retrying transaction and settle its Exit.
5676
+ *
5677
+ * Owns, for every dialect:
5678
+ * 1. the connection handshake — `withTransaction` + `TransactionConnection`,
5679
+ * with a loud failure rather than a silent non-transactional run when the
5680
+ * service is somehow absent;
5681
+ * 2. **commit-defect promotion** — `@effect/sql` runs COMMIT as `Effect.orDie`,
5682
+ * so a serialization/deadlock error raised AT COMMIT arrives as a defect and
5683
+ * `Effect.retry` never sees it. A retryable defect is promoted back to a
5684
+ * typed failure so the schedule can replay it; anything else re-dies, so
5685
+ * genuine defects keep crash semantics;
5686
+ * 3. the retry schedule — exponential backoff from 10ms, up to 3 retries,
5687
+ * gated on the dialect's `isRetryable`. Each attempt re-runs `body` from
5688
+ * scratch against a FRESH transaction;
5689
+ * 4. the span, covering all attempts as one logical operation;
5690
+ * 5. settling through `settleTransactionExit`, never `runPromise`.
5691
+ *
5692
+ * `body` receives the attempt number (1-based) so a caller can build per-attempt
5693
+ * state that must not be shared across a retry.
5694
+ */
5695
+ export declare const runRetryingTransaction: <A>(spec: DialectTransactionSpec & {
5696
+ readonly body: (conn: TransactionConnectionContext, attempt: number) => Promise<A>;
5697
+ }) => Promise<A>;
5698
+
5699
+ /**
5700
+ * THE entry point every dialect store's `transactional()` — and postgres'
5701
+ * `runInNamespace()` — routes through.
5702
+ *
5703
+ * On top of `runRetryingTransaction` it owns the two things that are about the
5704
+ * CALLER rather than the engine:
5705
+ *
5706
+ * - **write attribution.** Captured HERE, synchronously, before any await:
5707
+ * `transactional()` is entered from the request's async-local scope but the
5708
+ * callback runs from inside the Effect, where — measured against live
5709
+ * postgres — that scope is EMPTY. The captured value is handed to
5710
+ * `makeView` so every write the view makes carries the identity explicitly
5711
+ * (a pool handoff cannot strand it), and the scope is re-entered around
5712
+ * `work` for anything the view reaches that is not threaded. `undefined`
5713
+ * stays `undefined`: "no request behind this write" and "a request that knew
5714
+ * nothing" are different facts and must not blur.
5715
+ * - **event drain ordering.** A fresh view per attempt, and `commitEvents()`
5716
+ * only after the Exit settled successfully — so a retried attempt's buffered
5717
+ * events are dropped with its unreachable view and subscribers see exactly
5718
+ * the winning attempt, once.
5719
+ */
5720
+ export declare const runStoreTransaction: <T, V extends DataStore & TransactionalViewHandle>(spec: DialectTransactionSpec & {
5721
+ readonly work: (tx: DataStore) => Promise<T>;
5722
+ readonly makeView: (conn: TransactionConnectionContext, attr: WriteAttribution | undefined) => V;
5723
+ }) => Promise<T>;
5724
+
5051
5725
  /**
5052
5726
  * Run `fn` with `attribution` active for its whole (sync + awaited) execution.
5053
5727
  *
@@ -5065,6 +5739,16 @@ export declare const runWithWriteAttribution: <T>(attribution: WriteAttribution,
5065
5739
  */
5066
5740
  export declare const runWriteRecorders: (port: TxnRecorderPort, write: RecordedWrite) => Promise<void>;
5067
5741
 
5742
+ /**
5743
+ * Are two connections pointed at the same database?
5744
+ *
5745
+ * Three answers, and the third is the load-bearing one: `'unknown'` means at
5746
+ * least one side could not be identified, and the caller keeps whatever
5747
+ * conservative behaviour it had. Collapsing that into `false` is how a guard
5748
+ * stops guarding.
5749
+ */
5750
+ export declare const sameDatabase: (a: string | undefined, b: string | undefined) => "same" | "different" | "unknown";
5751
+
5068
5752
  /**
5069
5753
  * Lower a tenant id to the safe identifier fragment used inside a
5070
5754
  * namespace name. ONLY `[a-z0-9_]` survive; every other character —
@@ -5082,6 +5766,15 @@ export declare const runWriteRecorders: (port: TxnRecorderPort, write: RecordedW
5082
5766
  */
5083
5767
  export declare const sanitizeIdentifierFragment: (tenantId: string) => string;
5084
5768
 
5769
+ /** What a schema-change firing knows: which tables the apply actually touched.
5770
+ * A seed watches table NAMES (`watchedTables`), so this is the list the
5771
+ * runner intersects against — an empty list fires nothing. */
5772
+ export declare interface SchemaChangeSeedEvent {
5773
+ readonly changedTables: ReadonlyArray<string>;
5774
+ }
5775
+
5776
+ export declare type SchemaChangeSeedHook = (event: SchemaChangeSeedEvent) => Promise<void>;
5777
+
5085
5778
  /**
5086
5779
  * The PUBLIC structural view of a built table — name, columns, and applied
5087
5780
  * indexes — with NONE of the fluent builder methods.
@@ -5129,6 +5822,7 @@ export declare interface SeedDefinition {
5129
5822
  readonly name: string;
5130
5823
  readonly lifecycle: SeedLifecycle;
5131
5824
  readonly cron?: string;
5825
+ readonly timezone?: string;
5132
5826
  readonly watchedTables?: ReadonlyArray<string>;
5133
5827
  readonly fingerprint?: (ctx: {
5134
5828
  src: string;
@@ -5143,6 +5837,15 @@ export declare interface SeedDefinitionInput {
5143
5837
  readonly lifecycle: SeedLifecycle;
5144
5838
  /** Required for `lifecycle: 'cron'`. Standard cron expression or `@hourly`/`@daily` etc. */
5145
5839
  readonly cron?: string;
5840
+ /**
5841
+ * IANA timezone the `cron` expression is read in. Default `'UTC'`.
5842
+ *
5843
+ * Defaulted rather than required, unlike `defineSchedule`'s: a seed is
5844
+ * reference data, so "02:00 in Europe/Berlin" is a much rarer requirement
5845
+ * than it is for a business schedule — but server-local is never the answer,
5846
+ * so the default is an explicit UTC rather than the container's clock.
5847
+ */
5848
+ readonly timezone?: string;
5146
5849
  /** Required for `lifecycle: 'onSchemaChange'`. Table names to watch. */
5147
5850
  readonly watchedTables?: ReadonlyArray<string>;
5148
5851
  /**
@@ -5268,6 +5971,12 @@ export declare const serverOnlyColumns: (table: TableLike) => ReadonlyArray<stri
5268
5971
  * to the pure resolvers in tests instead. */
5269
5972
  export declare const setResidencyConfig: (config: ResidencyConfig) => ResidencyConfig;
5270
5973
 
5974
+ /** Install (or, with `null`, remove) the `onSchemaChange` runner. */
5975
+ export declare const setSchemaChangeSeedHook: (hook: SchemaChangeSeedHook | null) => void;
5976
+
5977
+ /** Install (or, with `null`, remove) the `onTenantCreate` runner. */
5978
+ export declare const setTenantCreateSeedHook: (hook: TenantCreateSeedHook | null) => void;
5979
+
5271
5980
  /**
5272
5981
  * Unwrap a transaction program's `Exit`: return the value, or throw the
5273
5982
  * ORIGINAL error rather than Effect's `FiberFailure` wrapper.
@@ -5490,6 +6199,51 @@ export declare interface StreamTableOptions {
5490
6199
  readonly retry?: Schedule.Schedule<unknown, TableStreamError>;
5491
6200
  }
5492
6201
 
6202
+ export declare interface SubjectGraph {
6203
+ readonly subjectTable: string;
6204
+ readonly paths: ReadonlyArray<SubjectPath>;
6205
+ readonly limitations: ReadonlyArray<SubjectGraphLimitation>;
6206
+ }
6207
+
6208
+ /** A known blind spot in a derived graph. Always reported. */
6209
+ export declare interface SubjectGraphLimitation {
6210
+ readonly kind:
6211
+ /** BFS hit `maxDepth`; tables beyond `table` were not explored. */
6212
+ 'depth-truncated'
6213
+ /** A table nothing links to the subject — invisible to this derivation. */
6214
+ | 'unreachable'
6215
+ /** Excluded by configuration. */
6216
+ | 'excluded';
6217
+ readonly table: string;
6218
+ readonly detail: string;
6219
+ }
6220
+
6221
+ /** One hop away from the previous table. */
6222
+ export declare interface SubjectHop {
6223
+ /** The table this hop lands on. */
6224
+ readonly table: string;
6225
+ /** Column on `table` holding the PREVIOUS row's key. */
6226
+ readonly foreignKey: string;
6227
+ /** Column on the PREVIOUS table whose value `foreignKey` holds. Almost always
6228
+ * `id`; `sourceKey` on a relation can move it. */
6229
+ readonly parentKey: string;
6230
+ }
6231
+
6232
+ /**
6233
+ * An ordered route from the subject table to a table holding their data.
6234
+ * `hops` is empty for the subject table itself (the row keyed by the subject id).
6235
+ */
6236
+ export declare interface SubjectPath {
6237
+ /** Terminal table — where rows are read/erased. */
6238
+ readonly table: string;
6239
+ readonly hops: ReadonlyArray<SubjectHop>;
6240
+ /** `hops.length`. 0 = the subject row itself, 1 = a direct child. */
6241
+ readonly depth: number;
6242
+ /** Which declaration produced it — a declared `relations()` block, an
6243
+ * inferred `reference()` column, or an app-supplied `subjectScopes` entry. */
6244
+ readonly via: 'relation' | 'reference' | 'declared';
6245
+ }
6246
+
5493
6247
  /**
5494
6248
  * `col IN (SELECT col FROM ...)` / `col NOT IN (SELECT ...)` — the
5495
6249
  * RHS is a sub-query descriptor. The sub-query MUST project a
@@ -6117,6 +6871,17 @@ export declare interface TableUnique {
6117
6871
  */
6118
6872
  export declare const teardownBranch: (branchId: string, mechanism: BranchMechanism, executor: BranchExecutor) => Promise<ReadonlyArray<BranchStep>>;
6119
6873
 
6874
+ /** What a tenant-create firing knows: which namespace was just provisioned. */
6875
+ export declare interface TenantCreateSeedEvent {
6876
+ /** The namespace (postgres schema / mysql database / attached sqlite file)
6877
+ * that was just created. Seeds run scoped to THIS namespace, never the
6878
+ * shared one. */
6879
+ readonly namespace: string;
6880
+ readonly dialect: string;
6881
+ }
6882
+
6883
+ export declare type TenantCreateSeedHook = (event: TenantCreateSeedEvent) => Promise<void>;
6884
+
6120
6885
  /** A tenant's declared home — the region it's pinned to, optionally a named
6121
6886
  * connection/secret key for that region's DB. */
6122
6887
  export declare interface TenantHome {
@@ -6212,6 +6977,22 @@ export declare const timestampMs: Schema.Schema<Date, number>;
6212
6977
  */
6213
6978
  export declare const timestampMsOrNull: Schema.Schema<Date | null, number | null>;
6214
6979
 
6980
+ /**
6981
+ * A store's per-transaction `DataStore` view: buffers the ChangeEvents its
6982
+ * writes produce and drains them to the parent emitter only once the
6983
+ * transaction has actually committed.
6984
+ */
6985
+ export declare interface TransactionalViewHandle {
6986
+ commitEvents(): void;
6987
+ }
6988
+
6989
+ /**
6990
+ * The `[Connection, depth]` tuple `@effect/sql` provides inside
6991
+ * `withTransaction`. Every statement that must run on the transaction's OWN
6992
+ * connection (rather than a fresh one from the pool) is given this explicitly.
6993
+ */
6994
+ export declare type TransactionConnectionContext = Context.Tag.Service<typeof TransactionConnection>;
6995
+
6215
6996
  /** Append one row inside the caller's transaction. Insert-only by design. */
6216
6997
  export declare type TxnAppend = (table: string, row: Row) => Promise<void>;
6217
6998
 
@@ -6730,6 +7511,21 @@ export declare interface WriteAttribution {
6730
7511
  /** `null` is meaningful: a resolved subject with no acting user (an
6731
7512
  * anonymous or system call), as distinct from `undefined` = no request. */
6732
7513
  readonly subjectId?: string | null;
7514
+ /**
7515
+ * The write was made BY AN AGENT on the subject's behalf.
7516
+ *
7517
+ * `subjectId` stays the PERSON — an agent never escalates identity, which is
7518
+ * the whole property `@voltro/ai`'s `agentActor` encodes. So without a second
7519
+ * field, "Anna archived this invoice" and "an agent archived it while acting
7520
+ * as Anna" are the same row, and the difference is the one a reviewer of an
7521
+ * agent rollout actually asks about.
7522
+ *
7523
+ * Set by the transports that KNOW: the app-tool surface reached over MCP
7524
+ * (`@voltro/cli`'s `agentToolSurface`) and any in-process `appTools` caller
7525
+ * that opts in. Absent for an ordinary request — absent means "a human or a
7526
+ * service called this directly", not "unknown".
7527
+ */
7528
+ readonly via?: 'agent';
6733
7529
  }
6734
7530
 
6735
7531
  export declare type WriteRecorder = (port: TxnRecorderPort, write: RecordedWrite) => Promise<void>;