@voltro/database 0.24.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.
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'`,
@@ -2215,6 +2260,35 @@ export declare interface ExistsPredicate {
2215
2260
  readonly subquery: QueryDescriptor;
2216
2261
  }
2217
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
+
2218
2292
  /** The cipher the store middleware injects. Operates on opaque strings. */
2219
2293
  export declare interface FieldCipher {
2220
2294
  readonly encrypt: (plaintext: string) => string;
@@ -2664,6 +2738,16 @@ export declare type InferRowFromFields<F extends Record<string, ColumnDefinition
2664
2738
  /** Row type for a view — mirrors `InferRow` for tables. */
2665
2739
  export declare type InferViewRow<V> = V extends View<string, infer F> ? InferRowFromFields<F> : never;
2666
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
+
2667
2751
  /**
2668
2752
  * Insert a row, typed against the table: the payload must carry every required
2669
2753
  * column (NOT NULL, no default, not auto-filled) or it is a compile error.
@@ -3373,6 +3457,26 @@ export declare const planNeonBranchProvision: (branchId: string, options?: NeonB
3373
3457
  * never delete a non-branch; idempotent on the executor side. */
3374
3458
  export declare const planNeonBranchTeardown: (branchId: string) => ReadonlyArray<BranchStep>;
3375
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
+
3376
3480
  export declare type Predicate = PredicateLeaf | AndPredicate | OrPredicate | NotPredicate | SubqueryInPredicate | ExistsPredicate;
3377
3481
 
3378
3482
  export declare interface PredicateLeaf {
@@ -3849,6 +3953,10 @@ export declare interface QueryDescriptor<R = Row> {
3849
3953
  * (storeMiddleware read path + reactive-query finalize) honors.
3850
3954
  */
3851
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;
3852
3960
  /**
3853
3961
  * Opt OUT of the runtime's automatic tenant scope on `tenant()` tables.
3854
3962
  * Set by `.unscoped()`. Honored by the runtime, same as `includeDeleted`.
@@ -4810,6 +4918,20 @@ export declare const setResidencyConfig: (config: ResidencyConfig) => ResidencyC
4810
4918
  */
4811
4919
  export declare const settleTransactionExit: <A>(exit: Exit.Exit<A, unknown>) => A;
4812
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
+
4813
4935
  /**
4814
4936
  * PostGIS spatial-distance clause carried on a {@link QueryDescriptor}.
4815
4937
  * Drives a `ST_Distance` projected column (distance-as-a-value) and/or a
@@ -5923,6 +6045,34 @@ export declare interface VectorOptions {
5923
6045
  readonly precision?: 'float32' | 'half';
5924
6046
  }
5925
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
+
5926
6076
  /**
5927
6077
  * A read-only SQL view descriptor. Structurally a `TableLike` (carries
5928
6078
  * `tableName` + `fields`) so the query builder + row decoder + by-name
@@ -5970,6 +6120,9 @@ export declare const view: <const Name extends string, const Input extends Field
5970
6120
  */
5971
6121
  export declare const viewSql: (v: View<string, Record<string, ColumnDefinition<unknown>>>, dialect: DialectId, schema?: string | null) => string;
5972
6122
 
6123
+ /** Stable mixin id — the runtime keys read-filtering off it. */
6124
+ export declare const VOLTRO_EXPIRES_MIXIN_ID = "voltro/expires";
6125
+
5973
6126
  /**
5974
6127
  * `_voltro_api_keys` — first-class API key management. Only the SHA-256 HASH
5975
6128
  * of a key is stored (`hashedKey`, unique); the raw token is shown once at