@classytic/repo-core 0.22.0 → 0.23.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/CHANGELOG.md +908 -890
- package/dist/errors/conflict.d.mts +84 -0
- package/dist/errors/conflict.mjs +63 -0
- package/dist/errors/index.d.mts +2 -1
- package/dist/errors/index.mjs +2 -1
- package/dist/lock/index.d.mts +21 -0
- package/dist/lock/index.mjs +10 -3
- package/dist/repository/capabilities.d.mts +44 -2
- package/dist/repository/index.d.mts +4 -2
- package/dist/repository/index.mjs +3 -1
- package/dist/repository/read-only.d.mts +60 -0
- package/dist/repository/read-only.mjs +80 -0
- package/dist/repository/resilience.d.mts +15 -1
- package/dist/repository/resilience.mjs +24 -1
- package/dist/repository/retrying-transaction.d.mts +48 -0
- package/dist/repository/retrying-transaction.mjs +35 -0
- package/dist/repository/types.d.mts +36 -2
- package/dist/testing/conformance.mjs +63 -0
- package/dist/testing/types.d.mts +11 -0
- package/package.json +182 -182
|
@@ -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 };
|
package/dist/errors/index.d.mts
CHANGED
|
@@ -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 };
|
package/dist/errors/index.mjs
CHANGED
|
@@ -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 };
|
package/dist/lock/index.d.mts
CHANGED
|
@@ -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 {
|
package/dist/lock/index.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
108
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { neverTransient } from "../errors/conflict.mjs";
|
|
2
|
+
import { withRetry } from "./resilience.mjs";
|
|
3
|
+
//#region src/repository/retrying-transaction.ts
|
|
4
|
+
/**
|
|
5
|
+
* Run `fn` inside `repo.withTransaction`, re-running on transient conflicts.
|
|
6
|
+
*
|
|
7
|
+
* Throws immediately (no retry) when the repository cannot provide
|
|
8
|
+
* transactions — a caller asking for transactional semantics on a backend
|
|
9
|
+
* that cannot deliver them is a wiring error to surface at the call site,
|
|
10
|
+
* not a mode to degrade through. Method presence alone is NOT the test: kits
|
|
11
|
+
* expose `withTransaction` unconditionally and fail at BEGIN, so a
|
|
12
|
+
* repository that publishes a capability descriptor is held to it
|
|
13
|
+
* (`transactions !== true` — which `'unknown'` deliberately reports, failing
|
|
14
|
+
* closed on an unconfirmed deployment).
|
|
15
|
+
*/
|
|
16
|
+
async function retryingTransaction(repo, fn, options = {}) {
|
|
17
|
+
if (typeof repo.withTransaction !== "function") throw new Error("retryingTransaction requires repository.withTransaction — this backend does not provide transactions (capability `transactions: false`). Wire a transactional kit, or drop the transactional envelope for this resource.");
|
|
18
|
+
const capabilities = repo.capabilities;
|
|
19
|
+
if (capabilities && capabilities.transactions !== true) throw new Error(`retryingTransaction: repository.withTransaction exists but the repository declares \`transactions: ${String(capabilities.transactions)}\` — this deployment cannot run transactions (standalone MongoDB, D1, or an unconfirmed topology). Every call would fail at BEGIN. Wire a transactional deployment, or drop the transactional envelope.`);
|
|
20
|
+
const isTransient = options.isTransient ?? (typeof repo.isTransientConflictError === "function" ? repo.isTransientConflictError.bind(repo) : neverTransient);
|
|
21
|
+
if ((options.retryOwner ?? capabilities?.transactionRetry ?? "managed") === "managed") return repo.withTransaction(fn, options.transactionOptions);
|
|
22
|
+
return withRetry(() => repo.withTransaction(fn, options.transactionOptions), {
|
|
23
|
+
maxAttempts: options.maxAttempts ?? 5,
|
|
24
|
+
baseDelayMs: options.baseDelayMs ?? 50,
|
|
25
|
+
maxDelayMs: options.maxDelayMs ?? 2e3,
|
|
26
|
+
jitter: true,
|
|
27
|
+
shouldRetry: (err, attempt) => {
|
|
28
|
+
if (!isTransient(err)) return false;
|
|
29
|
+
options.onRetry?.(err, attempt);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}, options.signal);
|
|
33
|
+
}
|
|
34
|
+
//#endregion
|
|
35
|
+
export { retryingTransaction };
|