@classytic/repo-core 0.12.0 → 0.14.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.
@@ -3,6 +3,7 @@ import { FALSE, TRUE, and, anyOf as in_, between, contains, endsWith, eq, exists
3
3
  import { recordToFilter } from "./from-record.mjs";
4
4
  import { isFilter } from "./guard.mjs";
5
5
  import { asPredicate, matchFilter } from "./match.mjs";
6
+ import { matchesRecordFilter, policyRecordToFilter } from "./match-record.mjs";
6
7
  import { SCOPE_ANY, buildTenantScope, mergeScope } from "./scope.mjs";
7
8
  import { collectFields, mapFilter, walkFilter } from "./walk.mjs";
8
- export { FALSE, type Filter, type FilterAnd, type FilterEq, type FilterExists, type FilterFalse, type FilterGt, type FilterGte, type FilterIn, type FilterLike, type FilterLt, type FilterLte, type FilterNe, type FilterNin, type FilterNot, type FilterOp, type FilterOr, type FilterRaw, type FilterRegex, type FilterTrue, SCOPE_ANY, TRUE, and, in_ as anyOf, asPredicate, between, buildTenantScope, collectFields, contains, endsWith, eq, exists, gt, gte, iEq, in_, not as invert, isFilter, isNotNull, isNull, like, lt, lte, mapFilter, matchFilter, mergeScope, ne, nin, nin as noneOf, not, or, raw, recordToFilter, regex, startsWith, walkFilter };
9
+ export { FALSE, type Filter, type FilterAnd, type FilterEq, type FilterExists, type FilterFalse, type FilterGt, type FilterGte, type FilterIn, type FilterLike, type FilterLt, type FilterLte, type FilterNe, type FilterNin, type FilterNot, type FilterOp, type FilterOr, type FilterRaw, type FilterRegex, type FilterTrue, SCOPE_ANY, TRUE, and, in_ as anyOf, asPredicate, between, buildTenantScope, collectFields, contains, endsWith, eq, exists, gt, gte, iEq, in_, not as invert, isFilter, isNotNull, isNull, like, lt, lte, mapFilter, matchFilter, matchesRecordFilter, mergeScope, ne, nin, nin as noneOf, not, or, policyRecordToFilter, raw, recordToFilter, regex, startsWith, walkFilter };
@@ -3,5 +3,6 @@ import { collectFields, mapFilter, walkFilter } from "./walk.mjs";
3
3
  import { FALSE, TRUE, and, anyOf as in_, between, contains, endsWith, eq, exists, gt, gte, iEq, invert as not, isNotNull, isNull, like, lt, lte, ne, nin, or, raw, regex, startsWith } from "./builders.mjs";
4
4
  import { recordToFilter } from "./from-record.mjs";
5
5
  import { asPredicate, matchFilter } from "./match.mjs";
6
+ import { matchesRecordFilter, policyRecordToFilter } from "./match-record.mjs";
6
7
  import { SCOPE_ANY, buildTenantScope, mergeScope } from "./scope.mjs";
7
- export { FALSE, SCOPE_ANY, TRUE, and, in_ as anyOf, asPredicate, between, buildTenantScope, collectFields, contains, endsWith, eq, exists, gt, gte, iEq, in_, not as invert, isFilter, isNotNull, isNull, like, lt, lte, mapFilter, matchFilter, mergeScope, ne, nin, nin as noneOf, not, or, raw, recordToFilter, regex, startsWith, walkFilter };
8
+ export { FALSE, SCOPE_ANY, TRUE, and, in_ as anyOf, asPredicate, between, buildTenantScope, collectFields, contains, endsWith, eq, exists, gt, gte, iEq, in_, not as invert, isFilter, isNotNull, isNull, like, lt, lte, mapFilter, matchFilter, matchesRecordFilter, mergeScope, ne, nin, nin as noneOf, not, or, policyRecordToFilter, raw, recordToFilter, regex, startsWith, walkFilter };
@@ -0,0 +1,19 @@
1
+ import { Filter } from "./types.mjs";
2
+ //#region src/filter/match-record.d.ts
3
+ /**
4
+ * Convert an arc Mongo-record `_policyFilters` object into Filter IR.
5
+ * `{}` → `TRUE`. Throws on unsupported top-level operators.
6
+ */
7
+ declare function policyRecordToFilter(record: Record<string, unknown>): Filter;
8
+ /**
9
+ * Evaluate an arc Mongo-record `_policyFilters` object against a document —
10
+ * converts to Filter IR, then delegates to the shared {@link matchFilter}
11
+ * engine. THE canonical `DataAdapter.matchesFilter` implementation; every
12
+ * kit's adapter delegates here.
13
+ *
14
+ * @param item The already-fetched document / row.
15
+ * @param filters Arc's `_policyFilters` in Mongo record syntax.
16
+ */
17
+ declare function matchesRecordFilter(item: unknown, filters: Record<string, unknown>): boolean;
18
+ //#endregion
19
+ export { matchesRecordFilter, policyRecordToFilter };
@@ -0,0 +1,187 @@
1
+ import { FALSE, TRUE, and, anyOf as in_, eq, gt, gte, invert as not, isNotNull, isNull, lt, lte, ne, or, regex } from "./builders.mjs";
2
+ import { matchFilter } from "./match.mjs";
3
+ //#region src/filter/match-record.ts
4
+ /**
5
+ * Mongo-record `_policyFilters` → Filter IR conversion + in-memory match.
6
+ *
7
+ * The CANONICAL, single home for evaluating arc's row-level policy filters
8
+ * against an already-fetched document IN PROCESS (no DB round-trip). Every
9
+ * kit's `DataAdapter.matchesFilter` delegates here — one contract, one IR,
10
+ * no per-kit matcher.
11
+ *
12
+ * Arc's permission helpers emit policy filters in Mongo record syntax,
13
+ * kit-agnostic (`requireOwnership` → `{ ownerId }`, multiTenant →
14
+ * `{ organizationId }`, `requireGrant` list resolutions →
15
+ * `{ $or: [{ ownerId }, { _id: { $in } }] }`). This module converts that
16
+ * record into the portable {@link Filter} IR and evaluates it with the
17
+ * shared {@link matchFilter} engine — the SAME IR kits compile to SQL /
18
+ * Mongo, so in-memory and DB-level enforcement agree by construction.
19
+ *
20
+ * `matchFilter` is id-coercion aware (Mongo `ObjectId` `_id` matches its
21
+ * string form — no kit-specific coercion) and array-aware (dot-paths fan
22
+ * out over subdocument arrays; scalar conditions on array fields match any
23
+ * element). See `match.ts`.
24
+ *
25
+ * SCOPE — the operators arc's policy filters emit. Fails LOUD on anything
26
+ * else so a silent mismatch never masquerades as a denial:
27
+ *
28
+ * logical: $or, $and, $nor, $not
29
+ * comparison: implicit-eq, $eq, $ne, $gt, $gte, $lt, $lte
30
+ * membership: $in, $nin
31
+ * existence: $exists (see divergence note below)
32
+ * pattern: $regex (+ $options; RegExp literal accepted)
33
+ *
34
+ * MongoDB parity (validated against the MongoDB manual + sift/mingo):
35
+ * - Missing field ≡ null for `{field: null}`, `$ne`/`$nin`, and a `null`
36
+ * member of `$in`/`$nin` — the authorization-critical rule (a policy
37
+ * filter `{ status: { $ne: 'archived' } }` MUST return docs lacking the
38
+ * field, exactly as MongoDB does).
39
+ * - `$in` accepts RegExp-literal members (Mongo allows `/re/` in `$in`).
40
+ * - Comparison ops are TYPE-BRACKETED: no cross-type ordering
41
+ * (`{ n: { $gt: 5 } }` never matches a string `n`); `$gt: null` matches
42
+ * nothing. NaN equals NaN for `$eq`.
43
+ * - Dot-paths fan out over arrays AND resolve numeric segments as
44
+ * positional indices (`items.0.sku`).
45
+ *
46
+ * DELIBERATE divergences (documented, not bugs):
47
+ * - `$exists` = present-AND-non-null (a null value reads as absent),
48
+ * matching the shared IR `exists` op + SQL `IS NOT NULL` + sift.js.
49
+ * MongoDB/mingo treat present-null as existing; that would require a
50
+ * separate key-presence IR op threaded through every kit's SQL/Mongo
51
+ * compiler. Arc's built-in policy helpers never emit `$exists`; a
52
+ * custom filter that needs Mongo key-presence should use
53
+ * `{ field: { $ne: null } }` (present + non-null) or `{ field: null }`
54
+ * (null OR missing) instead.
55
+ * - `$gt`/`$lt` allow ONE cross-type leniency: a `Date` field compares
56
+ * against an ISO-string operand (JSON policy filters carry dates as
57
+ * strings). Consistent with `$eq`'s Date⇄string coercion.
58
+ * - An array-literal operand (`{ tags: ['a','b'] }`) is element-matched,
59
+ * not exact-array-matched — policy filters never assert whole-array
60
+ * equality.
61
+ *
62
+ * Distinct from {@link recordToFilter}, which is the ergonomic
63
+ * record→IR normalizer for BARE-operator query shorthand (`{ price:
64
+ * { gte } }`) and deliberately does NOT accept `$`-prefixed operators or
65
+ * logical `$or`/`$and`. This function is the arc-policy-filter dialect
66
+ * (`$`-prefixed, with logical operators).
67
+ */
68
+ /** Field operators understood inside a `{ field: { ... } }` condition. */
69
+ const FIELD_OPS = [
70
+ "$eq",
71
+ "$ne",
72
+ "$gt",
73
+ "$gte",
74
+ "$lt",
75
+ "$lte",
76
+ "$in",
77
+ "$nin",
78
+ "$exists",
79
+ "$regex"
80
+ ];
81
+ function isOperatorObject(value) {
82
+ if (value === null || typeof value !== "object") return false;
83
+ if (Array.isArray(value) || value instanceof Date) return false;
84
+ const keys = Object.keys(value);
85
+ return keys.length > 0 && keys.every((k) => k.startsWith("$"));
86
+ }
87
+ /**
88
+ * `$in` with MongoDB parity: a `null` member also matches a MISSING field
89
+ * (inherits `{field: null}` semantics), and RegExp-literal members match by
90
+ * pattern (Mongo allows `/re/` inside `$in`). Split members into
91
+ * null / regex / scalar and OR the branches.
92
+ */
93
+ function buildIn(field, members) {
94
+ const branches = [];
95
+ const scalars = [];
96
+ let hasNull = false;
97
+ for (const m of members) if (m === null || m === void 0) hasNull = true;
98
+ else if (m instanceof RegExp) branches.push(regex(field, m.source, m.flags));
99
+ else scalars.push(m);
100
+ if (hasNull) branches.push(isNull(field));
101
+ if (scalars.length > 0) branches.push(in_(field, scalars));
102
+ if (branches.length === 0) return FALSE;
103
+ return branches.length === 1 ? branches[0] : or(...branches);
104
+ }
105
+ /** `$nin` is the negation of `$in` — none of the members may match. */
106
+ function buildNin(field, members) {
107
+ const inFilter = buildIn(field, members);
108
+ return inFilter.op === "false" ? TRUE : not(inFilter);
109
+ }
110
+ /** Convert a single `{ field: condition }` entry into a Filter IR node. */
111
+ function fieldFilter(field, condition) {
112
+ if (!isOperatorObject(condition)) return condition === null ? isNull(field) : eq(field, condition);
113
+ const parts = [];
114
+ const options = typeof condition["$options"] === "string" ? condition["$options"] : void 0;
115
+ for (const [op, operand] of Object.entries(condition)) switch (op) {
116
+ case "$options": break;
117
+ case "$eq":
118
+ parts.push(operand === null ? isNull(field) : eq(field, operand));
119
+ break;
120
+ case "$ne":
121
+ parts.push(operand === null ? isNotNull(field) : ne(field, operand));
122
+ break;
123
+ case "$gt":
124
+ parts.push(gt(field, operand));
125
+ break;
126
+ case "$gte":
127
+ parts.push(gte(field, operand));
128
+ break;
129
+ case "$lt":
130
+ parts.push(lt(field, operand));
131
+ break;
132
+ case "$lte":
133
+ parts.push(lte(field, operand));
134
+ break;
135
+ case "$in":
136
+ parts.push(buildIn(field, operand ?? []));
137
+ break;
138
+ case "$nin":
139
+ parts.push(buildNin(field, operand ?? []));
140
+ break;
141
+ case "$exists":
142
+ parts.push(operand ? isNotNull(field) : isNull(field));
143
+ break;
144
+ case "$regex": {
145
+ const pattern = operand instanceof RegExp ? operand.source : String(operand);
146
+ const flags = operand instanceof RegExp ? operand.flags : options;
147
+ parts.push(flags ? regex(field, pattern, flags) : regex(field, pattern));
148
+ break;
149
+ }
150
+ default: throw new Error(`[repo-core] matchesRecordFilter: unsupported field operator '${op}'. Supported: ${FIELD_OPS.join(", ")}.`);
151
+ }
152
+ return parts.length === 1 ? parts[0] : and(...parts);
153
+ }
154
+ /**
155
+ * Convert an arc Mongo-record `_policyFilters` object into Filter IR.
156
+ * `{}` → `TRUE`. Throws on unsupported top-level operators.
157
+ */
158
+ function policyRecordToFilter(record) {
159
+ const parts = [];
160
+ for (const [key, value] of Object.entries(record)) if (key === "$or") parts.push(or(...asFilterArray(value)));
161
+ else if (key === "$and") parts.push(and(...asFilterArray(value)));
162
+ else if (key === "$nor") parts.push(not(or(...asFilterArray(value))));
163
+ else if (key === "$not") parts.push(not(policyRecordToFilter(value)));
164
+ else if (key.startsWith("$")) throw new Error(`[repo-core] matchesRecordFilter: unsupported top-level operator '${key}'. Supported: $and, $or, $nor, $not.`);
165
+ else parts.push(fieldFilter(key, value));
166
+ if (parts.length === 0) return TRUE;
167
+ return parts.length === 1 ? parts[0] : and(...parts);
168
+ }
169
+ function asFilterArray(value) {
170
+ if (!Array.isArray(value)) throw new Error("[repo-core] matchesRecordFilter: $or/$and/$nor operand must be an array");
171
+ return value.map((entry) => policyRecordToFilter(entry));
172
+ }
173
+ /**
174
+ * Evaluate an arc Mongo-record `_policyFilters` object against a document —
175
+ * converts to Filter IR, then delegates to the shared {@link matchFilter}
176
+ * engine. THE canonical `DataAdapter.matchesFilter` implementation; every
177
+ * kit's adapter delegates here.
178
+ *
179
+ * @param item The already-fetched document / row.
180
+ * @param filters Arc's `_policyFilters` in Mongo record syntax.
181
+ */
182
+ function matchesRecordFilter(item, filters) {
183
+ if (item === null || typeof item !== "object") return false;
184
+ return matchFilter(item, policyRecordToFilter(filters));
185
+ }
186
+ //#endregion
187
+ export { matchesRecordFilter, policyRecordToFilter };
@@ -7,35 +7,26 @@ function matchFilter(doc, filter) {
7
7
  case "and": return filter.children.every((child) => matchFilter(doc, child));
8
8
  case "or": return filter.children.some((child) => matchFilter(doc, child));
9
9
  case "not": return !matchFilter(doc, filter.child);
10
- case "eq": return equals(getField(doc, filter.field), filter.value);
11
- case "ne": return !equals(getField(doc, filter.field), filter.value);
12
- case "gt": return compare(getField(doc, filter.field), filter.value) > 0;
13
- case "gte": return compare(getField(doc, filter.field), filter.value) >= 0;
14
- case "lt": return compare(getField(doc, filter.field), filter.value) < 0;
15
- case "lte": return compare(getField(doc, filter.field), filter.value) <= 0;
16
- case "in": {
17
- const v = getField(doc, filter.field);
18
- return filter.values.some((candidate) => equals(v, candidate));
19
- }
20
- case "nin": {
21
- const v = getField(doc, filter.field);
22
- return !filter.values.some((candidate) => equals(v, candidate));
23
- }
10
+ case "eq": return someValue(resolve(doc, filter.field), (v) => equals(v, filter.value));
11
+ case "ne": return !someValue(resolve(doc, filter.field), (v) => equals(v, filter.value));
12
+ case "gt": return someValue(resolve(doc, filter.field), (v) => compare(v, filter.value) > 0);
13
+ case "gte": return someValue(resolve(doc, filter.field), (v) => compare(v, filter.value) >= 0);
14
+ case "lt": return someValue(resolve(doc, filter.field), (v) => compare(v, filter.value) < 0);
15
+ case "lte": return someValue(resolve(doc, filter.field), (v) => compare(v, filter.value) <= 0);
16
+ case "in": return someValue(resolve(doc, filter.field), (v) => filter.values.some((candidate) => equals(v, candidate)));
17
+ case "nin": return !someValue(resolve(doc, filter.field), (v) => filter.values.some((candidate) => equals(v, candidate)));
24
18
  case "exists": {
25
- const v = getField(doc, filter.field);
26
- const present = v !== void 0 && v !== null;
19
+ const present = fieldPresent(doc, filter.field);
27
20
  return filter.exists ? present : !present;
28
21
  }
29
22
  case "like": {
30
- const v = getField(doc, filter.field);
31
- if (typeof v !== "string") return false;
32
23
  const flags = filter.caseSensitivity === "sensitive" ? "" : "i";
33
- return getOrCompileLike(filter.pattern, flags).test(v);
24
+ const re = getOrCompileLike(filter.pattern, flags);
25
+ return someValue(resolve(doc, filter.field), (v) => regexTest(re, v));
34
26
  }
35
27
  case "regex": {
36
- const v = getField(doc, filter.field);
37
- if (typeof v !== "string") return false;
38
- return getOrCompileRegex(filter.pattern, filter.flags).test(v);
28
+ const re = getOrCompileRegex(filter.pattern, filter.flags);
29
+ return someValue(resolve(doc, filter.field), (v) => regexTest(re, v));
39
30
  }
40
31
  case "raw": return false;
41
32
  }
@@ -47,27 +38,92 @@ function matchFilter(doc, filter) {
47
38
  function asPredicate(filter) {
48
39
  return (doc) => matchFilter(doc, filter);
49
40
  }
50
- function getField(doc, path) {
51
- if (!doc || typeof doc !== "object") return void 0;
52
- const segments = path.split(".");
53
- let cursor = doc;
54
- for (const segment of segments) {
55
- if (cursor === null || cursor === void 0) return void 0;
56
- if (typeof cursor !== "object") return void 0;
57
- cursor = cursor[segment];
41
+ /**
42
+ * Resolve a dot-path to the SET of values it reaches, fanning out over
43
+ * arrays on intermediate segments (Mongo/JSON-path array semantics):
44
+ * `items.sku` on `{ items: [{ sku: 1 }, { sku: 2 }] }` → `[1, 2]`. A path
45
+ * with no array yields a single-element list, so scalar leaf ops behave
46
+ * exactly as before. A leaf array field (`tags`) is returned as one value
47
+ * (the array) so `equals`'s array-contains handles it.
48
+ */
49
+ /**
50
+ * Path segments that must never be resolved — reading them can surface an
51
+ * inherited member (or, for a JSON-parsed doc where `JSON.parse('{"__proto__":
52
+ * …}')` created a real OWN `__proto__`, a crafted value) and produce a WRONG
53
+ * authorization answer. Denied string-normalized (the object-path CVE-2021-23434
54
+ * lesson: an array-typed segment bypassed a `===` check). Fail closed: any
55
+ * path touching one of these resolves to no values → no match.
56
+ */
57
+ const DANGEROUS_SEGMENTS = /* @__PURE__ */ new Set([
58
+ "__proto__",
59
+ "constructor",
60
+ "prototype"
61
+ ]);
62
+ function resolve(doc, path) {
63
+ let frontier = [doc];
64
+ for (const segment of path.split(".")) {
65
+ if (DANGEROUS_SEGMENTS.has(String(segment))) return [];
66
+ const next = [];
67
+ for (const node of frontier) {
68
+ if (node === null || node === void 0 || typeof node !== "object") continue;
69
+ if (Array.isArray(node)) {
70
+ const idx = Number(segment);
71
+ if (Number.isInteger(idx) && idx >= 0 && idx < node.length) next.push(node[idx]);
72
+ for (const el of node) if (el && typeof el === "object" && Object.hasOwn(el, segment)) next.push(el[segment]);
73
+ } else if (Object.hasOwn(node, segment)) next.push(node[segment]);
74
+ }
75
+ if (next.length === 0) return [];
76
+ frontier = next;
58
77
  }
59
- return cursor;
78
+ return frontier;
79
+ }
80
+ /** Is any value reachable at `path` present (defined + non-null)? */
81
+ function fieldPresent(doc, path) {
82
+ return resolve(doc, path).some((v) => v !== void 0 && v !== null);
83
+ }
84
+ /**
85
+ * Apply a scalar predicate to a resolved value SET, unwrapping leaf array
86
+ * values so a scalar condition on an array field (`tags`, `scores`)
87
+ * matches when ANY element satisfies it — Mongo + SQL array semantics,
88
+ * concentrated in ONE place so `equals`/`compare`/regex stay pure scalar.
89
+ */
90
+ function someValue(values, pred) {
91
+ for (const v of values) if (Array.isArray(v)) {
92
+ if (v.some(pred)) return true;
93
+ } else if (pred(v)) return true;
94
+ return false;
60
95
  }
61
96
  function equals(a, b) {
97
+ if (a === b) return true;
98
+ if (typeof a === "number" && typeof b === "number") return Number.isNaN(a) && Number.isNaN(b);
62
99
  if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
63
100
  if (a instanceof Date && typeof b === "string") return a.toISOString() === b;
64
101
  if (b instanceof Date && typeof a === "string") return b.toISOString() === a;
65
- return a === b;
102
+ const as = idString(a);
103
+ const bs = idString(b);
104
+ if (as !== void 0 && bs !== void 0) return as === bs;
105
+ return false;
106
+ }
107
+ /** String form of an id-like value for coercing comparison; else undefined. */
108
+ function idString(value) {
109
+ if (typeof value === "string") return value;
110
+ if (value === null || value === void 0 || typeof value !== "object") return void 0;
111
+ if (Array.isArray(value)) return void 0;
112
+ const s = String(value);
113
+ return s === "[object Object]" ? void 0 : s;
66
114
  }
67
115
  function compare(a, b) {
116
+ if (a instanceof Date && typeof b === "string") {
117
+ const t = Date.parse(b);
118
+ if (!Number.isNaN(t)) b = new Date(t);
119
+ } else if (b instanceof Date && typeof a === "string") {
120
+ const t = Date.parse(a);
121
+ if (!Number.isNaN(t)) a = new Date(t);
122
+ }
68
123
  const aNum = toComparable(a);
69
124
  const bNum = toComparable(b);
70
125
  if (aNum === void 0 || bNum === void 0) return NaN;
126
+ if (typeof aNum !== typeof bNum) return NaN;
71
127
  if (aNum < bNum) return -1;
72
128
  if (aNum > bNum) return 1;
73
129
  return 0;
@@ -114,6 +170,21 @@ function getOrCompileRegex(pattern, flags) {
114
170
  regexCache.set(key, re);
115
171
  return re;
116
172
  }
173
+ /**
174
+ * Max string length fed to a regex `.test()`. ReDoS is `pattern × input`;
175
+ * even a benign developer-written pattern can go quadratic on a pathological
176
+ * INPUT string — and in a realtime fan-out the matcher runs once per
177
+ * subscriber per record, so one slow match blocks the event loop and
178
+ * amplifies across the whole subscriber set. A field value longer than this
179
+ * is treated as NO MATCH (fail closed) rather than risking a stall; policy
180
+ * filters never regex-test megabyte fields. (Trusted-source patterns make
181
+ * pattern-side ReDoS a non-issue; this caps the input side.)
182
+ */
183
+ const MAX_REGEX_INPUT = 64 * 1024;
184
+ /** Guarded regex test: string-only, input-length-capped (see MAX_REGEX_INPUT). */
185
+ function regexTest(re, v) {
186
+ return typeof v === "string" && v.length <= MAX_REGEX_INPUT && re.test(v);
187
+ }
117
188
  /** SQL `LIKE` pattern → JS regex body. Escapes regex metachars; `%` → `.*`, `_` → `.`. */
118
189
  function likeToRegex(pattern) {
119
190
  let out = "";
@@ -344,7 +344,7 @@ interface ClaimTransition {
344
344
  }
345
345
  /**
346
346
  * Structural contract for the state machine consumed by
347
- * `StandardRepo.transition()` — the CANONICAL shape; mongokit's
347
+ * `StandardRepo.applyTransition()` — the CANONICAL shape; mongokit's
348
348
  * `TransitionMachine` and primitives' `StateMachine` (from
349
349
  * `@classytic/primitives/state-machine`, whose `defineStateMachine()`
350
350
  * satisfies it as-is) are structurally identical by design. Neither
@@ -361,7 +361,7 @@ interface TransitionMachine {
361
361
  assertTransition(entityId: string, from: string, to: string): void;
362
362
  }
363
363
  /**
364
- * Args for `StandardRepo.transition()` — the state-machine-backed CAS
364
+ * Args for `StandardRepo.applyTransition()` — the state-machine-backed CAS
365
365
  * with status history. See the method JSDoc for semantics.
366
366
  */
367
367
  interface TransitionArgs {
@@ -373,6 +373,10 @@ interface TransitionArgs {
373
373
  set?: Record<string, unknown>;
374
374
  /** Kit-specific extra append entries merged beside the history append. */
375
375
  push?: Record<string, unknown>;
376
+ /** Counter increments applied alongside the state write. */
377
+ inc?: Record<string, number>;
378
+ /** Field paths cleared alongside the state write. */
379
+ unset?: Record<string, unknown>;
376
380
  /** Additional CAS guards, AND-merged into the match (same as `ClaimTransition.where`). */
377
381
  where?: Record<string, unknown>;
378
382
  by?: string;
@@ -1452,7 +1456,7 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
1452
1456
  claimVersion(id: string, transition: ClaimVersionTransition, update: Record<string, unknown>, options?: WriteOptions): Promise<TDoc | null>;
1453
1457
  /**
1454
1458
  * State-machine-backed CAS transition with status history — the
1455
- * canonical domain-verb shape (mongokit 3.22 `Repository.transition`).
1459
+ * canonical domain-verb shape (mongokit 3.22 `Repository.applyTransition`).
1456
1460
  * One call: machine legality pre-flight (the machine throws the
1457
1461
  * DOMAIN's typed error), CAS via `claim`, appended history entry
1458
1462
  * (`{ status: to, occurredAt, by?, note? }` onto `args.history` /
@@ -1470,7 +1474,7 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
1470
1474
  * required once sqlitekit implements it (SQL kits compile the
1471
1475
  * history append to their JSON-array/audit-table strategy).
1472
1476
  */
1473
- transition?(id: string, machine: TransitionMachine, args: TransitionArgs, options?: WriteOptions): Promise<TDoc>;
1477
+ applyTransition?(id: string, machine: TransitionMachine, args: TransitionArgs, options?: WriteOptions): Promise<TDoc>;
1474
1478
  /**
1475
1479
  * Classify an error from a write as a unique-constraint violation.
1476
1480
  * Arc's idempotency + outbox adapters need this to distinguish
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/repo-core",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Driver-agnostic repository primitives: hooks, Filter IR, operations, pagination, cache contract. Foundation for mongokit, sqlitekit, pgkit, and prismakit. Lean by design — no plugins ship here; each kit owns its own.",
5
5
  "type": "module",
6
6
  "sideEffects": false,