@voltro/database 0.71.0 → 0.72.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
@@ -1791,6 +1791,11 @@ export declare interface ColumnDefinition<TsType, Type extends ColumnType = Colu
1791
1791
  * `boolean` does not distribute. */
1792
1792
  declare type ColumnHasDefault<D> = D extends ColumnDefinition<unknown, ColumnType, infer HD> ? ([HD] extends [true] ? true : false) : false;
1793
1793
 
1794
+ /** A column as the DSL builds it — `text().nullable()`, `integer().default(0)` — or its definition. */
1795
+ export declare type ColumnInput = {
1796
+ __definition(): ColumnDefinition<unknown>;
1797
+ } | ColumnDefinition<unknown>;
1798
+
1794
1799
  /** A column may be omitted from an insert when it is auto-filled, has a default,
1795
1800
  * or is nullable. */
1796
1801
  declare type ColumnOptionalForInsert<K extends PropertyKey, D> = K extends AutoFilledColumn ? true : ColumnHasDefault<D> extends true ? true : null extends InferColumn<D> ? true : false;
@@ -1811,6 +1816,232 @@ declare type ColumnOptionalForInsert<K extends PropertyKey, D> = K extends AutoF
1811
1816
  */
1812
1817
  export declare const columnSchema: <TsType, C extends ColumnType>(def: ColumnDefinition<TsType, C>) => Schema.Schema.Any;
1813
1818
 
1819
+ /**
1820
+ * A column as seen by the planner: either declared in code (extracted
1821
+ * from `ColumnDefinition`) or live in the DB (extracted via introspection).
1822
+ *
1823
+ * The shape is intentionally minimal — only the bits that affect diff
1824
+ * classification land here. Things like `idScheme`, `computed`,
1825
+ * `defaultFactory` etc. live in the runtime layer and don't survive
1826
+ * the round-trip to `information_schema`.
1827
+ */
1828
+ declare interface ColumnSnapshot {
1829
+ readonly name: string;
1830
+ readonly type: ColumnType;
1831
+ readonly nullable: boolean;
1832
+ readonly unique: boolean;
1833
+ readonly hasDefault: boolean;
1834
+ readonly defaultValue?: unknown;
1835
+ /**
1836
+ * Type-narrowing acknowledgement from `.narrowedFrom(from, { using })`.
1837
+ * Only ever set on the DECLARED side; introspection leaves it
1838
+ * undefined (the live DB has no notion of a prior type). When
1839
+ * `from` equals the live column type the planner downgrades the
1840
+ * otherwise-blocked type change to `needs-backfill` and threads
1841
+ * `using` into the `ALTER COLUMN … TYPE … USING <using>` cast. */
1842
+ readonly narrowedFrom?: {
1843
+ readonly from: ColumnType;
1844
+ readonly using?: string;
1845
+ };
1846
+ /**
1847
+ * Bounded text length from `text().maxLength(n)` — carried on BOTH sides.
1848
+ *
1849
+ * DECLARED: from the column definition. LIVE: from introspection, and ONLY when
1850
+ * the dialect materialises a length. An unbounded text family member
1851
+ * (`text`/`longtext`/`mediumtext`/`clob`) leaves this `undefined` rather than
1852
+ * reporting the type's theoretical maximum — mysql answers 4294967295 for
1853
+ * `longtext`, and comparing that against a declared `undefined` would re-emit an
1854
+ * ALTER on every boot forever.
1855
+ *
1856
+ * WHY IT IS HERE AT ALL. It was absent from the snapshot entirely: the value
1857
+ * lived on the column definition, was read only when rendering CREATE DDL, and
1858
+ * never reached the comparison. So the differ was not comparing lengths wrongly
1859
+ * — it could not see them. Adding `.maxLength(n)` to an EXISTING column planned
1860
+ * zero operations and reported "schema is up to date" while the live column
1861
+ * stayed `longtext`. A deployment hit that trying to apply the documented remedy
1862
+ * for MariaDB's hash long-unique, and their contrast is what pinned it: a column
1863
+ * bounded AT CREATION was `varchar(64)` (DDL path, fine), one bounded afterwards
1864
+ * stayed `longtext` (diff path, blind).
1865
+ *
1866
+ * NOT compared on sqlite: it has no length-enforced type, so the declared value
1867
+ * is real and the live side can never report one. Comparing them there would
1868
+ * re-emit forever. See `sameColumnShape`.
1869
+ */
1870
+ readonly maxLength?: number;
1871
+ readonly references?: {
1872
+ readonly table: string;
1873
+ readonly column: string;
1874
+ readonly onDelete?: 'cascade' | 'restrict' | 'setNull' | 'noAction';
1875
+ readonly onUpdate?: 'cascade' | 'restrict' | 'setNull' | 'noAction';
1876
+ /**
1877
+ * Orphan-handling policy for adding this FK to a POPULATED column
1878
+ * (`reference(() => t, { orphanPolicy })`). Only ever set on the
1879
+ * DECLARED side; introspection leaves it undefined (the live DB has
1880
+ * no notion of it). `normRef` strips it before comparison so it never
1881
+ * causes FK churn — the planner reads it directly to classify +
1882
+ * drive the applier's orphan pre-step. See the references-diff in
1883
+ * `classifyColumnChange`.
1884
+ */
1885
+ readonly orphanPolicy?: 'fail' | 'null' | 'delete';
1886
+ /**
1887
+ * The constraint's catalog name, on the LIVE side only. A reader reports
1888
+ * it (postgres, the mysql family, mssql; sqlite names nothing) so a drop
1889
+ * can name the constraint that is actually there rather than assume the
1890
+ * `<table>_<column>_fkey` convention the framework uses when IT creates
1891
+ * one. `normRef` strips it before comparison and before hashing: a name
1892
+ * is a catalog identity, not schema.
1893
+ */
1894
+ readonly constraintName?: string;
1895
+ };
1896
+ /** Physical representation for `reference()`; defaults to `text`. */
1897
+ readonly referenceStorage?: 'text' | 'numeric';
1898
+ /** `oneOf(['a','b'])` → CHECK constraint values. */
1899
+ readonly oneOf?: ReadonlyArray<string>;
1900
+ /** Required/observed JSON validity on SQL Server. Unverified means no
1901
+ * recognized enabled and trusted check proves the existing data valid. */
1902
+ readonly jsonValidation?: 'value' | 'container' | 'unverified';
1903
+ /** Live SQL Server JSON checks recognized by complete predicate shape.
1904
+ * Names are catalog identities for DDL, not portable schema fingerprints. */
1905
+ readonly jsonChecks?: ReadonlyArray<{
1906
+ readonly name: string;
1907
+ readonly validation: 'value' | 'container';
1908
+ readonly enabled: boolean;
1909
+ readonly trusted: boolean;
1910
+ }>;
1911
+ /**
1912
+ * Fixed-point precision + scale for a `decimal(p,s)` / `numeric(p,s)`
1913
+ * column (`type === 'decimal'`). Drives the emitted `NUMERIC(p,s)` /
1914
+ * `DECIMAL(p,s)` DDL. Set on the DECLARED side; introspection leaves them
1915
+ * undefined (the planner compares the `decimal` type structurally, not the
1916
+ * exact precision — an ALTER of precision alone is not auto-classified, same
1917
+ * posture as `generatedAs`). See `snapshotPgType` / `snapshotMysqlType`.
1918
+ */
1919
+ readonly precision?: number;
1920
+ readonly scale?: number;
1921
+ /**
1922
+ * Id-generation scheme for `type: 'id'` columns. Mirrors
1923
+ * `ColumnDefinition.idScheme.kind` so the applier can tell a numeric
1924
+ * auto-increment PK (`kind: 'numeric'`) from a typeid/string PK and
1925
+ * emit the dialect-correct auto-increment DDL (BIGSERIAL / BIGINT
1926
+ * AUTO_INCREMENT / IDENTITY / INTEGER AUTOINCREMENT). Only ever set on
1927
+ * the DECLARED side; introspection leaves it undefined (the live DB
1928
+ * doesn't report a scheme — a numeric PK is detectable there from the
1929
+ * integer type + auto_increment, which the diff doesn't need).
1930
+ */
1931
+ readonly idScheme?: {
1932
+ readonly kind: string;
1933
+ };
1934
+ /**
1935
+ * What the `id` KIND is made of, on the LIVE side.
1936
+ *
1937
+ * Introspection cannot see a DSL spelling, only a column, and it reports the
1938
+ * `id` kind for ANY sole primary key named `id` — a NAME-based inference. So
1939
+ * the kind alone erases the one distinction that matters when the two sides
1940
+ * disagree: a text key and a sequence are both reported as `id`, and a
1941
+ * declaration that changed from one to the other compared EQUAL. That
1942
+ * approximation predates the `text()` spelling and got wider with it.
1943
+ *
1944
+ * This carries the storage the kind was inferred over, derived from the same
1945
+ * raw type the introspector already read. Set by all four dialect readers
1946
+ * wherever they apply the inference, and derived for the DECLARED side from
1947
+ * `idScheme` — so the comparison pairs like with like instead of pairing on
1948
+ * the name.
1949
+ */
1950
+ readonly idStorage?: 'text' | 'numeric';
1951
+ /**
1952
+ * What this column's declared KIND is stored AS, when the two differ and no
1953
+ * reader can report the kind back.
1954
+ *
1955
+ * An `array()` is `<element>[]` on postgres and JSON / NVARCHAR(MAX) / TEXT
1956
+ * elsewhere, and every reader answers with the STORAGE — so the declared
1957
+ * kind and the live one never matched and the table hashed apart from its own
1958
+ * database forever. The planner already collapses this per dialect; the
1959
+ * fingerprint has no dialect, so the DECLARED side states the answer here at
1960
+ * snapshot time (`declaredSnapshot` takes the dialect) and the hash reads it.
1961
+ * Set on the declared side only; a live snapshot needs none, because its type
1962
+ * IS the storage.
1963
+ */
1964
+ readonly storageType?: ColumnType;
1965
+ /**
1966
+ * Verbatim DDL fragment for a `raw(ddl)` column (`type === 'raw'`).
1967
+ * Emitted unchanged as the column's SQL type on every dialect — the
1968
+ * applier and the CREATE-TABLE emitter both defer to it. Only ever set
1969
+ * on the declared side; the live DB reports a concrete type, not the
1970
+ * original fragment, so introspection leaves it undefined.
1971
+ */
1972
+ readonly rawDdl?: string;
1973
+ /**
1974
+ * `.generatedAs(expr, { stored })` — a DB-computed column. When set the
1975
+ * applier emits `GENERATED ALWAYS AS (<expr>) STORED|VIRTUAL` (mssql:
1976
+ * `AS (<expr>) PERSISTED`) and SUPPRESSES NOT NULL / DEFAULT / inline
1977
+ * UNIQUE / REFERENCES (the value is computed; the DB rejects those
1978
+ * alongside a generation clause). Only ever set on the DECLARED side —
1979
+ * introspection does NOT read generation expressions, so the live snapshot
1980
+ * leaves it undefined. The planner's `sameColumnShape` deliberately does
1981
+ * NOT compare this field, so a declared-generated column never diffs
1982
+ * against its introspected-as-plain live form (no churn). Consequence:
1983
+ * the declarative engine creates generated columns correctly but does NOT
1984
+ * detect a CHANGE to the expression after the fact — recreate the table
1985
+ * (drop + re-migrate) to alter a generation expression.
1986
+ */
1987
+ readonly generatedAs?: {
1988
+ readonly expr: string;
1989
+ readonly stored: boolean;
1990
+ };
1991
+ /**
1992
+ * Data-sensitivity classification from `.sensitive(class)` (or implied by
1993
+ * `.encrypted()`). DECLARED-side only — introspection never reports it. Read
1994
+ * by the export masker to pseudonymise/anonymise the column when copying to a
1995
+ * lower environment; the planner ignores it (no DDL, no churn). See `safe`.
1996
+ */
1997
+ readonly sensitive?: {
1998
+ readonly class: string;
1999
+ };
2000
+ /**
2001
+ * Reviewed-safe marker from `.safe()`. DECLARED-side only. Lets the export
2002
+ * masker be fail-closed: a column that is neither `sensitive` nor `safe`
2003
+ * blocks a masking export until classified.
2004
+ */
2005
+ readonly safe?: boolean;
2006
+ /**
2007
+ * Type PARAMETERS for the three `ColumnType`s whose DDL is not determined by
2008
+ * the type tag alone — `vector(n)`, `array(of)`, `enum(name, values)`.
2009
+ * DECLARED-side only (introspection reports a concrete SQL type, not the
2010
+ * declaration that produced it).
2011
+ *
2012
+ * These exist because the applier renders its DDL from a ColumnSnapshot, not
2013
+ * from the `ColumnDefinition` that `migrate.ts`'s canonical `sqlType` reads.
2014
+ * Without them the snapshot renderers had nothing to render and collapsed all
2015
+ * three to `text` — which is not a smaller mistake than it looks. A declared
2016
+ * `vector(1536)` against a live `text` column planned an `alter-column-type`
2017
+ * that emitted `ALTER COLUMN … TYPE text`: valid SQL, applied successfully,
2018
+ * changed nothing. The plan re-emitted it on every boot, the applier reported
2019
+ * success every time, and the schema never converged. (Reported from a live
2020
+ * pod: "applied 31 op(s)" with none of the 31 present in the database.)
2021
+ *
2022
+ * The rule for anything added later: if the applier has to RENDER it, the
2023
+ * snapshot has to CARRY it — a snapshot renderer must never invent a type it
2024
+ * wasn't given.
2025
+ */
2026
+ readonly vectorDim?: number;
2027
+ readonly vectorPrecision?: 'float32' | 'half';
2028
+ readonly arrayElement?: ColumnType;
2029
+ readonly enumName?: string;
2030
+ readonly enumValues?: ReadonlyArray<string>;
2031
+ /**
2032
+ * PostGIS `geography(kind, srid)` / `geometry(kind, srid)` parameters. Same
2033
+ * reason as the three above: a spatial column declares `type: 'text'`, so
2034
+ * WITHOUT this the applier's `add-column` created a plain `text` column and
2035
+ * introspection (`USER-DEFINED` → `text`) agreed with it — no churn, no
2036
+ * error, and no spatial column. DECLARED-side only.
2037
+ */
2038
+ readonly spatial?: {
2039
+ readonly kind: 'geography' | 'geometry';
2040
+ readonly geomKind: string;
2041
+ readonly srid: number;
2042
+ };
2043
+ }
2044
+
1814
2045
  /**
1815
2046
  * How many bind parameters one row costs.
1816
2047
  *
@@ -2871,6 +3102,15 @@ export declare const decodeFieldValue: (cipher: FieldCipher, ciphertext: string,
2871
3102
 
2872
3103
  export declare type DecoderDialectId = 'postgres' | 'mysql' | 'mariadb' | 'sqlite' | 'mssql' | 'turso';
2873
3104
 
3105
+ /**
3106
+ * The same decode, for a caller that HOLDS the table definition rather than
3107
+ * a name to look up — the migration applier reads rows of a table it was
3108
+ * handed as a declaration, on a process where nothing registered it.
3109
+ */
3110
+ export declare const decodeRowsForTable: <T extends Record<string, unknown>>(rows: ReadonlyArray<T>, table: {
3111
+ readonly fields: Record<string, unknown>;
3112
+ }, dialect: DecoderDialectId) => ReadonlyArray<T>;
3113
+
2874
3114
  /**
2875
3115
  * Decode every row of a query result against the table's registered
2876
3116
  * column types + the dialect's read quirks. Returns a fresh array of
@@ -2960,6 +3200,9 @@ export declare const defineMixin: <const Input extends FieldsInput>(options: Mix
2960
3200
 
2961
3201
  export declare const defineSeed: (input: SeedDefinitionInput) => SeedDefinition;
2962
3202
 
3203
+ /** Forget the selection. A no-op when there is none. */
3204
+ export declare const deleteTenantSelection: (store: DataStore, identity: TenantSelectionRowIdentity) => Promise<void>;
3205
+
2963
3206
  export declare const denseRank: (alias?: string) => WindowBuilder;
2964
3207
 
2965
3208
  /**
@@ -3209,6 +3452,18 @@ export declare const drainIdentifierWarnings: () => ReadonlyArray<string>;
3209
3452
  */
3210
3453
  export declare const dropped: () => ColumnBuilder<unknown, "text">;
3211
3454
 
3455
+ export declare interface EachOptions {
3456
+ /** Rows per page. Default 1000. Pages are keyed by the `id` column. */
3457
+ readonly batchSize?: number;
3458
+ /** Milliseconds to sleep between pages, to share the database with live traffic. Default 0. */
3459
+ readonly sleepMs?: number;
3460
+ }
3461
+
3462
+ export declare interface EachReport {
3463
+ readonly scanned: number;
3464
+ readonly pages: number;
3465
+ }
3466
+
3212
3467
  /**
3213
3468
  * Raised when a relation declared `one` matches MORE than one row for the same
3214
3469
  * parent key — the mis-declaration that silently hands the app an arbitrary
@@ -3660,26 +3915,72 @@ export declare interface FileMigration {
3660
3915
  /** Human description that surfaces in `voltro db plans` + the
3661
3916
  * history timeline. Keep it short — what the migration does. */
3662
3917
  readonly description: string;
3663
- /** The forward body. Runs inside a transaction on dialects that
3664
- * support transactional DDL (postgres / mssql / sqlite); runs as
3665
- * separate implicit-commit statements on mysql / mariadb. */
3918
+ /**
3919
+ * Run `up` and the ledger row that records it inside ONE transaction, on
3920
+ * every dialect. **The default.** A body that fails or crashes leaves
3921
+ * nothing behind — not half its rows, and not a ledger row for work that did
3922
+ * not finish. `transactional: false` is one unit per statement with the
3923
+ * ledger row written after the body: a crash in the middle leaves the rows
3924
+ * already written AND no record, so the body runs again on the next boot
3925
+ * over half-converted data, and has to be written to survive that.
3926
+ *
3927
+ * The decision is per MIGRATION, not per dialect, because the engine
3928
+ * property that matters is about DDL: MySQL, MariaDB and Turso cannot hold
3929
+ * DDL inside a transaction — the MySQL family commits everything before it
3930
+ * on every `ALTER`. A transactional migration there is data only. The
3931
+ * runner MEASURES this rather than trusting it: a body that broke its own
3932
+ * transaction is recorded, so it does not run twice, and the run then fails
3933
+ * with the migration's name and the fix (`transactional: false`, or the
3934
+ * schema change through `ctx.schema` in a migration of its own).
3935
+ */
3936
+ readonly transactional?: boolean;
3937
+ /** The forward body. */
3666
3938
  readonly up: (ctx: FileMigrationContext) => Promise<void> | Effect.Effect<void, unknown, never>;
3667
- /** The reverse body — required. The runner uses this for
3668
- * `voltro db rollback <id>`. Non-destructive inverses don't
3669
- * bring back data dropped in `up`; design accordingly (see
3670
- * Rollback docs). */
3939
+ /**
3940
+ * The reverse body — required, so that the decision is written down. A
3941
+ * migration that repairs a shape has no inverse: say so with
3942
+ * `down: irreversible('…')`, and `voltro db rollback-file` refuses with
3943
+ * that reason instead of running a body that pretends.
3944
+ */
3671
3945
  readonly down: (ctx: FileMigrationContext) => Promise<void> | Effect.Effect<void, unknown, never>;
3672
3946
  }
3673
3947
 
3674
3948
  /**
3675
3949
  * The runtime context a file-based migration's `up` / `down` body
3676
3950
  * receives.
3951
+ *
3952
+ * `store` is the same typed data store the application runs on — the same
3953
+ * column codecs, the same dialect, the same query builder — bound to the
3954
+ * database this migration is running against. A row read through it has its
3955
+ * JSON parsed and its timestamps as `Date`s on every dialect; a row written
3956
+ * through it is encoded the same way. `sql` stays beside it for what the
3957
+ * builder does not express.
3677
3958
  */
3678
3959
  export declare interface FileMigrationContext {
3679
- /** The live `@effect/sql` SqlClient — issue raw DDL via
3680
- * `sql.unsafe(...)`, tagged-template fragments via `sql\`...\``,
3681
- * per-dialect dispatch via `sql.onDialectOrElse({...})`. */
3682
- readonly sql: SqlClient.SqlClient;
3960
+ /**
3961
+ * The escape hatch, and it asks for a reason. `raw('why')` returns the
3962
+ * `@effect/sql` client — `sql.unsafe(...)`, `sql\`...\``,
3963
+ * `sql.onDialectOrElse({...})` — for the one thing neither `store` nor
3964
+ * `schema` expresses. The reason is required (an empty one throws), it is
3965
+ * what `voltro doctor` lists beside every migration that reaches for raw
3966
+ * SQL, and it is the sentence a reviewer reads first. A migration written
3967
+ * against the raw client is bound to the dialect it was written on; the
3968
+ * rest of this context is not.
3969
+ */
3970
+ readonly raw: (reason: string) => SqlClient.SqlClient;
3971
+ /**
3972
+ * The application's data store over this database. Typed reads through a
3973
+ * descriptor (`store.query(database.vacations.descriptor)`) are checked
3974
+ * against the CURRENT declaration at compile time — a column renamed since
3975
+ * this migration was written fails the build, not the deploy. Reads by
3976
+ * table name (`store.query({ table: 'vacations', … })`) go to the database
3977
+ * as it is, decoded through whatever the current declaration says about
3978
+ * the columns it still knows; a column it no longer declares passes
3979
+ * through as the driver returned it.
3980
+ */
3981
+ readonly store: DataStore;
3982
+ /** The dialect this migration is running against. */
3983
+ readonly dialect: DialectId;
3683
3984
  /**
3684
3985
  * Lightweight log surface scoped to the migration. Lands in
3685
3986
  * `voltro logs --filter 'migrations:file:<id>'` for any apply
@@ -3692,6 +3993,63 @@ export declare interface FileMigrationContext {
3692
3993
  /** ISO-8601 timestamp when this migration started (deterministic
3693
3994
  * across the up/down pair via the applied_at column). */
3694
3995
  readonly appliedAt: string;
3996
+ /** Schema questions and dialect-aware schema operations. */
3997
+ readonly schema: MigrationSchemaOps;
3998
+ /**
3999
+ * Visit every row of `table` in pages keyed by `id`. `fn` may return a
4000
+ * Promise or an Effect. The table needs an `id` column, which every
4001
+ * `table()` with `id()` has.
4002
+ */
4003
+ readonly each: <R extends Row = Row>(table: string, fn: (row: R) => Promise<void> | Effect.Effect<void, unknown, never>, options?: EachOptions) => Effect.Effect<EachReport, unknown>;
4004
+ /**
4005
+ * Read rows in pages, compute a patch per row, write it back under an
4006
+ * optimistic guard, and repeat until a pass changes nothing.
4007
+ *
4008
+ * The guard is the row's own values: the UPDATE carries `WHERE id = ? AND
4009
+ * <each patched column> = <the value just read>` (plus `options.guard`), so
4010
+ * a row another process changed between the read and the write is not
4011
+ * overwritten — it is counted as contended and read again on the next
4012
+ * pass. That is the guard a migration needs while the previous deploy is
4013
+ * still writing, without a hashing function a dialect may not have.
4014
+ *
4015
+ * `fn` returns the patch, or `undefined` for a row that is already in
4016
+ * shape. A pass in which every row returns `undefined` and nothing was
4017
+ * contended is the stable state that ends the rewrite.
4018
+ */
4019
+ readonly rewrite: <R extends Row = Row>(table: string, fn: (row: R) => Partial<R> | undefined | Promise<Partial<R> | undefined>, options?: RewriteOptions) => Effect.Effect<RewriteReport, unknown>;
4020
+ /**
4021
+ * A post-condition. Registered during `up`, evaluated after it — every one
4022
+ * must hold, or the migration FAILS and is not recorded, so the next run
4023
+ * tries again. This is the "read it back and check" every data migration
4024
+ * ends with, as a contract the runner enforces instead of a pattern each
4025
+ * body repeats.
4026
+ */
4027
+ readonly expect: (description: string, check: () => Promise<boolean> | Effect.Effect<boolean, unknown, never>) => void;
4028
+ /**
4029
+ * A precondition. When `condition` is false the body stops HERE, the
4030
+ * migration is recorded as applied with `skipped: <reason>` in its notes,
4031
+ * and it never runs again on this database — the same answer Liquibase's
4032
+ * `MARK_RAN` gives. For a migration that must not be recorded when its
4033
+ * precondition fails, fail it instead.
4034
+ *
4035
+ * yield* ctx.skipUnless(ctx.schema.tableExists('vacations'), 'no vacations table on this database')
4036
+ */
4037
+ readonly skipUnless: (condition: boolean | Promise<boolean> | Effect.Effect<boolean, unknown, never>, reason: string) => Effect.Effect<void, unknown>;
4038
+ /**
4039
+ * Run `work` inside ONE database transaction: the `store` it receives is
4040
+ * the transaction's view, and an Effect it returns is executed on the
4041
+ * transaction's connection, so `sql` statements yielded inside it are part
4042
+ * of the same unit. A migration that is transactional as a whole declares
4043
+ * `transactional: true` instead and gets the ledger row in the same unit.
4044
+ *
4045
+ * What the engine cannot do, this cannot do either: on MySQL and MariaDB a
4046
+ * DDL statement inside the unit commits everything before it. A
4047
+ * transactional scope is for data.
4048
+ */
4049
+ readonly transactional: <T>(work: (tx: {
4050
+ readonly store: DataStore;
4051
+ readonly raw: (reason: string) => SqlClient.SqlClient;
4052
+ }) => Promise<T> | Effect.Effect<T, unknown, SqlClient.SqlClient>) => Effect.Effect<T, unknown>;
3695
4053
  }
3696
4054
 
3697
4055
  /**
@@ -4041,6 +4399,32 @@ export declare interface IndexKindOptions {
4041
4399
  };
4042
4400
  }
4043
4401
 
4402
+ declare interface IndexSnapshot {
4403
+ readonly name: string;
4404
+ readonly table: string;
4405
+ readonly columns: ReadonlyArray<string>;
4406
+ readonly unique: boolean;
4407
+ /** Physical access method; absent means btree. Catalogs may name extension methods. */
4408
+ readonly kind?: string;
4409
+ /** Resolved HNSW build parameters and operator class, not a query-time metric. */
4410
+ readonly kindOptions?: IndexKindOptions;
4411
+ /** SQL predicate for a partial/filtered index; never a full UNIQUE constraint. */
4412
+ readonly where?: string;
4413
+ /** False for an invalid PostgreSQL index left by an interrupted online build. */
4414
+ readonly valid?: boolean;
4415
+ /**
4416
+ * Expression / json-path index (`expressionIndex(...)` / `jsonIndex(...)`),
4417
+ * i.e. at least one key is a SQL expression rather than a bare column. The
4418
+ * DB normalises the expression text on introspection (`(lower("email"))` →
4419
+ * `lower(email)`), so it can NEVER round-trip byte-for-byte. The planner
4420
+ * therefore matches such indexes by NAME + uniqueness only (see
4421
+ * `sameIndexShape`) — without this they re-emit on every `db plan` forever
4422
+ * (postgres pre-fix: they were INVISIBLE to introspect — an `add` with no
4423
+ * `drop` that never converged to "up to date").
4424
+ */
4425
+ readonly expression?: boolean;
4426
+ }
4427
+
4044
4428
  export declare type InferColumn<C> = C extends ColumnDefinition<infer T, ColumnType> ? T : never;
4045
4429
 
4046
4430
  /**
@@ -4209,6 +4593,25 @@ export declare const intersect: (...queries: ReadonlyArray<QueryWithDescriptorAn
4209
4593
  */
4210
4594
  export declare const interval: () => ColumnBuilder<string, "interval", boolean>;
4211
4595
 
4596
+ /**
4597
+ * A `down` that says there is no inverse — with the reason, which is what a
4598
+ * rollback prints. A data migration that repairs a broken shape HAS no
4599
+ * inverse (its inverse would be the defect), and a `down` that only logs
4600
+ * "nothing to undo" and returns success lets `voltro db rollback-file`
4601
+ * delete the ledger row for a change that is still in the database.
4602
+ */
4603
+ export declare const irreversible: (reason: string) => FileMigration["down"];
4604
+
4605
+ export declare class IrreversibleMigrationError extends Error {
4606
+ readonly migrationId: string;
4607
+ readonly reason: string;
4608
+ readonly _tag = "IrreversibleMigrationError";
4609
+ constructor(migrationId: string, reason: string);
4610
+ }
4611
+
4612
+ /** The reason a `down` was declared irreversible with, or `undefined` for an ordinary body. */
4613
+ export declare const irreversibleReason: (down: FileMigration["down"]) => string | undefined;
4614
+
4212
4615
  /** Is this value a BOUND ciphertext (`enc:v2:`)? An unbound `enc:v1:` value
4213
4616
  * reads fine and is what `voltro db encrypt-column` re-binds. */
4214
4617
  export declare const isBoundCiphertext: (value: unknown) => value is string;
@@ -4689,16 +5092,13 @@ export declare type MergeMixinFields<Mixins extends ReadonlyArray<AnyMixin>> = M
4689
5092
  * export default migration({
4690
5093
  * id: '20260415_120000_split_address_out',
4691
5094
  * description: 'Move users.address* into a separate addresses table.',
4692
- * up: async ({ sql, log }) => {
4693
- * await sql.unsafe(`CREATE TABLE addresses ( ... )`)
4694
- * await sql.unsafe(`INSERT INTO addresses (...) SELECT (...) FROM users`)
4695
- * await sql.unsafe(`ALTER TABLE users DROP COLUMN addressStreet`)
4696
- * log.info('migrated address fields to new table')
4697
- * },
4698
- * down: async ({ sql }) => {
4699
- * await sql.unsafe(`ALTER TABLE users ADD COLUMN addressStreet text`)
4700
- * // ... etc — see file-based docs for the full pattern
4701
- * },
5095
+ * up: ({ sql, store, log }) => Effect.gen(function* () {
5096
+ * yield* sql.unsafe(`CREATE TABLE addresses ( ... )`)
5097
+ * yield* sql.unsafe(`INSERT INTO addresses (...) SELECT (...) FROM users`)
5098
+ * yield* sql.unsafe(`ALTER TABLE users DROP COLUMN addressStreet`)
5099
+ * yield* log.info('migrated address fields to new table')
5100
+ * }),
5101
+ * down: irreversible('the address rows have no column to go back to'),
4702
5102
  * })
4703
5103
  */
4704
5104
  export declare const migration: (spec: FileMigration) => FileMigration;
@@ -4735,6 +5135,109 @@ export declare interface MigrationDefinitionInput {
4735
5135
  */
4736
5136
  export declare type MigrationEffect = Effect.Effect<unknown, unknown, SqlClient.SqlClient>;
4737
5137
 
5138
+ /** One or more `ctx.expect` post-conditions did not hold after `up`. */
5139
+ export declare class MigrationExpectationFailed extends Error {
5140
+ readonly migrationId: string;
5141
+ readonly failed: ReadonlyArray<string>;
5142
+ readonly _tag = "MigrationExpectationFailed";
5143
+ constructor(migrationId: string, failed: ReadonlyArray<string>);
5144
+ }
5145
+
5146
+ /**
5147
+ * Schema questions and schema operations a migration body can ask of the
5148
+ * database it is running against, spelled once for every dialect.
5149
+ *
5150
+ * Every operation is idempotent by INSPECTION, not by error tolerance: it
5151
+ * reads the live schema first and answers `already-absent` /
5152
+ * `already-present` when there is nothing to do. That is the portable
5153
+ * `IF EXISTS` — MySQL has no such clause for a foreign key, and its two
5154
+ * "already gone" error codes are not the kind of thing a migration should
5155
+ * be matching on.
5156
+ *
5157
+ * On SQLite the dropping operations are table rebuilds (the dialect has no
5158
+ * `DROP CONSTRAINT`), built from the live table minus the change — the same
5159
+ * rebuild the planner's applier performs, with foreign-key enforcement off
5160
+ * for its duration and checked before the connection is handed back.
5161
+ */
5162
+ export declare interface MigrationSchemaOps {
5163
+ /** The live schema, read fresh on every call — an operation above may have changed it. */
5164
+ readonly snapshot: () => Effect.Effect<SchemaSnapshot, unknown>;
5165
+ readonly tableExists: (table: string) => Effect.Effect<boolean, unknown>;
5166
+ readonly columnExists: (table: string, column: string) => Effect.Effect<boolean, unknown>;
5167
+ readonly indexExists: (table: string, index: string) => Effect.Effect<boolean, unknown>;
5168
+ /** Whether `table.column` currently carries a FOREIGN KEY. */
5169
+ readonly foreignKeyExists: (table: string, column: string) => Effect.Effect<boolean, unknown>;
5170
+ /** Drop the FOREIGN KEY on `table.column`, under the name the catalog reports for it. */
5171
+ readonly dropForeignKey: (table: string, column: string) => Effect.Effect<SchemaOpOutcome, unknown>;
5172
+ readonly dropIndex: (table: string, index: string) => Effect.Effect<SchemaOpOutcome, unknown>;
5173
+ /** Drop a column — and first the FOREIGN KEY it carries, if any, on the
5174
+ * dialects that refuse to drop a column a constraint still holds. */
5175
+ readonly dropColumn: (table: string, column: string) => Effect.Effect<SchemaOpOutcome, unknown>;
5176
+ /** Rename a column. `already-present` when `to` exists and `from` does not:
5177
+ * a re-run after a crash that landed the rename. */
5178
+ readonly renameColumn: (table: string, from: string, to: string) => Effect.Effect<SchemaOpOutcome, unknown>;
5179
+ /** Create a table from a declaration. `already-present` when a table of that name exists. */
5180
+ readonly createTable: (table: TableLike) => Effect.Effect<SchemaOpOutcome, unknown>;
5181
+ /** Drop a table and everything in it. Data loss is the caller's explicit intent here. */
5182
+ readonly dropTable: (table: string) => Effect.Effect<SchemaOpOutcome, unknown>;
5183
+ /** Rename a table. `already-present` when `to` exists and `from` does not. */
5184
+ readonly renameTable: (from: string, to: string) => Effect.Effect<SchemaOpOutcome, unknown>;
5185
+ /**
5186
+ * Add a column. A NOT NULL column without a default cannot be added to a
5187
+ * table that has rows on any dialect: add it nullable or with a default,
5188
+ * fill it (`rewrite`), then `alterColumn(table, name, { nullable: false })`.
5189
+ */
5190
+ readonly addColumn: (table: string, name: string, column: ColumnInput) => Effect.Effect<SchemaOpOutcome, unknown>;
5191
+ /**
5192
+ * Change a column's nullability and/or default. `default: null` removes the
5193
+ * default; a type change goes through `evolve`, where the planner decides
5194
+ * whether it is lossy.
5195
+ */
5196
+ readonly alterColumn: (table: string, name: string, change: {
5197
+ readonly nullable?: boolean;
5198
+ readonly default?: unknown;
5199
+ }) => Effect.Effect<SchemaOpOutcome, unknown>;
5200
+ /** Add an index over `columns`; the name defaults to `<table>_<cols>_idx`. */
5201
+ readonly addIndex: (table: string, columns: ReadonlyArray<string>, options?: {
5202
+ readonly unique?: boolean;
5203
+ readonly name?: string;
5204
+ }) => Effect.Effect<SchemaOpOutcome, unknown>;
5205
+ /** Add a FOREIGN KEY from `table.column` to `target.table`'s `target.column` (default `id`). */
5206
+ readonly addForeignKey: (table: string, column: string, target: {
5207
+ readonly table: string;
5208
+ readonly column?: string;
5209
+ }, options?: {
5210
+ readonly onDelete?: 'cascade' | 'restrict' | 'setNull' | 'noAction';
5211
+ readonly onUpdate?: 'cascade' | 'restrict' | 'setNull' | 'noAction';
5212
+ }) => Effect.Effect<SchemaOpOutcome, unknown>;
5213
+ /** Remove every row. `DELETE FROM` where the dialect has no TRUNCATE inside a unit. */
5214
+ readonly truncate: (table: string) => Effect.Effect<void, unknown>;
5215
+ /**
5216
+ * The Voltro way to change a table's shape in a migration: DECLARE the
5217
+ * table as it should be from here on, and the same planner that drives
5218
+ * `voltro db apply` diffs it against the live table and applies the
5219
+ * difference — add, drop, rename, retype, index, constraint — with the same
5220
+ * per-dialect statements, the same three-step backfills, and the same
5221
+ * convergence proof (an `evolve` whose re-plan is not empty fails). A drop
5222
+ * is allowed without a `dropped()` marker: calling `evolve` from a
5223
+ * migration IS the explicit intent the marker stands for elsewhere.
5224
+ */
5225
+ readonly evolve: (table: TableLike) => Effect.Effect<{
5226
+ readonly applied: number;
5227
+ }, unknown>;
5228
+ }
5229
+
5230
+ /**
5231
+ * The signal `ctx.skipUnless` raises. The runner treats it as an OUTCOME,
5232
+ * not a failure: the migration is recorded with the reason and never runs
5233
+ * again on this database.
5234
+ */
5235
+ export declare class MigrationSkipped {
5236
+ readonly reason: string;
5237
+ readonly _tag = "MigrationSkipped";
5238
+ constructor(reason: string);
5239
+ }
5240
+
4738
5241
  export declare interface MigrationStep {
4739
5242
  readonly name: string;
4740
5243
  readonly apply: MigrationEffect;
@@ -6157,6 +6660,9 @@ export declare interface ReadableByColumn {
6157
6660
  */
6158
6661
  export declare const readableByColumns: (table: TableLike) => ReadonlyArray<ReadableByColumn>;
6159
6662
 
6663
+ /** The stored tenant, or null when the subject has no selection. */
6664
+ export declare const readTenantSelection: (store: DataStore, identity: TenantSelectionRowIdentity) => Promise<string | null>;
6665
+
6160
6666
  /** Floating-point number. Postgres `DOUBLE PRECISION`, mysql/mariadb `DOUBLE`,
6161
6667
  * mssql `FLOAT`, sqlite `REAL`. Drivers return JS numbers — no codec. Use for
6162
6668
  * fractional values (ratings, percentages, measurements) where `integer()`
@@ -6609,6 +7115,39 @@ export declare const retentionTtlMsFromEnv: (envValue: string | undefined, defau
6609
7115
 
6610
7116
  export declare type RetryDecision = 'retry' | 'noRetry';
6611
7117
 
7118
+ export declare interface RewriteOptions extends EachOptions {
7119
+ /**
7120
+ * Columns whose CURRENT value must still hold when the patch is written, in
7121
+ * addition to the columns the patch itself changes. A JSON column cannot be
7122
+ * compared by value on every dialect, so a rewrite of one guards on what
7123
+ * you name here — an `updatedAt`, a `version` — rather than on the document.
7124
+ */
7125
+ readonly guard?: ReadonlyArray<string>;
7126
+ /**
7127
+ * How many passes over the table before contention is a failure. A pass
7128
+ * that rewrites nothing and contends on nothing is STABLE and ends the
7129
+ * rewrite; a row that changed under the migration between its read and its
7130
+ * write is contended and is read again on the next pass. Default 10.
7131
+ */
7132
+ readonly maxPasses?: number;
7133
+ }
7134
+
7135
+ export declare interface RewriteReport {
7136
+ readonly passes: number;
7137
+ readonly scanned: number;
7138
+ readonly rewritten: number;
7139
+ /** Writes lost to a concurrent change and retried on a later pass. */
7140
+ readonly contended: number;
7141
+ }
7142
+
7143
+ /** A `ctx.rewrite` that was still contended after `maxPasses`. */
7144
+ export declare class RewriteUnstableError extends Error {
7145
+ readonly table: string;
7146
+ readonly report: RewriteReport;
7147
+ readonly _tag = "RewriteUnstableError";
7148
+ constructor(table: string, report: RewriteReport);
7149
+ }
7150
+
6612
7151
  export declare type Row = Readonly<Record<string, unknown>>;
6613
7152
 
6614
7153
  declare type Row_2 = Readonly<Record<string, unknown>>;
@@ -6805,6 +7344,18 @@ export declare interface SchemaChangeSeedEvent {
6805
7344
 
6806
7345
  export declare type SchemaChangeSeedHook = (event: SchemaChangeSeedEvent) => Promise<void>;
6807
7346
 
7347
+ /** What a schema operation on the migration context found to do. */
7348
+ export declare type SchemaOpOutcome = 'applied' | 'already-absent' | 'already-present';
7349
+
7350
+ /**
7351
+ * The full schema as the planner sees it on one side of the diff.
7352
+ * Both the declared side and the introspected side conform to this
7353
+ * shape so the diff algorithm is symmetric.
7354
+ */
7355
+ declare interface SchemaSnapshot {
7356
+ readonly tables: ReadonlyArray<TableSnapshot>;
7357
+ }
7358
+
6808
7359
  /**
6809
7360
  * The PUBLIC structural view of a built table — name, columns, and applied
6810
7361
  * indexes — with NONE of the fluent builder methods.
@@ -8049,6 +8600,36 @@ export declare interface TableRule {
8049
8600
  readonly severity: RuleSeverity;
8050
8601
  }
8051
8602
 
8603
+ declare interface TableSnapshot {
8604
+ readonly name: string;
8605
+ readonly columns: ReadonlyArray<ColumnSnapshot>;
8606
+ readonly indexes: ReadonlyArray<IndexSnapshot>;
8607
+ /**
8608
+ * Composite PRIMARY KEY column set (`.primaryKey(['a','b'])`). Set on the
8609
+ * DECLARED side only — it drives the `PRIMARY KEY (a, b)` line in the
8610
+ * applier's create-table emission. Absent (the default) means the single
8611
+ * `id()` column carries the PK inline. Introspection leaves it undefined;
8612
+ * the live PK is compared via the synthesised `<table>_pkey` index.
8613
+ */
8614
+ readonly primaryKey?: ReadonlyArray<string>;
8615
+ /**
8616
+ * Approximate row count from the live DB's planner stats — used by
8617
+ * the migration planner to promote `safe` ops to `online-required`
8618
+ * when the table exceeds the configured threshold (default 50_000).
8619
+ * Pulled from `pg_class.reltuples` on postgres; equivalent stats
8620
+ * on other dialects. Absent on the declared side of the diff (no
8621
+ * data exists yet), present from introspection.
8622
+ */
8623
+ readonly rowCount?: number;
8624
+ /**
8625
+ * The name this table previously had (`table(name, fields, { renamedFrom })`).
8626
+ * DECLARED side only — introspection has no way to know. The planner pairs it
8627
+ * against a live table of that name and folds what would otherwise be
8628
+ * DROP + CREATE into a `rename-table`.
8629
+ */
8630
+ readonly renamedFrom?: string;
8631
+ }
8632
+
8052
8633
  /** A page read failed while streaming a table. Carries the underlying store
8053
8634
  * error as `cause` so callers can inspect the transient-ness for retry. */
8054
8635
  export declare class TableStreamError extends TableStreamError_base<{
@@ -8076,6 +8657,8 @@ export declare interface TableUnique {
8076
8657
  */
8077
8658
  export declare const teardownBranch: (branchId: string, mechanism: BranchMechanism, executor: BranchExecutor) => Promise<ReadonlyArray<BranchStep>>;
8078
8659
 
8660
+ export declare const TENANT_SELECTIONS_TABLE: string;
8661
+
8079
8662
  /** What a tenant-create firing knows: which namespace was just provisioned. */
8080
8663
  export declare interface TenantCreateSeedEvent {
8081
8664
  /** The namespace (postgres schema / mysql database / attached sqlite file)
@@ -8137,6 +8720,19 @@ export declare class TenantResidencyUnresolved extends Error {
8137
8720
  constructor(message: string);
8138
8721
  }
8139
8722
 
8723
+ /** The row id a subject's selection lives under: a hash of the identity, so
8724
+ * the key is bounded on every dialect and a second selection for one subject
8725
+ * is the same row, never a second one. */
8726
+ export declare const tenantSelectionId: (identity: TenantSelectionRowIdentity) => string;
8727
+
8728
+ /** The two fields that name a subject across credentials. Structurally the
8729
+ * protocol's `TenantSelectionIdentity`; typed loosely here because this
8730
+ * package does not depend on the protocol. */
8731
+ export declare interface TenantSelectionRowIdentity {
8732
+ readonly type: string;
8733
+ readonly id: string;
8734
+ }
8735
+
8140
8736
  export declare const text: () => ColumnBuilder<string, "text", boolean>;
8141
8737
 
8142
8738
  export declare const timestamp: () => ColumnBuilder<Date, "timestamp", boolean>;
@@ -8191,6 +8787,21 @@ export declare interface TransactionalViewHandle {
8191
8787
  commitEvents(): void;
8192
8788
  }
8193
8789
 
8790
+ /**
8791
+ * A `transactional` migration whose body committed the transaction from
8792
+ * inside — DDL on the MySQL family, or an explicit COMMIT. The body's work
8793
+ * IS in the database and the migration IS recorded (running it again would
8794
+ * repeat the committed half); what failed is the guarantee the migration
8795
+ * asked for, and the run stops so nobody deploys on top of an assumption
8796
+ * that did not hold.
8797
+ */
8798
+ export declare class TransactionBrokenByBodyError extends Error {
8799
+ readonly migrationId: string;
8800
+ readonly dialect: DialectId;
8801
+ readonly _tag = "TransactionBrokenByBodyError";
8802
+ constructor(migrationId: string, dialect: DialectId);
8803
+ }
8804
+
8194
8805
  /**
8195
8806
  * The `[Connection, depth]` tuple `@effect/sql` provides inside
8196
8807
  * `withTransaction`. Every statement that must run on the transaction's OWN
@@ -8679,6 +9290,18 @@ export declare const _voltroCdcOffsetsTable: TableLike;
8679
9290
  */
8680
9291
  export declare const _voltroDataTransfersTable: TableLike;
8681
9292
 
9293
+ /**
9294
+ * `_voltro_devices` — device identity beside `_voltro_api_keys`: a machine
9295
+ * caller with a tenant whose credential is short-lived and rotated. Only
9296
+ * hashes: the one-time pairing code, the current access token, the current
9297
+ * refresh token and the refresh token rotated OUT last (a replay of it revokes
9298
+ * the device). `generation` counts rotations. Hash columns are indexed, not
9299
+ * unique: they are nullable, and a UNIQUE over NULLs is the partial-unique
9300
+ * trap on MariaDB. Created for every sql app (empty unless `devices` is
9301
+ * enabled in app.config). See `@voltro/runtime`'s `DeviceService`.
9302
+ */
9303
+ export declare const _voltroDevicesTable: SchemaTable;
9304
+
8682
9305
  /**
8683
9306
  * `_voltro_idempotency` — one row per (scope, key) for HTTP idempotency
8684
9307
  * (the `Idempotency-Key` header on REST routes). `scope` namespaces the key
@@ -8701,15 +9324,6 @@ export declare const _voltroDataTransfersTable: TableLike;
8701
9324
  */
8702
9325
  export declare const _voltroIdempotencyTable: SchemaTable;
8703
9326
 
8704
- /**
8705
- * `_voltro_kv` — the durable key-value store behind `ctx.kv` / the `Kv`
8706
- * service when `KV_BACKEND=database` (the default for sql apps). One row per
8707
- * key; `key` is UNIQUE and already carries any namespace the facade layered on
8708
- * (e.g. a per-tenant prefix). `value` is opaque JSON; `expiresAt` drives the
8709
- * optional TTL (NULL = never). Distinct from the cache: entries are NEVER
8710
- * evicted for capacity — they live until deleted or their TTL lapses. Created
8711
- * for every sql app (empty unless `ctx.kv` is used).
8712
- */
8713
9327
  export declare const _voltroKvTable: SchemaTable;
8714
9328
 
8715
9329
  /**
@@ -8795,6 +9409,19 @@ export declare const _voltroReplicaObservationsTable: SchemaTable;
8795
9409
  */
8796
9410
  export declare const _voltroSeedsTable: SchemaTable;
8797
9411
 
9412
+ /**
9413
+ * `_voltro_tenant_selections` — the ACTIVE tenant a subject chose among the
9414
+ * tenants it belongs to, stored beside the subject rather than inside a
9415
+ * credential so it survives the credential: the next JWT from an external
9416
+ * IdP, the next API-key call, finds it. One row per subject identity —
9417
+ * `id` is `sha256(type, id)` (`tenantSelectionId` in `@voltro/runtime`), so
9418
+ * a `text` primary key stays bounded on every dialect. Verified against the
9419
+ * app's memberships on every request it applies to and cleared when the
9420
+ * membership is gone. Created for every sql app (empty unless `auth.memberships`
9421
+ * is configured and someone switches).
9422
+ */
9423
+ export declare const _voltroTenantSelectionsTable: SchemaTable;
9424
+
8798
9425
  export declare const walkLeaves: (predicate: Predicate, conjunctive?: boolean) => Generator<PredicateLeafWithContext>;
8799
9426
 
8800
9427
  /** Three chars from the floor — at this point a typical next-column
@@ -8940,4 +9567,8 @@ export declare type WriteRecorder = (port: TxnRecorderPort, write: RecordedWrite
8940
9567
  /** Are recorders suspended right here? Exported so a caller can SAY so. */
8941
9568
  export declare const writeRecordersSuspended: () => boolean;
8942
9569
 
9570
+ /** Store the selection — an upsert on the derived id, so it is a replace. The
9571
+ * caller has verified the membership; this writes, and only writes. */
9572
+ export declare const writeTenantSelection: (store: DataStore, identity: TenantSelectionRowIdentity, tenantId: string) => Promise<void>;
9573
+
8943
9574
  export { }