@voltro/database 0.28.0 → 0.30.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
@@ -712,6 +712,33 @@ export declare class ColumnBuilder<TsType, Type extends ColumnType, HasDefault e
712
712
  * neither.)
713
713
  */
714
714
  serverOnly(): this;
715
+ /**
716
+ * Restrict this column's WIRE visibility to subjects holding one of `scopes`.
717
+ * The graded middle of the exposure axis: a plain column is visible to every
718
+ * client, `.serverOnly()` hides it from every client, and
719
+ * `.readableBy('billing:read')` strips it from wire OUTPUT for any subject that
720
+ * holds NONE of the listed scopes while leaving it for one that holds ANY.
721
+ *
722
+ * amountCents: integer().readableBy('billing:read'),
723
+ * ssn: text().readableBy('hr:read', 'admin:pii'),
724
+ *
725
+ * The scope strings are the SAME vocabulary as `guards:` / `ctx.access`, and
726
+ * are matched against the subject's EFFECTIVE scopes (raw subject scopes ∪ any
727
+ * rbac role-derived scopes). `admin:full` sees every `.readableBy()` column,
728
+ * exactly as it satisfies every guard. The strip runs at the runtime wire
729
+ * boundary (the Dispatcher's read chokepoint, shared with `.serverOnly()`), so
730
+ * server-internal `ctx.query` reads still see the value — this is an exposure
731
+ * concern, not an at-rest one.
732
+ *
733
+ * Declaration rules, both rejected at the schema file:
734
+ * - at least ONE scope is required — `.readableBy()` with none would mean
735
+ * "readable by nobody", which is `.serverOnly()`; the empty call is
736
+ * almost always that mistake, so it throws rather than silently behaving
737
+ * like `.serverOnly()`;
738
+ * - each scope must be a non-blank string (a typo'd `''` never unlocks
739
+ * anything, so it is refused up front).
740
+ */
741
+ readableBy(...scopes: ReadonlyArray<string>): this;
715
742
  /**
716
743
  * Mark this column as the row's OPTIMISTIC-CONCURRENCY version.
717
744
  *
@@ -762,6 +789,13 @@ export declare class ColumnBuilder<TsType, Type extends ColumnType, HasDefault e
762
789
  *
763
790
  * Pairs with {@link safe}: under a masking profile, every column in an
764
791
  * exported table must be one or the other, or the export refuses (fail-closed).
792
+ *
793
+ * NOT a wire-exposure or authorization control. This is the EXPORT-masking axis
794
+ * only; it never strips the column from a client response and never gates a
795
+ * read. To keep a column off the wire, use {@link serverOnly} (hidden from every
796
+ * client) or {@link readableBy} (hidden unless the subject holds a scope) — those
797
+ * are enforced at the runtime wire boundary. Marking a PII column `.sensitive()`
798
+ * does NOT stop it reaching an anonymous caller; that is a job for `.readableBy()`.
765
799
  */
766
800
  sensitive(cls: SensitivityClass): this;
767
801
  /**
@@ -1182,6 +1216,45 @@ export declare interface ColumnDefinition<TsType, Type extends ColumnType = Colu
1182
1216
  * COSTS is per command — see `serverOnly()` below for the matrix.
1183
1217
  */
1184
1218
  readonly serverOnly?: boolean;
1219
+ /**
1220
+ * Field-level read scopes, set by `.readableBy(...scopes)`. A NON-EMPTY list
1221
+ * of scope strings; the column is stripped from wire OUTPUT for any subject
1222
+ * that holds NONE of them, and present for a subject holding at least ONE.
1223
+ * This is the graded middle of the exposure axis: `.serverOnly()` hides a
1224
+ * column from EVERYONE on the wire, a plain column is visible to everyone,
1225
+ * and `.readableBy('billing:read')` is visible only to callers scoped for it.
1226
+ *
1227
+ * The scope strings are the SAME vocabulary as `guards:` / `ctx.access` — they
1228
+ * are checked against the subject's EFFECTIVE scope set (raw subject scopes ∪
1229
+ * any rbac role-derived scopes), and the `admin:full` bypass sees every
1230
+ * `.readableBy()` column, exactly as it satisfies every guard. Empty here is a
1231
+ * declaration error (`.readableBy()` with no scope would mean "nobody" — use
1232
+ * `.serverOnly()` for that), so the field is never present as `[]`.
1233
+ *
1234
+ * Orthogonal to `.encrypted()` (storage-at-rest) and to `.sensitive()` /
1235
+ * `.safe()` (export-time masking): this is purely the WIRE-exposure axis.
1236
+ */
1237
+ readonly readableBy?: ReadonlyArray<string>;
1238
+ /**
1239
+ * CRDT-managed marker, set by the `crdtText()` column constructor. When `true`
1240
+ * the column holds an ENCODED CRDT state (opaque bytes — a Yjs update blob
1241
+ * behind `@voltro/local-first`'s swappable backend) rather than a plain value.
1242
+ *
1243
+ * It changes NOTHING about the column's DDL or how the declarative differ
1244
+ * treats it: the storage type is `bytes` (BYTEA / BLOB / LONGBLOB /
1245
+ * VARBINARY), a normal storable type, and the planner compares it structurally
1246
+ * like any other `bytes` column — introspection never reports this flag, and
1247
+ * `sameColumnShape` never reads it, so a `crdtText()` column round-trips
1248
+ * through a plan with zero churn (no phantom re-plan).
1249
+ *
1250
+ * What it DOES drive lives above the DDL, in the runtime write path: the
1251
+ * MutationStore reads it (via the SchemaRegistry) to run the AUTHORITATIVE
1252
+ * server-side merge on write — an incoming encoded update is folded into the
1253
+ * stored state with `mergeCrdtStates` before the row is written, so two
1254
+ * concurrent clients converge. Orthogonal to every other flag here; a CRDT
1255
+ * column is just `bytes` with server-merge semantics.
1256
+ */
1257
+ readonly crdtManaged?: boolean;
1185
1258
  /**
1186
1259
  * Optimistic-concurrency marker, set by `.version()`.
1187
1260
  *
@@ -1543,6 +1616,49 @@ export declare const count: (alias?: string) => AggregateColumn;
1543
1616
 
1544
1617
  export declare const countDistinct: (column: string, alias?: string) => AggregateColumn;
1545
1618
 
1619
+ /**
1620
+ * The names of a table's CRDT-managed columns (`crdtText()`). These store an
1621
+ * encoded CRDT state as `bytes` and get an authoritative SERVER-SIDE merge on
1622
+ * every write (the runtime folds the incoming update into the stored state
1623
+ * before writing). Pure — no store, no context; the runtime's SchemaRegistry
1624
+ * uses the same signal to gate `mergeCrdtStates` on the write path.
1625
+ */
1626
+ export declare const crdtManagedColumns: (table: TableLike) => ReadonlyArray<string>;
1627
+
1628
+ /**
1629
+ * CRDT-managed text column — a field whose value is a CONFLICT-FREE replicated
1630
+ * text document rather than a plain string. Two clients editing it at the same
1631
+ * time converge without a last-write-wins loser.
1632
+ *
1633
+ * ```ts
1634
+ * export const documents = table('documents', {
1635
+ * id: id(),
1636
+ * title: text(),
1637
+ * body: crdtText(), // CRDT-managed field
1638
+ * })
1639
+ * ```
1640
+ *
1641
+ * STORAGE. The column stores the ENCODED CRDT state as `bytes` (BYTEA / BLOB /
1642
+ * LONGBLOB / VARBINARY) — an opaque Yjs update blob produced by
1643
+ * `@voltro/local-first`. There is no special DDL: to the declarative differ it
1644
+ * is an ordinary nullable `bytes` column, so it plans and round-trips like any
1645
+ * other (no phantom re-plan). The row type is `Uint8Array | null`; decode it to
1646
+ * a string with `@voltro/local-first`'s `decodeCrdtText(state)`, and produce
1647
+ * writes with a `crdtText()` handle's `.encode()`.
1648
+ *
1649
+ * NULLABLE by design: an insert may omit the column (no document yet), and the
1650
+ * first write establishes the state. A stored `null` is treated as the empty
1651
+ * document by the server merge.
1652
+ *
1653
+ * SERVER MERGE. The authoritative merge runs in the runtime on write: the
1654
+ * MutationStore folds an incoming encoded update into the stored state with
1655
+ * `mergeCrdtStates` before writing, and the reactive engine broadcasts the
1656
+ * merged result. That is what makes the column converge; the browser-safe merge
1657
+ * primitives it uses live in `@voltro/local-first` and never import the
1658
+ * `database` handle.
1659
+ */
1660
+ export declare const crdtText: () => ColumnBuilder<Uint8Array | null, "bytes">;
1661
+
1546
1662
  /** The attribution on the current async stack, or `undefined` outside a request. */
1547
1663
  export declare const currentWriteAttribution: () => WriteAttribution | undefined;
1548
1664
 
@@ -2852,6 +2968,13 @@ export declare const isFileMigration: (value: unknown) => value is FileMigration
2852
2968
  * but no user schema declares — never plan a drop for it. */
2853
2969
  export declare const isFrameworkOwnedLiveTable: (name: string) => boolean;
2854
2970
 
2971
+ /**
2972
+ * Whether a table carries `localFirst()`. Pure reflection over its applied
2973
+ * mixins — the same signal the runtime registry uses, exposed for tooling and
2974
+ * for a client that builds its sync set from the schema.
2975
+ */
2976
+ export declare const isLocalFirst: (table: LocalFirstMarked) => boolean;
2977
+
2855
2978
  export declare const isMigrationDefinition: (value: unknown) => value is MigrationDefinition;
2856
2979
 
2857
2980
  /**
@@ -3020,6 +3143,38 @@ export declare type LeafOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' |
3020
3143
  /** Every currently-registered retention spec. The boot sweep reads this each tick. */
3021
3144
  export declare const listRetentions: () => ReadonlyArray<RetentionSpec>;
3022
3145
 
3146
+ /**
3147
+ * Opt a table into local-first behaviour.
3148
+ *
3149
+ * ```ts
3150
+ * export const documents = table('documents', {
3151
+ * id: id(),
3152
+ * title: text(),
3153
+ * body: crdtText(), // CRDT-managed field
3154
+ * }).with(timestamps, tenant, localFirst())
3155
+ * ```
3156
+ *
3157
+ * Adds no column — it is a marker the framework reflects on to sync + persist
3158
+ * the table client-side. Compose it with `crdtText()` columns for converging
3159
+ * rich fields; plain columns on a local-first table sync last-write-wins.
3160
+ */
3161
+ export declare const localFirst: () => MixinDefinition<{}>;
3162
+
3163
+ /** The minimum shape {@link isLocalFirst} reflects on — a table's applied
3164
+ * mixins. Compatible with `Table` from `@voltro/database`. */
3165
+ export declare interface LocalFirstMarked {
3166
+ readonly appliedMixins: ReadonlyArray<AnyMixin>;
3167
+ }
3168
+
3169
+ /**
3170
+ * The names of the local-first tables in a schema (a record of `table()`s).
3171
+ * The discovery surface a boot step / client-sync-set builder consumes to learn
3172
+ * which tables get a local mirror + sync loop.
3173
+ */
3174
+ export declare const localFirstTables: (schema: Readonly<Record<string, LocalFirstMarked & {
3175
+ readonly tableName: string;
3176
+ }>>) => ReadonlyArray<string>;
3177
+
3023
3178
  export declare const lt: <RowOf = Record<string, unknown>, K extends keyof RowOf & string = keyof RowOf & string>(column: K, value: RowOf[K]) => PredicateLeaf;
3024
3179
 
3025
3180
  export declare const lte: <RowOf = Record<string, unknown>, K extends keyof RowOf & string = keyof RowOf & string>(column: K, value: RowOf[K]) => PredicateLeaf;
@@ -4414,6 +4569,25 @@ declare interface RawSqlFragment {
4414
4569
  readonly dependsOn?: ReadonlyArray<string>;
4415
4570
  }
4416
4571
 
4572
+ /** One `.readableBy(...scopes)` column: the field NAME plus the scope set that
4573
+ * makes it visible. */
4574
+ export declare interface ReadableByColumn {
4575
+ readonly column: string;
4576
+ /** Non-empty — a subject holding ANY of these sees the column. */
4577
+ readonly scopes: ReadonlyArray<string>;
4578
+ }
4579
+
4580
+ /**
4581
+ * The `.readableBy(...scopes)` columns of a table — the field-level read-scope
4582
+ * layer of the wire-exposure axis. Each entry names a column and the scope set
4583
+ * that unlocks it (a subject holding ANY of the scopes sees it; one holding
4584
+ * NONE has it stripped from wire output). Empty when the table declares none —
4585
+ * which lets the runtime strip stay subject-INDEPENDENT (and its read/diff memo
4586
+ * shared) for the common table that has no per-field permissions. Pure — no
4587
+ * store, no context.
4588
+ */
4589
+ export declare const readableByColumns: (table: TableLike) => ReadonlyArray<ReadableByColumn>;
4590
+
4417
4591
  /** Floating-point number. Postgres `DOUBLE PRECISION`, mysql/mariadb `DOUBLE`,
4418
4592
  * mssql `FLOAT`, sqlite `REAL`. Drivers return JS numbers — no codec. Use for
4419
4593
  * fractional values (ratings, percentages, measurements) where `integer()`
@@ -4797,6 +4971,50 @@ export declare const rowSchema: <T extends TableLike>(table: T, options?: {
4797
4971
  readonly omit?: ReadonlyArray<string>;
4798
4972
  }) => Schema.Schema.Any;
4799
4973
 
4974
+ /** The context a rule predicate is evaluated with. */
4975
+ export declare interface RuleContext {
4976
+ /** Transactional store — read other tables in the mutation's snapshot. */
4977
+ readonly store: RuleReadStore;
4978
+ /** The calling subject (tenant / user), for tenant-aware rules. */
4979
+ readonly subject: unknown;
4980
+ }
4981
+
4982
+ /**
4983
+ * What a rule predicate may return. `true` / `void` / `undefined` = the rule
4984
+ * HOLDS. `false` = violated with the rule's default detail. A
4985
+ * `RuleViolationDetail` object = violated, carrying per-row i18n params / a
4986
+ * field pointer for THIS specific row.
4987
+ */
4988
+ export declare type RuleOutcome = boolean | void | RuleViolationDetail;
4989
+
4990
+ /**
4991
+ * A rule predicate over the POST-WRITE row. Runs inside the mutation
4992
+ * transaction. Return `true`/nothing when the invariant holds; return `false`
4993
+ * or a `RuleViolationDetail` to signal a violation. May be async (cross-table
4994
+ * reads).
4995
+ */
4996
+ export declare type RulePredicate = (row: Record<string, unknown>, context: RuleContext) => RuleOutcome | Promise<RuleOutcome>;
4997
+
4998
+ /** Minimal READ surface (a subset of `DataStore`) handed to a rule predicate —
4999
+ * the SAME transactional store the mutation writes through, so cross-table
5000
+ * reads share its MVCC snapshot and cannot race the write the rule guards. */
5001
+ export declare interface RuleReadStore {
5002
+ query(descriptor: QueryDescriptor): Promise<ReadonlyArray<Record<string, unknown>>>;
5003
+ }
5004
+
5005
+ /** Severity of a business rule. `error` rolls the mutation back; `warning`
5006
+ * logs + audits but lets the write commit (useful during data migrations). */
5007
+ export declare type RuleSeverity = 'error' | 'warning';
5008
+
5009
+ export declare interface RuleViolationDetail {
5010
+ /** i18n params for the violation message (per this row). */
5011
+ readonly params?: Record<string, unknown>;
5012
+ /** Field path the violation pinpoints, if any. */
5013
+ readonly field?: string;
5014
+ /** Human-readable message (fallback / dev aid). */
5015
+ readonly message?: string;
5016
+ }
5017
+
4800
5018
  /**
4801
5019
  * Run `fn` with `attribution` active for its whole (sync + awaited) execution.
4802
5020
  *
@@ -5307,6 +5525,12 @@ export declare interface Table<Name extends string, Fields extends Record<string
5307
5525
  readonly appliedUniques: ReadonlyArray<TableUnique>;
5308
5526
  readonly appliedFullText: ReadonlyArray<TableFullTextIndex>;
5309
5527
  readonly appliedChecks: ReadonlyArray<TableCheck>;
5528
+ /**
5529
+ * The cross-table business rules DECLARED on this table, set by
5530
+ * `.rule(name, predicate)`. Evaluated by the runtime inside the mutation
5531
+ * transaction (see `TableRule`); they emit no DDL.
5532
+ */
5533
+ readonly appliedRules: ReadonlyArray<TableRule>;
5310
5534
  /**
5311
5535
  * The name this table used to have, set by `.renamedFrom('old_name')`.
5312
5536
  * Named apart from the METHOD that sets it — one interface cannot carry both.
@@ -5568,6 +5792,38 @@ export declare interface Table<Name extends string, Fields extends Record<string
5568
5792
  * For a single-column check, `column.check(expr)` is the terser form.
5569
5793
  */
5570
5794
  check: <const CkName extends string>(name: CkName, expr: string) => Table<Name, Fields, Reactive, IxNames>;
5795
+ /**
5796
+ * Declare a CROSS-TABLE business rule (S3). Unlike `.check(name, expr)` — a
5797
+ * single-row SQL `CHECK` the database enforces — a rule is a PREDICATE the
5798
+ * runtime evaluates INSIDE the mutation's transaction, after the write and
5799
+ * before commit. It can read OTHER tables in the same snapshot and rolls the
5800
+ * mutation back with a typed `BusinessRuleViolation` on failure.
5801
+ *
5802
+ * The predicate receives the POST-WRITE row and a `context` whose `store` is
5803
+ * the SAME transactional store the mutation wrote through. Return `true`
5804
+ * (or nothing) when the invariant holds; return `false` or a
5805
+ * `RuleViolationDetail` (i18n `params` / a `field` pointer) to signal a
5806
+ * violation.
5807
+ *
5808
+ * ```ts
5809
+ * table('invoices', { id: id(), total: integer() }).rule(
5810
+ * 'totalMatchesLineItems',
5811
+ * async (row, { store }) => {
5812
+ * const items = await store.query(
5813
+ * lineItems.where(eq('invoiceId', row.id)).descriptor,
5814
+ * )
5815
+ * const sum = items.reduce((a, l) => a + (l.amount as number), 0)
5816
+ * return sum === row.total || { params: { computed: sum, declared: row.total } }
5817
+ * },
5818
+ * )
5819
+ * ```
5820
+ *
5821
+ * `severity: 'warning'` logs + audits the violation but lets the write
5822
+ * commit (useful during data migration); the default `'error'` rolls back.
5823
+ */
5824
+ rule: <const RuleName extends string>(name: RuleName, predicate: RulePredicate, options?: {
5825
+ readonly severity?: RuleSeverity;
5826
+ }) => Table<Name, Fields, Reactive, IxNames>;
5571
5827
  /**
5572
5828
  * Declare a full-text-search index (S7). One method, three back-
5573
5829
  * ends:
@@ -5786,6 +6042,21 @@ export declare interface TableLike {
5786
6042
  * Internally we always store the thunk form. */
5787
6043
  declare type TableRef = TableLike | (() => TableLike);
5788
6044
 
6045
+ /**
6046
+ * A named cross-table business rule, attached by `.rule(name, predicate)`.
6047
+ *
6048
+ * UNLIKE `.check(name, expr)` — a single-row SQL `CHECK` emitted as DDL and
6049
+ * enforced by the database — a rule is a PREDICATE the runtime evaluates INSIDE
6050
+ * the mutation's transaction, AFTER the write and BEFORE commit. It can read
6051
+ * OTHER tables in the same MVCC snapshot and rolls the whole mutation back
6052
+ * (typed `BusinessRuleViolation`) on violation. It emits no DDL.
6053
+ */
6054
+ export declare interface TableRule {
6055
+ readonly name: string;
6056
+ readonly predicate: RulePredicate;
6057
+ readonly severity: RuleSeverity;
6058
+ }
6059
+
5789
6060
  /** A page read failed while streaming a table. Carries the underlying store
5790
6061
  * error as `cause` so callers can inspect the transient-ness for retry. */
5791
6062
  export declare class TableStreamError extends TableStreamError_base<{
@@ -6252,6 +6523,11 @@ export declare const viewSql: (v: View<string, Record<string, ColumnDefinition<u
6252
6523
  /** Stable mixin id — the runtime keys read-filtering off it. */
6253
6524
  export declare const VOLTRO_EXPIRES_MIXIN_ID = "voltro/expires";
6254
6525
 
6526
+ /** Stable mixin id — the runtime keys local-first discovery off it. Re-declared
6527
+ * (without importing this mixin) in the runtime's SchemaRegistry; kept in step
6528
+ * by `localFirstMixinId.test.ts`. */
6529
+ export declare const VOLTRO_LOCAL_FIRST_MIXIN_ID: "voltro/localFirst";
6530
+
6255
6531
  /**
6256
6532
  * `_voltro_api_keys` — first-class API key management. Only the SHA-256 HASH
6257
6533
  * of a key is stored (`hashedKey`, unique); the raw token is shown once at