@classytic/repo-core 0.22.0 → 0.24.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.
@@ -85,10 +85,27 @@ interface CleanupStepExecuteContext extends CleanupStepContext {
85
85
  * One preview line — maps 1:1 onto a host plan item. A business record class
86
86
  * (`'sales facts'`, `'journal entries'`), never a collection name.
87
87
  */
88
+ /**
89
+ * What a step's `estimated` COUNTS. Absent ⇒ `'remove'`.
90
+ *
91
+ * `destructive: false` is NOT a substitute: it is true of both a protective
92
+ * guard (counts records it defends) and a projection rebuild (counts records it
93
+ * recomputes), and those mean opposite things in a "records to remove" headline.
94
+ * A guard reporting 173 protected journal entries once pushed that headline to
95
+ * 540 on a plan that removed 367 — a plausible, internally consistent, wrong
96
+ * number shown at the exact moment an operator authorises destruction.
97
+ */
98
+ type CleanupStepDisposition = 'remove' | 'protect' | 'rebuild';
88
99
  interface CleanupStepEstimate {
89
100
  readonly resource: string;
90
101
  /** Estimated records this step would affect. */
91
102
  readonly estimated: number;
103
+ /**
104
+ * Whether `estimated` counts records REMOVED, PROTECTED, or REBUILT.
105
+ * Defaults to `'remove'`, so every existing purge step is unchanged and only
106
+ * a step that means something else has to say so.
107
+ */
108
+ readonly disposition?: CleanupStepDisposition | undefined;
92
109
  /** What this step RETAINS (e.g. `'measures kept, PII redacted'`). */
93
110
  readonly retained?: string | undefined;
94
111
  /**
@@ -147,6 +164,22 @@ interface CleanupStep {
147
164
  * recipe is destructive iff ANY of its steps is.
148
165
  */
149
166
  readonly destructive: boolean;
167
+ /**
168
+ * What this step IS, declared once — not merely what one estimate counted.
169
+ *
170
+ * Needed at the STEP because a caller has to know a step is protective
171
+ * WITHOUT running it. A host resolving an operator's exclusion list must
172
+ * refuse to switch a guard off, and asking `estimate()` to find out would mean
173
+ * running the counting queries for a line that is being taken out — and
174
+ * surfacing that line's blockers, so excluding a domain could still be refused
175
+ * because of it.
176
+ *
177
+ * `destructive: false` is not the same question: it is true of a guard AND of
178
+ * a projection rebuild, and a rebuild is perfectly reasonable to exclude.
179
+ *
180
+ * An estimate may restate it; absent everywhere ⇒ `'remove'`.
181
+ */
182
+ readonly disposition?: CleanupStepDisposition | undefined;
150
183
  /**
151
184
  * Projection / scaffolding rebuilds this step performs AFTER its cleanup —
152
185
  * surfaced in the preview's `rebuildActions` (e.g. `'rebuild sales rollup'`).
@@ -164,4 +197,4 @@ interface CleanupStep {
164
197
  verify?(ctx: CleanupStepContext): Promise<readonly CleanupStepCheck[]>;
165
198
  }
166
199
  //#endregion
167
- export { CleanupStep, CleanupStepCheck, CleanupStepContext, CleanupStepEstimate, CleanupStepExecuteContext, CleanupStepLogger, CleanupStepOutcome, CleanupStepProgress };
200
+ export { CleanupStep, CleanupStepCheck, CleanupStepContext, CleanupStepDisposition, CleanupStepEstimate, CleanupStepExecuteContext, CleanupStepLogger, CleanupStepOutcome, CleanupStepProgress };
@@ -0,0 +1,84 @@
1
+ //#region src/errors/conflict.d.ts
2
+ /**
3
+ * Concurrency-conflict taxonomy — driver-agnostic contract.
4
+ *
5
+ * Two DIFFERENT conflicts hide under "the write lost a race", and they demand
6
+ * opposite responses:
7
+ *
8
+ * 1. **Transient transaction conflict** — the backend aborted a transaction
9
+ * because of concurrent access, and re-running the SAME work is the
10
+ * designed recovery. Retryable by definition.
11
+ *
12
+ * | Backend | Signal |
13
+ * |------------|------------------------------------------------------------|
14
+ * | MongoDB | error label `TransientTransactionError` / code 112 |
15
+ * | Postgres | `40001` serialization_failure / `40P01` deadlock_detected |
16
+ * | SQLite | `SQLITE_BUSY` / `SQLITE_LOCKED` |
17
+ * | Prisma | `P2034` (transaction conflict) |
18
+ *
19
+ * 2. **Version conflict** — an optimistic-concurrency CAS (`ifVersion`) found
20
+ * the record changed since it was read. Re-running the same write would
21
+ * overwrite someone else's change, so it is NEVER auto-retried: the caller
22
+ * must re-read and re-decide (HTTP maps it to 409 + `If-Match` semantics).
23
+ *
24
+ * Classification of (1) belongs in the kit that knows its driver — same rule
25
+ * as `IsDuplicateKeyErrorFn`. Repositories expose it as
26
+ * `isTransientConflictError`; the shared `retryingTransaction` envelope
27
+ * consumes the boolean. The default is `neverTransient`: retrying work whose
28
+ * failure class is UNKNOWN is the unsafe direction (it re-runs side effects
29
+ * on validation/permission failures), so silence means "don't".
30
+ */
31
+ /** Predicate shape kits implement and repositories expose as `isTransientConflictError`. */
32
+ type IsTransientConflictFn = (err: unknown) => boolean;
33
+ /**
34
+ * Safe default: nothing is transient until the kit says so. The opposite
35
+ * default (retry everything) turns a deterministic failure into N delayed
36
+ * copies of itself — and re-runs side effects that already happened.
37
+ */
38
+ declare const neverTransient: IsTransientConflictFn;
39
+ /**
40
+ * Conservative MongoDB FALLBACK — not mongokit's classifier, and not an
41
+ * exception to the ownership rule above. Exactly the role (and placement) of
42
+ * `conservativeMongoIsDuplicateKey`: a floor for a repository that exposes no
43
+ * `isTransientConflictError` of its own, so back-compat with a kit predating
44
+ * the predicate degrades to "the driver's own explicit signals" rather than
45
+ * to `neverTransient`. Kits still own classification — mongokit's method
46
+ * delegates here deliberately (there is nothing to add), and non-Mongo kits
47
+ * MUST implement their own: this returns `false` for `SQLITE_BUSY`, `P2034`,
48
+ * `40001`, and every other native signal.
49
+ *
50
+ * Matches ONLY the `TransientTransactionError` label the server attaches and
51
+ * the bare WriteConflict code — never message text.
52
+ */
53
+ declare const conservativeMongoIsTransientConflict: IsTransientConflictFn;
54
+ /**
55
+ * Optimistic-concurrency violation: an `ifVersion` CAS write found a
56
+ * different stored version than the caller read.
57
+ *
58
+ * A CLASS (not a factory) for the same reason primitives' outbox errors are
59
+ * classes: consumers branch on `instanceof` across package boundaries, and a
60
+ * version conflict must be distinguishable from not-found — a repository
61
+ * signals "no such record" with `null`, and MUST NOT collapse a stale version
62
+ * into it, or the caller retries a write that would clobber a concurrent one.
63
+ */
64
+ declare class VersionConflictError extends Error {
65
+ readonly code: 'version_conflict';
66
+ /** HTTP mapping — 409 Conflict, pairs with ETag/`If-Match`. */
67
+ readonly status: 409;
68
+ readonly expectedVersion: number;
69
+ /** The stored version at CAS time, when the backend can report it cheaply. */
70
+ readonly actualVersion?: number;
71
+ readonly id?: string;
72
+ constructor(opts: {
73
+ expectedVersion: number;
74
+ actualVersion?: number;
75
+ id?: string;
76
+ });
77
+ }
78
+ /**
79
+ * Duck-typed check that survives two repo-core copies in one dependency
80
+ * graph, where `instanceof` silently fails across the boundary.
81
+ */
82
+ declare function isVersionConflictError(err: unknown): err is VersionConflictError;
83
+ //#endregion
84
+ export { IsTransientConflictFn, VersionConflictError, conservativeMongoIsTransientConflict, isVersionConflictError, neverTransient };
@@ -0,0 +1,63 @@
1
+ //#region src/errors/conflict.ts
2
+ /**
3
+ * Safe default: nothing is transient until the kit says so. The opposite
4
+ * default (retry everything) turns a deterministic failure into N delayed
5
+ * copies of itself — and re-runs side effects that already happened.
6
+ */
7
+ const neverTransient = () => false;
8
+ /**
9
+ * Conservative MongoDB FALLBACK — not mongokit's classifier, and not an
10
+ * exception to the ownership rule above. Exactly the role (and placement) of
11
+ * `conservativeMongoIsDuplicateKey`: a floor for a repository that exposes no
12
+ * `isTransientConflictError` of its own, so back-compat with a kit predating
13
+ * the predicate degrades to "the driver's own explicit signals" rather than
14
+ * to `neverTransient`. Kits still own classification — mongokit's method
15
+ * delegates here deliberately (there is nothing to add), and non-Mongo kits
16
+ * MUST implement their own: this returns `false` for `SQLITE_BUSY`, `P2034`,
17
+ * `40001`, and every other native signal.
18
+ *
19
+ * Matches ONLY the `TransientTransactionError` label the server attaches and
20
+ * the bare WriteConflict code — never message text.
21
+ */
22
+ const conservativeMongoIsTransientConflict = (err) => {
23
+ if (!err || typeof err !== "object") return false;
24
+ const e = err;
25
+ if (Array.isArray(e.errorLabels) && e.errorLabels.includes("TransientTransactionError")) return true;
26
+ if (typeof e.hasErrorLabel === "function" && e.hasErrorLabel("TransientTransactionError")) return true;
27
+ return e.code === 112 || e.codeName === "WriteConflict";
28
+ };
29
+ /**
30
+ * Optimistic-concurrency violation: an `ifVersion` CAS write found a
31
+ * different stored version than the caller read.
32
+ *
33
+ * A CLASS (not a factory) for the same reason primitives' outbox errors are
34
+ * classes: consumers branch on `instanceof` across package boundaries, and a
35
+ * version conflict must be distinguishable from not-found — a repository
36
+ * signals "no such record" with `null`, and MUST NOT collapse a stale version
37
+ * into it, or the caller retries a write that would clobber a concurrent one.
38
+ */
39
+ var VersionConflictError = class extends Error {
40
+ code = "version_conflict";
41
+ /** HTTP mapping — 409 Conflict, pairs with ETag/`If-Match`. */
42
+ status = 409;
43
+ expectedVersion;
44
+ /** The stored version at CAS time, when the backend can report it cheaply. */
45
+ actualVersion;
46
+ id;
47
+ constructor(opts) {
48
+ super(`Version conflict${opts.id ? ` on "${opts.id}"` : ""}: expected v${opts.expectedVersion}` + (opts.actualVersion !== void 0 ? `, found v${opts.actualVersion}` : ""));
49
+ this.name = "VersionConflictError";
50
+ this.expectedVersion = opts.expectedVersion;
51
+ if (opts.actualVersion !== void 0) this.actualVersion = opts.actualVersion;
52
+ if (opts.id !== void 0) this.id = opts.id;
53
+ }
54
+ };
55
+ /**
56
+ * Duck-typed check that survives two repo-core copies in one dependency
57
+ * graph, where `instanceof` silently fails across the boundary.
58
+ */
59
+ function isVersionConflictError(err) {
60
+ return err instanceof VersionConflictError || typeof err === "object" && err !== null && err.name === "VersionConflictError" && err.code === "version_conflict";
61
+ }
62
+ //#endregion
63
+ export { VersionConflictError, conservativeMongoIsTransientConflict, isVersionConflictError, neverTransient };
@@ -1,6 +1,7 @@
1
+ import { IsTransientConflictFn, VersionConflictError, conservativeMongoIsTransientConflict, isVersionConflictError, neverTransient } from "./conflict.mjs";
1
2
  import { DuplicateKeyMeta, ERROR_CODES, ErrorCode, ErrorContract, ErrorDetail, HttpError, ValidationErrorMeta } from "./types.mjs";
2
3
  import { statusToErrorCode, toErrorContract } from "./contract.mjs";
3
4
  import { createError, isHttpError } from "./create-error.mjs";
4
5
  import { IsDuplicateKeyErrorFn, ToDuplicateKeyHttpErrorOptions, conservativeMongoIsDuplicateKey, toDuplicateKeyHttpError } from "./duplicate-key.mjs";
5
6
  import { errorContractSchema, errorDetailSchema } from "./schema.mjs";
6
- export { type DuplicateKeyMeta, ERROR_CODES, type ErrorCode, type ErrorContract, type ErrorDetail, type HttpError, type IsDuplicateKeyErrorFn, type ToDuplicateKeyHttpErrorOptions, type ValidationErrorMeta, conservativeMongoIsDuplicateKey, createError, errorContractSchema, errorDetailSchema, isHttpError, statusToErrorCode, toDuplicateKeyHttpError, toErrorContract };
7
+ export { type DuplicateKeyMeta, ERROR_CODES, type ErrorCode, type ErrorContract, type ErrorDetail, type HttpError, type IsDuplicateKeyErrorFn, type IsTransientConflictFn, type ToDuplicateKeyHttpErrorOptions, type ValidationErrorMeta, VersionConflictError, conservativeMongoIsDuplicateKey, conservativeMongoIsTransientConflict, createError, errorContractSchema, errorDetailSchema, isHttpError, isVersionConflictError, neverTransient, statusToErrorCode, toDuplicateKeyHttpError, toErrorContract };
@@ -1,6 +1,7 @@
1
+ import { VersionConflictError, conservativeMongoIsTransientConflict, isVersionConflictError, neverTransient } from "./conflict.mjs";
1
2
  import { ERROR_CODES } from "./types.mjs";
2
3
  import { statusToErrorCode, toErrorContract } from "./contract.mjs";
3
4
  import { createError, isHttpError } from "./create-error.mjs";
4
5
  import { conservativeMongoIsDuplicateKey, toDuplicateKeyHttpError } from "./duplicate-key.mjs";
5
6
  import { errorContractSchema, errorDetailSchema } from "./schema.mjs";
6
- export { ERROR_CODES, conservativeMongoIsDuplicateKey, createError, errorContractSchema, errorDetailSchema, isHttpError, statusToErrorCode, toDuplicateKeyHttpError, toErrorContract };
7
+ export { ERROR_CODES, VersionConflictError, conservativeMongoIsDuplicateKey, conservativeMongoIsTransientConflict, createError, errorContractSchema, errorDetailSchema, isHttpError, isVersionConflictError, neverTransient, statusToErrorCode, toDuplicateKeyHttpError, toErrorContract };
@@ -65,6 +65,25 @@ interface LockAdapter {
65
65
  * single round-trip; a read-then-write split is racy.
66
66
  */
67
67
  tryAcquire(name: string, holderId: string, leaseMs: number): Promise<boolean> | boolean;
68
+ /**
69
+ * Optional FENCED acquire — same atomic semantics as `tryAcquire`, but a
70
+ * successful (or extended) acquisition returns a MONOTONIC token minted by
71
+ * the STORE. A process cannot fence itself: only the CAS authority can
72
+ * guarantee that a later holder's token is strictly greater, which is what
73
+ * lets downstream stores reject a stale ex-holder's writes after lease
74
+ * loss — the overlap that serialized renewal narrows but cannot close.
75
+ *
76
+ * Contract: token increases on every CHANGE of holder (extension by the
77
+ * same holder keeps its token); `null` = not acquired. Adapters that
78
+ * cannot mint monotonic tokens (pure-memory across restarts) omit this
79
+ * method — callers feature-detect, and the delivery-guarantees matrix
80
+ * documents which stores fence.
81
+ */
82
+ tryAcquireFenced?(name: string, holderId: string, leaseMs: number): Promise<{
83
+ token: number;
84
+ } | null> | {
85
+ token: number;
86
+ } | null;
68
87
  /**
69
88
  * Release the lock if held by `holderId`. Returns `true` on actual
70
89
  * release, `false` when the lock isn't held by this holder. Safe
@@ -91,6 +110,8 @@ interface LockState {
91
110
  expiresAt: Date;
92
111
  /** When the current holder first acquired (or last extended) the lock. */
93
112
  acquiredAt: Date;
113
+ /** Fencing token of the current holder, when the adapter fences. */
114
+ token?: number;
94
115
  }
95
116
  /** Adapter-construction options that every backend shares. */
96
117
  interface BaseLockAdapterOptions {
@@ -76,6 +76,7 @@ import { randomUUID } from "node:crypto";
76
76
  function createMemoryLockAdapter(options = {}) {
77
77
  const { defaultLeaseMs = 3e4 } = options;
78
78
  const store = /* @__PURE__ */ new Map();
79
+ const fenceSeq = /* @__PURE__ */ new Map();
79
80
  function readLive(name, now) {
80
81
  const entry = store.get(name);
81
82
  if (!entry) return void 0;
@@ -87,16 +88,22 @@ function createMemoryLockAdapter(options = {}) {
87
88
  }
88
89
  return {
89
90
  tryAcquire(name, holderId, leaseMs) {
91
+ return this.tryAcquireFenced?.(name, holderId, leaseMs) !== null;
92
+ },
93
+ tryAcquireFenced(name, holderId, leaseMs) {
90
94
  const ms = leaseMs > 0 ? leaseMs : defaultLeaseMs;
91
95
  const now = Date.now();
92
96
  const live = readLive(name, now);
93
- if (live && live.holder !== holderId) return false;
97
+ if (live && live.holder !== holderId) return null;
98
+ const token = live ? live.token : (fenceSeq.get(name) ?? 0) + 1;
99
+ if (!live) fenceSeq.set(name, token);
94
100
  store.set(name, {
95
101
  holder: holderId,
96
102
  expiresAt: now + ms,
97
- acquiredAt: live ? live.acquiredAt : now
103
+ acquiredAt: live ? live.acquiredAt : now,
104
+ token
98
105
  });
99
- return true;
106
+ return { token };
100
107
  },
101
108
  release(name, holderId) {
102
109
  const live = readLive(name, Date.now());
@@ -102,12 +102,54 @@ interface AggregateOpsSupport {
102
102
  interface RepoCapabilities {
103
103
  /** `withTransaction(fn)` — D1 throws, standalone Mongo throws 263. */
104
104
  transactions: boolean;
105
+ /**
106
+ * `WriteOptions.ifVersion` CAS honored (stale version → thrown
107
+ * `VersionConflictError`, success increments the version). Kits without
108
+ * it throw on the option — see the `ifVersion` contract.
109
+ */
110
+ optimisticConcurrency?: boolean;
105
111
  /**
106
112
  * True if calling `withTransaction` inside another `withTransaction`
107
- * callback is expected to work. Mongo's driver supports it via the
108
- * same session; SQL drivers typically reject it.
113
+ * callback is expected to work as observed on THIS repository, not on
114
+ * the underlying driver. A kit whose tx-bound repo throws on nested
115
+ * `withTransaction` declares `false` even when its driver would allow
116
+ * nesting on the raw session: the capability describes what a caller
117
+ * holding this object may do. Conformance asserts the two agree.
109
118
  */
110
119
  nestedTransactions: boolean;
120
+ /**
121
+ * WHO owns retry of a transaction aborted by a transient conflict.
122
+ *
123
+ * - `'managed'` — `withTransaction` retries internally (MongoDB's
124
+ * convenient transaction API re-runs the callback on
125
+ * `TransientTransactionError` / `UnknownTransactionCommitResult` for up
126
+ * to 120s). The caller MUST invoke it exactly once.
127
+ * - `'caller'` — one attempt per call; an outer envelope
128
+ * (`retryingTransaction`) owns the loop. Manual BEGIN/COMMIT kits.
129
+ *
130
+ * **Absent means `'managed'`** — i.e. `retryingTransaction` does not add a
131
+ * retry layer. Wrapping a self-retrying transaction multiplies the retry
132
+ * budget (5 outer attempts × a 120s inner window) and makes the callback's
133
+ * execution count unpredictable, which is the strictly worse failure: a
134
+ * missing retry surfaces a conflict as a 409, a nested one re-runs side
135
+ * effects an unbounded number of times. Same posture as
136
+ * {@link neverTransient} — silence means "don't".
137
+ *
138
+ * A kit exposing `withTransaction` MUST declare this (conformance checks).
139
+ */
140
+ transactionRetry?: 'managed' | 'caller';
141
+ /**
142
+ * This repository refuses writes — another component owns them and
143
+ * enforces invariants the table cannot express (Better Auth's identity
144
+ * collections, a SQL view, a read replica). Write methods throw
145
+ * `ReadOnlyRepositoryError`; hosts should refuse write ROUTES at boot
146
+ * rather than surfacing the wall on the first request.
147
+ *
148
+ * Absent means writable, so nothing changes for ordinary repositories.
149
+ * Set it via {@link asReadOnlyRepo} rather than by hand — the flag alone
150
+ * is a label, and a label is not a control.
151
+ */
152
+ readOnly?: boolean;
111
153
  /** `findOneAndUpdate` with upsert: true. */
112
154
  upsert: boolean;
113
155
  /** `isDuplicateKeyError(err)` classifier. */
@@ -6,8 +6,10 @@ import { ArchiveOptions, ArchivePort, ArchiveProgress, ArchiveResult, ArchiveSin
6
6
  import { PLUGIN_ORDER_CONSTRAINTS, Plugin, PluginFunction, PluginType, validatePluginOrder } from "./plugin-types.mjs";
7
7
  import { RepositoryBase, RepositoryBaseOptions } from "./base.mjs";
8
8
  import { AggregateOpsSupport, RepoCapabilities } from "./capabilities.mjs";
9
- import { AggCacheOptions, AggDateBucket, AggDateBucketInterval, AggDateBucketUnit, AggExecutionHints, AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, AggTopN, AggTopNTies, BulkCreateResult, BulkWriteOperation, BulkWriteResult, ChangeEvent, ClaimTransition, ClaimVersionTransition, CursorOptions, DeleteManyResult, DeleteOptions, DeleteResult, FilterInput, FindAllOptions, FindOneAndUpdateOptions, InferDoc, KeysetAggPaginationResult, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, TenantPurgeOptions, TenantPurgeProgress, TenantPurgeResult, TenantPurgeStrategy, TransitionArgs, TransitionMachine, UpdateManyResult, WatchOptions, WriteOptions } from "./types.mjs";
9
+ import { AggCacheOptions, AggDateBucket, AggDateBucketInterval, AggDateBucketUnit, AggExecutionHints, AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, AggTopN, AggTopNTies, BulkCreateResult, BulkWriteOperation, BulkWriteResult, ChangeEvent, ClaimTransition, ClaimVersionTransition, CursorOptions, DeleteManyResult, DeleteOptions, DeleteResult, FilterInput, FindAllOptions, FindOneAndUpdateOptions, InferDoc, KeysetAggPaginationResult, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, TenantPurgeOptions, TenantPurgeProgress, TenantPurgeResult, TenantPurgeStrategy, TransactionHandle, TransitionArgs, TransitionMachine, UpdateManyResult, WatchOptions, WriteOptions } from "./types.mjs";
10
10
  import { DistributionConfig, DistributionMissHandler, createDistributionGuard, filterReferencesKey } from "./distribution.mjs";
11
11
  import { STANDARD_REPO_OPTION_KEYS, StandardRepoOptionKey } from "./options.mjs";
12
12
  import { PurgePort, WritingPurgeStrategy, runChunkedPurge } from "./purge.mjs";
13
- export { type AggCacheOptions, type AggDateBucket, type AggDateBucketInterval, type AggDateBucketUnit, type AggExecutionHints, type AggMeasure, type AggPaginationRequest, type AggRequest, type AggResult, type AggRow, type AggTopN, type AggTopNTies, type AggregateOpsSupport, type ArchiveOptions, type ArchivePort, type ArchiveProgress, type ArchiveResult, type ArchiveSink, type BulkCreateResult, type BulkWriteOperation, type BulkWriteResult, type ChangeEvent, type ClaimTransition, type ClaimVersionTransition, type CursorOptions, type DeleteManyResult, type DeleteOptions, type DeleteResult, type DistributionConfig, type DistributionMissHandler, type FilterInput, type FindAllOptions, type FindOneAndUpdateOptions, type InferDoc, type KeysetAggPaginationResult, type LookupPopulateOptions, type LookupPopulateResult, type LookupRow, type LookupSpec, type MinimalRepo, PLUGIN_ORDER_CONSTRAINTS, type PaginationParams, type Plugin, type PluginFunction, type PluginType, type PurgePort, type QueryOptions, type RepoCapabilities, RepositoryBase, type RepositoryBaseOptions, type RepositorySession, type RetryPolicy, STANDARD_REPO_OPTION_KEYS, type StandardRepo, type StandardRepoOptionKey, type TenantPurgeOptions, type TenantPurgeProgress, type TenantPurgeResult, type TenantPurgeStrategy, type TransitionArgs, type TransitionMachine, type UpdateInput, type UpdateManyResult, type WatchOptions, type WriteOptions, type WritingPurgeStrategy, createDistributionGuard, filterReferencesKey, nestDottedKeys, nestDottedKeysAll, runChunkedArchive, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry };
13
+ import { ReadOnlyRepoOptions, ReadOnlyRepositoryError, asReadOnlyRepo, isReadOnlyRepo } from "./read-only.mjs";
14
+ import { RetryingTransactionOptions, retryingTransaction } from "./retrying-transaction.mjs";
15
+ export { type AggCacheOptions, type AggDateBucket, type AggDateBucketInterval, type AggDateBucketUnit, type AggExecutionHints, type AggMeasure, type AggPaginationRequest, type AggRequest, type AggResult, type AggRow, type AggTopN, type AggTopNTies, type AggregateOpsSupport, type ArchiveOptions, type ArchivePort, type ArchiveProgress, type ArchiveResult, type ArchiveSink, type BulkCreateResult, type BulkWriteOperation, type BulkWriteResult, type ChangeEvent, type ClaimTransition, type ClaimVersionTransition, type CursorOptions, type DeleteManyResult, type DeleteOptions, type DeleteResult, type DistributionConfig, type DistributionMissHandler, type FilterInput, type FindAllOptions, type FindOneAndUpdateOptions, type InferDoc, type KeysetAggPaginationResult, type LookupPopulateOptions, type LookupPopulateResult, type LookupRow, type LookupSpec, type MinimalRepo, PLUGIN_ORDER_CONSTRAINTS, type PaginationParams, type Plugin, type PluginFunction, type PluginType, type PurgePort, type QueryOptions, type ReadOnlyRepoOptions, ReadOnlyRepositoryError, type RepoCapabilities, RepositoryBase, type RepositoryBaseOptions, type RepositorySession, type RetryPolicy, type RetryingTransactionOptions, STANDARD_REPO_OPTION_KEYS, type StandardRepo, type StandardRepoOptionKey, type TenantPurgeOptions, type TenantPurgeProgress, type TenantPurgeResult, type TenantPurgeStrategy, type TransactionHandle, type TransitionArgs, type TransitionMachine, type UpdateInput, type UpdateManyResult, type WatchOptions, type WriteOptions, type WritingPurgeStrategy, asReadOnlyRepo, createDistributionGuard, filterReferencesKey, isReadOnlyRepo, nestDottedKeys, nestDottedKeysAll, retryingTransaction, runChunkedArchive, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry };
@@ -6,4 +6,6 @@ import { RepositoryBase } from "./base.mjs";
6
6
  import { createDistributionGuard, filterReferencesKey } from "./distribution.mjs";
7
7
  import { STANDARD_REPO_OPTION_KEYS } from "./options.mjs";
8
8
  import { runChunkedPurge } from "./purge.mjs";
9
- export { PLUGIN_ORDER_CONSTRAINTS, RepositoryBase, STANDARD_REPO_OPTION_KEYS, createDistributionGuard, filterReferencesKey, nestDottedKeys, nestDottedKeysAll, runChunkedArchive, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry };
9
+ import { ReadOnlyRepositoryError, asReadOnlyRepo, isReadOnlyRepo } from "./read-only.mjs";
10
+ import { retryingTransaction } from "./retrying-transaction.mjs";
11
+ export { PLUGIN_ORDER_CONSTRAINTS, ReadOnlyRepositoryError, RepositoryBase, STANDARD_REPO_OPTION_KEYS, asReadOnlyRepo, createDistributionGuard, filterReferencesKey, isReadOnlyRepo, nestDottedKeys, nestDottedKeysAll, retryingTransaction, runChunkedArchive, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry };
@@ -0,0 +1,60 @@
1
+ //#region src/repository/read-only.d.ts
2
+ /**
3
+ * `asReadOnlyRepo` — seal a repository whose rows are owned by ANOTHER writer.
4
+ *
5
+ * ## The failure this exists to make impossible
6
+ *
7
+ * Some tables are projected into a repository for reading — pagination, query
8
+ * parser, filters, OpenAPI, permissions — while a different component owns
9
+ * their writes and enforces invariants the table itself cannot express.
10
+ * Better Auth's `user` / `session` / `account` / `member` collections are the
11
+ * canonical case: it hashes credentials, cascades org membership, revokes
12
+ * sessions, and fires plugin hooks on every mutation.
13
+ *
14
+ * A full read/write repository handed to a generic CRUD layer turns that into
15
+ * one config line away from disaster: `POST /users` writes a row Better Auth
16
+ * never saw, with no password hashing and no hooks. The overlay's docstring
17
+ * saying "read-side" is not a control.
18
+ *
19
+ * So the seal is structural. Write methods throw, and `capabilities.readOnly`
20
+ * is `true` so a host can refuse write ROUTES at boot instead of discovering
21
+ * the wall on the first request. Reads pass through untouched.
22
+ *
23
+ * ## Why a Proxy and not a hand-written wrapper
24
+ *
25
+ * The write surface is open-ended: kits contribute methods (`claim`,
26
+ * `applyTransition`, plugin-added helpers) that no fixed list here would
27
+ * cover, and a wrapper that forwarded unknown properties would leak exactly
28
+ * those. The Proxy inverts the default — a method is readable only if it is
29
+ * on the KNOWN-read list, so a kit's novel write method is sealed by default
30
+ * rather than by remembering to add it.
31
+ */
32
+ interface ReadOnlyRepoOptions {
33
+ /**
34
+ * Who owns writes to these rows, and how a caller should perform them.
35
+ * Surfaces verbatim in the thrown error — a developer hitting the wall
36
+ * needs the alternative, not just the refusal.
37
+ *
38
+ * @example 'Better Auth owns writes to `user`; mutate via auth.api'
39
+ */
40
+ reason: string;
41
+ }
42
+ /** Error thrown when a write is attempted through a sealed repository. */
43
+ declare class ReadOnlyRepositoryError extends Error {
44
+ /** The method that was refused. */
45
+ readonly method: string;
46
+ constructor(method: string, reason: string);
47
+ }
48
+ /**
49
+ * Wrap `repo` so every write throws {@link ReadOnlyRepositoryError} and
50
+ * `capabilities.readOnly` reports `true`.
51
+ *
52
+ * Reads are forwarded to the original (bound to it, so `this` stays correct
53
+ * for kits that use private state). The wrapper is transparent to type
54
+ * inference: it returns the same type it was given.
55
+ */
56
+ declare function asReadOnlyRepo<TRepo extends object>(repo: TRepo, options: ReadOnlyRepoOptions): TRepo;
57
+ /** True when a repository (or adapter repository) declares itself read-only. */
58
+ declare function isReadOnlyRepo(repo: unknown): boolean;
59
+ //#endregion
60
+ export { ReadOnlyRepoOptions, ReadOnlyRepositoryError, asReadOnlyRepo, isReadOnlyRepo };
@@ -0,0 +1,80 @@
1
+ //#region src/repository/read-only.ts
2
+ /**
3
+ * Methods that only READ. Everything else callable is refused — a novel
4
+ * kit-contributed method is sealed until it is listed here deliberately.
5
+ */
6
+ const READ_METHODS = /* @__PURE__ */ new Set([
7
+ "getAll",
8
+ "getById",
9
+ "getOne",
10
+ "getByQuery",
11
+ "getByIds",
12
+ "findAll",
13
+ "count",
14
+ "exists",
15
+ "distinct",
16
+ "aggregate",
17
+ "cursor",
18
+ "stream",
19
+ "watch",
20
+ "explain",
21
+ "getDeleted",
22
+ "isDuplicateKeyError",
23
+ "isTransientConflictError"
24
+ ]);
25
+ /** Non-callable properties that pass through (introspection, not behaviour). */
26
+ const PASSTHROUGH_PROPS = /* @__PURE__ */ new Set([
27
+ "idField",
28
+ "modelName",
29
+ "Model",
30
+ "model",
31
+ "db",
32
+ "tables",
33
+ "schema",
34
+ "name"
35
+ ]);
36
+ /** Error thrown when a write is attempted through a sealed repository. */
37
+ var ReadOnlyRepositoryError = class extends Error {
38
+ /** The method that was refused. */
39
+ method;
40
+ constructor(method, reason) {
41
+ super(`Repository is read-only: refused \`${method}()\`. ${reason}.`);
42
+ this.name = "ReadOnlyRepositoryError";
43
+ this.method = method;
44
+ }
45
+ };
46
+ /**
47
+ * Wrap `repo` so every write throws {@link ReadOnlyRepositoryError} and
48
+ * `capabilities.readOnly` reports `true`.
49
+ *
50
+ * Reads are forwarded to the original (bound to it, so `this` stays correct
51
+ * for kits that use private state). The wrapper is transparent to type
52
+ * inference: it returns the same type it was given.
53
+ */
54
+ function asReadOnlyRepo(repo, options) {
55
+ return new Proxy(repo, {
56
+ get(target, prop, receiver) {
57
+ if (prop === "capabilities") return {
58
+ ...Reflect.get(target, prop, receiver) ?? {},
59
+ readOnly: true
60
+ };
61
+ if (typeof prop === "symbol" || PASSTHROUGH_PROPS.has(prop)) return Reflect.get(target, prop, receiver);
62
+ const value = Reflect.get(target, prop, receiver);
63
+ if (typeof value !== "function") return value;
64
+ if (READ_METHODS.has(prop)) return value.bind(target);
65
+ return () => {
66
+ throw new ReadOnlyRepositoryError(prop, options.reason);
67
+ };
68
+ },
69
+ set(_target, prop) {
70
+ throw new ReadOnlyRepositoryError(String(prop), options.reason);
71
+ }
72
+ });
73
+ }
74
+ /** True when a repository (or adapter repository) declares itself read-only. */
75
+ function isReadOnlyRepo(repo) {
76
+ if (!repo || typeof repo !== "object") return false;
77
+ return repo.capabilities?.readOnly === true;
78
+ }
79
+ //#endregion
80
+ export { ReadOnlyRepositoryError, asReadOnlyRepo, isReadOnlyRepo };
@@ -32,7 +32,21 @@ interface RetryPolicy {
32
32
  maxAttempts?: number;
33
33
  /** Base delay (ms) for exponential backoff. Default 100ms; doubles each attempt. */
34
34
  baseDelayMs?: number;
35
- /** Decide whether a given error is transient. Default: retry every error. */
35
+ /** Ceiling for a single backoff delay. Default: uncapped. */
36
+ maxDelayMs?: number;
37
+ /**
38
+ * Full jitter: each delay is `random(0, computed)`. Default `false` for
39
+ * back-compat. Turn it ON for any policy shared by concurrent writers —
40
+ * synchronized deterministic backoff makes colliding transactions collide
41
+ * again on every attempt.
42
+ */
43
+ jitter?: boolean;
44
+ /**
45
+ * Decide whether a given error is transient. Default: retry every error —
46
+ * kept for back-compat, but UNSAFE as a shared default (it re-runs side
47
+ * effects on deterministic failures); pass an explicit predicate.
48
+ * `retryingTransaction` always does.
49
+ */
36
50
  shouldRetry?: (err: unknown, attempt: number) => boolean;
37
51
  }
38
52
  /**
@@ -1,5 +1,25 @@
1
1
  //#region src/repository/resilience.ts
2
2
  /**
3
+ * Abortable sleep. A plain `setTimeout` promise holds the process through an
4
+ * abort — a cancelled request would silently wait out its full backoff before
5
+ * noticing. Listener is removed on the timer path so repeated retries don't
6
+ * accumulate listeners on one long-lived signal.
7
+ */
8
+ function sleep(ms, signal) {
9
+ return new Promise((resolve, reject) => {
10
+ if (signal?.aborted) return reject(signal.reason);
11
+ const timer = setTimeout(() => {
12
+ signal?.removeEventListener("abort", onAbort);
13
+ resolve();
14
+ }, ms);
15
+ const onAbort = () => {
16
+ clearTimeout(timer);
17
+ reject(signal?.reason);
18
+ };
19
+ signal?.addEventListener("abort", onAbort, { once: true });
20
+ });
21
+ }
22
+ /**
3
23
  * Run `fn` with exponential backoff when a policy is provided. Falls
4
24
  * through to a single attempt when `policy` is undefined — callers wrap
5
25
  * unconditionally and the no-policy path costs nothing.
@@ -21,7 +41,10 @@ async function withRetry(fn, policy, signal) {
21
41
  lastErr = err;
22
42
  if (attempt === maxAttempts - 1) break;
23
43
  if (!shouldRetry(err, attempt + 1)) break;
24
- await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** attempt));
44
+ let delay = baseDelayMs * 2 ** attempt;
45
+ if (policy.maxDelayMs !== void 0) delay = Math.min(delay, policy.maxDelayMs);
46
+ if (policy.jitter) delay = Math.random() * delay;
47
+ await sleep(delay, signal);
25
48
  }
26
49
  }
27
50
  throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
@@ -0,0 +1,48 @@
1
+ import { StandardRepo, TransactionHandle } from "./types.mjs";
2
+ import { IsTransientConflictFn } from "../errors/conflict.mjs";
3
+ //#region src/repository/retrying-transaction.d.ts
4
+ interface RetryingTransactionOptions {
5
+ /** Max attempts including the first. Default 5. */
6
+ maxAttempts?: number;
7
+ /** Base backoff (ms); exponential, FULL-jittered. Default 50. */
8
+ baseDelayMs?: number;
9
+ /** Backoff ceiling (ms). Default 2000. */
10
+ maxDelayMs?: number;
11
+ /**
12
+ * Conflict classifier. Default: the repository's own
13
+ * `isTransientConflictError`, else `neverTransient` — an unclassified
14
+ * error runs ONCE and surfaces, because re-running side effects on an
15
+ * unknown failure is the unsafe direction.
16
+ */
17
+ isTransient?: IsTransientConflictFn;
18
+ /**
19
+ * Override the repository's declared `capabilities.transactionRetry`.
20
+ *
21
+ * Escape hatch for a repository that cannot declare (a hand-written test
22
+ * double, a proxy that hides `capabilities`). Prefer fixing the
23
+ * declaration — an override that disagrees with the kit re-creates the
24
+ * double-retry this option exists to prevent.
25
+ */
26
+ retryOwner?: 'managed' | 'caller';
27
+ /** Abort between attempts (never mid-transaction). */
28
+ signal?: AbortSignal;
29
+ /** Observability tap: called before each re-run with the conflict. */
30
+ onRetry?: (err: unknown, attempt: number) => void;
31
+ /** Forwarded to `withTransaction` untouched. */
32
+ transactionOptions?: Record<string, unknown>;
33
+ }
34
+ /**
35
+ * Run `fn` inside `repo.withTransaction`, re-running on transient conflicts.
36
+ *
37
+ * Throws immediately (no retry) when the repository cannot provide
38
+ * transactions — a caller asking for transactional semantics on a backend
39
+ * that cannot deliver them is a wiring error to surface at the call site,
40
+ * not a mode to degrade through. Method presence alone is NOT the test: kits
41
+ * expose `withTransaction` unconditionally and fail at BEGIN, so a
42
+ * repository that publishes a capability descriptor is held to it
43
+ * (`transactions !== true` — which `'unknown'` deliberately reports, failing
44
+ * closed on an unconfirmed deployment).
45
+ */
46
+ declare function retryingTransaction<TDoc, T>(repo: StandardRepo<TDoc>, fn: (txRepo: StandardRepo<TDoc>, uow?: TransactionHandle) => Promise<T>, options?: RetryingTransactionOptions): Promise<T>;
47
+ //#endregion
48
+ export { RetryingTransactionOptions, retryingTransaction };