@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.
- package/CHANGELOG.md +908 -890
- package/dist/cleanup/types.d.mts +34 -1
- 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/tenant/index.d.mts +2 -2
- package/dist/tenant/index.mjs +2 -2
- package/dist/tenant/resolve.d.mts +32 -1
- package/dist/tenant/resolve.mjs +40 -1
- package/dist/testing/conformance.mjs +63 -0
- package/dist/testing/types.d.mts +11 -0
- package/package.json +182 -182
|
@@ -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 };
|
|
@@ -23,6 +23,20 @@ type FilterInput = Filter | Record<string, unknown>;
|
|
|
23
23
|
* the session through uses `unknown`; kits narrow at the boundary.
|
|
24
24
|
*/
|
|
25
25
|
type RepositorySession = unknown;
|
|
26
|
+
/**
|
|
27
|
+
* What a transaction callback receives BESIDE the tx-bound repository.
|
|
28
|
+
*
|
|
29
|
+
* `session` is the raw driver handle, exposed so work that lives OUTSIDE the
|
|
30
|
+
* repository can join the same transaction — the canonical consumer is an
|
|
31
|
+
* outbox writer: `outbox.store(event, { session: uow.session })` commits the
|
|
32
|
+
* event row atomically with the business write. Present when the driver has a
|
|
33
|
+
* per-transaction handle (Mongo's ClientSession); connection-bound backends
|
|
34
|
+
* (SQLite) pass an empty handle — their tx-bound repo IS the only join point.
|
|
35
|
+
* Kits MUST pass a handle object (possibly empty), never omit the argument.
|
|
36
|
+
*/
|
|
37
|
+
interface TransactionHandle {
|
|
38
|
+
session?: RepositorySession;
|
|
39
|
+
}
|
|
26
40
|
/**
|
|
27
41
|
* Read-operation options. The index signature is the escape hatch kits use
|
|
28
42
|
* for driver-specific flags (`populate`, `select`, `readPreference`,
|
|
@@ -72,6 +86,17 @@ interface QueryOptions {
|
|
|
72
86
|
interface WriteOptions extends QueryOptions {
|
|
73
87
|
/** Upsert on update/replace. */
|
|
74
88
|
upsert?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Optimistic-concurrency CAS. When set, the write applies ONLY if the
|
|
91
|
+
* stored version equals `ifVersion`; on mismatch the kit MUST throw
|
|
92
|
+
* `VersionConflictError` (`@classytic/repo-core/errors`) — never return
|
|
93
|
+
* `null`, which means not-found and would invite a blind retry that
|
|
94
|
+
* clobbers the concurrent write. A successful versioned write increments
|
|
95
|
+
* the stored version. Requires the `optimisticConcurrency` capability;
|
|
96
|
+
* kits without it MUST throw on the option rather than ignore it (a
|
|
97
|
+
* silently dropped guard is the defect, not a degraded mode).
|
|
98
|
+
*/
|
|
99
|
+
ifVersion?: number;
|
|
75
100
|
}
|
|
76
101
|
/**
|
|
77
102
|
* Options for the optional `findAll` verb.
|
|
@@ -1502,6 +1527,15 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
|
|
|
1502
1527
|
* lives in the kit that knows its driver.
|
|
1503
1528
|
*/
|
|
1504
1529
|
isDuplicateKeyError?(err: unknown): boolean;
|
|
1530
|
+
/**
|
|
1531
|
+
* Classify an error from a transactional write as a TRANSIENT concurrency
|
|
1532
|
+
* conflict — one the backend expects callers to recover from by re-running
|
|
1533
|
+
* the same work (Mongo `TransientTransactionError` label, PG 40001/40P01,
|
|
1534
|
+
* `SQLITE_BUSY`, Prisma P2034). Consumed by `retryingTransaction`; same
|
|
1535
|
+
* ownership rule as `isDuplicateKeyError` — the kit knows its driver.
|
|
1536
|
+
* Absent = nothing retries (`neverTransient`), the safe default.
|
|
1537
|
+
*/
|
|
1538
|
+
isTransientConflictError?(err: unknown): boolean;
|
|
1505
1539
|
/** Find a single doc by compound filter (used by arc's AccessControl). */
|
|
1506
1540
|
getOne?(filter: FilterInput, options?: QueryOptions): Promise<TDoc | null>;
|
|
1507
1541
|
/** Alias many kits expose alongside `getOne`. Arc checks both names. */
|
|
@@ -1810,7 +1844,7 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
|
|
|
1810
1844
|
* // Either both writes commit or neither does.
|
|
1811
1845
|
* ```
|
|
1812
1846
|
*/
|
|
1813
|
-
withTransaction?<T>(fn: (txRepo: StandardRepo<TDoc
|
|
1847
|
+
withTransaction?<T>(fn: (txRepo: StandardRepo<TDoc>, uow?: TransactionHandle) => Promise<T>, options?: Record<string, unknown>): Promise<T>;
|
|
1814
1848
|
/**
|
|
1815
1849
|
* Portable change feed — `for await` over committed mutations:
|
|
1816
1850
|
*
|
|
@@ -1870,4 +1904,4 @@ interface CursorOptions {
|
|
|
1870
1904
|
[key: string]: unknown;
|
|
1871
1905
|
}
|
|
1872
1906
|
//#endregion
|
|
1873
|
-
export { 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 };
|
|
1907
|
+
export { 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 };
|
package/dist/tenant/index.d.mts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { ResolvedTenantConfig, TenantConfig, TenantFieldType, TenantStrategy } from "./types.mjs";
|
|
2
|
-
import { DEFAULT_TENANT_CONFIG, resolveTenantConfig, resolveTenantField } from "./resolve.mjs";
|
|
3
|
-
export { DEFAULT_TENANT_CONFIG, type ResolvedTenantConfig, type TenantConfig, type TenantFieldType, type TenantStrategy, resolveTenantConfig, resolveTenantField };
|
|
2
|
+
import { DEFAULT_TENANT_CONFIG, assertNoLegacyTenantKeys, resolveTenantConfig, resolveTenantField } from "./resolve.mjs";
|
|
3
|
+
export { DEFAULT_TENANT_CONFIG, type ResolvedTenantConfig, type TenantConfig, type TenantFieldType, type TenantStrategy, assertNoLegacyTenantKeys, resolveTenantConfig, resolveTenantField };
|
package/dist/tenant/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { DEFAULT_TENANT_CONFIG, resolveTenantConfig, resolveTenantField } from "./resolve.mjs";
|
|
2
|
-
export { DEFAULT_TENANT_CONFIG, resolveTenantConfig, resolveTenantField };
|
|
1
|
+
import { DEFAULT_TENANT_CONFIG, assertNoLegacyTenantKeys, resolveTenantConfig, resolveTenantField } from "./resolve.mjs";
|
|
2
|
+
export { DEFAULT_TENANT_CONFIG, assertNoLegacyTenantKeys, resolveTenantConfig, resolveTenantField };
|
|
@@ -28,5 +28,36 @@ declare function resolveTenantConfig(config?: TenantConfig | boolean): ResolvedT
|
|
|
28
28
|
* scoping" and all return `false` — callers get one thing to branch on.
|
|
29
29
|
*/
|
|
30
30
|
declare function resolveTenantField(config?: TenantConfig | boolean): string | false;
|
|
31
|
+
/**
|
|
32
|
+
* Refuse a config that still carries a pre-consolidation tenant key.
|
|
33
|
+
*
|
|
34
|
+
* ## Why this belongs in repo-core and not in each kernel
|
|
35
|
+
*
|
|
36
|
+
* Kernels are migrating from a per-package tenant shape (`multiTenant`, plus a
|
|
37
|
+
* sibling `tenantFieldType` in some) onto {@link TenantConfig} under the key
|
|
38
|
+
* `tenant`. `@classytic/ledger` has landed; catalog, order, cart, crm, flow,
|
|
39
|
+
* party, review, transfer and yard have adopted the TYPE but still read
|
|
40
|
+
* `multiTenant`. Each of those is a future rename.
|
|
41
|
+
*
|
|
42
|
+
* The rename itself is trivial. What is not trivial is the failure mode when a
|
|
43
|
+
* CALLER misses it, because every one of these resolvers reads
|
|
44
|
+
* `resolveTenantConfig(config.tenant ?? false)`: an absent `tenant` resolves to
|
|
45
|
+
* `strategy: 'none'` — no tenant field, no tenant filter, every read spanning
|
|
46
|
+
* ALL tenants, no error, and figures that look plausible. A host that asked for
|
|
47
|
+
* tenancy gets none, silently.
|
|
48
|
+
*
|
|
49
|
+
* Nine packages each remembering to hand-roll that check is nine chances to
|
|
50
|
+
* forget, and the one that forgets is the one that ships the leak. So the guard
|
|
51
|
+
* lives beside the resolver every one of them already calls.
|
|
52
|
+
*
|
|
53
|
+
* Additive and non-breaking: nothing calls it until a package renames.
|
|
54
|
+
*
|
|
55
|
+
* @param config the raw, unresolved shape as the host supplied it
|
|
56
|
+
* @param pkg package name for the message (e.g. `'defineOrder'`)
|
|
57
|
+
* @param extra additional legacy keys this package is retiring, as
|
|
58
|
+
* `[oldKey, newPath]` — pass `['tenantFieldType', 'tenant.fieldType']` when
|
|
59
|
+
* the package carried a sibling field-type option.
|
|
60
|
+
*/
|
|
61
|
+
declare function assertNoLegacyTenantKeys(config: unknown, pkg: string, extra?: ReadonlyArray<readonly [string, string]>): void;
|
|
31
62
|
//#endregion
|
|
32
|
-
export { DEFAULT_TENANT_CONFIG, resolveTenantConfig, resolveTenantField };
|
|
63
|
+
export { DEFAULT_TENANT_CONFIG, assertNoLegacyTenantKeys, resolveTenantConfig, resolveTenantField };
|
package/dist/tenant/resolve.mjs
CHANGED
|
@@ -71,5 +71,44 @@ function resolveTenantField(config) {
|
|
|
71
71
|
if (!resolved.enabled || resolved.strategy === "none") return false;
|
|
72
72
|
return resolved.tenantField;
|
|
73
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Refuse a config that still carries a pre-consolidation tenant key.
|
|
76
|
+
*
|
|
77
|
+
* ## Why this belongs in repo-core and not in each kernel
|
|
78
|
+
*
|
|
79
|
+
* Kernels are migrating from a per-package tenant shape (`multiTenant`, plus a
|
|
80
|
+
* sibling `tenantFieldType` in some) onto {@link TenantConfig} under the key
|
|
81
|
+
* `tenant`. `@classytic/ledger` has landed; catalog, order, cart, crm, flow,
|
|
82
|
+
* party, review, transfer and yard have adopted the TYPE but still read
|
|
83
|
+
* `multiTenant`. Each of those is a future rename.
|
|
84
|
+
*
|
|
85
|
+
* The rename itself is trivial. What is not trivial is the failure mode when a
|
|
86
|
+
* CALLER misses it, because every one of these resolvers reads
|
|
87
|
+
* `resolveTenantConfig(config.tenant ?? false)`: an absent `tenant` resolves to
|
|
88
|
+
* `strategy: 'none'` — no tenant field, no tenant filter, every read spanning
|
|
89
|
+
* ALL tenants, no error, and figures that look plausible. A host that asked for
|
|
90
|
+
* tenancy gets none, silently.
|
|
91
|
+
*
|
|
92
|
+
* Nine packages each remembering to hand-roll that check is nine chances to
|
|
93
|
+
* forget, and the one that forgets is the one that ships the leak. So the guard
|
|
94
|
+
* lives beside the resolver every one of them already calls.
|
|
95
|
+
*
|
|
96
|
+
* Additive and non-breaking: nothing calls it until a package renames.
|
|
97
|
+
*
|
|
98
|
+
* @param config the raw, unresolved shape as the host supplied it
|
|
99
|
+
* @param pkg package name for the message (e.g. `'defineOrder'`)
|
|
100
|
+
* @param extra additional legacy keys this package is retiring, as
|
|
101
|
+
* `[oldKey, newPath]` — pass `['tenantFieldType', 'tenant.fieldType']` when
|
|
102
|
+
* the package carried a sibling field-type option.
|
|
103
|
+
*/
|
|
104
|
+
function assertNoLegacyTenantKeys(config, pkg, extra = []) {
|
|
105
|
+
if (config === null || typeof config !== "object") return;
|
|
106
|
+
const record = config;
|
|
107
|
+
const retired = [["multiTenant", "tenant"], ...extra];
|
|
108
|
+
for (const [key, became] of retired) {
|
|
109
|
+
if (record[key] === void 0) continue;
|
|
110
|
+
throw new Error(`${pkg}: \`${key}\` was renamed to \`${became}\`. It is REFUSED rather than ignored because ignoring it disables tenancy SILENTLY — no tenant field, no tenant filter, and every read spanning all tenants while returning plausible numbers. Move the value to \`tenant\` (\`tenant: false\` for a single-tenant deployment).`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
74
113
|
//#endregion
|
|
75
|
-
export { DEFAULT_TENANT_CONFIG, resolveTenantConfig, resolveTenantField };
|
|
114
|
+
export { DEFAULT_TENANT_CONFIG, assertNoLegacyTenantKeys, resolveTenantConfig, resolveTenantField };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isVersionConflictError } from "../errors/conflict.mjs";
|
|
1
2
|
import { and, anyOf as in_, eq, gt, isNull, like, ne, or } from "../filter/builders.mjs";
|
|
2
3
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
3
4
|
//#region src/testing/conformance.ts
|
|
@@ -1176,6 +1177,14 @@ function runStandardRepoConformance(harness) {
|
|
|
1176
1177
|
const rows = await ctx.repo.findAll({ name: "tx-rollback" });
|
|
1177
1178
|
expect(rows).toHaveLength(0);
|
|
1178
1179
|
});
|
|
1180
|
+
it("the callback receives a TransactionHandle — the outbox join point", async () => {
|
|
1181
|
+
let handle = "never-called";
|
|
1182
|
+
await ctx.repo.withTransaction(async (_txRepo, uow) => {
|
|
1183
|
+
handle = uow;
|
|
1184
|
+
});
|
|
1185
|
+
expect(typeof handle).toBe("object");
|
|
1186
|
+
expect(handle).not.toBeNull();
|
|
1187
|
+
});
|
|
1179
1188
|
it("reads inside the txRepo see writes inside the same callback", async () => {
|
|
1180
1189
|
await ctx.repo.withTransaction(async (txRepo) => {
|
|
1181
1190
|
const id = idOf(await txRepo.create(harness.makeDoc({
|
|
@@ -1186,6 +1195,60 @@ function runStandardRepoConformance(harness) {
|
|
|
1186
1195
|
expect(back?.name).toBe("tx-read");
|
|
1187
1196
|
});
|
|
1188
1197
|
});
|
|
1198
|
+
it("declares WHO retries a transient conflict — undeclared ownership stacks retry policies", () => {
|
|
1199
|
+
const caps = ctx.repo.capabilities;
|
|
1200
|
+
expect(caps?.transactionRetry, "a repository exposing withTransaction must declare capabilities.transactionRetry: 'managed' (the kit retries internally, callers invoke it once) or 'caller' (single attempt, an outer envelope owns the loop)").toMatch(/^(managed|caller)$/);
|
|
1201
|
+
});
|
|
1202
|
+
it("nestedTransactions matches what a nested call ACTUALLY does", async () => {
|
|
1203
|
+
const caps = ctx.repo.capabilities;
|
|
1204
|
+
let nestedWorked;
|
|
1205
|
+
try {
|
|
1206
|
+
await ctx.repo.withTransaction(async (txRepo) => {
|
|
1207
|
+
await txRepo.withTransaction?.(async () => {});
|
|
1208
|
+
});
|
|
1209
|
+
nestedWorked = true;
|
|
1210
|
+
} catch {
|
|
1211
|
+
nestedWorked = false;
|
|
1212
|
+
}
|
|
1213
|
+
expect(nestedWorked, `capabilities.nestedTransactions is ${String(caps?.nestedTransactions)} but a nested withTransaction ${nestedWorked ? "succeeded" : "threw"} — the descriptor must describe THIS repository, not the underlying driver`).toBe(caps?.nestedTransactions === true);
|
|
1214
|
+
});
|
|
1215
|
+
});
|
|
1216
|
+
describe.skipIf(!harness.features.optimisticConcurrency)("ifVersion CAS", () => {
|
|
1217
|
+
const versionOf = (doc) => Number(doc[harness.versionField ?? "version"]);
|
|
1218
|
+
it("a matching version applies the write and increments the version", async () => {
|
|
1219
|
+
const created = await ctx.repo.create(harness.makeDoc({
|
|
1220
|
+
name: "cas-ok",
|
|
1221
|
+
email: "cas@x.com"
|
|
1222
|
+
}));
|
|
1223
|
+
const id = idOf(created, harness.idField);
|
|
1224
|
+
const v0 = versionOf(created);
|
|
1225
|
+
const updated = await ctx.repo.update(id, { name: "cas-ok-2" }, { ifVersion: v0 });
|
|
1226
|
+
expect(updated?.name).toBe("cas-ok-2");
|
|
1227
|
+
expect(versionOf(updated)).toBe(v0 + 1);
|
|
1228
|
+
});
|
|
1229
|
+
it("a STALE version throws VersionConflictError — never null", async () => {
|
|
1230
|
+
const created = await ctx.repo.create(harness.makeDoc({
|
|
1231
|
+
name: "cas-stale",
|
|
1232
|
+
email: "stale@x.com"
|
|
1233
|
+
}));
|
|
1234
|
+
const id = idOf(created, harness.idField);
|
|
1235
|
+
const v0 = versionOf(created);
|
|
1236
|
+
await ctx.repo.update(id, { name: "cas-stale-2" }, { ifVersion: v0 });
|
|
1237
|
+
let caught;
|
|
1238
|
+
try {
|
|
1239
|
+
await ctx.repo.update(id, { name: "cas-stale-3" }, { ifVersion: v0 });
|
|
1240
|
+
} catch (e) {
|
|
1241
|
+
caught = e;
|
|
1242
|
+
}
|
|
1243
|
+
expect(isVersionConflictError(caught)).toBe(true);
|
|
1244
|
+
const current = await ctx.repo.getById(id);
|
|
1245
|
+
expect(current?.name).toBe("cas-stale-2");
|
|
1246
|
+
});
|
|
1247
|
+
it("not-found stays null — a missing record is NOT a version conflict", async () => {
|
|
1248
|
+
const ghost = harness.missingId ?? "000000000000000000000000";
|
|
1249
|
+
const out = await ctx.repo.update(ghost, { name: "x" }, { ifVersion: 1 });
|
|
1250
|
+
expect(out).toBeNull();
|
|
1251
|
+
});
|
|
1189
1252
|
});
|
|
1190
1253
|
describe("purgeByField (tenant cleanup)", () => {
|
|
1191
1254
|
const seedTwoTenants = async () => {
|
package/dist/testing/types.d.mts
CHANGED
|
@@ -97,6 +97,17 @@ interface ConformanceHarness<TDoc extends ConformanceDoc = ConformanceDoc> {
|
|
|
97
97
|
idField: string;
|
|
98
98
|
/** Feature support flags — skipped scenarios show as `skipped` in vitest output. */
|
|
99
99
|
features: ConformanceFeatures;
|
|
100
|
+
/**
|
|
101
|
+
* Field carrying the optimistic-concurrency version, when the
|
|
102
|
+
* `optimisticConcurrency` capability is declared. Default `'version'`.
|
|
103
|
+
*/
|
|
104
|
+
versionField?: string;
|
|
105
|
+
/**
|
|
106
|
+
* A syntactically valid id that matches no record — the ifVersion cases
|
|
107
|
+
* assert not-found stays `null` (never a version conflict). Default is a
|
|
108
|
+
* Mongo-shaped all-zero ObjectId; SQL/string-id kits supply their own.
|
|
109
|
+
*/
|
|
110
|
+
missingId?: string;
|
|
100
111
|
/** Create a fresh, isolated repo + cleanup closure. Called per test. */
|
|
101
112
|
setup(): Promise<ConformanceContext<TDoc>>;
|
|
102
113
|
/**
|