@voltro/database 0.24.0 → 0.26.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
@@ -11,6 +11,11 @@ import { Stream } from 'effect';
11
11
  import { VoidIfEmpty } from 'effect/Types';
12
12
  import { YieldableError } from 'effect/Cause';
13
13
 
14
+ /** The store shape this needs — a read, nothing more. */
15
+ export declare interface ActorLookupStore {
16
+ query: (descriptor: unknown) => Promise<ReadonlyArray<Record<string, unknown>>>;
17
+ }
18
+
14
19
  /** The framework's built-in `actors` core table. */
15
20
  export declare const actorsTable: Table<'actors', Record<string, ColumnDefinition<unknown>>, true, never>;
16
21
 
@@ -185,6 +190,14 @@ export declare const attributionFields: (explicit?: WriteAttribution | undefined
185
190
  * never match. `(table, op, pk)` is stable through any re-encoding. */
186
191
  export declare const attributionKey: (table: string, op: string, primaryKey: unknown) => string;
187
192
 
193
+ /** A snapshot of who acted, as of the call. */
194
+ export declare interface AuditActor {
195
+ readonly id: string;
196
+ readonly type: string;
197
+ readonly displayName: string | null;
198
+ readonly email: string | null;
199
+ }
200
+
188
201
  /**
189
202
  * Audit a list of tables in one call. Convenience for the CLI's boot
190
203
  * path; same shape as calling `auditTableIndexes` per table and
@@ -670,6 +683,34 @@ export declare class ColumnBuilder<TsType, Type extends ColumnType, HasDefault e
670
683
  * neither.)
671
684
  */
672
685
  serverOnly(): this;
686
+ /**
687
+ * Mark this column as the row's OPTIMISTIC-CONCURRENCY version.
688
+ *
689
+ * ```ts
690
+ * table('documents', { id: id(), title: text(), version: integer().version() })
691
+ * ```
692
+ *
693
+ * From then on the store increments it on every update, and an update that
694
+ * carries `expectedVersion` fails with a typed `VersionConflict` when the row
695
+ * has moved on. That is the whole feature, and the reason it is a COLUMN
696
+ * marker rather than a table mixin is that only the author knows which column
697
+ * (if any) their schema already uses for this — several apps arrive with one.
698
+ *
699
+ * WHAT IT REPLACES. Nothing, which is the point: today the second of two
700
+ * concurrent writers wins silently. `updatedAt` is the obvious substitute and
701
+ * it does not work — same-millisecond writes are indistinguishable and replica
702
+ * clocks disagree, so a comparison that looks correct in a test loses rows
703
+ * under load. An integer the DATABASE owns has neither problem.
704
+ *
705
+ * WHAT IT DOES NOT DO. It is not a history: it records that a row changed, not
706
+ * what it changed to. For that use `plugin-versioning`. It is also not a
707
+ * distributed lock — a conflicting write is REPORTED, never queued or merged,
708
+ * because merging two intents is a decision only the application can make.
709
+ *
710
+ * Must be an `integer()`. A `text()` version cannot be incremented and a
711
+ * `timestamp()` one reintroduces the clock, so both fail at declaration.
712
+ */
713
+ version(): this;
673
714
  /**
674
715
  * Classify this column as SENSITIVE — it holds personal / sensitive data of
675
716
  * `class` (`'email'`, `'fullName'`, `'phone'`, `'address'`, `'secret'`, …).
@@ -1112,6 +1153,23 @@ export declare interface ColumnDefinition<TsType, Type extends ColumnType = Colu
1112
1153
  * COSTS is per command — see `serverOnly()` below for the matrix.
1113
1154
  */
1114
1155
  readonly serverOnly?: boolean;
1156
+ /**
1157
+ * Optimistic-concurrency marker, set by `.version()`.
1158
+ *
1159
+ * The column the store INCREMENTS on every update, and the one an update may
1160
+ * carry an expectation of. Two clients that read the same row and both write
1161
+ * it are the normal case, not an exotic one — and without this the second
1162
+ * write silently wins, which is the shape of every "my change disappeared"
1163
+ * report ever filed.
1164
+ *
1165
+ * A TIMESTAMP cannot do this job. Two writes in the same millisecond are
1166
+ * indistinguishable, and across replicas the clocks disagree; this repo has
1167
+ * already lost rows to exactly that (an analytics sink dropped 7 of 40 events
1168
+ * written in the same millisecond as the query bounding them). A monotonic
1169
+ * integer has neither problem, and it also answers the other half of the
1170
+ * question — which of two copies is NEWER — without consulting a clock.
1171
+ */
1172
+ readonly versionColumn?: boolean;
1115
1173
  /**
1116
1174
  * Data-sensitivity classification, set by `.sensitive(class)`. Declares that
1117
1175
  * this column holds personal / sensitive data of a given CLASS (`'email'`,
@@ -2215,6 +2273,35 @@ export declare interface ExistsPredicate {
2215
2273
  readonly subquery: QueryDescriptor;
2216
2274
  }
2217
2275
 
2276
+ /**
2277
+ * Add `expiresAt` — after that instant the row is invisible to reads.
2278
+ *
2279
+ * ```ts
2280
+ * table('inviteLinks', { id: id(), email: text() }).with(expires())
2281
+ *
2282
+ * await ctx.store.insert('inviteLinks', {
2283
+ * email, expiresAt: new Date(Date.now() + 24 * 3_600_000),
2284
+ * })
2285
+ * ```
2286
+ *
2287
+ * `expiresAt` is NULLABLE and null means never — so adding the mixin to an
2288
+ * existing table does not make its rows vanish, and a row that should outlive
2289
+ * the others simply leaves it unset. A non-null default would have been the
2290
+ * tidier API and the wrong one: it would turn "I added a mixin" into "I set an
2291
+ * expiry on every existing row".
2292
+ *
2293
+ * Reads filter it out on EVERY dialect from the moment it passes; the physical
2294
+ * delete is a postgres-only sweep. Read the header before relying on the second
2295
+ * half — an expired row is INVISIBLE everywhere and still PRESENT on four of
2296
+ * five dialects, which is the right trade and a surprising one.
2297
+ *
2298
+ * Opt out for a deliberate read of expired rows the same way soft-delete does:
2299
+ * `ctx.store.select('inviteLinks').includeExpired()`.
2300
+ */
2301
+ export declare const expires: () => MixinDefinition<{
2302
+ readonly expiresAt: ColumnDefinition<Date | null, "timestamp", boolean>;
2303
+ }>;
2304
+
2218
2305
  /** The cipher the store middleware injects. Operates on opaque strings. */
2219
2306
  export declare interface FieldCipher {
2220
2307
  readonly encrypt: (plaintext: string) => string;
@@ -2664,6 +2751,16 @@ export declare type InferRowFromFields<F extends Record<string, ColumnDefinition
2664
2751
  /** Row type for a view — mirrors `InferRow` for tables. */
2665
2752
  export declare type InferViewRow<V> = V extends View<string, infer F> ? InferRowFromFields<F> : never;
2666
2753
 
2754
+ /**
2755
+ * The version a freshly-inserted row starts at.
2756
+ *
2757
+ * 1 rather than 0 so that "has this row ever been written?" and "what version is
2758
+ * it?" are never the same question — a 0 reads as absent in too many places
2759
+ * (`if (row.version)`), and a version that is falsy on a real row is a trap
2760
+ * waiting for the first person who writes that check.
2761
+ */
2762
+ export declare const INITIAL_VERSION = 1;
2763
+
2667
2764
  /**
2668
2765
  * Insert a row, typed against the table: the payload must carry every required
2669
2766
  * column (NOT NULL, no default, not auto-filled) or it is a compile error.
@@ -3373,6 +3470,75 @@ export declare const planNeonBranchProvision: (branchId: string, options?: NeonB
3373
3470
  * never delete a non-branch; idempotent on the executor side. */
3374
3471
  export declare const planNeonBranchTeardown: (branchId: string) => ReadonlyArray<BranchStep>;
3375
3472
 
3473
+ /**
3474
+ * The version an UPDATE must write, and the guard it must carry.
3475
+ *
3476
+ * Returns `undefined` when the table has no version column, so a store's fast
3477
+ * path is a single check and an app that does not use the feature pays nothing.
3478
+ *
3479
+ * `expected` is read from the PATCH rather than passed separately, because the
3480
+ * alternative — a second argument on every store method — is a change every
3481
+ * caller has to make and most would skip. Putting it in the patch means the
3482
+ * caller who cares opts in at the one call site that cares.
3483
+ */
3484
+ export declare const planVersionedUpdate: (input: {
3485
+ readonly versionColumn: string | undefined;
3486
+ readonly patch: Readonly<Record<string, unknown>>;
3487
+ }) => {
3488
+ readonly nextVersion: number | undefined;
3489
+ readonly expected: number | undefined;
3490
+ readonly patch: Record<string, unknown>;
3491
+ };
3492
+
3493
+ /**
3494
+ * A typed id column pointing at a plugin-owned row, with an orphan rule.
3495
+ *
3496
+ * No foreign key is emitted — see the header. The rule is enforced by the
3497
+ * runtime on the target's delete.
3498
+ */
3499
+ export declare const pluginRef: (target: () => TableLike, options?: PluginRefOptions) => ColumnBuilder<string, ColumnType>;
3500
+
3501
+ export declare interface PluginRefOptions {
3502
+ /** What to do with this row when the target row is deleted. Default `'keep'`,
3503
+ * which is today's behaviour — declaring the rule is opt-in, and a default
3504
+ * that deleted rows would be a footgun in a patch release. */
3505
+ readonly orphanPolicy?: PluginRefOrphanPolicy;
3506
+ /**
3507
+ * Also fire on a SOFT delete of the target.
3508
+ *
3509
+ * Default `false`, and the asymmetry is real rather than an oversight: some
3510
+ * plugin tables carry `deletedAt` and some do not (`_voltro_ai_flows` does,
3511
+ * `_voltro_ai_flow_runs` does not). A soft delete is a state change the
3512
+ * target can undo, so cascading on it destroys rows that a restore cannot
3513
+ * bring back. Opt in when your rule is about visibility rather than
3514
+ * existence.
3515
+ */
3516
+ readonly onSoftDelete?: boolean;
3517
+ }
3518
+
3519
+ /** What happens to the referencing row when the target row goes away. */
3520
+ export declare type PluginRefOrphanPolicy =
3521
+ /** Delete the referencing row. For a row that only exists to point at it —
3522
+ * a favourite, a pin, a share. */
3523
+ 'delete'
3524
+ /** Null the column, keeping the row. Requires `.nullable()`; declaring it on
3525
+ * a non-nullable column is refused at declaration rather than failing at the
3526
+ * first delete, months later. */
3527
+ | 'null'
3528
+ /** Do nothing. The explicit "I know, and I handle it myself" — different from
3529
+ * omitting the option, which is the same behaviour arrived at by accident. */
3530
+ | 'keep';
3531
+
3532
+ /** Declared metadata for one plugin reference — read by the boot wiring. */
3533
+ export declare interface PluginRefSpec {
3534
+ readonly target: () => TableLike;
3535
+ readonly orphanPolicy: PluginRefOrphanPolicy;
3536
+ readonly onSoftDelete: boolean;
3537
+ }
3538
+
3539
+ /** The declared spec for a column builder, when it is a `pluginRef`. */
3540
+ export declare const pluginRefSpecOf: (column: unknown) => PluginRefSpec | undefined;
3541
+
3376
3542
  export declare type Predicate = PredicateLeaf | AndPredicate | OrPredicate | NotPredicate | SubqueryInPredicate | ExistsPredicate;
3377
3543
 
3378
3544
  export declare interface PredicateLeaf {
@@ -3849,6 +4015,10 @@ export declare interface QueryDescriptor<R = Row> {
3849
4015
  * (storeMiddleware read path + reactive-query finalize) honors.
3850
4016
  */
3851
4017
  readonly includeDeleted?: boolean;
4018
+ /** Opt OUT of the `expires()` read filter — a deliberate read of expired
4019
+ * rows (an admin view, a grace-period check). Same shape and same posture as
4020
+ * `includeDeleted`. */
4021
+ readonly includeExpired?: boolean;
3852
4022
  /**
3853
4023
  * Opt OUT of the runtime's automatic tenant scope on `tenant()` tables.
3854
4024
  * Set by `.unscoped()`. Honored by the runtime, same as `includeDeleted`.
@@ -4459,6 +4629,16 @@ declare type Resolve<T> = {
4459
4629
  readonly [K in keyof T]: T[K];
4460
4630
  } & {};
4461
4631
 
4632
+ /**
4633
+ * Snapshot the `actors` row for `subjectId`.
4634
+ *
4635
+ * `email` is read opportunistically: the framework's own `actors` carries
4636
+ * `id` / `kind` / `displayName`, and apps commonly extend it. Reading whatever
4637
+ * identifies the actor is the point — insisting on a fixed shape would make the
4638
+ * field useless for the deployments that need it most.
4639
+ */
4640
+ export declare const resolveActorSnapshot: (store: ActorLookupStore, subjectId: string | null | undefined, subjectType: string) => Promise<AuditActor | undefined>;
4641
+
4462
4642
  /**
4463
4643
  * Pick the branch mechanism. Prioritises the Neon copy-on-write fast-path when
4464
4644
  * the owned DB is Neon AND the branch snapshots parent data (`seed: 'copy'`);
@@ -4810,6 +4990,20 @@ export declare const setResidencyConfig: (config: ResidencyConfig) => ResidencyC
4810
4990
  */
4811
4991
  export declare const settleTransactionExit: <A>(exit: Exit.Exit<A, unknown>) => A;
4812
4992
 
4993
+ /**
4994
+ * Decide the outcome of a versioned update from what the row actually held.
4995
+ *
4996
+ * Separated from the SQL so every store shares the DECISION and only the
4997
+ * statement differs. `current` is the row's version before the write, or `null`
4998
+ * when the row is gone.
4999
+ */
5000
+ export declare const settleVersionedUpdate: (input: {
5001
+ readonly table: string;
5002
+ readonly id: string;
5003
+ readonly expected: number | undefined;
5004
+ readonly current: number | null;
5005
+ }) => VersionConflict | undefined;
5006
+
4813
5007
  /**
4814
5008
  * PostGIS spatial-distance clause carried on a {@link QueryDescriptor}.
4815
5009
  * Drives a `ST_Distance` projected column (distance-as-a-value) and/or a
@@ -5923,6 +6117,34 @@ export declare interface VectorOptions {
5923
6117
  readonly precision?: 'float32' | 'half';
5924
6118
  }
5925
6119
 
6120
+ /** The column marked `.version()`, or `undefined` when a table has none. */
6121
+ export declare const versionColumnOf: (columns: Readonly<Record<string, {
6122
+ readonly definition?: {
6123
+ readonly versionColumn?: boolean;
6124
+ };
6125
+ }>>) => string | undefined;
6126
+
6127
+ /**
6128
+ * A write lost the race.
6129
+ *
6130
+ * Carries both numbers because "someone else changed it" is not actionable and
6131
+ * "you had 3, it is now 7" is: four intervening writes is a different situation
6132
+ * from one, and a UI can say so.
6133
+ */
6134
+ export declare class VersionConflict extends VersionConflict_base {
6135
+ }
6136
+
6137
+ declare const VersionConflict_base: Schema.TaggedErrorClass<VersionConflict, "VersionConflict", {
6138
+ readonly _tag: Schema.tag<"VersionConflict">;
6139
+ } & {
6140
+ table: typeof Schema.String;
6141
+ id: typeof Schema.String;
6142
+ /** What the caller believed the row was at. */
6143
+ expected: typeof Schema.Number;
6144
+ /** What it is actually at. `null` when the row is gone entirely. */
6145
+ actual: Schema.NullOr<typeof Schema.Number>;
6146
+ }>;
6147
+
5926
6148
  /**
5927
6149
  * A read-only SQL view descriptor. Structurally a `TableLike` (carries
5928
6150
  * `tableName` + `fields`) so the query builder + row decoder + by-name
@@ -5970,6 +6192,9 @@ export declare const view: <const Name extends string, const Input extends Field
5970
6192
  */
5971
6193
  export declare const viewSql: (v: View<string, Record<string, ColumnDefinition<unknown>>>, dialect: DialectId, schema?: string | null) => string;
5972
6194
 
6195
+ /** Stable mixin id — the runtime keys read-filtering off it. */
6196
+ export declare const VOLTRO_EXPIRES_MIXIN_ID = "voltro/expires";
6197
+
5973
6198
  /**
5974
6199
  * `_voltro_api_keys` — first-class API key management. Only the SHA-256 HASH
5975
6200
  * of a key is stored (`hashedKey`, unique); the raw token is shown once at