@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.
- package/CHANGELOG.md +153 -1
- package/README.md +28 -7
- package/dist/errors/contract.d.mts +37 -0
- package/dist/errors/contract.mjs +75 -0
- package/dist/errors/index.d.mts +3 -2
- package/dist/errors/index.mjs +3 -1
- package/dist/errors/types.d.mts +113 -8
- package/dist/errors/types.mjs +29 -0
- package/dist/pagination/canonical.d.mts +35 -0
- package/dist/pagination/canonical.mjs +32 -0
- package/dist/pagination/index.d.mts +3 -2
- package/dist/pagination/index.mjs +2 -1
- package/dist/pagination/types.d.mts +65 -1
- package/dist/repository/base.mjs +21 -0
- package/dist/repository/index.d.mts +3 -2
- package/dist/repository/types.d.mts +63 -10
- package/dist/schema/field-rules.d.mts +19 -8
- package/dist/schema/field-rules.mjs +29 -9
- package/dist/schema/generator.d.mts +72 -0
- package/dist/schema/generator.mjs +16 -0
- package/dist/schema/index.d.mts +2 -1
- package/dist/schema/index.mjs +2 -1
- package/dist/schema/types.d.mts +56 -3
- package/dist/tenant/index.d.mts +3 -0
- package/dist/tenant/index.mjs +2 -0
- package/dist/tenant/resolve.d.mts +27 -0
- package/dist/tenant/resolve.mjs +69 -0
- package/dist/tenant/types.d.mts +142 -0
- package/dist/update/builders.d.mts +52 -0
- package/dist/update/builders.mjs +92 -0
- package/dist/update/compile.d.mts +46 -0
- package/dist/update/compile.mjs +33 -0
- package/dist/update/guard.d.mts +20 -0
- package/dist/update/guard.mjs +24 -0
- package/dist/update/index.d.mts +5 -0
- package/dist/update/index.mjs +4 -0
- package/dist/update/types.d.mts +62 -0
- 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.
|
|
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",
|