@voltro/database 0.40.0 → 0.42.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
@@ -765,6 +765,19 @@ export declare const chunkRowsForInsert: <T extends Record<string, unknown>>(row
765
765
  */
766
766
  export declare const claimPendingAttribution: (key: string) => WriteAttribution | undefined;
767
767
 
768
+ /**
769
+ * Classify a driver failure — already flattened by `extractDbCause` — into the
770
+ * safe facts above, or `undefined` when it is not a constraint violation at all
771
+ * (a deadlock, a dead connection, a timeout, a syntax error).
772
+ *
773
+ * `operation` is the write the framework was performing. It is consulted for
774
+ * ONE thing: sqlite reports both FK directions as the same bare
775
+ * `FOREIGN KEY constraint failed` with no name and no column, so a delete that
776
+ * trips it is `foreignKeyInUse` and an insert is `foreignKey`. Every other
777
+ * dialect states the direction itself and the operation is not consulted.
778
+ */
779
+ export declare const classifyConstraintViolation: (dbCause: Record<string, unknown>, operation?: string) => ConstraintFacts | undefined;
780
+
768
781
  /** Wipe — used by tests + the dev-loop hot-reload. */
769
782
  export declare const clearEnumRenames: () => void;
770
783
 
@@ -1865,6 +1878,48 @@ export declare interface ConnectionIdentityInput {
1865
1878
  readonly database?: string | undefined;
1866
1879
  }
1867
1880
 
1881
+ /**
1882
+ * The SAFE facts about a constraint violation: which rule, and the NAME of the
1883
+ * constraint / column it fired on. Never a value, and never the driver's
1884
+ * sentence.
1885
+ *
1886
+ * That exclusion is the whole design, and it is measured, not cautious:
1887
+ *
1888
+ * - postgres attaches `detail: "Failing row contains (t4, l1, null, null,
1889
+ * null)."` to a not-null and a check violation — **the entire row**, every
1890
+ * column, including anything a table marked `.sensitive()`.
1891
+ * - mysql/mariadb: `Duplicate entry 's1' for key 'tasks.slug'`.
1892
+ * - mssql: `The duplicate key value is (s1).` and, on truncation,
1893
+ * `Truncated value: '…'`.
1894
+ *
1895
+ * So "just pass the driver message through" is not a smaller version of this —
1896
+ * it is a different feature, one that ships row contents to whoever provoked
1897
+ * the error. A constraint NAME is schema, the same class of fact the framework
1898
+ * already puts on the wire in `TableValidationFailed.table`; a row is data.
1899
+ *
1900
+ * Everything here is captured as an anchored identifier group out of the
1901
+ * driver text — never a slice of it — so a regex that fails to match yields
1902
+ * `undefined` rather than a sentence that happens to contain a value.
1903
+ */
1904
+ export declare interface ConstraintFacts {
1905
+ readonly kind: ConstraintKind;
1906
+ /** The constraint / index name, when the dialect names one. */
1907
+ readonly constraint?: string;
1908
+ /** The column, when the dialect names one instead of (or beside) a constraint. */
1909
+ readonly column?: string;
1910
+ }
1911
+
1912
+ /**
1913
+ * Which integrity rule the database refused on.
1914
+ *
1915
+ * `foreignKey` and `foreignKeyInUse` are the two DIRECTIONS of one constraint
1916
+ * and mean opposite things to a caller — "the row you pointed at does not
1917
+ * exist" vs "you may not remove this row, others still point at it". A UI
1918
+ * branches differently on each, so they are separate members rather than one
1919
+ * member plus a string a caller would have to grep.
1920
+ */
1921
+ export declare type ConstraintKind = 'foreignKey' | 'foreignKeyInUse' | 'unique' | 'notNull' | 'check';
1922
+
1868
1923
  /** Case-insensitive substring match on a text column. Non-indexable —
1869
1924
  * always evaluated as an unindexed leaf (the matcher's pre-filter only
1870
1925
  * recognizes eq/in/range). Lowers to `ILIKE '%…%'` on Postgres. */
@@ -2343,6 +2398,46 @@ export declare const decimal: (precision: number, scale?: number) => ColumnBuild
2343
2398
  */
2344
2399
  export declare const declareEnumRename: (enumName: string, rename: EnumValueRename) => void;
2345
2400
 
2401
+ /**
2402
+ * Read an encrypted field, in EITHER encoding.
2403
+ *
2404
+ * ── Why this reads two forms when there is only one writer ─────────────────
2405
+ *
2406
+ * Because the old form is already on somebody's production disk, and a fix that
2407
+ * requires rewriting it before the app works is not a fix — it is an outage with
2408
+ * a migration attached. "No back-compat" is about not keeping old CODE paths; a
2409
+ * row written last month is data, and data is read where it is.
2410
+ *
2411
+ * ── Why this is deterministic and not a heuristic ──────────────────────────
2412
+ *
2413
+ * The two forms are `encrypt(JSON.stringify(v))` and `encrypt(v)`. After
2414
+ * decrypting, they are told apart by what the plaintext PARSES to, against the
2415
+ * column's declared type:
2416
+ *
2417
+ * - parse THROWS → raw form. A JSON encoding always parses.
2418
+ * - parse yields a STRING → JSON form. `JSON.stringify` of any string
2419
+ * yields a quoted string, and a raw secret is
2420
+ * not quoted (see the one exception below).
2421
+ * - parse yields a NON-string → depends on the column:
2422
+ * · a TEXT-like column can only hold a string, so a number/object here is
2423
+ * the raw form being parsed by accident (`"12345"` → `12345`) → raw.
2424
+ * · a number / boolean / json column stores exactly that, so the parsed
2425
+ * value IS the value → JSON form.
2426
+ *
2427
+ * No branch guesses. `expected` is the column's declared type; omit it (the
2428
+ * `string → string` escape hatch) and the text-like rule applies, which is
2429
+ * correct for a caller that can only have written a string.
2430
+ *
2431
+ * ── The one case nothing can separate, stated rather than hidden ───────────
2432
+ *
2433
+ * A raw secret whose literal text is `"abc"` — quotes included as characters —
2434
+ * is byte-identical to the JSON encoding of `abc`. No algorithm can tell them
2435
+ * apart, because they are the same bytes. A token or key never looks like that;
2436
+ * it is written here so the next reader does not go looking for the branch that
2437
+ * handles it.
2438
+ */
2439
+ export declare const decodeFieldValue: (cipher: FieldCipher, ciphertext: string, expected?: string) => unknown;
2440
+
2346
2441
  export declare type DecoderDialectId = 'postgres' | 'mysql' | 'mariadb' | 'sqlite' | 'mssql' | 'turso';
2347
2442
 
2348
2443
  /**
@@ -2753,6 +2848,35 @@ export declare interface EagerRootPlan {
2753
2848
 
2754
2849
  declare type EmptyMerge = unknown;
2755
2850
 
2851
+ /**
2852
+ * THE encoding for an encrypted field. One definition, called by every writer
2853
+ * and every reader — the store middleware, the raw-SQL escape hatch
2854
+ * (`encryptField` / `decryptField` in `@voltro/runtime`), and the
2855
+ * `voltro db encrypt-column` backfill.
2856
+ *
2857
+ * ── Why this is one function and not three copies ──────────────────────────
2858
+ *
2859
+ * It was three. The store wrote `encrypt(JSON.stringify(v))`, the escape hatch
2860
+ * wrote `encrypt(v)`, and the backfill wrote `encrypt(v)`. All three produce the
2861
+ * `enc:v1:` envelope and NOTHING distinguishes them. A consumer wrote session
2862
+ * rows through the escape hatch and read them through the store, which threw
2863
+ * `FieldDecryptionError` blaming the key — the key was right; the ENCODING was
2864
+ * not. Eight of their ten session rows, and the failure looked like a key
2865
+ * rotation the whole time.
2866
+ *
2867
+ * The JSON form wins because it is the only one that can carry a non-string: an
2868
+ * `.encrypted()` column may be a number or a json column, and `encrypt(v)` for
2869
+ * those was already lossy. The escape hatch's `string → string` signature is
2870
+ * unchanged by it — a stringified string parses back to the same string.
2871
+ *
2872
+ * ── The distinction the error has to make ──────────────────────────────────
2873
+ *
2874
+ * A GCM failure and a JSON failure are different diagnoses with different
2875
+ * remedies, and collapsing them is what cost the consumer a day. `decodeFieldValue`
2876
+ * therefore decrypts and decodes as two separate steps and says which one failed.
2877
+ */
2878
+ export declare const encodeFieldValue: (cipher: FieldCipher, value: unknown) => string;
2879
+
2756
2880
  /**
2757
2881
  * Serialize a row's json()/array() cells to JSON strings so they can be
2758
2882
  * bound as scalar SQL parameters. Symmetric to `decodeRowsFromSchema`.
@@ -2901,6 +3025,8 @@ export declare const expires: () => MixinDefinition<{
2901
3025
  */
2902
3026
  export declare const externalChangeEvent: (event: ChangeEvent, attribution?: Partial<ChangeEventMeta>) => ChangeEvent;
2903
3027
 
3028
+ export declare const extractDbCause: (err: unknown) => Record<string, unknown>;
3029
+
2904
3030
  /** The subset of `@voltro/logger`'s logger this module needs. Structural so
2905
3031
  * this package keeps its browser-safe import surface. */
2906
3032
  export declare interface FallbackLogger {
@@ -3449,6 +3575,15 @@ export declare const isFieldDecryptionError: (e: unknown) => e is FieldDecryptio
3449
3575
  /** Type-guard for the discovery walker. */
3450
3576
  export declare const isFileMigration: (value: unknown) => value is FileMigration;
3451
3577
 
3578
+ /**
3579
+ * Foreign-key violation, either direction, across every dialect. Derived from
3580
+ * `classifyConstraintViolation` rather than re-listing the codes: this
3581
+ * predicate and the classifier disagreeing is invisible until a tenant-FK
3582
+ * failure quietly stops being typed, and mssql already spent a release in
3583
+ * exactly that state (547 was read off `.code`, where it never appears).
3584
+ */
3585
+ export declare const isForeignKeyViolation: (dbCause: Record<string, unknown>) => boolean;
3586
+
3452
3587
  /** True when `name` is a live table the framework / its runtime engines create
3453
3588
  * but no user schema declares — never plan a drop for it. */
3454
3589
  export declare const isFrameworkOwnedLiveTable: (name: string) => boolean;
@@ -3462,6 +3597,18 @@ export declare const isLocalFirst: (table: LocalFirstMarked) => boolean;
3462
3597
 
3463
3598
  export declare const isMigrationDefinition: (value: unknown) => value is MigrationDefinition;
3464
3599
 
3600
+ /**
3601
+ * MySQL and MariaDB. They share a driver, a quoting style and most of a
3602
+ * grammar — and they are NOT interchangeable in DDL.
3603
+ *
3604
+ * The one that has already cost us: **MySQL/InnoDB silently discards a
3605
+ * column-inline `REFERENCES` clause** (parsed, thrown away, no warning, CREATE
3606
+ * succeeds), while MariaDB honours it and creates the constraint. Measured on
3607
+ * mysql 8.4 and mariadb 11. Anything referential that this family emits has to
3608
+ * be table-level to work on both.
3609
+ */
3610
+ export declare const isMysqlFamily: (dialect: DialectId) => boolean;
3611
+
3465
3612
  /**
3466
3613
  * True when the connection string points at a Neon serverless-postgres host
3467
3614
  * (`*.neon.tech` / `*.neon.build`, including the `-pooler` endpoints). Neon's
@@ -3477,6 +3624,21 @@ export declare const isNull: <RowOf = Record<string, unknown>, K extends keyof R
3477
3624
 
3478
3625
  export declare const isPrimaryKeyConflictError: (err: unknown) => err is PrimaryKeyConflictError;
3479
3626
 
3627
+ /** A query cancelled for exceeding the `statementTimeoutMs` deadline, across
3628
+ * dialects: pg `57014` (query_canceled — what `statement_timeout` raises),
3629
+ * MySQL `3024` (ER_QUERY_TIMEOUT), MariaDB `1969` (ER_STATEMENT_TIMEOUT), mssql
3630
+ * `ETIMEOUT` (tedious request timeout), sqlite `SQLITE_INTERRUPT`. NOT a
3631
+ * transient error — a re-run just repeats the runaway, so it must not retry
3632
+ * (`servePipeline`'s transient set deliberately excludes it). Lets a consumer /
3633
+ * observability layer name the failure instead of reading an opaque SqlError. */
3634
+ export declare const isQueryTimeout: (dbCause: Record<string, unknown>) => boolean;
3635
+
3636
+ /** Is this ciphertext in the OLD raw encoding? Used by `voltro db encrypt-column`
3637
+ * to normalise a column without touching rows that are already current. Answers
3638
+ * `undefined` when the two forms coincide (a numeric column, or a value that
3639
+ * parses to itself) — nothing to do either way. */
3640
+ export declare const isRawEncoding: (cipher: FieldCipher, ciphertext: string, expected?: string) => boolean | undefined;
3641
+
3480
3642
  /** Can THIS deployment serve the region? */
3481
3643
  export declare const isRegionServable: (region: string, config: ResidencyConfig) => boolean;
3482
3644