@classytic/repo-core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,39 @@ All notable changes to `@classytic/repo-core` are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [Unreleased]
7
+ ## [0.2.0] - 2026-04-22
8
+
9
+ ### Added — Update IR (portable write-side primitive)
10
+
11
+ - **New `@classytic/repo-core/update` subpath.** The write-side counterpart to `@classytic/repo-core/filter`. Plugins and arc's infrastructure stores compose an `UpdateSpec` once; each kit compiles it to its native shape.
12
+ - **Types:** `UpdateSpec` (tagged union on `op: 'update'`, four buckets: `set` / `unset` / `setOnInsert` / `inc`), `UpdateInput` (union of `UpdateSpec` | kit-native `Record<string, unknown>` | Mongo pipeline `Record<string, unknown>[]`).
13
+ - **Builders:** `update({ set, unset, setOnInsert, inc })` (root), `setFields`, `unsetFields(...f)`, `setOnInsertFields`, `incFields`, `combineUpdates(...specs)` (later-wins merge, `unset` de-duplicates).
14
+ - **Guards:** `isUpdateSpec` (routes portable IR to the compiler), `isUpdatePipeline` (lets SQL kits short-circuit with `UnsupportedOperationError`).
15
+ - **Compilers:** `compileUpdateSpecToMongo(spec)` emits `{ $set, $unset, $setOnInsert, $inc }`. `compileUpdateSpecToSql(spec)` emits a `SqlUpdatePlan` with `data` / `unset` / `inc` / `insertDefaults` buckets, leaving SQL generation (quoting, `ON CONFLICT`, parameter binding) to the kit.
16
+ - **`StandardRepo.findOneAndUpdate` + `updateMany` widened to `UpdateInput`.** Accepts all three forms — portable `UpdateSpec`, kit-native record, Mongo aggregation pipeline. Kits dispatch with `isUpdateSpec`. The existing raw-record and pipeline paths remain unchanged; the IR is purely additive so consumers don't need to migrate. Arc's infrastructure stores (outbox, idempotency, audit) will switch to the IR over a subsequent release to close the "Mongo-shaped store" gap flagged in the April 2026 cross-surface review.
17
+
18
+ **Motivation (Arc April 2026 review):** arc's `EventOutbox`, `IdempotencyStore`, and `AuditStore` adapters use Mongo operator records (`$set`, `$inc`, `$unset`, `$setOnInsert`, `$or`, `$lte`, ...) directly against `RepositoryLike.findOneAndUpdate`. That works on mongokit but fails on sqlitekit — whose `findOneAndUpdate` treats `data` as flat column overwrites and would literally set a column named `$set`. The Update IR closes the gap without forcing every kit to ship its own Mongo-operator compatibility layer.
19
+
20
+ **Rationale for scope:** the IR covers the subset every backend supports (atomic set / unset / inc / insert-default). Kit-native features — Mongo `$push`/`$pull`/`$addToSet`, aggregation pipeline updates, Postgres `jsonb_set`, SQL `CASE` expressions — stay on the kit-native path via `UpdateInput`'s raw-record and pipeline forms. No lowest-common-denominator bloat; no feature loss for kits that already offer more.
21
+
22
+ **Test delta**: 193 → 230 tests (37 new across `tests/unit/update/builders`, `/guard`, `/compile`).
23
+
24
+ ### Changed — breaking: `StandardRepo` write signatures
25
+
26
+ `StandardRepo.findOneAndUpdate(filter, update, ...)` and `updateMany(filter, data, ...)` — the second parameter is now typed `UpdateInput` (was `Record<string, unknown> | Record<string, unknown>[]` and `Record<string, unknown>` respectively). Every call site that compiled against 0.1.0 keeps compiling: `Record<string, unknown>` and `Record<string, unknown>[]` are subtypes of `UpdateInput`. The break is on the **implementer** side — any kit that declared only the old parameter type no longer structurally satisfies `StandardRepo` under strict contravariance. mongokit 3.11.0 and sqlitekit 0.1.1 already ship with the widened signatures; third-party kits need to widen before bumping their `@classytic/repo-core` peer dep.
27
+
28
+ ### Changed — breaking: `updateMany` + `deleteMany` promoted to required members of `StandardRepo`
29
+
30
+ Both methods were optional (`updateMany?` / `deleteMany?`) in 0.1.0; they're required in 0.2.0. Rationale: every real backend has a native bulk-update and bulk-delete primitive, and arc's infrastructure stores (outbox, idempotency, audit cleanup) assume both are callable without feature-detection. Leaving them optional invited the "forgot to wire `batchOperationsPlugin`" runtime `TypeError` footgun that earlier releases of mongokit shipped — with the promotion, the type system catches missing implementations at the kit boundary.
31
+
32
+ **Impact**:
33
+ - **Kits**: any kit declaring `class FooRepo<T> implements StandardRepo<T>` must provide `updateMany` and `deleteMany` or fail to compile. mongokit 3.11.0 and sqlitekit 0.1.1 both ship these as class primitives, so their conformance stays green.
34
+ - **Consumers of `RepositoryLike<T> = MinimalRepo<T> & Partial<StandardRepo<T>>`** (arc's pattern) are unaffected — `Partial` reimposes optionality for feature detection at the arc adapter boundary. `if (repo.updateMany)` guards keep working.
35
+ - `bulkWrite` stays optional — the mongoose-shaped `BulkWriteOperation` has no clean SQL analogue, and every kit would ship an uninteresting fan-out wrapper otherwise.
36
+
37
+ ### Naming — `UpdateInput` collision with mongokit
38
+
39
+ `@classytic/repo-core/update` exports `UpdateInput` as the union `UpdateSpec | Record<string, unknown> | Record<string, unknown>[]`. `@classytic/mongokit` currently **also** exports a type named `UpdateInput<TDoc> = Partial<Omit<TDoc, '_id' | 'createdAt' | '__v'>>` — a completely different, document-typed shape used by `repo.update(id, data)`. If you write `import { UpdateInput } from '@classytic/mongokit'`, you get mongokit's generic; if you write `import type { UpdateInput } from '@classytic/repo-core/update'`, you get the union. Both names may appear in the same consumer file — import at least one with an alias (`import type { UpdateInput as UpdatePatch } from '@classytic/mongokit'`). mongokit will rename its local type in a follow-up release to close the collision permanently.
8
40
 
9
41
  ### Added
10
42
  - Phase 0 scaffold: package.json, tsconfig, tsdown, biome, vitest (4-tier), knip
@@ -1,5 +1,6 @@
1
1
  import { LookupPopulateOptions, LookupPopulateResult, LookupRow, LookupSpec } from "../lookup/types.mjs";
2
+ import { UpdateInput } from "../update/types.mjs";
2
3
  import { PLUGIN_ORDER_CONSTRAINTS, Plugin, PluginFunction, PluginType, validatePluginOrder } from "./plugin-types.mjs";
3
4
  import { RepositoryBase, RepositoryBaseOptions } from "./base.mjs";
4
- import { AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, BulkWriteOperation, BulkWriteResult, DeleteManyResult, DeleteOptions, DeleteResult, FindOneAndUpdateOptions, InferDoc, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, UpdateManyResult, WriteOptions } from "./types.mjs";
5
- export { type AggMeasure, type AggPaginationRequest, type AggRequest, type AggResult, type AggRow, type BulkWriteOperation, type BulkWriteResult, type DeleteManyResult, type DeleteOptions, type DeleteResult, type FindOneAndUpdateOptions, type InferDoc, type LookupPopulateOptions, type LookupPopulateResult, type LookupRow, type LookupSpec, type MinimalRepo, PLUGIN_ORDER_CONSTRAINTS, type PaginationParams, type Plugin, type PluginFunction, type PluginType, type QueryOptions, RepositoryBase, type RepositoryBaseOptions, type RepositorySession, type StandardRepo, type UpdateManyResult, type WriteOptions, validatePluginOrder };
5
+ import { AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, BulkWriteOperation, BulkWriteResult, DeleteManyResult, DeleteOptions, DeleteResult, FilterInput, FindOneAndUpdateOptions, InferDoc, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, UpdateManyResult, WriteOptions } from "./types.mjs";
6
+ export { type AggMeasure, type AggPaginationRequest, type AggRequest, type AggResult, type AggRow, type BulkWriteOperation, type BulkWriteResult, type DeleteManyResult, type DeleteOptions, type DeleteResult, type FilterInput, type FindOneAndUpdateOptions, type InferDoc, type LookupPopulateOptions, type LookupPopulateResult, type LookupRow, type LookupSpec, type MinimalRepo, PLUGIN_ORDER_CONSTRAINTS, type PaginationParams, type Plugin, type PluginFunction, type PluginType, type QueryOptions, RepositoryBase, type RepositoryBaseOptions, type RepositorySession, type StandardRepo, type UpdateInput, type UpdateManyResult, type WriteOptions, validatePluginOrder };
@@ -1,6 +1,7 @@
1
1
  import { Filter } from "../filter/types.mjs";
2
2
  import { OffsetPaginationResult } from "../pagination/types.mjs";
3
3
  import { LookupPopulateOptions, LookupPopulateResult } from "../lookup/types.mjs";
4
+ import { UpdateInput } from "../update/types.mjs";
4
5
 
5
6
  //#region src/repository/types.d.ts
6
7
  /**
@@ -286,7 +287,17 @@ interface AggResult<TRow extends AggRow = AggRow> {
286
287
  * - **Raw** — neither; kit returns all matching docs (may be large).
287
288
  */
288
289
  interface PaginationParams<TDoc = unknown> {
289
- filters?: Partial<TDoc> & Record<string, unknown>;
290
+ /**
291
+ * Predicate narrowing the rows that feed into the list query. Accepts
292
+ * the portable Filter IR (`and(eq(...), gt(...))`) OR a flat kit-native
293
+ * record (`{ status: 'active', age: { $gt: 18 } }`). Every kit's
294
+ * `getAll` compiler handles both forms.
295
+ *
296
+ * The `Partial<TDoc>` intersection preserves the old "typed flat record"
297
+ * DX for callers that pass a POJO — they still get autocomplete on
298
+ * known document fields while the union branch allows the Filter IR.
299
+ */
300
+ filters?: (Partial<TDoc> & Record<string, unknown>) | Filter;
290
301
  sort?: string | Record<string, 1 | -1>;
291
302
  page?: number;
292
303
  limit?: number;
@@ -373,8 +384,26 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
373
384
  * Required for arc's outbox, distributed-lock, and workflow-semaphore
374
385
  * patterns. Kits without atomic CAS should simulate it inside a
375
386
  * transaction — arc's stores assume single-round-trip semantics.
387
+ *
388
+ * **Update argument forms** (see {@link UpdateInput}):
389
+ *
390
+ * 1. `UpdateSpec` — portable IR built via `update({ set, unset, inc,
391
+ * setOnInsert })`. Every kit compiles this to its native shape.
392
+ * **Prefer this for portable code** (arc's infrastructure stores,
393
+ * plugins targeting multiple backends).
394
+ * 2. `Record<string, unknown>` — kit-native raw record. mongokit
395
+ * treats this as a Mongo operator document (`$set`, `$inc`,
396
+ * `$unset`, ...). SQL kits treat it as flat column overwrites. Use
397
+ * for kit-specific fast paths.
398
+ * 3. `Record<string, unknown>[]` — Mongo aggregation pipeline. Only
399
+ * mongokit executes this; SQL kits throw `UnsupportedOperationError`.
400
+ * Use for the rare cases where you need `$ifNull` / `$cond` /
401
+ * `$toLower` to preserve invariants atomically (e.g. outbox's
402
+ * `firstFailedAt`).
403
+ *
404
+ * Kits dispatch via `isUpdateSpec(update)` from `@classytic/repo-core/update`.
376
405
  */
377
- findOneAndUpdate?(filter: FilterInput, update: Record<string, unknown> | Record<string, unknown>[], options?: FindOneAndUpdateOptions): Promise<TDoc | null>;
406
+ findOneAndUpdate?(filter: FilterInput, update: UpdateInput, options?: FindOneAndUpdateOptions): Promise<TDoc | null>;
378
407
  /**
379
408
  * Classify an error from a write as a unique-constraint violation.
380
409
  * Arc's idempotency + outbox adapters need this to distinguish
@@ -397,14 +426,38 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
397
426
  findAll?(filter?: FilterInput, options?: QueryOptions): Promise<TDoc[]>;
398
427
  getOrCreate?(filter: FilterInput, data: Partial<TDoc>, options?: WriteOptions): Promise<TDoc | null>;
399
428
  createMany?(items: Partial<TDoc>[], options?: WriteOptions): Promise<TDoc[]>;
400
- updateMany?(filter: FilterInput, data: Record<string, unknown>, options?: WriteOptions): Promise<UpdateManyResult>;
401
- deleteMany?(filter: FilterInput, options?: DeleteOptions): Promise<DeleteManyResult>;
402
429
  /**
403
- * Heterogeneous bulk write. Kits dispatch each op against the
404
- * appropriate driver primitive inside a single transaction; see each
405
- * kit's docs for the exact semantics of `upsert` and operator-shaped
406
- * update values (mongokit honors `$set` etc., SQL kits treat `update`
407
- * as a flat column overwrite).
430
+ * Apply the same update to every matching document. Required — every
431
+ * `StandardRepo` kit must implement bulk update; arc's outbox,
432
+ * idempotency, and cleanup stores depend on it. `data` accepts the
433
+ * same three forms as {@link findOneAndUpdate}: portable `UpdateSpec`,
434
+ * kit-native raw record, or Mongo aggregation pipeline (mongokit-only).
435
+ *
436
+ * **Promoted from optional to required in repo-core 0.2.0** — sqlitekit
437
+ * and mongokit both ship this as a class primitive. Third-party kits
438
+ * that previously omitted it now need to implement. Kits that lack a
439
+ * native bulk-update primitive should fan out in a transaction.
440
+ */
441
+ updateMany(filter: FilterInput, data: UpdateInput, options?: WriteOptions): Promise<UpdateManyResult>;
442
+ /**
443
+ * Delete every document matching the filter. Required — symmetrically
444
+ * with `updateMany`. Pass `{ mode: 'hard' }` to bypass soft-delete
445
+ * interception; kits without soft-delete accept and ignore the flag.
446
+ *
447
+ * **Promoted from optional to required in repo-core 0.2.0.**
448
+ */
449
+ deleteMany(filter: FilterInput, options?: DeleteOptions): Promise<DeleteManyResult>;
450
+ /**
451
+ * Heterogeneous bulk write. Stays optional — kits dispatch each op
452
+ * against the appropriate driver primitive inside a single transaction;
453
+ * see each kit's docs for the exact semantics of `upsert` and
454
+ * operator-shaped update values (mongokit honors `$set` etc., SQL kits
455
+ * treat `update` as a flat column overwrite).
456
+ *
457
+ * Kept optional because the mongoose-shaped `BulkWriteOperation` has no
458
+ * clean SQL analogue beyond "loop and dispatch" — forcing every kit to
459
+ * implement it would push kits to ship a thin wrapper around updateMany
460
+ * / deleteMany that offers nothing over calling them directly.
408
461
  */
409
462
  bulkWrite?(operations: readonly BulkWriteOperation<TDoc>[]): Promise<BulkWriteResult>;
410
463
  /**
@@ -467,4 +520,4 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
467
520
  withTransaction?<T>(fn: (txRepo: StandardRepo<TDoc>) => Promise<T>, options?: Record<string, unknown>): Promise<T>;
468
521
  }
469
522
  //#endregion
470
- export { AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, BulkWriteOperation, BulkWriteResult, DeleteManyResult, DeleteOptions, DeleteResult, FindOneAndUpdateOptions, InferDoc, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, UpdateManyResult, WriteOptions };
523
+ export { AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, BulkWriteOperation, BulkWriteResult, DeleteManyResult, DeleteOptions, DeleteResult, FilterInput, FindOneAndUpdateOptions, InferDoc, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, UpdateManyResult, WriteOptions };
@@ -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.2.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"