@voltro/database 0.23.0 → 0.25.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.
@@ -32,6 +32,15 @@ var r = (e) => {
32
32
  serverOnly: !0
33
33
  });
34
34
  }
35
+ version() {
36
+ if (this.rejectIfRaw("version"), this.definition.type !== "integer") throw Error(`.version() requires an integer() column (got ${this.definition.type}). The store increments it, so it has to be a number — and a timestamp version reintroduces exactly the clock problem optimistic locking exists to avoid: two writes in the same millisecond are indistinguishable, and replica clocks disagree.`);
37
+ return new e({
38
+ ...this.definition,
39
+ versionColumn: !0,
40
+ hasDefault: !0,
41
+ defaultValue: 1
42
+ });
43
+ }
35
44
  sensitive(t) {
36
45
  return new e({
37
46
  ...this.definition,
package/dist/index.d.ts CHANGED
@@ -670,6 +670,34 @@ export declare class ColumnBuilder<TsType, Type extends ColumnType, HasDefault e
670
670
  * neither.)
671
671
  */
672
672
  serverOnly(): this;
673
+ /**
674
+ * Mark this column as the row's OPTIMISTIC-CONCURRENCY version.
675
+ *
676
+ * ```ts
677
+ * table('documents', { id: id(), title: text(), version: integer().version() })
678
+ * ```
679
+ *
680
+ * From then on the store increments it on every update, and an update that
681
+ * carries `expectedVersion` fails with a typed `VersionConflict` when the row
682
+ * has moved on. That is the whole feature, and the reason it is a COLUMN
683
+ * marker rather than a table mixin is that only the author knows which column
684
+ * (if any) their schema already uses for this — several apps arrive with one.
685
+ *
686
+ * WHAT IT REPLACES. Nothing, which is the point: today the second of two
687
+ * concurrent writers wins silently. `updatedAt` is the obvious substitute and
688
+ * it does not work — same-millisecond writes are indistinguishable and replica
689
+ * clocks disagree, so a comparison that looks correct in a test loses rows
690
+ * under load. An integer the DATABASE owns has neither problem.
691
+ *
692
+ * WHAT IT DOES NOT DO. It is not a history: it records that a row changed, not
693
+ * what it changed to. For that use `plugin-versioning`. It is also not a
694
+ * distributed lock — a conflicting write is REPORTED, never queued or merged,
695
+ * because merging two intents is a decision only the application can make.
696
+ *
697
+ * Must be an `integer()`. A `text()` version cannot be incremented and a
698
+ * `timestamp()` one reintroduces the clock, so both fail at declaration.
699
+ */
700
+ version(): this;
673
701
  /**
674
702
  * Classify this column as SENSITIVE — it holds personal / sensitive data of
675
703
  * `class` (`'email'`, `'fullName'`, `'phone'`, `'address'`, `'secret'`, …).
@@ -1112,6 +1140,23 @@ export declare interface ColumnDefinition<TsType, Type extends ColumnType = Colu
1112
1140
  * COSTS is per command — see `serverOnly()` below for the matrix.
1113
1141
  */
1114
1142
  readonly serverOnly?: boolean;
1143
+ /**
1144
+ * Optimistic-concurrency marker, set by `.version()`.
1145
+ *
1146
+ * The column the store INCREMENTS on every update, and the one an update may
1147
+ * carry an expectation of. Two clients that read the same row and both write
1148
+ * it are the normal case, not an exotic one — and without this the second
1149
+ * write silently wins, which is the shape of every "my change disappeared"
1150
+ * report ever filed.
1151
+ *
1152
+ * A TIMESTAMP cannot do this job. Two writes in the same millisecond are
1153
+ * indistinguishable, and across replicas the clocks disagree; this repo has
1154
+ * already lost rows to exactly that (an analytics sink dropped 7 of 40 events
1155
+ * written in the same millisecond as the query bounding them). A monotonic
1156
+ * integer has neither problem, and it also answers the other half of the
1157
+ * question — which of two copies is NEWER — without consulting a clock.
1158
+ */
1159
+ readonly versionColumn?: boolean;
1115
1160
  /**
1116
1161
  * Data-sensitivity classification, set by `.sensitive(class)`. Declares that
1117
1162
  * this column holds personal / sensitive data of a given CLASS (`'email'`,
@@ -1404,6 +1449,31 @@ export declare interface ConnectionConfig {
1404
1449
  */
1405
1450
  readonly ssl?: boolean;
1406
1451
  readonly maxConnections?: number;
1452
+ /**
1453
+ * Per-statement timeout in ms — a runaway query (missing index, cartesian
1454
+ * join) is aborted instead of holding a pooled connection forever, which under
1455
+ * load exhausts the pool and stalls the whole app. Applied at the CONNECTION
1456
+ * level so every query is bounded (no per-call opt-in). Env `DB_STATEMENT_TIMEOUT_MS`.
1457
+ * Runtime-path only — the migration path (`voltro db apply`) deliberately does
1458
+ * NOT read this, so a long backfill / index build is never cancelled by the app
1459
+ * query ceiling.
1460
+ *
1461
+ * **Wired for postgres only** (the default dialect), enforced server-side via
1462
+ * node-postgres' `statement_timeout` pool option — a runaway query is cancelled
1463
+ * with SQLSTATE 57014, not merely disconnected. Every OTHER dialect currently
1464
+ * ACCEPTS the field but IGNORES it, and the honest reasons differ:
1465
+ * - **mssql** — `@effect/sql-mssql` exposes only `connectTimeout` (connection
1466
+ * establishment), not tedious's per-request `requestTimeout`; mapping this
1467
+ * onto `connectTimeout` would bound the wrong phase, so it is left unwired
1468
+ * rather than wrong.
1469
+ * - **mysql/mariadb** — the server's `max_execution_time` bounds SELECTs only
1470
+ * (writes run unbounded), so a connection-level setting would be a partial,
1471
+ * misleading guarantee; not wired in V1.
1472
+ * - **sqlite** — in-process, one connection, no pool to exhaust; nothing to
1473
+ * protect.
1474
+ * Unset (or any non-postgres dialect) = no timeout — behaviour is unchanged.
1475
+ */
1476
+ readonly statementTimeoutMs?: number;
1407
1477
  /**
1408
1478
  * Postgres only: the schema the app's tables live in, pinned as the
1409
1479
  * connection `search_path` (env `DB_SCHEMA`). When set, EVERY pooled
@@ -2190,6 +2260,35 @@ export declare interface ExistsPredicate {
2190
2260
  readonly subquery: QueryDescriptor;
2191
2261
  }
2192
2262
 
2263
+ /**
2264
+ * Add `expiresAt` — after that instant the row is invisible to reads.
2265
+ *
2266
+ * ```ts
2267
+ * table('inviteLinks', { id: id(), email: text() }).with(expires())
2268
+ *
2269
+ * await ctx.store.insert('inviteLinks', {
2270
+ * email, expiresAt: new Date(Date.now() + 24 * 3_600_000),
2271
+ * })
2272
+ * ```
2273
+ *
2274
+ * `expiresAt` is NULLABLE and null means never — so adding the mixin to an
2275
+ * existing table does not make its rows vanish, and a row that should outlive
2276
+ * the others simply leaves it unset. A non-null default would have been the
2277
+ * tidier API and the wrong one: it would turn "I added a mixin" into "I set an
2278
+ * expiry on every existing row".
2279
+ *
2280
+ * Reads filter it out on EVERY dialect from the moment it passes; the physical
2281
+ * delete is a postgres-only sweep. Read the header before relying on the second
2282
+ * half — an expired row is INVISIBLE everywhere and still PRESENT on four of
2283
+ * five dialects, which is the right trade and a surprising one.
2284
+ *
2285
+ * Opt out for a deliberate read of expired rows the same way soft-delete does:
2286
+ * `ctx.store.select('inviteLinks').includeExpired()`.
2287
+ */
2288
+ export declare const expires: () => MixinDefinition<{
2289
+ readonly expiresAt: ColumnDefinition<Date | null, "timestamp", boolean>;
2290
+ }>;
2291
+
2193
2292
  /** The cipher the store middleware injects. Operates on opaque strings. */
2194
2293
  export declare interface FieldCipher {
2195
2294
  readonly encrypt: (plaintext: string) => string;
@@ -2639,6 +2738,16 @@ export declare type InferRowFromFields<F extends Record<string, ColumnDefinition
2639
2738
  /** Row type for a view — mirrors `InferRow` for tables. */
2640
2739
  export declare type InferViewRow<V> = V extends View<string, infer F> ? InferRowFromFields<F> : never;
2641
2740
 
2741
+ /**
2742
+ * The version a freshly-inserted row starts at.
2743
+ *
2744
+ * 1 rather than 0 so that "has this row ever been written?" and "what version is
2745
+ * it?" are never the same question — a 0 reads as absent in too many places
2746
+ * (`if (row.version)`), and a version that is falsy on a real row is a trap
2747
+ * waiting for the first person who writes that check.
2748
+ */
2749
+ export declare const INITIAL_VERSION = 1;
2750
+
2642
2751
  /**
2643
2752
  * Insert a row, typed against the table: the payload must carry every required
2644
2753
  * column (NOT NULL, no default, not auto-filled) or it is a compile error.
@@ -3348,6 +3457,26 @@ export declare const planNeonBranchProvision: (branchId: string, options?: NeonB
3348
3457
  * never delete a non-branch; idempotent on the executor side. */
3349
3458
  export declare const planNeonBranchTeardown: (branchId: string) => ReadonlyArray<BranchStep>;
3350
3459
 
3460
+ /**
3461
+ * The version an UPDATE must write, and the guard it must carry.
3462
+ *
3463
+ * Returns `undefined` when the table has no version column, so a store's fast
3464
+ * path is a single check and an app that does not use the feature pays nothing.
3465
+ *
3466
+ * `expected` is read from the PATCH rather than passed separately, because the
3467
+ * alternative — a second argument on every store method — is a change every
3468
+ * caller has to make and most would skip. Putting it in the patch means the
3469
+ * caller who cares opts in at the one call site that cares.
3470
+ */
3471
+ export declare const planVersionedUpdate: (input: {
3472
+ readonly versionColumn: string | undefined;
3473
+ readonly patch: Readonly<Record<string, unknown>>;
3474
+ }) => {
3475
+ readonly nextVersion: number | undefined;
3476
+ readonly expected: number | undefined;
3477
+ readonly patch: Record<string, unknown>;
3478
+ };
3479
+
3351
3480
  export declare type Predicate = PredicateLeaf | AndPredicate | OrPredicate | NotPredicate | SubqueryInPredicate | ExistsPredicate;
3352
3481
 
3353
3482
  export declare interface PredicateLeaf {
@@ -3824,6 +3953,10 @@ export declare interface QueryDescriptor<R = Row> {
3824
3953
  * (storeMiddleware read path + reactive-query finalize) honors.
3825
3954
  */
3826
3955
  readonly includeDeleted?: boolean;
3956
+ /** Opt OUT of the `expires()` read filter — a deliberate read of expired
3957
+ * rows (an admin view, a grace-period check). Same shape and same posture as
3958
+ * `includeDeleted`. */
3959
+ readonly includeExpired?: boolean;
3827
3960
  /**
3828
3961
  * Opt OUT of the runtime's automatic tenant scope on `tenant()` tables.
3829
3962
  * Set by `.unscoped()`. Honored by the runtime, same as `includeDeleted`.
@@ -4785,6 +4918,20 @@ export declare const setResidencyConfig: (config: ResidencyConfig) => ResidencyC
4785
4918
  */
4786
4919
  export declare const settleTransactionExit: <A>(exit: Exit.Exit<A, unknown>) => A;
4787
4920
 
4921
+ /**
4922
+ * Decide the outcome of a versioned update from what the row actually held.
4923
+ *
4924
+ * Separated from the SQL so every store shares the DECISION and only the
4925
+ * statement differs. `current` is the row's version before the write, or `null`
4926
+ * when the row is gone.
4927
+ */
4928
+ export declare const settleVersionedUpdate: (input: {
4929
+ readonly table: string;
4930
+ readonly id: string;
4931
+ readonly expected: number | undefined;
4932
+ readonly current: number | null;
4933
+ }) => VersionConflict | undefined;
4934
+
4788
4935
  /**
4789
4936
  * PostGIS spatial-distance clause carried on a {@link QueryDescriptor}.
4790
4937
  * Drives a `ST_Distance` projected column (distance-as-a-value) and/or a
@@ -5898,6 +6045,34 @@ export declare interface VectorOptions {
5898
6045
  readonly precision?: 'float32' | 'half';
5899
6046
  }
5900
6047
 
6048
+ /** The column marked `.version()`, or `undefined` when a table has none. */
6049
+ export declare const versionColumnOf: (columns: Readonly<Record<string, {
6050
+ readonly definition?: {
6051
+ readonly versionColumn?: boolean;
6052
+ };
6053
+ }>>) => string | undefined;
6054
+
6055
+ /**
6056
+ * A write lost the race.
6057
+ *
6058
+ * Carries both numbers because "someone else changed it" is not actionable and
6059
+ * "you had 3, it is now 7" is: four intervening writes is a different situation
6060
+ * from one, and a UI can say so.
6061
+ */
6062
+ export declare class VersionConflict extends VersionConflict_base {
6063
+ }
6064
+
6065
+ declare const VersionConflict_base: Schema.TaggedErrorClass<VersionConflict, "VersionConflict", {
6066
+ readonly _tag: Schema.tag<"VersionConflict">;
6067
+ } & {
6068
+ table: typeof Schema.String;
6069
+ id: typeof Schema.String;
6070
+ /** What the caller believed the row was at. */
6071
+ expected: typeof Schema.Number;
6072
+ /** What it is actually at. `null` when the row is gone entirely. */
6073
+ actual: Schema.NullOr<typeof Schema.Number>;
6074
+ }>;
6075
+
5901
6076
  /**
5902
6077
  * A read-only SQL view descriptor. Structurally a `TableLike` (carries
5903
6078
  * `tableName` + `fields`) so the query builder + row decoder + by-name
@@ -5945,6 +6120,9 @@ export declare const view: <const Name extends string, const Input extends Field
5945
6120
  */
5946
6121
  export declare const viewSql: (v: View<string, Record<string, ColumnDefinition<unknown>>>, dialect: DialectId, schema?: string | null) => string;
5947
6122
 
6123
+ /** Stable mixin id — the runtime keys read-filtering off it. */
6124
+ export declare const VOLTRO_EXPIRES_MIXIN_ID = "voltro/expires";
6125
+
5948
6126
  /**
5949
6127
  * `_voltro_api_keys` — first-class API key management. Only the SHA-256 HASH
5950
6128
  * of a key is stored (`hashedKey`, unique); the raw token is shown once at