domain-objects
Version:
A simple, convenient way to represent domain objects, leverage domain knowledge, and add runtime validation in your code base.
191 lines • 12.6 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.getContractRef = void 0;
const helpful_errors_1 = require("helpful-errors");
const validate_1 = require("../instantiation/validate/validate");
const hasDeclaredUniqueKey_1 = require("../reference/hasDeclaredUniqueKey");
/**
* .what = per-class, per-`by` memo of the stamped ref contract
* .why = `.pick()`/`.meta()` return fresh schemas each call; cache so repeated access is idempotent
* (same instance back), mirrors `contractByClass` — the vision's pit-of-success contract
*/
const contractRefByClass = new WeakMap();
/**
* .what = reads the dobj's static schema; fails fast unless it is a zod *object* schema
* .why = `.contract.ref` derives key fields via `.pick()`, which only a zod object supports;
* the three guards mirror `.contract`'s strictness (no-schema / non-zod), plus an object check
* .note = as-cast boundary (`rule.forbid.as-cast` exception): zod's `ZodSchema<any>` type does not
* surface the object-schema instance methods (`.pick`/`.shape`) to a type-only consumer, so we
* probe them at runtime — the two `typeof` guards ARE the type guard that makes the final
* `as unknown as ZodObjectLike` sound. removal path: when this lib accepts a typed `z.ZodObject`
* argument instead of the schema-agnostic `SchemaOptions`, the probes + cast can be dropped.
* .note = the no-schema / non-zod guards are near-unreachable on the common path: the sole public
* route here is `X.contract.ref(by)`, and `getContract` fail-fasts on both facts before it
* attaches `.ref`, so the FIRST `.ref(...)` call never reaches them. they are not strictly dead,
* though — `.contract` memoizes on first access while each new `by` re-reads `dobj.schema` fresh,
* so a `dobj.schema` mutated after that first access could reach them via the public surface. they
* stay to keep this fn sound (incl. a direct, unexported call) and to fail loud with a
* `.contract.ref`-specific message; the object-shape guard is the routinely-reachable one.
*/
const getObjectSchemaOrThrow = (dobj) => {
const { schema } = dobj;
if (!schema)
throw new helpful_errors_1.ConstraintError(`${dobj.name}.contract.ref requires a static schema. declare \`static schema\` (zod) on ${dobj.name} to use .contract.ref`, { domainObject: dobj.name });
if (!(0, validate_1.isZodSchema)(schema))
throw new helpful_errors_1.ConstraintError(`${dobj.name}.contract.ref requires a zod schema; joi/yup cannot carry json-schema identity`, { domainObject: dobj.name });
// runtime probe of the zod-object instance methods (see .note: guards the cast below)
if (typeof schema.pick !== 'function' ||
typeof schema.shape !== 'object')
throw new helpful_errors_1.ConstraintError(`${dobj.name}.contract.ref requires an object schema (z.object) to derive key fields`, { domainObject: dobj.name });
return schema;
};
/**
* .what = the declared key names for a `primary`/`unique` grain; fails fast if absent
* .why = a ref must name real key fields; an absent `static primary`/`unique` is caller-must-fix
*/
const getKeysOrThrow = (dobj, by) => {
const keys = by === 'primary' ? dobj.primary : dobj.unique;
if (!keys)
throw new helpful_errors_1.ConstraintError(`${dobj.name}.contract.ref('${by}') requires \`static ${by}\` on ${dobj.name}`, { domainObject: dobj.name, by });
return keys;
};
/**
* .what = stamps a schema with the `x-domain-object-ref` pragma via zod's `.meta()` registry
* .why = returns a fresh schema (author's untouched); the stamp rides through `z.toJSONSchema()`
*/
const stampRef = (schema, of, by) => schema.meta({ 'x-domain-object-ref': { of, by } });
/**
* .what = guards + flat pick of the declared key fields for a grain (no nested reduction)
* .why = the trivial half of a key contract: fail loud on an absent key, then pick the key sub-shape.
* isolated from the nested-graph reduction so the simple path stays simple and independently testable.
*/
const pickDeclaredKeys = (dobj, by) => {
const schema = getObjectSchemaOrThrow(dobj);
const keys = getKeysOrThrow(dobj, by);
const { shape } = schema;
// fail loud if any declared key is absent from the schema (never pick a hole)
for (const key of keys)
if (!(key in shape))
throw new helpful_errors_1.ConstraintError(`${dobj.name}.contract.ref('${by}'): key '${key}' is declared in \`static ${by}\` but absent from \`static schema\``, { domainObject: dobj.name, by, key });
// pick only the declared key fields
return schema.pick(Object.fromEntries(keys.map((key) => [key, true])));
};
/**
* .what = reduces each nested-dobj unique key to its own unique ref, atop the flat `picked` base
* .why = for `unique`, a key that is itself a nested dobj recurses to that dobj's own unique ref —
* to match `refByUnique`'s runtime reduction (vision q2). the genuinely-complex half: graph
* recursion, cyclic guard, and the polymorphic-arm fail-fast, isolated from the flat pick.
* .note = `seen` guards a cyclic unique-key graph (A.unique → B, B.unique → A) — fail loud.
* the nested recursion goes through `getContractRefSeen` (not a raw build) so a nested dobj's ref
* is memoized too — the same instance a direct `Nested.contract.ref('unique')` returns (idempotency).
*/
const reduceNestedUniqueKeys = (dobj, picked, seen) => {
const nested = dobj.nested;
const keys = dobj.unique ?? [];
return keys.reduce((acc, key) => {
const declaration = nested?.[key];
// a scalar unique key (absent from `nested`) stays a flat pick — matches runtime
if (!declaration)
return acc;
// a polymorphic (array-of-choices) nested unique key: runtime refByUnique reduces the LIVE
// instance to whichever arm's own unique ref, so if ANY arm declares `static unique`, the
// schema cannot know the arm at build time — a flat embed would silently drift from runtime
// (vision q2). fail loud rather than lie. (a union of each arm's own `.contract.ref('unique')`
// is the faithful form; deferred as a focused follow-up.) if NO arm declares unique, runtime
// embeds the whole value for every arm, so the flat pick is faithful — keep it.
if (Array.isArray(declaration)) {
const arms = declaration;
const armsWithUnique = arms.filter((Arm) => (0, hasDeclaredUniqueKey_1.hasDeclaredUniqueKey)(Arm));
if (armsWithUnique.length)
throw new helpful_errors_1.ConstraintError(`${dobj.name}.contract.ref('unique'): unique key '${key}' is a polymorphic nested dobj (choices: ${arms
.map((Arm) => Arm.name)
.join(', ')}); at least one choice declares \`static unique\`, so a single faithful ref sub-shape cannot be derived at schema-build time — runtime refByUnique reduces the live instance to its arm's own unique ref. reference each choice by a single-dobj key instead`, { domainObject: dobj.name, by: 'unique', key });
return acc;
}
const NestedDobj = declaration;
// recurse only when the nested dobj declares its own `static unique` (the gate shared with
// refByUnique via hasDeclaredUniqueKey); else leave the flat embed of its whole schema (no
// recursion, no throw) — the normal shape for a DomainLiteral unique key, which declares none
if (!(0, hasDeclaredUniqueKey_1.hasDeclaredUniqueKey)(NestedDobj))
return acc;
// cyclic guard by class reference (not name): two distinct classes that share a name must not
// false-trip; consistent with `contractRefByClass`, which keys by class reference too
if (seen.includes(NestedDobj))
throw new helpful_errors_1.ConstraintError(`${dobj.name}.contract.ref('unique'): cyclic unique-key graph via '${key}' → ${NestedDobj.name}`, { domainObject: dobj.name, by: 'unique', key, nested: NestedDobj.name });
// memoized recursion: same stamped instance a direct Nested.contract.ref('unique') returns
const nestedRef = getContractRefSeen(NestedDobj, 'unique', [...seen, dobj]);
return acc.extend({ [key]: nestedRef });
}, picked);
};
/**
* .what = builds the (unstamped) object schema for one key grain: flat pick, then (for unique)
* reduce each nested-dobj key to its own unique ref
* .why = composes the two halves — `pickDeclaredKeys` (trivial) + `reduceNestedUniqueKeys` (complex)
* — so a primary grain (or a unique with no nested keys) is just the flat pick, unchanged
*/
const buildKeyContract = (dobj, by, seen) => {
const picked = pickDeclaredKeys(dobj, by);
// primary (or a unique with no nested keys) → the flat pick is the ref contract
if (by !== 'unique' || !dobj.nested)
return picked;
// unique → reduce each nested-dobj key to its own unique ref (match runtime refByUnique)
return reduceNestedUniqueKeys(dobj, picked, seen);
};
/**
* .what = builds the stamped ref contract for a `by` grain (threads `seen` for the cyclic guard)
* .why = primary/unique → a stamped key-object; ref → a union of the declared grains, stamped once
* at the top (arms unstamped), with a union-of-one degrade to the single declared grain (vision q3)
*/
const buildContractRef = (dobj, by, seen) => {
if (by === 'primary' || by === 'unique')
return stampRef(buildKeyContract(dobj, by, seen), dobj.name, by);
// fail loud on any value that is not a declared grain. `DomainObjectRefBy` is a ts-only union,
// so a js/joi/yup caller can pass an unrecognized `by`; without this guard it would fall through
// to the 'ref' branch and get relabeled `by: 'ref'` in the output pragma — a silent mislabel.
// mirrors the fail-fast strictness of every other boundary in this file.
if (by !== 'ref')
throw new helpful_errors_1.ConstraintError(`${dobj.name}.contract.ref received an unrecognized 'by' value: ${JSON.stringify(by)}. expected 'primary' | 'unique' | 'ref'`, { domainObject: dobj.name, by });
// by === 'ref' → union of whichever grains the dobj declares
const arms = [];
if (dobj.primary)
arms.push(buildKeyContract(dobj, 'primary', seen));
if (dobj.unique)
arms.push(buildKeyContract(dobj, 'unique', seen));
if (arms.length === 0)
throw new helpful_errors_1.ConstraintError(`${dobj.name}.contract.ref('ref') requires at least one of \`static primary\` / \`static unique\` on ${dobj.name}`, { domainObject: dobj.name });
// union-of-one degrades to the single declared grain (no pointless union); else a real union
const union = arms.length === 1 ? arms[0] : arms[0].or(arms[1]);
return stampRef(union, dobj.name, 'ref');
};
/**
* .what = the memoized, `seen`-threaded core of `getContractRef`
* .why = memoize per class + per `by` so every access — top-level OR nested-recursive — returns the
* SAME stamped instance (the vision's idempotency contract, honored even for nested-dobj refs).
* `seen` threads through the build so a nested recursion still guards a cyclic unique-key graph.
*/
const getContractRefSeen = (dobj, by, seen) => {
// return the memoized ref if this class+by was already stamped (idempotent, shared across nests)
const memoByBy = contractRefByClass.get(dobj) ??
new Map();
const memoized = memoByBy.get(by);
if (memoized)
return memoized;
// build (threads `seen` for the cyclic guard), then memoize per class + per by
const contractRef = buildContractRef(dobj, by, seen);
memoByBy.set(by, contractRef);
contractRefByClass.set(dobj, memoByBy);
return contractRef;
};
/**
* .what = returns a dobj's `.contract.ref(by)`: a zod schema of only the referenced key fields,
* stamped with an `x-domain-object-ref` pragma so a *reference* survives `z.toJSONSchema()`
* .why =
* - `.contract` embeds the WHOLE dobj (composition); a field that only *names* another dobj by
* key needs a smaller, stamped form — this is that form
* - lets a cross-service consumer emit a typed `RefBy*<typeof X>` instead of an anonymous shape
* .note = memoized per class + per `by` (via getContractRefSeen), so repeated access — and any
* nested-dobj recursion — returns the same stamped instance
*/
const getContractRef = (dobj, by) => getContractRefSeen(dobj, by, []);
exports.getContractRef = getContractRef;
//# sourceMappingURL=getContractRef.js.map