@classytic/repo-core 0.1.0 → 0.3.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +153 -1
  2. package/README.md +28 -7
  3. package/dist/errors/contract.d.mts +37 -0
  4. package/dist/errors/contract.mjs +75 -0
  5. package/dist/errors/index.d.mts +3 -2
  6. package/dist/errors/index.mjs +3 -1
  7. package/dist/errors/types.d.mts +113 -8
  8. package/dist/errors/types.mjs +29 -0
  9. package/dist/pagination/canonical.d.mts +35 -0
  10. package/dist/pagination/canonical.mjs +32 -0
  11. package/dist/pagination/index.d.mts +3 -2
  12. package/dist/pagination/index.mjs +2 -1
  13. package/dist/pagination/types.d.mts +65 -1
  14. package/dist/repository/base.mjs +21 -0
  15. package/dist/repository/index.d.mts +3 -2
  16. package/dist/repository/types.d.mts +63 -10
  17. package/dist/schema/field-rules.d.mts +19 -8
  18. package/dist/schema/field-rules.mjs +29 -9
  19. package/dist/schema/generator.d.mts +72 -0
  20. package/dist/schema/generator.mjs +16 -0
  21. package/dist/schema/index.d.mts +2 -1
  22. package/dist/schema/index.mjs +2 -1
  23. package/dist/schema/types.d.mts +56 -3
  24. package/dist/tenant/index.d.mts +3 -0
  25. package/dist/tenant/index.mjs +2 -0
  26. package/dist/tenant/resolve.d.mts +27 -0
  27. package/dist/tenant/resolve.mjs +69 -0
  28. package/dist/tenant/types.d.mts +142 -0
  29. package/dist/update/builders.d.mts +52 -0
  30. package/dist/update/builders.mjs +92 -0
  31. package/dist/update/compile.d.mts +46 -0
  32. package/dist/update/compile.mjs +33 -0
  33. package/dist/update/guard.d.mts +20 -0
  34. package/dist/update/guard.mjs +24 -0
  35. package/dist/update/index.d.mts +5 -0
  36. package/dist/update/index.mjs +4 -0
  37. package/dist/update/types.d.mts +62 -0
  38. package/package.json +13 -1
@@ -0,0 +1,142 @@
1
+ //#region src/tenant/types.d.ts
2
+ /**
3
+ * Tenant scope configuration — canonical static contract for the org.
4
+ *
5
+ * **`@classytic/repo-core/tenant` is the single source of truth.** Every
6
+ * multi-tenant-capable package (`@classytic/mongokit`, `@classytic/sqlitekit`,
7
+ * future kits, arc presets, services) consumes {@link TenantConfig} for its
8
+ * static fields and extends with kit-specific runtime callbacks via
9
+ * `Pick<TenantConfig, ...>` to lock the field vocabulary by structural typing.
10
+ *
11
+ * Three strategies are supported:
12
+ * - `'field'` (default) — filter every query by a scalar field on documents.
13
+ * The common case; used by `multiTenantPlugin` in mongokit and sqlitekit.
14
+ * - `'none'` — disable scoping entirely (single-tenant app). Equivalent to
15
+ * `enabled: false`; `strategy: 'none'` is the explicit form.
16
+ * - `'custom'` — caller supplies a `resolve(ctx)` function that returns the
17
+ * filter shape to inject. **The escape hatch for custom systems** —
18
+ * covers multi-field composite tenants, context-derived filters
19
+ * (region + partner id), non-scalar scope keys, or any tenancy model that
20
+ * doesn't fit the simple `field === id` pattern.
21
+ *
22
+ * **Why this layer is static-only.** Runtime callbacks (`skipWhen(ctx, op)`,
23
+ * `resolveContext()`, `resolveTenantId(ctx)`) genuinely differ across kits
24
+ * because their `RepositoryContext` shapes differ — mongokit's resolver
25
+ * returns just an id, sqlitekit's takes a richer context object. Each kit
26
+ * extends `TenantConfig` with its own runtime-callback fields. Hosts who
27
+ * need a single config object can compose: pass the static `TenantConfig`
28
+ * through {@link resolveTenantConfig} once, then forward the resolved
29
+ * static fields into each kit's runtime options alongside the kit-specific
30
+ * callbacks.
31
+ */
32
+ /**
33
+ * Storage / cast strategy for the tenant identifier on documents.
34
+ *
35
+ * - `'objectId'` (recommended for new packages) — `Schema.Types.ObjectId`
36
+ * with `ref`. Enables `$lookup`, `.populate()`, QueryParser `?lookup=...`
37
+ * on Mongo-shaped kits. SQL kits typically ignore this and rely on
38
+ * schema-defined column types instead.
39
+ * - `'string'` — plain string. Use when the host auth system issues UUIDs
40
+ * or slugs rather than ObjectIds.
41
+ */
42
+ type TenantFieldType = 'objectId' | 'string';
43
+ /** Scope resolution strategy. */
44
+ type TenantStrategy = 'field' | 'none' | 'custom';
45
+ interface TenantConfig {
46
+ /**
47
+ * Scope strategy. Omit for the common `'field'` case — explicit `'none'`
48
+ * / `'custom'` lets packages collapse what used to live in a separate
49
+ * `ScopeConfig` type.
50
+ *
51
+ * @default 'field'
52
+ */
53
+ strategy?: TenantStrategy;
54
+ /**
55
+ * Whether tenant scoping is active. When `false`, the package runs in
56
+ * single-tenant mode — no filter injection, no tenant field on documents.
57
+ * Equivalent to `strategy: 'none'`.
58
+ *
59
+ * @default true
60
+ */
61
+ enabled?: boolean;
62
+ /**
63
+ * Document / column field name that stores the tenant id. Used when
64
+ * `strategy === 'field'`.
65
+ *
66
+ * @default 'organizationId'
67
+ */
68
+ tenantField?: string;
69
+ /**
70
+ * How to store / cast the tenant id.
71
+ *
72
+ * @default 'objectId'
73
+ */
74
+ fieldType?: TenantFieldType;
75
+ /**
76
+ * Mongoose ref for `'objectId'` types. Ignored by SQL kits and when
77
+ * `fieldType === 'string'`.
78
+ *
79
+ * @default 'organization'
80
+ */
81
+ ref?: string;
82
+ /**
83
+ * Which key on the repository context to read the tenant id from.
84
+ *
85
+ * Defaults cascade: if omitted, falls back to the caller's `tenantField`
86
+ * (if supplied), else to `'organizationId'`. Rationale: when a host renames
87
+ * `tenantField` to e.g. `'branchId'`, their context almost always carries
88
+ * the value under the same key — mirroring `tenantField` is the
89
+ * least-surprise behavior. Override explicitly if the context key diverges
90
+ * from the document field (e.g. `tenantField: 'branchId'`,
91
+ * `contextKey: 'organizationId'`).
92
+ *
93
+ * @default tenantField ?? 'organizationId'
94
+ */
95
+ contextKey?: string;
96
+ /**
97
+ * Whether the field is required. When `false`, the package permits
98
+ * unscoped / cross-tenant reads (typically only for admin paths).
99
+ *
100
+ * @default true
101
+ */
102
+ required?: boolean;
103
+ /**
104
+ * Custom resolver — called when `strategy === 'custom'` to produce the
105
+ * filter object injected into queries. Packages pass the request /
106
+ * repository context; the resolver returns the filter shape.
107
+ *
108
+ * Use for tenancy models that don't fit the simple `field === id`
109
+ * pattern: multi-field composites, context-derived filters
110
+ * (region + partner id), hash-derived shards, etc.
111
+ *
112
+ * @example
113
+ * ```ts
114
+ * {
115
+ * strategy: 'custom',
116
+ * resolve: (ctx) => ({
117
+ * organizationId: ctx.organizationId,
118
+ * region: ctx.region,
119
+ * partnerId: ctx.partnerId,
120
+ * }),
121
+ * }
122
+ * ```
123
+ */
124
+ resolve?: (ctx: Record<string, unknown>) => Record<string, unknown>;
125
+ }
126
+ /**
127
+ * Resolved shape returned by `resolveTenantConfig`. Always includes the
128
+ * field defaults (so packages can inspect field names even when
129
+ * `enabled: false`) and threads `resolve` when `strategy === 'custom'`.
130
+ */
131
+ type ResolvedTenantConfig = {
132
+ strategy: TenantStrategy;
133
+ enabled: boolean;
134
+ tenantField: string;
135
+ fieldType: TenantFieldType;
136
+ ref: string;
137
+ contextKey: string;
138
+ required: boolean;
139
+ resolve?: TenantConfig['resolve'];
140
+ };
141
+ //#endregion
142
+ export { ResolvedTenantConfig, TenantConfig, TenantFieldType, TenantStrategy };
@@ -0,0 +1,52 @@
1
+ import { UpdateSpec } from "./types.mjs";
2
+
3
+ //#region src/update/builders.d.ts
4
+ /**
5
+ * Compose an `UpdateSpec` from the four primitive mutations.
6
+ *
7
+ * ```ts
8
+ * update({
9
+ * set: { status: 'pending', visibleAt: new Date() },
10
+ * unset: ['leaseOwner'],
11
+ * setOnInsert: { createdAt: new Date() },
12
+ * inc: { attempts: 1 },
13
+ * });
14
+ * ```
15
+ *
16
+ * Keys with `undefined` values in `set` / `setOnInsert` are dropped — a
17
+ * common gotcha when spreading optional fields. To actually clear a field,
18
+ * use `unset` instead (matches mongokit's `$set: { x: undefined }` / `$unset`
19
+ * distinction).
20
+ *
21
+ * Throws if every mutation bucket is empty — an update with nothing to
22
+ * do is always a caller bug.
23
+ */
24
+ declare function update(spec: {
25
+ set?: Record<string, unknown>;
26
+ unset?: readonly string[];
27
+ setOnInsert?: Record<string, unknown>;
28
+ inc?: Record<string, number>;
29
+ }): UpdateSpec;
30
+ /** Sugar: `update({ set: fields })`. Most updates are simple assignments. */
31
+ declare function setFields(fields: Record<string, unknown>): UpdateSpec;
32
+ /** Sugar: `update({ unset: fields })`. */
33
+ declare function unsetFields(...fields: string[]): UpdateSpec;
34
+ /** Sugar: `update({ inc: deltas })`. Each key's value is the delta (positive or negative). */
35
+ declare function incFields(deltas: Record<string, number>): UpdateSpec;
36
+ /** Sugar: `update({ setOnInsert: fields })`. Pairs with an upsert. */
37
+ declare function setOnInsertFields(fields: Record<string, unknown>): UpdateSpec;
38
+ /**
39
+ * Merge multiple `UpdateSpec` values into one.
40
+ *
41
+ * - `set` / `setOnInsert` / `inc`: shallow-merged, later entries win per
42
+ * key. For `inc`, later-wins is usually a bug — callers who want to
43
+ * stack deltas should pass `inc({ x: a + b })` directly.
44
+ * - `unset`: concatenated + de-duplicated.
45
+ *
46
+ * Empty input returns an identity-style spec with `set: {}`, which
47
+ * `update()` would reject — so we throw here too. An empty combine is
48
+ * always a caller bug.
49
+ */
50
+ declare function combineUpdates(...specs: readonly UpdateSpec[]): UpdateSpec;
51
+ //#endregion
52
+ export { combineUpdates, incFields, setFields, setOnInsertFields, unsetFields, update };
@@ -0,0 +1,92 @@
1
+ //#region src/update/builders.ts
2
+ /**
3
+ * Compose an `UpdateSpec` from the four primitive mutations.
4
+ *
5
+ * ```ts
6
+ * update({
7
+ * set: { status: 'pending', visibleAt: new Date() },
8
+ * unset: ['leaseOwner'],
9
+ * setOnInsert: { createdAt: new Date() },
10
+ * inc: { attempts: 1 },
11
+ * });
12
+ * ```
13
+ *
14
+ * Keys with `undefined` values in `set` / `setOnInsert` are dropped — a
15
+ * common gotcha when spreading optional fields. To actually clear a field,
16
+ * use `unset` instead (matches mongokit's `$set: { x: undefined }` / `$unset`
17
+ * distinction).
18
+ *
19
+ * Throws if every mutation bucket is empty — an update with nothing to
20
+ * do is always a caller bug.
21
+ */
22
+ function update(spec) {
23
+ const set = stripUndefined(spec.set);
24
+ const setOnInsert = stripUndefined(spec.setOnInsert);
25
+ const inc = spec.inc ? Object.freeze({ ...spec.inc }) : void 0;
26
+ const unset = spec.unset && spec.unset.length > 0 ? Object.freeze([...spec.unset]) : void 0;
27
+ if (!(set && Object.keys(set).length > 0 || setOnInsert && Object.keys(setOnInsert).length > 0 || inc && Object.keys(inc).length > 0 || unset && unset.length > 0)) throw new Error("update(): spec is empty. At least one of `set`, `unset`, `setOnInsert`, or `inc` must be populated.");
28
+ const node = {
29
+ op: "update",
30
+ ...set && { set },
31
+ ...unset && { unset },
32
+ ...setOnInsert && { setOnInsert },
33
+ ...inc && { inc }
34
+ };
35
+ return Object.freeze(node);
36
+ }
37
+ /** Sugar: `update({ set: fields })`. Most updates are simple assignments. */
38
+ function setFields(fields) {
39
+ return update({ set: fields });
40
+ }
41
+ /** Sugar: `update({ unset: fields })`. */
42
+ function unsetFields(...fields) {
43
+ return update({ unset: fields });
44
+ }
45
+ /** Sugar: `update({ inc: deltas })`. Each key's value is the delta (positive or negative). */
46
+ function incFields(deltas) {
47
+ return update({ inc: deltas });
48
+ }
49
+ /** Sugar: `update({ setOnInsert: fields })`. Pairs with an upsert. */
50
+ function setOnInsertFields(fields) {
51
+ return update({ setOnInsert: fields });
52
+ }
53
+ /**
54
+ * Merge multiple `UpdateSpec` values into one.
55
+ *
56
+ * - `set` / `setOnInsert` / `inc`: shallow-merged, later entries win per
57
+ * key. For `inc`, later-wins is usually a bug — callers who want to
58
+ * stack deltas should pass `inc({ x: a + b })` directly.
59
+ * - `unset`: concatenated + de-duplicated.
60
+ *
61
+ * Empty input returns an identity-style spec with `set: {}`, which
62
+ * `update()` would reject — so we throw here too. An empty combine is
63
+ * always a caller bug.
64
+ */
65
+ function combineUpdates(...specs) {
66
+ if (specs.length === 0) throw new Error("combineUpdates(): at least one spec required.");
67
+ if (specs.length === 1) return specs[0];
68
+ const set = {};
69
+ const setOnInsert = {};
70
+ const inc = {};
71
+ const unsetSet = /* @__PURE__ */ new Set();
72
+ for (const s of specs) {
73
+ if (s.set) Object.assign(set, s.set);
74
+ if (s.setOnInsert) Object.assign(setOnInsert, s.setOnInsert);
75
+ if (s.inc) Object.assign(inc, s.inc);
76
+ if (s.unset) for (const f of s.unset) unsetSet.add(f);
77
+ }
78
+ return update({
79
+ ...Object.keys(set).length > 0 && { set },
80
+ ...Object.keys(setOnInsert).length > 0 && { setOnInsert },
81
+ ...Object.keys(inc).length > 0 && { inc },
82
+ ...unsetSet.size > 0 && { unset: Array.from(unsetSet) }
83
+ });
84
+ }
85
+ function stripUndefined(record) {
86
+ if (!record) return void 0;
87
+ const out = {};
88
+ for (const [k, v] of Object.entries(record)) if (v !== void 0) out[k] = v;
89
+ return Object.keys(out).length > 0 ? Object.freeze(out) : void 0;
90
+ }
91
+ //#endregion
92
+ export { combineUpdates, incFields, setFields, setOnInsertFields, unsetFields, update };
@@ -0,0 +1,46 @@
1
+ import { UpdateSpec } from "./types.mjs";
2
+
3
+ //#region src/update/compile.d.ts
4
+ /**
5
+ * Compile an `UpdateSpec` to a Mongo operator record.
6
+ *
7
+ * Empty buckets are omitted — passing `{ $set: {} }` to Mongo is a no-op
8
+ * per-op but still round-trips through the driver as a valid update, so
9
+ * leaving them out keeps the wire format tidy.
10
+ *
11
+ * `$unset` values follow the Mongo convention (empty string) — the value
12
+ * is ignored by the server, only the key matters.
13
+ */
14
+ declare function compileUpdateSpecToMongo(spec: UpdateSpec): Record<string, unknown>;
15
+ /**
16
+ * Compile an `UpdateSpec` to a SQL-friendly breakdown.
17
+ *
18
+ * SQL kits can't express a single "update record" the way Mongo can — they
19
+ * need:
20
+ *
21
+ * - `data` for plain `SET col = ?` assignments (both `set` and
22
+ * `setOnInsert` feed here; the kit decides how to route `setOnInsert`
23
+ * when the UPDATE path hits a matched row vs inserted row).
24
+ * - `unset` for `SET col = NULL` clauses.
25
+ * - `inc` for `SET col = coalesce(col, 0) + ?` clauses.
26
+ * - `insertDefaults` for the INSERT branch of an upsert — fields that
27
+ * should ONLY apply when no row matched.
28
+ *
29
+ * Callers build the final SQL from these pieces. This helper intentionally
30
+ * doesn't emit SQL strings — driver quoting, parameter binding, and
31
+ * `ON CONFLICT` grammar differ too much between SQLite, Postgres, and
32
+ * Prisma for a shared compiler to own it.
33
+ */
34
+ interface SqlUpdatePlan {
35
+ /** Plain column assignments. Merge of `set` — applied on UPDATE and INSERT. */
36
+ readonly data: Readonly<Record<string, unknown>>;
37
+ /** Columns to set NULL. */
38
+ readonly unset: readonly string[];
39
+ /** Atomic numeric deltas — kit emits `col = coalesce(col, 0) + ?`. */
40
+ readonly inc: Readonly<Record<string, number>>;
41
+ /** Fields to set only when the upsert takes the INSERT branch. */
42
+ readonly insertDefaults: Readonly<Record<string, unknown>>;
43
+ }
44
+ declare function compileUpdateSpecToSql(spec: UpdateSpec): SqlUpdatePlan;
45
+ //#endregion
46
+ export { SqlUpdatePlan, compileUpdateSpecToMongo, compileUpdateSpecToSql };
@@ -0,0 +1,33 @@
1
+ //#region src/update/compile.ts
2
+ /**
3
+ * Compile an `UpdateSpec` to a Mongo operator record.
4
+ *
5
+ * Empty buckets are omitted — passing `{ $set: {} }` to Mongo is a no-op
6
+ * per-op but still round-trips through the driver as a valid update, so
7
+ * leaving them out keeps the wire format tidy.
8
+ *
9
+ * `$unset` values follow the Mongo convention (empty string) — the value
10
+ * is ignored by the server, only the key matters.
11
+ */
12
+ function compileUpdateSpecToMongo(spec) {
13
+ const out = {};
14
+ if (spec.set && Object.keys(spec.set).length > 0) out["$set"] = { ...spec.set };
15
+ if (spec.unset && spec.unset.length > 0) {
16
+ const unsetRecord = {};
17
+ for (const field of spec.unset) unsetRecord[field] = "";
18
+ out["$unset"] = unsetRecord;
19
+ }
20
+ if (spec.setOnInsert && Object.keys(spec.setOnInsert).length > 0) out["$setOnInsert"] = { ...spec.setOnInsert };
21
+ if (spec.inc && Object.keys(spec.inc).length > 0) out["$inc"] = { ...spec.inc };
22
+ return out;
23
+ }
24
+ function compileUpdateSpecToSql(spec) {
25
+ return Object.freeze({
26
+ data: Object.freeze({ ...spec.set ?? {} }),
27
+ unset: Object.freeze([...spec.unset ?? []]),
28
+ inc: Object.freeze({ ...spec.inc ?? {} }),
29
+ insertDefaults: Object.freeze({ ...spec.setOnInsert ?? {} })
30
+ });
31
+ }
32
+ //#endregion
33
+ export { compileUpdateSpecToMongo, compileUpdateSpecToSql };
@@ -0,0 +1,20 @@
1
+ import { UpdateSpec } from "./types.mjs";
2
+
3
+ //#region src/update/guard.d.ts
4
+ /**
5
+ * True when `value` is an `UpdateSpec` — i.e. the portable, compile-to-native
6
+ * form.
7
+ *
8
+ * Fast structural gate: checks the discriminant tag. Deeper validation (no
9
+ * `$`-prefixed keys inside `set`, `inc` values are numbers, ...) is left
10
+ * to the compiler; that's where kit-specific constraints live.
11
+ */
12
+ declare function isUpdateSpec(value: unknown): value is UpdateSpec;
13
+ /**
14
+ * True when `value` is a Mongo aggregation pipeline (`findOneAndUpdate`'s
15
+ * array form). Kits use this to short-circuit SQL paths that can't execute
16
+ * pipelines.
17
+ */
18
+ declare function isUpdatePipeline(value: unknown): value is Record<string, unknown>[];
19
+ //#endregion
20
+ export { isUpdatePipeline, isUpdateSpec };
@@ -0,0 +1,24 @@
1
+ //#region src/update/guard.ts
2
+ /**
3
+ * True when `value` is an `UpdateSpec` — i.e. the portable, compile-to-native
4
+ * form.
5
+ *
6
+ * Fast structural gate: checks the discriminant tag. Deeper validation (no
7
+ * `$`-prefixed keys inside `set`, `inc` values are numbers, ...) is left
8
+ * to the compiler; that's where kit-specific constraints live.
9
+ */
10
+ function isUpdateSpec(value) {
11
+ if (!value || typeof value !== "object") return false;
12
+ if (Array.isArray(value)) return false;
13
+ return value.op === "update";
14
+ }
15
+ /**
16
+ * True when `value` is a Mongo aggregation pipeline (`findOneAndUpdate`'s
17
+ * array form). Kits use this to short-circuit SQL paths that can't execute
18
+ * pipelines.
19
+ */
20
+ function isUpdatePipeline(value) {
21
+ return Array.isArray(value);
22
+ }
23
+ //#endregion
24
+ export { isUpdatePipeline, isUpdateSpec };
@@ -0,0 +1,5 @@
1
+ import { UpdateInput, UpdateSpec } from "./types.mjs";
2
+ import { combineUpdates, incFields, setFields, setOnInsertFields, unsetFields, update } from "./builders.mjs";
3
+ import { SqlUpdatePlan, compileUpdateSpecToMongo, compileUpdateSpecToSql } from "./compile.mjs";
4
+ import { isUpdatePipeline, isUpdateSpec } from "./guard.mjs";
5
+ export { type SqlUpdatePlan, type UpdateInput, type UpdateSpec, combineUpdates, compileUpdateSpecToMongo, compileUpdateSpecToSql, incFields, isUpdatePipeline, isUpdateSpec, setFields, setOnInsertFields, unsetFields, update };
@@ -0,0 +1,4 @@
1
+ import { combineUpdates, incFields, setFields, setOnInsertFields, unsetFields, update } from "./builders.mjs";
2
+ import { compileUpdateSpecToMongo, compileUpdateSpecToSql } from "./compile.mjs";
3
+ import { isUpdatePipeline, isUpdateSpec } from "./guard.mjs";
4
+ export { combineUpdates, compileUpdateSpecToMongo, compileUpdateSpecToSql, incFields, isUpdatePipeline, isUpdateSpec, setFields, setOnInsertFields, unsetFields, update };
@@ -0,0 +1,62 @@
1
+ //#region src/update/types.d.ts
2
+ /**
3
+ * Update IR — driver-agnostic mutation spec.
4
+ *
5
+ * An `UpdateSpec` is a structured description of an atomic update that every
6
+ * kit compiles to its native shape — mongokit emits `$set`/`$unset`/`$inc`/
7
+ * `$setOnInsert` records, SQL kits emit column assignments + `NULL` columns
8
+ * + `column = coalesce(column, 0) + delta`, Prisma emits the equivalent
9
+ * `update` arg.
10
+ *
11
+ * The IR covers the subset every backend supports:
12
+ *
13
+ * - **set** — assign field values (mongokit `$set`, SQL column = ?)
14
+ * - **unset** — clear fields (mongokit `$unset`, SQL column = NULL)
15
+ * - **setOnInsert** — only on upsert insert (mongokit `$setOnInsert`, SQL INSERT default)
16
+ * - **inc** — atomic numeric delta (mongokit `$inc`, SQL col = col + ?)
17
+ *
18
+ * Kit-native update features (Mongo `$push`/`$pull`/`$addToSet`, aggregation
19
+ * pipeline updates, Postgres `jsonb_set`, SQL CASE expressions) stay
20
+ * kit-native. Pass a raw Mongo operator record or pipeline array when you
21
+ * need them — the `UpdateInput` union accepts both.
22
+ *
23
+ * **Compat invariant:** mongokit's existing Mongo-operator records (`$set`,
24
+ * `$unset`, ...) are NOT `UpdateSpec` values. Kits route by the `op:
25
+ * 'update'` tag via `isUpdateSpec`, treating raw records as pre-compiled
26
+ * and passing them to the driver unchanged.
27
+ */
28
+ /**
29
+ * Portable update spec — the tagged-union root every kit compiles.
30
+ *
31
+ * At least one of `set` / `unset` / `setOnInsert` / `inc` must be populated.
32
+ * An empty spec is a wiring bug (nothing to update); kits MAY treat it as
33
+ * a no-op or throw.
34
+ */
35
+ interface UpdateSpec {
36
+ readonly op: 'update';
37
+ /** Fields to assign. Overrides existing values. */
38
+ readonly set?: Readonly<Record<string, unknown>>;
39
+ /** Fields to clear. Mongo `$unset`, SQL `NULL`. */
40
+ readonly unset?: readonly string[];
41
+ /** Fields to set only when upsert creates a new row. Ignored otherwise. */
42
+ readonly setOnInsert?: Readonly<Record<string, unknown>>;
43
+ /** Atomic numeric deltas. Kits compile to `$inc` / `col = col + ?`. */
44
+ readonly inc?: Readonly<Record<string, number>>;
45
+ }
46
+ /**
47
+ * Accepted update argument across every write method. A kit's
48
+ * `findOneAndUpdate` / `updateMany` implementation accepts:
49
+ *
50
+ * 1. `UpdateSpec` — portable, kit-agnostic. Compiles to the native shape.
51
+ * 2. `Record<string, unknown>` — kit-native raw record (mongokit
52
+ * `$`-operators, Prisma `update` input). Passed through unchanged.
53
+ * 3. `Record<string, unknown>[]` — Mongo aggregation pipeline. Mongo-only
54
+ * kits execute it; SQL kits throw `UnsupportedOperationError`.
55
+ *
56
+ * Arc's stores (outbox, idempotency, audit) should prefer form (1). Forms
57
+ * (2) and (3) remain for kit-specific fast paths and the aggregation-update
58
+ * escape hatch (e.g. outbox's `$ifNull` to preserve `firstFailedAt`).
59
+ */
60
+ type UpdateInput = UpdateSpec | Record<string, unknown> | Record<string, unknown>[];
61
+ //#endregion
62
+ export { UpdateInput, UpdateSpec };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/repo-core",
3
- "version": "0.1.0",
3
+ "version": "0.3.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,
@@ -38,6 +38,10 @@
38
38
  "types": "./dist/filter/index.d.mts",
39
39
  "default": "./dist/filter/index.mjs"
40
40
  },
41
+ "./update": {
42
+ "types": "./dist/update/index.d.mts",
43
+ "default": "./dist/update/index.mjs"
44
+ },
41
45
  "./query-parser": {
42
46
  "types": "./dist/query-parser/index.d.mts",
43
47
  "default": "./dist/query-parser/index.mjs"
@@ -58,6 +62,10 @@
58
62
  "types": "./dist/testing/index.d.mts",
59
63
  "default": "./dist/testing/index.mjs"
60
64
  },
65
+ "./tenant": {
66
+ "types": "./dist/tenant/index.d.mts",
67
+ "default": "./dist/tenant/index.mjs"
68
+ },
61
69
  "./lookup": {
62
70
  "types": "./dist/lookup/index.d.mts",
63
71
  "default": "./dist/lookup/index.mjs"
@@ -104,13 +112,17 @@
104
112
  "format": "biome format src tests --write",
105
113
  "check": "biome ci src tests --diagnostic-level=error",
106
114
  "knip": "knip",
115
+ "push": "classytic-push",
107
116
  "prepublishOnly": "npm run check && npm run build && npm run typecheck && npm test",
117
+ "release:tag": "node -e \"require('child_process').execSync('npm run push -- v'+require('./package.json').version,{stdio:'inherit'})\"",
118
+ "release": "npm run push -- main && npm run release:tag && npm publish",
108
119
  "publish:dry": "npm publish --dry-run --access public",
109
120
  "publish:npm": "npm publish --access public"
110
121
  },
111
122
  "devDependencies": {
112
123
  "@arethetypeswrong/cli": "^0.18.2",
113
124
  "@biomejs/biome": "^2.4.12",
125
+ "@classytic/dev-tools": "^0.2.0",
114
126
  "@types/node": "^22.0.0",
115
127
  "@vitest/coverage-v8": "^4.1.4",
116
128
  "knip": "^6.3.0",