Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/soft-hoops-hammer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@btravstack/entity": minor
---

Treat `decoded.add` fields as implicitly immutable: they are excluded from `updateInput` and `Patch`, and `update()` drops them at runtime.
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,17 @@ its output is what makes that cast honest: if `add` ever produces data its own
declared schema would reject, that is a **defect** (a bug in domain code), not
ordinary bad input. See [Error handling](#error-handling).

**Added fields are implicitly immutable**, whether or not `immutable` names
them: they are absent from `updateInput` and from the `Patch` type, and
`update()` drops them even if smuggled in at runtime. They are also not
recomputed. That is not an omission but the only honest option — `add` reads
the **encoded** object, and by `update()` time only the decoded one is left,
which no longer carries the omitted source field (`secret`) the computation
needs; this is the same asymmetry as `decode(x.encode())` not round-tripping.
Given the choice between a value that silently drifts out of step with its
source and one a caller can contradict outright, the package offers neither: a
derived value only changes by re-`decode`-ing a fresh encoded payload.

## `invariants`

An invariant is a rule spanning two or more fields that no single field's
Expand Down
9 changes: 8 additions & 1 deletion packages/entity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ class name it labels, ahead of the field map.
| `generated` | keys the domain supplies, not the caller — omitted from `createInput` |
| `immutable` | keys that never change after creation — omitted from `updateInput` |
| `decoded.omit` | keys present on the wire but not stored (e.g. a raw secret) |
| `decoded.add` | computed fields, declared with the `add` helper (see below) |
| `decoded.add` | computed fields, declared with the `add` helper — implicitly immutable (see below) |
| `invariants` | `(decoded) => readonly string[]` — non-empty means rejected, checked on every `decode`, `make`, `create` and `update` |

## Statics
Expand Down Expand Up @@ -122,6 +122,13 @@ add({ fingerprint: Fingerprint })((e) => ({
visible to compute from) and the callback's return type is checked against
the declared fields, so every value must already be branded.

Added fields are **implicitly immutable**: they are absent from `updateInput`
and from `Patch`, and `update()` ignores them even if smuggled in at runtime.
They are not recomputed either, because `add` reads the encoded shape and
`update()` only holds the decoded one — the omitted source field is already
gone by then. A derived value therefore changes only by decoding a fresh
encoded payload.

## Helper types

Four generic type-level helpers name each shape by reading it off an entity
Expand Down
19 changes: 14 additions & 5 deletions packages/entity/src/contract.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ const OrgId = z.uuid().brand("OrgId");
const Secret = z.string().min(16).brand("Secret");
const Fingerprint = z.string().length(12).brand("Fingerprint");
const Instant = z.iso.datetime().brand("Instant");
const Label = z.string().min(1).brand("Label");

class ApiKey extends Entity("ApiKey")(
{ id: ApiKeyId, orgId: OrgId.readonly(), secret: Secret, createdAt: Instant },
{ id: ApiKeyId, orgId: OrgId.readonly(), secret: Secret, label: Label, createdAt: Instant },
{
generated: ["id", "createdAt"],
immutable: ["id", "orgId", "createdAt"],
Expand All @@ -35,19 +36,27 @@ const props = (s: z.ZodType, io: "input" | "output") => {
};

test("encoded drives the full request schema", () => {
expect(props(ApiKey.encoded, "input")).toEqual(["createdAt", "id", "orgId", "secret"]);
expect(props(ApiKey.encoded, "input")).toEqual(["createdAt", "id", "label", "orgId", "secret"]);
});

test("decoded drives the response schema", () => {
expect(props(ApiKey.decoded, "output")).toEqual(["createdAt", "fingerprint", "id", "orgId"]);
expect(props(ApiKey.decoded, "output")).toEqual([
"createdAt",
"fingerprint",
"id",
"label",
"orgId",
]);
});

test("createInput is the create request schema", () => {
expect(props(ApiKey.createInput, "input")).toEqual(["orgId", "secret"]);
expect(props(ApiKey.createInput, "input")).toEqual(["label", "orgId", "secret"]);
});

test("updateInput is the update request schema", () => {
expect(props(ApiKey.updateInput, "input")).toEqual(["fingerprint"]);
// `fingerprint` is on the response schema but not this one: `add` fields are
// implicitly immutable, so they are never part of an update request.
expect(props(ApiKey.updateInput, "input")).toEqual(["label"]);
});

test("all four ZodObject members convert in both directions", () => {
Expand Down
37 changes: 37 additions & 0 deletions packages/entity/src/decoded.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const ApiKeyId = z.uuid().brand("ApiKeyId");
const OrgId = z.uuid().brand("OrgId");
const Secret = z.string().min(16).brand("Secret");
const Fingerprint = z.string().length(12).brand("Fingerprint");
const Slug = z.string().min(1).brand("Slug");
const Upper = z.string().min(1).brand("Upper");

const fingerprintOf = (s: string) => s.slice(0, 12) as z.infer<typeof Fingerprint>;

Expand Down Expand Up @@ -61,6 +63,41 @@ const issuesOf = (r: ReturnType<typeof ApiKey.decode>): readonly Flat[] =>
defect: () => [{ path: [], message: "DEFECT" }],
});

test("a computed field is absent from updateInput", () => {
// `add` fields are implicitly immutable: nothing declares `fingerprint` in
// `immutable`, yet it must not appear in the update request schema.
expect(Object.keys(ApiKey.updateInput.shape)).toEqual(["id", "orgId"]);
expect(ApiKey.decoded.shape).toHaveProperty("fingerprint");
});

test("updating a source field leaves the entity consistent", () => {
class Org extends Entity("Org")(
{ id: OrgId, slug: Slug },
{
decoded: {
add: add({ slugUpper: Upper })((e) => ({
slugUpper: e.slug.toUpperCase() as z.infer<typeof Upper>,
})),
},
},
) {}
const org = Org.decode({ id: raw.orgId, slug: "acme" }).getOrThrow();
const renamed = org.update({ slug: "beta" as z.infer<typeof Slug> }).getOrThrow();
// `slugUpper` is carried over, not recomputed — the encoded object `add`
// reads from is gone by `update` time. It stays pinned to the value the
// entity was decoded with, so the pair is never internally contradictory in
// the way a silently stale recomputation would be.
expect(renamed.slug).toBe("beta");
expect(renamed.slugUpper).toBe("ACME");
expect(Org.decode({ id: raw.orgId, slug: renamed.slug }).getOrThrow().slugUpper).toBe("BETA");
});

test("update ignores a computed field smuggled in at runtime", () => {
const key = ApiKey.decode(raw).getOrThrow();
const updated = key.update({ fingerprint: "LIESLIESLIES" } as never).getOrThrow();
expect(updated.fingerprint).toBe("sk_live_9f3c");
});

test("bad caller input is InvalidEntity, never a defect", () => {
const outcome = ApiKey.decode({ ...raw, secret: "short" }).match({
ok: () => "WRONGLY ACCEPTED",
Expand Down
25 changes: 25 additions & 0 deletions packages/entity/src/entity.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,31 @@ test("create rejects a generated field and update rejects an immutable one", ()
org.update({ slug: "ok" as never });
});

test("an added field is immutable without being declared immutable", () => {
const Upper = z.string().brand("Upper");
class Org extends Entity("Org")(
{ id: OrgId, slug: Slug },
{
decoded: {
add: add({ slugUpper: Upper })((e) => ({
slugUpper: e.slug.toUpperCase() as z.infer<typeof Upper>,
})),
},
},
) {}

const org = Org.make({}).getOrThrow();
// @ts-expect-error `slugUpper` is computed by `add`, so it is implicitly
// immutable — patching it would let a caller contradict its own source
org.update({ slugUpper: "LIES" as never });
org.update({ slug: "ok" as never });

// and it is gone from `updateInput` too, so the request schema agrees with
// the patch type; reading the key back is a compile error (TS2339)
// @ts-expect-error `slugUpper` is not part of the update request schema
void Org.updateInput.shape.slugUpper;
});

test("a misspelled immutable key is a compile error, not a silently-mutable field", () => {
// @ts-expect-error "slugg" is not a key of the decoded shape — a typo here
// must not compile, or the misspelled field is mutable by accident
Expand Down
31 changes: 23 additions & 8 deletions packages/entity/src/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,29 @@ export function Entity<Tag extends string>(tag: Tag) {
const generatedKeys = options?.generated ?? [];
const immutableKeys = options?.immutable ?? [];

/**
* Every key `update` refuses: the declared immutable ones, plus the keys
* `add` contributed. An added field is *implicitly* immutable — `add`
* reads the **encoded** object, and `update` only ever holds the decoded
* one, which no longer carries an omitted source field like `secret`, so
* there is nothing to recompute from. Freezing them is the only answer
* that keeps a computed field consistent with its source; see `PatchOf`.
* Typed at the widened runtime element type so both uses below — the
* `.omit()` mask and `update`'s drop-list — take it without a cast.
*/
const frozenKeys: readonly PropertyKey[] = [
...immutableKeys,
...(addSpec ? Object.keys(addSpec.fields) : []),
];

/** what a caller may send to create */
const createInput = omitBy(encoded as z.ZodObject<Fields>, generatedKeys) as z.ZodObject<
Omit<S, G[number]>
>;
/** what a caller may send to update */
const updateInput = omitBy(
decoded as z.ZodObject<Fields>,
immutableKeys,
).partial() as z.ZodObject<UpdateInputShapeOf<S, A, K, I>>;
const updateInput = omitBy(decoded as z.ZodObject<Fields>, frozenKeys).partial() as z.ZodObject<
UpdateInputShapeOf<S, A, K, I>
>;

type DecodedShape = DecodedOf<S, A, K>;
type EncodedShape = EncodedOf<S>;
Expand Down Expand Up @@ -244,10 +258,11 @@ export function Entity<Tag extends string>(tag: Tag) {
const current = this.encode() as Record<PropertyKey, unknown>;
const applied = { ...current };
for (const [k, v] of Object.entries(patch as object)) {
// immutable keys are a compile error already; drop them at runtime
// too — `immutableKeys` is generic `I`, so `.includes` narrows its
// parameter, hence the cast to the widened runtime element type.
if (!(immutableKeys as readonly PropertyKey[]).includes(k)) applied[k] = v;
// frozen keys — declared immutable, or contributed by `add` — are a
// compile error already; drop them at runtime too, so a patch that
// reached here as `unknown` cannot desynchronise a computed field
// from the source it was derived from.
if (!frozenKeys.includes(k)) applied[k] = v;
}
const Ctor = this.constructor as unknown as {
make: (state: unknown) => Result<Base, InvalidEntity>;
Expand Down
3 changes: 3 additions & 0 deletions packages/entity/src/types.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ test("PatchOf is partial and drops the immutable fields", () => {
type P = PatchOf<S, A, ["secret"], ["id", "createdAt"]>;
expectTypeOf<P>().not.toHaveProperty("id");
expectTypeOf<P["slug"]>().toEqualTypeOf<z.infer<typeof Slug> | undefined>();
// `fingerprint` is not in the immutable list, yet it is still gone: an
// `add`-produced field is implicitly immutable
expectTypeOf<P>().not.toHaveProperty("fingerprint");
});

test("Sealed cannot be produced from outside the module", () => {
Expand Down
19 changes: 15 additions & 4 deletions packages/entity/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,18 +127,29 @@ export type DeepReadonly<T> = T extends Immutable
? T
: { readonly [K in keyof T]: DeepReadonly<T[K]> };

/** What `update` accepts: a partial of the stored data, minus the immutable fields. */
/**
* What `update` accepts: a partial of the stored data, minus the immutable
* fields — and minus `keyof A`, because an `add`-produced field is
* *implicitly* immutable whether or not `immutable` names it. Nothing can
* honestly recompute one on update: `add`'s input is the *encoded* object, and
* `update` only has the decoded one, which no longer carries the omitted source
* field the computation reads from (the same asymmetry that stops
* `decode(x.encode())` round-tripping). Leaving a computed field patchable
* would let a caller set it to a value its own source contradicts, so it is
* excluded instead.
*/
export type PatchOf<
S extends Fields,
A extends Fields,
K extends readonly (keyof S)[],
I extends readonly (keyof DecodedOf<S, A, K>)[],
> = Partial<Omit<DecodedOf<S, A, K>, I[number]>>;
> = Partial<Omit<DecodedOf<S, A, K>, I[number] | keyof A>>;

/**
* The field *schemas* `updateInput` is built from: the decoded field map
* (`Omit<S, K[number]> & A`, the same construction `EntityStatic["decoded"]`
* uses), minus the immutable keys, with every remaining schema wrapped in
* uses), minus the immutable keys and minus `keyof A` — the added fields are
* implicitly immutable, see `PatchOf` — with every remaining schema wrapped in
* `ZodOptional` — the type-level mirror of what `.omit(...).partial()`
* produces at runtime. A mapped object type rather than the `Fields` index
* signature, so `Organization.updateInput.shape.name` is a named property
Expand All @@ -150,7 +161,7 @@ export type UpdateInputShapeOf<
K extends readonly (keyof S)[],
I extends readonly (keyof DecodedOf<S, A, K>)[],
> = {
[Key in Exclude<keyof (Omit<S, K[number]> & A), I[number]>]: z.ZodOptional<
[Key in Exclude<keyof (Omit<S, K[number]> & A), I[number] | keyof A>]: z.ZodOptional<
(Omit<S, K[number]> & A)[Key]
>;
};
Expand Down
Loading