@classytic/repo-core 0.30.0 → 0.31.1
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 +1337 -1283
- package/dist/errors/codes.d.mts +10 -1
- package/dist/errors/codes.mjs +9 -1
- package/dist/errors/index.d.mts +2 -2
- package/dist/errors/index.mjs +2 -2
- package/dist/pagination/index.d.mts +3 -1
- package/dist/pagination/index.mjs +3 -1
- package/dist/pagination/merge-keyset.d.mts +50 -0
- package/dist/pagination/merge-keyset.mjs +139 -0
- package/dist/query-parser/grammar.d.mts +6 -0
- package/dist/query-parser/grammar.mjs +21 -4
- package/dist/query-parser/reserved.mjs +2 -0
- package/dist/repository/bulk-upsert.d.mts +59 -0
- package/dist/repository/bulk-upsert.mjs +50 -0
- package/dist/repository/capabilities.d.mts +23 -0
- package/dist/repository/index.d.mts +2 -1
- package/dist/repository/index.mjs +2 -1
- package/dist/repository/types.d.mts +7 -1
- package/dist/testing/data-access-conformance.d.mts +89 -0
- package/dist/testing/data-access-conformance.mjs +331 -0
- package/dist/testing/index.d.mts +5 -1
- package/dist/testing/index.mjs +5 -1
- package/dist/testing/merge-keyset-conformance.d.mts +26 -0
- package/dist/testing/merge-keyset-conformance.mjs +85 -0
- package/dist/testing/pagination-property-conformance.d.mts +44 -0
- package/dist/testing/pagination-property-conformance.mjs +160 -0
- package/dist/testing/read-model-property-conformance.d.mts +13 -0
- package/dist/testing/read-model-property-conformance.mjs +74 -0
- package/package.json +11 -3
package/dist/errors/codes.d.mts
CHANGED
|
@@ -88,6 +88,8 @@ declare const ARC_ERROR_CODES: {
|
|
|
88
88
|
readonly WRITE_VERB_CONTRACT_VIOLATION: 'arc.write_verb.contract_violation';
|
|
89
89
|
readonly ADAPTER_CAPABILITY_REQUIRED: 'arc.adapter.capability_required';
|
|
90
90
|
readonly NO_ADAPTER: 'arc.no_adapter';
|
|
91
|
+
/** HTTP 400 — the `mode` query param is neither `offset` nor `keyset`. */
|
|
92
|
+
readonly PAGINATION_INVALID_MODE: 'arc.pagination.invalid_mode';
|
|
91
93
|
readonly AGGREGATION_MAX_GROUPS_EXCEEDED: 'arc.aggregation.max_groups_exceeded';
|
|
92
94
|
readonly AGGREGATION_REQUIRED_FILTER_MISSING: 'arc.aggregation.required_filter_missing';
|
|
93
95
|
readonly AGGREGATION_REQUIRED_DATE_RANGE_MISSING: 'arc.aggregation.required_date_range_missing';
|
|
@@ -112,6 +114,13 @@ declare const ARC_REASON_CODES: {
|
|
|
112
114
|
};
|
|
113
115
|
type ArcReasonCode = (typeof ARC_REASON_CODES)[keyof typeof ARC_REASON_CODES];
|
|
114
116
|
/** `details[].code` for a unique-constraint violation (see `toErrorContract`). */
|
|
117
|
+
declare const CURSOR_ERROR_CODES: {
|
|
118
|
+
/** HTTP 400 — malformed, truncated, wrong sort/version, or failed HMAC. */
|
|
119
|
+
readonly INVALID: 'repo.cursor.invalid';
|
|
120
|
+
/** HTTP 400 — minted for another collection, tenant, filter, collation or source set. */
|
|
121
|
+
readonly SCOPE_MISMATCH: 'repo.cursor.scope_mismatch';
|
|
122
|
+
};
|
|
123
|
+
type CursorErrorCode = (typeof CURSOR_ERROR_CODES)[keyof typeof CURSOR_ERROR_CODES];
|
|
115
124
|
declare const DUPLICATE_KEY_DETAIL_CODE = "duplicate_key";
|
|
116
125
|
//#endregion
|
|
117
|
-
export { ARC_ERROR_CODES, ARC_REASON_CODES, ArcErrorCode, ArcReasonCode, DUPLICATE_KEY_DETAIL_CODE, ERROR_CODES, ErrorCode };
|
|
126
|
+
export { ARC_ERROR_CODES, ARC_REASON_CODES, ArcErrorCode, ArcReasonCode, CURSOR_ERROR_CODES, CursorErrorCode, DUPLICATE_KEY_DETAIL_CODE, ERROR_CODES, ErrorCode };
|
package/dist/errors/codes.mjs
CHANGED
|
@@ -87,6 +87,8 @@ const ARC_ERROR_CODES = {
|
|
|
87
87
|
WRITE_VERB_CONTRACT_VIOLATION: "arc.write_verb.contract_violation",
|
|
88
88
|
ADAPTER_CAPABILITY_REQUIRED: "arc.adapter.capability_required",
|
|
89
89
|
NO_ADAPTER: "arc.no_adapter",
|
|
90
|
+
/** HTTP 400 — the `mode` query param is neither `offset` nor `keyset`. */
|
|
91
|
+
PAGINATION_INVALID_MODE: "arc.pagination.invalid_mode",
|
|
90
92
|
AGGREGATION_MAX_GROUPS_EXCEEDED: "arc.aggregation.max_groups_exceeded",
|
|
91
93
|
AGGREGATION_REQUIRED_FILTER_MISSING: "arc.aggregation.required_filter_missing",
|
|
92
94
|
AGGREGATION_REQUIRED_DATE_RANGE_MISSING: "arc.aggregation.required_date_range_missing",
|
|
@@ -109,6 +111,12 @@ const ARC_REASON_CODES = {
|
|
|
109
111
|
BEFORE_RESTORE_HOOK_ERROR: "BEFORE_RESTORE_HOOK_ERROR"
|
|
110
112
|
};
|
|
111
113
|
/** `details[].code` for a unique-constraint violation (see `toErrorContract`). */
|
|
114
|
+
const CURSOR_ERROR_CODES = {
|
|
115
|
+
/** HTTP 400 — malformed, truncated, wrong sort/version, or failed HMAC. */
|
|
116
|
+
INVALID: "repo.cursor.invalid",
|
|
117
|
+
/** HTTP 400 — minted for another collection, tenant, filter, collation or source set. */
|
|
118
|
+
SCOPE_MISMATCH: "repo.cursor.scope_mismatch"
|
|
119
|
+
};
|
|
112
120
|
const DUPLICATE_KEY_DETAIL_CODE = "duplicate_key";
|
|
113
121
|
//#endregion
|
|
114
|
-
export { ARC_ERROR_CODES, ARC_REASON_CODES, DUPLICATE_KEY_DETAIL_CODE, ERROR_CODES };
|
|
122
|
+
export { ARC_ERROR_CODES, ARC_REASON_CODES, CURSOR_ERROR_CODES, DUPLICATE_KEY_DETAIL_CODE, ERROR_CODES };
|
package/dist/errors/index.d.mts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { IsTransientConflictFn, VersionConflictError, conservativeMongoIsTransientConflict, isVersionConflictError, neverTransient } from "./conflict.mjs";
|
|
2
|
-
import { ARC_ERROR_CODES, ARC_REASON_CODES, ArcErrorCode, ArcReasonCode, DUPLICATE_KEY_DETAIL_CODE, ERROR_CODES, ErrorCode } from "./codes.mjs";
|
|
2
|
+
import { ARC_ERROR_CODES, ARC_REASON_CODES, ArcErrorCode, ArcReasonCode, CURSOR_ERROR_CODES, CursorErrorCode, DUPLICATE_KEY_DETAIL_CODE, ERROR_CODES, ErrorCode } from "./codes.mjs";
|
|
3
3
|
import { DuplicateKeyMeta, ErrorContract, ErrorDetail, HttpError, ValidationErrorMeta } from "./types.mjs";
|
|
4
4
|
import { statusToErrorCode, toErrorContract } from "./contract.mjs";
|
|
5
5
|
import { createError, isHttpError } from "./create-error.mjs";
|
|
6
6
|
import { IsDuplicateKeyErrorFn, ToDuplicateKeyHttpErrorOptions, conservativeMongoIsDuplicateKey, toDuplicateKeyHttpError } from "./duplicate-key.mjs";
|
|
7
7
|
import { errorContractSchema, errorDetailSchema } from "./schema.mjs";
|
|
8
|
-
export { ARC_ERROR_CODES, ARC_REASON_CODES, type ArcErrorCode, type ArcReasonCode, DUPLICATE_KEY_DETAIL_CODE, 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 };
|
|
8
|
+
export { ARC_ERROR_CODES, ARC_REASON_CODES, type ArcErrorCode, type ArcReasonCode, CURSOR_ERROR_CODES, type CursorErrorCode, DUPLICATE_KEY_DETAIL_CODE, 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,7 +1,7 @@
|
|
|
1
|
-
import { ARC_ERROR_CODES, ARC_REASON_CODES, DUPLICATE_KEY_DETAIL_CODE, ERROR_CODES } from "./codes.mjs";
|
|
1
|
+
import { ARC_ERROR_CODES, ARC_REASON_CODES, CURSOR_ERROR_CODES, DUPLICATE_KEY_DETAIL_CODE, ERROR_CODES } from "./codes.mjs";
|
|
2
2
|
import { VersionConflictError, conservativeMongoIsTransientConflict, isVersionConflictError, neverTransient } from "./conflict.mjs";
|
|
3
3
|
import { statusToErrorCode, toErrorContract } from "./contract.mjs";
|
|
4
4
|
import { createError, isHttpError } from "./create-error.mjs";
|
|
5
5
|
import { conservativeMongoIsDuplicateKey, toDuplicateKeyHttpError } from "./duplicate-key.mjs";
|
|
6
6
|
import { errorContractSchema, errorDetailSchema } from "./schema.mjs";
|
|
7
|
-
export { ARC_ERROR_CODES, ARC_REASON_CODES, DUPLICATE_KEY_DETAIL_CODE, ERROR_CODES, VersionConflictError, conservativeMongoIsDuplicateKey, conservativeMongoIsTransientConflict, createError, errorContractSchema, errorDetailSchema, isHttpError, isVersionConflictError, neverTransient, statusToErrorCode, toDuplicateKeyHttpError, toErrorContract };
|
|
7
|
+
export { ARC_ERROR_CODES, ARC_REASON_CODES, CURSOR_ERROR_CODES, DUPLICATE_KEY_DETAIL_CODE, ERROR_CODES, VersionConflictError, conservativeMongoIsDuplicateKey, conservativeMongoIsTransientConflict, createError, errorContractSchema, errorDetailSchema, isHttpError, isVersionConflictError, neverTransient, statusToErrorCode, toDuplicateKeyHttpError, toErrorContract };
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { AggregatePaginationResult, AggregatePaginationResultCore, AnyPaginationResult, BareListResult, CursorPayload, DecodedCursor, KeysetPaginationResult, KeysetPaginationResultCore, OffsetPaginationResult, OffsetPaginationResultCore, PaginatedResult, PaginationConfig, SortDirection, SortSpec, ValueType } from "./types.mjs";
|
|
2
|
+
import { CURSOR_ERROR_CODES } from "../errors/codes.mjs";
|
|
2
3
|
import { isPaginatedResult, toCanonicalList } from "./canonical.mjs";
|
|
3
4
|
import { decodeCursor, encodeCursor, validateCursorSort, validateCursorVersion } from "./cursor.mjs";
|
|
4
5
|
import { getPrimaryField, invertSort, normalizeSort, validateKeysetSort } from "./keyset.mjs";
|
|
6
|
+
import { KeysetSource, MergeKeysetOptions, MergedKeysetPage, mergeKeysetPages } from "./merge-keyset.mjs";
|
|
5
7
|
import { calculateSkip, calculateTotalPages, shouldWarnDeepPagination, validateLimit, validatePage } from "./offset.mjs";
|
|
6
|
-
export { type AggregatePaginationResult, type AggregatePaginationResultCore, type AnyPaginationResult, type BareListResult, type CursorPayload, type DecodedCursor, type KeysetPaginationResult, type KeysetPaginationResultCore, type OffsetPaginationResult, type OffsetPaginationResultCore, type PaginatedResult, type PaginationConfig, type SortDirection, type SortSpec, type ValueType, calculateSkip, calculateTotalPages, decodeCursor, encodeCursor, getPrimaryField, invertSort, isPaginatedResult, normalizeSort, shouldWarnDeepPagination, toCanonicalList, validateCursorSort, validateCursorVersion, validateKeysetSort, validateLimit, validatePage };
|
|
8
|
+
export { type AggregatePaginationResult, type AggregatePaginationResultCore, type AnyPaginationResult, type BareListResult, CURSOR_ERROR_CODES, type CursorPayload, type DecodedCursor, type KeysetPaginationResult, type KeysetPaginationResultCore, type KeysetSource, type MergeKeysetOptions, type MergedKeysetPage, type OffsetPaginationResult, type OffsetPaginationResultCore, type PaginatedResult, type PaginationConfig, type SortDirection, type SortSpec, type ValueType, calculateSkip, calculateTotalPages, decodeCursor, encodeCursor, getPrimaryField, invertSort, isPaginatedResult, mergeKeysetPages, normalizeSort, shouldWarnDeepPagination, toCanonicalList, validateCursorSort, validateCursorVersion, validateKeysetSort, validateLimit, validatePage };
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { CURSOR_ERROR_CODES } from "../errors/codes.mjs";
|
|
1
2
|
import { isPaginatedResult, toCanonicalList } from "./canonical.mjs";
|
|
2
3
|
import { decodeCursor, encodeCursor, validateCursorSort, validateCursorVersion } from "./cursor.mjs";
|
|
3
4
|
import { getPrimaryField, invertSort, normalizeSort, validateKeysetSort } from "./keyset.mjs";
|
|
5
|
+
import { mergeKeysetPages } from "./merge-keyset.mjs";
|
|
4
6
|
import { calculateSkip, calculateTotalPages, shouldWarnDeepPagination, validateLimit, validatePage } from "./offset.mjs";
|
|
5
|
-
export { calculateSkip, calculateTotalPages, decodeCursor, encodeCursor, getPrimaryField, invertSort, isPaginatedResult, normalizeSort, shouldWarnDeepPagination, toCanonicalList, validateCursorSort, validateCursorVersion, validateKeysetSort, validateLimit, validatePage };
|
|
7
|
+
export { CURSOR_ERROR_CODES, calculateSkip, calculateTotalPages, decodeCursor, encodeCursor, getPrimaryField, invertSort, isPaginatedResult, mergeKeysetPages, normalizeSort, shouldWarnDeepPagination, toCanonicalList, validateCursorSort, validateCursorVersion, validateKeysetSort, validateLimit, validatePage };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//#region src/pagination/merge-keyset.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* `mergeKeysetPages`: one keyset page over SEVERAL sources, each already ordered by the same total
|
|
4
|
+
* order, merged by the sort key with source order as the final tiebreaker. Each call reads at most
|
|
5
|
+
* one page of `limit` rows per source. ONE composite cursor carries every source's position (the
|
|
6
|
+
* merge frontier) plus a fingerprint of (source names, source scopes, sort); it is HMAC-signed when a
|
|
7
|
+
* secret is given. A cursor that does not decode or verify is `repo.cursor.invalid`; one minted
|
|
8
|
+
* for other sources, another scope or another sort is `repo.cursor.scope_mismatch` (both 400).
|
|
9
|
+
*/
|
|
10
|
+
interface KeysetSource<T> {
|
|
11
|
+
/** Unique among the merged sources; part of the cursor binding. */
|
|
12
|
+
readonly name: string;
|
|
13
|
+
/** What the source's position is relative to (tenant + filter); a change refuses old cursors. */
|
|
14
|
+
readonly scope?: string;
|
|
15
|
+
/** Rows strictly after `after` (this source's own cursor), in the shared order. */
|
|
16
|
+
page(args: {
|
|
17
|
+
after?: string;
|
|
18
|
+
limit: number;
|
|
19
|
+
}): Promise<{
|
|
20
|
+
data: readonly T[];
|
|
21
|
+
hasMore: boolean;
|
|
22
|
+
}>;
|
|
23
|
+
/**
|
|
24
|
+
* This source's cursor positioned AT `row` (its next page starts after it). `row` may come from
|
|
25
|
+
* ANOTHER source (the merge frontier), so every source shares the sort fields, and the final
|
|
26
|
+
* tiebreaker is unique across sources (e.g. an ObjectId).
|
|
27
|
+
*/
|
|
28
|
+
cursorOf(row: T): string | Promise<string>;
|
|
29
|
+
}
|
|
30
|
+
interface MergeKeysetOptions<T> {
|
|
31
|
+
/** The shared order, ending in a unique tiebreaker (e.g. `{ createdAt: -1, _id: -1 }`). */
|
|
32
|
+
sort: Readonly<Record<string, 1 | -1>>;
|
|
33
|
+
limit: number;
|
|
34
|
+
cursor?: string;
|
|
35
|
+
/** Sign the composite cursor; when set, an unsigned or mis-signed cursor is refused. */
|
|
36
|
+
secret?: string;
|
|
37
|
+
/** Sort-key reader for a row field (default: dotted-path property read). */
|
|
38
|
+
read?: (row: T, field: string) => unknown;
|
|
39
|
+
}
|
|
40
|
+
interface MergedKeysetPage<T> {
|
|
41
|
+
docs: Array<{
|
|
42
|
+
source: string;
|
|
43
|
+
row: T;
|
|
44
|
+
}>;
|
|
45
|
+
next: string | null;
|
|
46
|
+
hasNext: boolean;
|
|
47
|
+
}
|
|
48
|
+
declare function mergeKeysetPages<T>(sources: readonly KeysetSource<T>[], options: MergeKeysetOptions<T>): Promise<MergedKeysetPage<T>>;
|
|
49
|
+
//#endregion
|
|
50
|
+
export { KeysetSource, MergeKeysetOptions, MergedKeysetPage, mergeKeysetPages };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { CURSOR_ERROR_CODES } from "../errors/codes.mjs";
|
|
2
|
+
import { createError } from "../errors/create-error.mjs";
|
|
3
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
4
|
+
//#region src/pagination/merge-keyset.ts
|
|
5
|
+
/**
|
|
6
|
+
* `mergeKeysetPages`: one keyset page over SEVERAL sources, each already ordered by the same total
|
|
7
|
+
* order, merged by the sort key with source order as the final tiebreaker. Each call reads at most
|
|
8
|
+
* one page of `limit` rows per source. ONE composite cursor carries every source's position (the
|
|
9
|
+
* merge frontier) plus a fingerprint of (source names, source scopes, sort); it is HMAC-signed when a
|
|
10
|
+
* secret is given. A cursor that does not decode or verify is `repo.cursor.invalid`; one minted
|
|
11
|
+
* for other sources, another scope or another sort is `repo.cursor.scope_mismatch` (both 400).
|
|
12
|
+
*/
|
|
13
|
+
function fingerprint(sources, sort) {
|
|
14
|
+
return createHash("sha256").update(JSON.stringify([sources.map((s) => [s.name, s.scope ?? null]), Object.entries(sort)])).digest("hex").slice(0, 16);
|
|
15
|
+
}
|
|
16
|
+
function sign(body, secret) {
|
|
17
|
+
return createHmac("sha256", secret).update(body).digest("base64url");
|
|
18
|
+
}
|
|
19
|
+
/** A 400 carrying a closed code. */
|
|
20
|
+
function cursorError(message, code) {
|
|
21
|
+
return Object.assign(createError(400, message), { code });
|
|
22
|
+
}
|
|
23
|
+
const invalid = (why) => cursorError(`Invalid cursor: ${why}`, CURSOR_ERROR_CODES.INVALID);
|
|
24
|
+
function encode(payload, secret) {
|
|
25
|
+
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
26
|
+
return secret ? `${body}.${sign(body, secret)}` : body;
|
|
27
|
+
}
|
|
28
|
+
function decode(token, secret) {
|
|
29
|
+
const [body, mac, extra] = token.split(".");
|
|
30
|
+
if (!body || extra !== void 0) throw invalid("malformed");
|
|
31
|
+
if (secret) {
|
|
32
|
+
if (!mac) throw invalid("it carries no signature");
|
|
33
|
+
const want = Buffer.from(sign(body, secret));
|
|
34
|
+
const got = Buffer.from(mac);
|
|
35
|
+
if (want.length !== got.length || !timingSafeEqual(want, got)) throw invalid("signature does not verify");
|
|
36
|
+
} else if (mac !== void 0) throw invalid("signed cursor, but no secret is configured");
|
|
37
|
+
let payload;
|
|
38
|
+
try {
|
|
39
|
+
payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
40
|
+
} catch {
|
|
41
|
+
throw invalid("not a cursor token");
|
|
42
|
+
}
|
|
43
|
+
const p = payload;
|
|
44
|
+
if (!p || p.v !== 1 || typeof p.fp !== "string" || !Array.isArray(p.p) || !Array.isArray(p.d) || p.p.length !== p.d.length || !p.p.every((x) => x === null || typeof x === "string") || !p.d.every((x) => typeof x === "boolean")) throw invalid("malformed payload");
|
|
45
|
+
return p;
|
|
46
|
+
}
|
|
47
|
+
function readPath(row, field) {
|
|
48
|
+
let cur = row;
|
|
49
|
+
for (const seg of field.split(".")) {
|
|
50
|
+
if (cur === null || typeof cur !== "object") return void 0;
|
|
51
|
+
cur = cur[seg];
|
|
52
|
+
}
|
|
53
|
+
return cur;
|
|
54
|
+
}
|
|
55
|
+
/** BSON-like order for sort values: null / missing first, then numbers, strings, dates, ids. */
|
|
56
|
+
function rank(v) {
|
|
57
|
+
if (v === null || v === void 0) return [0, 0];
|
|
58
|
+
if (typeof v === "number") return [1, v];
|
|
59
|
+
if (typeof v === "bigint") return [1, Number(v)];
|
|
60
|
+
if (typeof v === "string") return [2, v];
|
|
61
|
+
if (v instanceof Date) return [4, v.getTime()];
|
|
62
|
+
if (typeof v === "boolean") return [3, v ? 1 : 0];
|
|
63
|
+
if (typeof v === "object") {
|
|
64
|
+
if (v._bsontype === "Decimal128") return [1, Number(String(v))];
|
|
65
|
+
const hex = v.toHexString;
|
|
66
|
+
if (typeof hex === "function") return [5, hex.call(v)];
|
|
67
|
+
}
|
|
68
|
+
return [6, String(v)];
|
|
69
|
+
}
|
|
70
|
+
function compareValues(a, b) {
|
|
71
|
+
const [ta, va] = rank(a);
|
|
72
|
+
const [tb, vb] = rank(b);
|
|
73
|
+
if (ta !== tb) return ta - tb;
|
|
74
|
+
return va < vb ? -1 : va > vb ? 1 : 0;
|
|
75
|
+
}
|
|
76
|
+
async function mergeKeysetPages(sources, options) {
|
|
77
|
+
const { sort, limit, secret } = options;
|
|
78
|
+
if (!Number.isInteger(limit) || limit < 1) throw new TypeError(`mergeKeysetPages: limit must be a positive integer, got ${limit}`);
|
|
79
|
+
if (new Set(sources.map((s) => s.name)).size !== sources.length) throw new TypeError("mergeKeysetPages: source names must be unique");
|
|
80
|
+
if (Object.keys(sort).length === 0) throw new TypeError("mergeKeysetPages: sort must name at least one field");
|
|
81
|
+
const read = options.read ?? readPath;
|
|
82
|
+
const fp = fingerprint(sources, sort);
|
|
83
|
+
let positions = sources.map(() => null);
|
|
84
|
+
let done = sources.map(() => false);
|
|
85
|
+
if (options.cursor) {
|
|
86
|
+
const p = decode(options.cursor, secret);
|
|
87
|
+
if (p.fp !== fp || p.p.length !== sources.length) throw cursorError("Invalid cursor: it was issued for different sources, scopes or sort", CURSOR_ERROR_CODES.SCOPE_MISMATCH);
|
|
88
|
+
positions = p.p;
|
|
89
|
+
done = p.d;
|
|
90
|
+
}
|
|
91
|
+
const pages = await Promise.all(sources.map((s, i) => done[i] ? {
|
|
92
|
+
data: [],
|
|
93
|
+
hasMore: false
|
|
94
|
+
} : s.page({
|
|
95
|
+
...positions[i] ? { after: positions[i] } : {},
|
|
96
|
+
limit
|
|
97
|
+
})));
|
|
98
|
+
const fields = Object.entries(sort);
|
|
99
|
+
const cmp = (a, b) => {
|
|
100
|
+
for (const [field, dir] of fields) {
|
|
101
|
+
const c = compareValues(read(a, field), read(b, field));
|
|
102
|
+
if (c !== 0) return c * dir;
|
|
103
|
+
}
|
|
104
|
+
return 0;
|
|
105
|
+
};
|
|
106
|
+
const heads = pages.map(() => 0);
|
|
107
|
+
const docs = [];
|
|
108
|
+
while (docs.length < limit) {
|
|
109
|
+
let best = -1;
|
|
110
|
+
for (let i = 0; i < sources.length; i++) {
|
|
111
|
+
const row = pages[i]?.data[heads[i]];
|
|
112
|
+
if (row === void 0) continue;
|
|
113
|
+
if (best === -1 || cmp(row, pages[best]?.data[heads[best]]) < 0) best = i;
|
|
114
|
+
}
|
|
115
|
+
if (best === -1) break;
|
|
116
|
+
const row = pages[best]?.data[heads[best]];
|
|
117
|
+
docs.push({
|
|
118
|
+
source: sources[best]?.name,
|
|
119
|
+
row
|
|
120
|
+
});
|
|
121
|
+
heads[best] = heads[best] + 1;
|
|
122
|
+
}
|
|
123
|
+
const frontier = docs[docs.length - 1]?.row;
|
|
124
|
+
const nextPositions = frontier === void 0 ? positions : await Promise.all(sources.map((s) => s.cursorOf(frontier)));
|
|
125
|
+
const nextDone = sources.map((_, i) => done[i] === true || pages[i]?.hasMore === false && heads[i] === pages[i]?.data.length);
|
|
126
|
+
const hasNext = nextDone.some((d) => !d);
|
|
127
|
+
return {
|
|
128
|
+
docs,
|
|
129
|
+
hasNext,
|
|
130
|
+
next: hasNext ? encode({
|
|
131
|
+
v: 1,
|
|
132
|
+
fp,
|
|
133
|
+
p: nextPositions,
|
|
134
|
+
d: nextDone
|
|
135
|
+
}, secret) : null
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
//#endregion
|
|
139
|
+
export { mergeKeysetPages };
|
|
@@ -75,6 +75,10 @@ interface PageRequest {
|
|
|
75
75
|
readonly page?: number;
|
|
76
76
|
/** Keyset cursor (`after`, or its alias `cursor`). */
|
|
77
77
|
readonly after?: string;
|
|
78
|
+
/** Keyset cursor for the PREVIOUS page. Never together with `after`. */
|
|
79
|
+
readonly before?: string;
|
|
80
|
+
/** Explicit paging mode, when the caller named one. */
|
|
81
|
+
readonly mode?: 'offset' | 'keyset';
|
|
78
82
|
}
|
|
79
83
|
interface PageOptions {
|
|
80
84
|
defaultLimit?: number | undefined;
|
|
@@ -86,6 +90,8 @@ declare function readPageRequest(input: {
|
|
|
86
90
|
limit?: unknown;
|
|
87
91
|
after?: unknown;
|
|
88
92
|
cursor?: unknown;
|
|
93
|
+
before?: unknown;
|
|
94
|
+
mode?: unknown;
|
|
89
95
|
}, options?: PageOptions): PageRequest;
|
|
90
96
|
/** `sort=-createdAt,name` or qs `sort[createdAt]=desc`. An invalid or disallowed field is refused. */
|
|
91
97
|
declare function readSort(raw: unknown, options?: {
|
|
@@ -294,16 +294,33 @@ function readPageRequest(input, options = {}) {
|
|
|
294
294
|
const maxLimit = options.maxLimit ?? 1e3;
|
|
295
295
|
const requested = positiveInt(input.limit, "limit");
|
|
296
296
|
const limit = Math.min(requested ?? options.defaultLimit ?? 20, maxLimit);
|
|
297
|
+
const mode = pageMode(input.mode);
|
|
298
|
+
const withMode = (r) => mode ? {
|
|
299
|
+
...r,
|
|
300
|
+
mode
|
|
301
|
+
} : r;
|
|
297
302
|
const after = cursorValue(input.after ?? input.cursor);
|
|
298
|
-
|
|
303
|
+
const before = cursorValue(input.before);
|
|
304
|
+
if (after !== void 0 && before !== void 0) throw new QueryGrammarError("before", "pass after or before, not both");
|
|
305
|
+
if (after !== void 0) return withMode({
|
|
299
306
|
limit,
|
|
300
307
|
after
|
|
301
|
-
};
|
|
308
|
+
});
|
|
309
|
+
if (before !== void 0) return withMode({
|
|
310
|
+
limit,
|
|
311
|
+
before
|
|
312
|
+
});
|
|
302
313
|
const page = positiveInt(input.page, "page");
|
|
303
|
-
return page === void 0 ? { limit } : {
|
|
314
|
+
return withMode(page === void 0 ? { limit } : {
|
|
304
315
|
limit,
|
|
305
316
|
page
|
|
306
|
-
};
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
function pageMode(raw) {
|
|
320
|
+
if (raw === void 0 || raw === null || raw === "") return void 0;
|
|
321
|
+
const value = String(raw).trim();
|
|
322
|
+
if (value === "offset" || value === "keyset") return value;
|
|
323
|
+
throw new QueryGrammarError("mode", "mode is offset or keyset");
|
|
307
324
|
}
|
|
308
325
|
/** `sort=-createdAt,name` or qs `sort[createdAt]=desc`. An invalid or disallowed field is refused. */
|
|
309
326
|
function readSort(raw, options = {}) {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { WriteOptions } from "./types.mjs";
|
|
2
|
+
//#region src/repository/bulk-upsert.d.ts
|
|
3
|
+
type BulkUpsertOutcome = 'inserted' | 'updated' | 'unchanged' | 'failed';
|
|
4
|
+
declare const BULK_UPSERT_FAILURE_CODES: {
|
|
5
|
+
/** A unique index other than (or racing on) the key refused the row. */
|
|
6
|
+
readonly DUPLICATE: 'duplicate';
|
|
7
|
+
/** Schema / document validation refused the row. */
|
|
8
|
+
readonly VALIDATION: 'validation';
|
|
9
|
+
/** The row lacks a key field (or carries a non-numeric `inc` field); it was not sent. */
|
|
10
|
+
readonly INVALID_ROW: 'invalid_row';
|
|
11
|
+
/** `ordered: true` stopped at an earlier failure; this row was not attempted. */
|
|
12
|
+
readonly NOT_ATTEMPTED: 'not_attempted';
|
|
13
|
+
/** Any other per-row write error. */
|
|
14
|
+
readonly WRITE_ERROR: 'write_error';
|
|
15
|
+
};
|
|
16
|
+
type BulkUpsertFailureCode = (typeof BULK_UPSERT_FAILURE_CODES)[keyof typeof BULK_UPSERT_FAILURE_CODES];
|
|
17
|
+
declare const BULK_UPSERT_ERROR_CODES: {
|
|
18
|
+
/** No unique index covers the key (with the policy scope): refused before any write. */
|
|
19
|
+
readonly KEY_NOT_UNIQUE: 'repo.bulk_upsert.key_not_unique';
|
|
20
|
+
/** A field is routed to two of `set` / `inc` / `setOnInsert`, or a key field is routed at all. */
|
|
21
|
+
readonly INVALID_OPTIONS: 'repo.bulk_upsert.invalid_options';
|
|
22
|
+
/** The backend cannot report a per-row outcome (Mongo: server < 8.0). */
|
|
23
|
+
readonly UNSUPPORTED: 'repo.bulk_upsert.unsupported';
|
|
24
|
+
};
|
|
25
|
+
interface BulkUpsertOptions extends WriteOptions {
|
|
26
|
+
/** Fields that identify a row. Required; a unique index must cover them. */
|
|
27
|
+
key: readonly string[];
|
|
28
|
+
/** When given, only these fields are `$set`; otherwise every non-key, non-routed field. */
|
|
29
|
+
set?: readonly string[];
|
|
30
|
+
/** Fields added with `$inc` (numeric). */
|
|
31
|
+
inc?: readonly string[];
|
|
32
|
+
/** Fields written only when the row is inserted. */
|
|
33
|
+
setOnInsert?: readonly string[];
|
|
34
|
+
/** Stop at the first failure (later rows `not_attempted`). Default `false`. */
|
|
35
|
+
ordered?: boolean;
|
|
36
|
+
}
|
|
37
|
+
interface BulkUpsertRowResult {
|
|
38
|
+
/** Position in the input array. */
|
|
39
|
+
index: number;
|
|
40
|
+
/** The row's key values, as given. */
|
|
41
|
+
key: Record<string, unknown>;
|
|
42
|
+
outcome: BulkUpsertOutcome;
|
|
43
|
+
/** Present iff `outcome === 'failed'`. */
|
|
44
|
+
code?: BulkUpsertFailureCode;
|
|
45
|
+
}
|
|
46
|
+
interface BulkUpsertResult {
|
|
47
|
+
/** One entry per input row, in input order. */
|
|
48
|
+
results: readonly BulkUpsertRowResult[];
|
|
49
|
+
inserted: number;
|
|
50
|
+
updated: number;
|
|
51
|
+
unchanged: number;
|
|
52
|
+
failed: number;
|
|
53
|
+
}
|
|
54
|
+
/** Validate field routing; returns the error message, or undefined when the options are valid. */
|
|
55
|
+
declare function bulkUpsertOptionsError(options: BulkUpsertOptions): string | undefined;
|
|
56
|
+
/** Tally a positional result list into the summary counts. */
|
|
57
|
+
declare function summarizeBulkUpsert(results: readonly BulkUpsertRowResult[]): BulkUpsertResult;
|
|
58
|
+
//#endregion
|
|
59
|
+
export { BULK_UPSERT_ERROR_CODES, BULK_UPSERT_FAILURE_CODES, BulkUpsertFailureCode, BulkUpsertOptions, BulkUpsertOutcome, BulkUpsertResult, BulkUpsertRowResult, bulkUpsertOptionsError, summarizeBulkUpsert };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//#region src/repository/bulk-upsert.ts
|
|
2
|
+
const BULK_UPSERT_FAILURE_CODES = {
|
|
3
|
+
/** A unique index other than (or racing on) the key refused the row. */
|
|
4
|
+
DUPLICATE: "duplicate",
|
|
5
|
+
/** Schema / document validation refused the row. */
|
|
6
|
+
VALIDATION: "validation",
|
|
7
|
+
/** The row lacks a key field (or carries a non-numeric `inc` field); it was not sent. */
|
|
8
|
+
INVALID_ROW: "invalid_row",
|
|
9
|
+
/** `ordered: true` stopped at an earlier failure; this row was not attempted. */
|
|
10
|
+
NOT_ATTEMPTED: "not_attempted",
|
|
11
|
+
/** Any other per-row write error. */
|
|
12
|
+
WRITE_ERROR: "write_error"
|
|
13
|
+
};
|
|
14
|
+
const BULK_UPSERT_ERROR_CODES = {
|
|
15
|
+
/** No unique index covers the key (with the policy scope): refused before any write. */
|
|
16
|
+
KEY_NOT_UNIQUE: "repo.bulk_upsert.key_not_unique",
|
|
17
|
+
/** A field is routed to two of `set` / `inc` / `setOnInsert`, or a key field is routed at all. */
|
|
18
|
+
INVALID_OPTIONS: "repo.bulk_upsert.invalid_options",
|
|
19
|
+
/** The backend cannot report a per-row outcome (Mongo: server < 8.0). */
|
|
20
|
+
UNSUPPORTED: "repo.bulk_upsert.unsupported"
|
|
21
|
+
};
|
|
22
|
+
/** Validate field routing; returns the error message, or undefined when the options are valid. */
|
|
23
|
+
function bulkUpsertOptionsError(options) {
|
|
24
|
+
if (!Array.isArray(options.key) || options.key.length === 0) return "`key` must name at least one field";
|
|
25
|
+
const seen = /* @__PURE__ */ new Map();
|
|
26
|
+
for (const [list, fields] of [
|
|
27
|
+
["key", options.key],
|
|
28
|
+
["set", options.set ?? []],
|
|
29
|
+
["inc", options.inc ?? []],
|
|
30
|
+
["setOnInsert", options.setOnInsert ?? []]
|
|
31
|
+
]) for (const field of fields) {
|
|
32
|
+
const prior = seen.get(field);
|
|
33
|
+
if (prior) return `field '${field}' is in both \`${prior}\` and \`${list}\``;
|
|
34
|
+
seen.set(field, list);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Tally a positional result list into the summary counts. */
|
|
38
|
+
function summarizeBulkUpsert(results) {
|
|
39
|
+
const out = {
|
|
40
|
+
results,
|
|
41
|
+
inserted: 0,
|
|
42
|
+
updated: 0,
|
|
43
|
+
unchanged: 0,
|
|
44
|
+
failed: 0
|
|
45
|
+
};
|
|
46
|
+
for (const r of results) out[r.outcome] += 1;
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
export { BULK_UPSERT_ERROR_CODES, BULK_UPSERT_FAILURE_CODES, bulkUpsertOptionsError, summarizeBulkUpsert };
|
|
@@ -220,6 +220,29 @@ interface RepoCapabilities {
|
|
|
220
220
|
lookupPopulate?: boolean;
|
|
221
221
|
/** `cursor(filter, options)` streaming reads (AsyncIterable batches). */
|
|
222
222
|
streaming?: boolean;
|
|
223
|
+
/**
|
|
224
|
+
* `getByIds(ids, { preserveOrder: true, chunkSize })`: an array aligned with `ids` (undefined
|
|
225
|
+
* for a miss), the same answer at any chunk size. Gates the `getByIds (ordered, chunked)`
|
|
226
|
+
* group of `runDataAccessConformance`.
|
|
227
|
+
*/
|
|
228
|
+
getByIdsOrdered?: boolean;
|
|
229
|
+
/**
|
|
230
|
+
* Every join (`aggregate({ lookups })`) reads the joined collection under its own tenant and
|
|
231
|
+
* soft-delete scope, and a join into a collection the kit cannot resolve is refused. Gates the
|
|
232
|
+
* `scoped joins` group of `runDataAccessConformance`.
|
|
233
|
+
*/
|
|
234
|
+
scopedJoins?: boolean;
|
|
235
|
+
/**
|
|
236
|
+
* Keyset walks equal an in-memory sort (nulls, ties), survive concurrent writes with no duplicate
|
|
237
|
+
* or gap, and offset pages never repeat a row. Gates `runPaginationPropertyConformance` (fast-check).
|
|
238
|
+
*/
|
|
239
|
+
paginationProperties?: boolean;
|
|
240
|
+
/** `bulkUpsert(rows, { key, set, inc, setOnInsert })` with exact per-row outcomes. Gates the `bulkUpsert` group. */
|
|
241
|
+
bulkUpsert?: boolean;
|
|
242
|
+
/** Read-model primitives (increments with dedupe, rebuild, reconcile). Gates the `read model` group. */
|
|
243
|
+
readModel?: boolean;
|
|
244
|
+
/** Kit sources compose with `mergeKeysetPages` (scope-bound per-row cursors). Gates `runMergeKeysetConformance`. */
|
|
245
|
+
mergeKeyset?: boolean;
|
|
223
246
|
}
|
|
224
247
|
//#endregion
|
|
225
248
|
export { AggregateOpsSupport, RepoCapabilities };
|
|
@@ -7,9 +7,10 @@ import { PLUGIN_ORDER_CONSTRAINTS, Plugin, PluginFunction, PluginType, validateP
|
|
|
7
7
|
import { RepositoryBase, RepositoryBaseOptions } from "./base.mjs";
|
|
8
8
|
import { AggregateOpsSupport, RepoCapabilities } from "./capabilities.mjs";
|
|
9
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
|
+
import { BULK_UPSERT_ERROR_CODES, BULK_UPSERT_FAILURE_CODES, BulkUpsertFailureCode, BulkUpsertOptions, BulkUpsertOutcome, BulkUpsertResult, BulkUpsertRowResult, bulkUpsertOptionsError, summarizeBulkUpsert } from "./bulk-upsert.mjs";
|
|
10
11
|
import { DistributionConfig, DistributionMissHandler, createDistributionGuard, filterReferencesKey } from "./distribution.mjs";
|
|
11
12
|
import { STANDARD_REPO_OPTION_KEYS, StandardRepoOptionKey } from "./options.mjs";
|
|
12
13
|
import { PurgePort, WritingPurgeStrategy, runChunkedPurge } from "./purge.mjs";
|
|
13
14
|
import { ReadOnlyRepoOptions, ReadOnlyRepositoryError, asReadOnlyRepo, isReadOnlyRepo } from "./read-only.mjs";
|
|
14
15
|
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, isTransientError, nestDottedKeys, nestDottedKeysAll, retryingTransaction, runChunkedArchive, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry };
|
|
16
|
+
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, BULK_UPSERT_ERROR_CODES, BULK_UPSERT_FAILURE_CODES, type BulkCreateResult, type BulkUpsertFailureCode, type BulkUpsertOptions, type BulkUpsertOutcome, type BulkUpsertResult, type BulkUpsertRowResult, 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, bulkUpsertOptionsError, createDistributionGuard, filterReferencesKey, isReadOnlyRepo, isTransientError, nestDottedKeys, nestDottedKeysAll, retryingTransaction, runChunkedArchive, runChunkedPurge, summarizeBulkUpsert, throwIfAborted, validatePluginOrder, withRetry };
|
|
@@ -3,9 +3,10 @@ import { isTransientError, throwIfAborted, withRetry } from "./resilience.mjs";
|
|
|
3
3
|
import { runChunkedArchive } from "./archive.mjs";
|
|
4
4
|
import { PLUGIN_ORDER_CONSTRAINTS, validatePluginOrder } from "./plugin-types.mjs";
|
|
5
5
|
import { RepositoryBase } from "./base.mjs";
|
|
6
|
+
import { BULK_UPSERT_ERROR_CODES, BULK_UPSERT_FAILURE_CODES, bulkUpsertOptionsError, summarizeBulkUpsert } from "./bulk-upsert.mjs";
|
|
6
7
|
import { createDistributionGuard, filterReferencesKey } from "./distribution.mjs";
|
|
7
8
|
import { STANDARD_REPO_OPTION_KEYS } from "./options.mjs";
|
|
8
9
|
import { runChunkedPurge } from "./purge.mjs";
|
|
9
10
|
import { ReadOnlyRepositoryError, asReadOnlyRepo, isReadOnlyRepo } from "./read-only.mjs";
|
|
10
11
|
import { retryingTransaction } from "./retrying-transaction.mjs";
|
|
11
|
-
export { PLUGIN_ORDER_CONSTRAINTS, ReadOnlyRepositoryError, RepositoryBase, STANDARD_REPO_OPTION_KEYS, asReadOnlyRepo, createDistributionGuard, filterReferencesKey, isReadOnlyRepo, isTransientError, nestDottedKeys, nestDottedKeysAll, retryingTransaction, runChunkedArchive, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry };
|
|
12
|
+
export { BULK_UPSERT_ERROR_CODES, BULK_UPSERT_FAILURE_CODES, PLUGIN_ORDER_CONSTRAINTS, ReadOnlyRepositoryError, RepositoryBase, STANDARD_REPO_OPTION_KEYS, asReadOnlyRepo, bulkUpsertOptionsError, createDistributionGuard, filterReferencesKey, isReadOnlyRepo, isTransientError, nestDottedKeys, nestDottedKeysAll, retryingTransaction, runChunkedArchive, runChunkedPurge, summarizeBulkUpsert, throwIfAborted, validatePluginOrder, withRetry };
|
|
@@ -4,8 +4,9 @@ import { LookupPopulateOptions, LookupPopulateResult, LookupSpec } from "../look
|
|
|
4
4
|
import { UpdateInput } from "../update/types.mjs";
|
|
5
5
|
import { RetryPolicy } from "./resilience.mjs";
|
|
6
6
|
import { ArchiveOptions, ArchiveResult, ArchiveSink } from "./archive.mjs";
|
|
7
|
-
import { RepoCapabilities } from "./capabilities.mjs";
|
|
8
7
|
import { CacheOptions } from "../cache/options.mjs";
|
|
8
|
+
import { RepoCapabilities } from "./capabilities.mjs";
|
|
9
|
+
import { BulkUpsertOptions, BulkUpsertResult } from "./bulk-upsert.mjs";
|
|
9
10
|
//#region src/repository/types.d.ts
|
|
10
11
|
/**
|
|
11
12
|
* Accepted filter input across every repository method. A repository
|
|
@@ -1622,6 +1623,11 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
|
|
|
1622
1623
|
* batches ≤ ~10k ids and chunk beyond.
|
|
1623
1624
|
*/
|
|
1624
1625
|
getByIds?(ids: readonly string[], options?: FindAllOptions): Promise<Map<string, TDoc>>;
|
|
1626
|
+
/**
|
|
1627
|
+
* Many keyed upserts in one command with an exact per-row outcome (see `./bulk-upsert.ts`).
|
|
1628
|
+
* Optional; gated by `RepoCapabilities.bulkUpsert`.
|
|
1629
|
+
*/
|
|
1630
|
+
bulkUpsert?(rows: readonly Record<string, unknown>[], options: BulkUpsertOptions): Promise<BulkUpsertResult>;
|
|
1625
1631
|
count?(filter?: FilterInput, options?: QueryOptions): Promise<number>;
|
|
1626
1632
|
exists?(filter: FilterInput, options?: QueryOptions): Promise<boolean | {
|
|
1627
1633
|
_id: unknown;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { RepoCapabilities } from "../repository/capabilities.mjs";
|
|
2
|
+
import { AggRequest, AggResult } from "../repository/types.mjs";
|
|
3
|
+
import { BulkUpsertOptions, BulkUpsertResult } from "../repository/bulk-upsert.mjs";
|
|
4
|
+
//#region src/testing/data-access-conformance.d.ts
|
|
5
|
+
/** `getByIds(..., { preserveOrder: true })` reached through the kit's own signature. */
|
|
6
|
+
interface OrderedIdsFixture {
|
|
7
|
+
/** Insert one record per name; returns their ids in the same order. */
|
|
8
|
+
insert(names: readonly string[]): Promise<string[]>;
|
|
9
|
+
getOrdered(ids: readonly string[], options: {
|
|
10
|
+
chunkSize?: number;
|
|
11
|
+
}): Promise<ReadonlyArray<{
|
|
12
|
+
name: string;
|
|
13
|
+
} | undefined>>;
|
|
14
|
+
/** A well-formed id that matches no record. */
|
|
15
|
+
missingId: string;
|
|
16
|
+
cleanup(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
/** A tenant-scoped base and a tenant-scoped, soft-deletable sibling it can join. */
|
|
19
|
+
interface ScopedJoinFixture {
|
|
20
|
+
/** The base repository's portable aggregate. Base rows carry `key`. */
|
|
21
|
+
aggregate(req: AggRequest, scope: Record<string, unknown>): Promise<AggResult>;
|
|
22
|
+
/** Name `lookups[].from` takes for the joined sibling. */
|
|
23
|
+
joined: string;
|
|
24
|
+
seedBase(tenant: string, keys: readonly string[]): Promise<void>;
|
|
25
|
+
/** Joined rows carry `key` and `qty`; `deleted` rows are soft-deleted. */
|
|
26
|
+
seedJoined(tenant: string, rows: ReadonlyArray<{
|
|
27
|
+
key: string;
|
|
28
|
+
qty: number;
|
|
29
|
+
deleted?: boolean;
|
|
30
|
+
}>): Promise<void>;
|
|
31
|
+
/** Call options that scope a read to `tenant`. */
|
|
32
|
+
scope(tenant: string): Record<string, unknown>;
|
|
33
|
+
cleanup(): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
/** A tenant-scoped collection keyed uniquely by (tenant, `sku`). Rows: `{ sku, qty, name, firstSeen }`. */
|
|
36
|
+
interface BulkUpsertFixture {
|
|
37
|
+
bulkUpsert(rows: readonly Record<string, unknown>[], options: BulkUpsertOptions, tenant: string): Promise<BulkUpsertResult>;
|
|
38
|
+
read(tenant: string): Promise<readonly Record<string, unknown>[]>;
|
|
39
|
+
/** `bulkUpsert` keyed on `name`, which has NO unique index: must be refused. */
|
|
40
|
+
bulkUpsertByNonUniqueKey(rows: readonly Record<string, unknown>[], tenant: string): Promise<unknown>;
|
|
41
|
+
cleanup(): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
/** Source facts `{ period, rate, tax }` and a totals read model keyed (tenant, period, rate). */
|
|
44
|
+
interface ReadModelFixture {
|
|
45
|
+
seedSource(tenant: string, rows: ReadonlyArray<{
|
|
46
|
+
period: string;
|
|
47
|
+
rate: number;
|
|
48
|
+
tax: number;
|
|
49
|
+
}>): Promise<void>;
|
|
50
|
+
/** Rebuild the tenant's totals from its source facts. */
|
|
51
|
+
rebuild(tenant: string): Promise<void>;
|
|
52
|
+
/** The same rebuild, issued inside a transaction: must reject. */
|
|
53
|
+
rebuildInTransaction(tenant: string): Promise<unknown>;
|
|
54
|
+
/** Drift between the source-derived totals and the read model, for the tenant. */
|
|
55
|
+
reconcile(tenant: string): Promise<ReadonlyArray<{
|
|
56
|
+
key: Record<string, unknown>;
|
|
57
|
+
expected: unknown;
|
|
58
|
+
actual: unknown;
|
|
59
|
+
}>>;
|
|
60
|
+
/** Corrupt one stored total. */
|
|
61
|
+
tamper(tenant: string, period: string, rate: number): Promise<void>;
|
|
62
|
+
/** Apply increments in one unit of work; a repeated dedupeKey must count once. */
|
|
63
|
+
applyIncrements(tenant: string, grains: ReadonlyArray<{
|
|
64
|
+
period: string;
|
|
65
|
+
rate: number;
|
|
66
|
+
tax: number;
|
|
67
|
+
dedupeKey: string;
|
|
68
|
+
}>): Promise<void>;
|
|
69
|
+
readTotals(tenant: string): Promise<ReadonlyArray<{
|
|
70
|
+
period: string;
|
|
71
|
+
rate: number;
|
|
72
|
+
tax: number;
|
|
73
|
+
}>>;
|
|
74
|
+
cleanup(): Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
interface DataAccessConformanceHarness {
|
|
77
|
+
name: string;
|
|
78
|
+
features: RepoCapabilities;
|
|
79
|
+
orderedIds?: () => Promise<OrderedIdsFixture>;
|
|
80
|
+
scopedJoin?: () => Promise<ScopedJoinFixture>;
|
|
81
|
+
bulkUpsert?: () => Promise<BulkUpsertFixture>;
|
|
82
|
+
readModel?: () => Promise<ReadModelFixture>;
|
|
83
|
+
}
|
|
84
|
+
type DataAccessCapability = 'getByIdsOrdered' | 'scopedJoins' | 'bulkUpsert' | 'readModel';
|
|
85
|
+
/** Whether a group runs, is skipped (capability not declared), or fails (declared, no fixture). */
|
|
86
|
+
declare function dataAccessGroupState(features: RepoCapabilities, capability: DataAccessCapability, fixture: unknown): 'run' | 'skip' | 'missing-fixture';
|
|
87
|
+
declare function runDataAccessConformance(harness: DataAccessConformanceHarness): void;
|
|
88
|
+
//#endregion
|
|
89
|
+
export { BulkUpsertFixture, DataAccessCapability, DataAccessConformanceHarness, OrderedIdsFixture, ReadModelFixture, ScopedJoinFixture, dataAccessGroupState, runDataAccessConformance };
|