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
13 changes: 13 additions & 0 deletions .changeset/lazy-pans-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@btravstack/entity": minor
---

`equals` now delegates to `node:util`'s `isDeepStrictEqual` instead of a
hand-rolled traversal. Behaviour is unchanged for every case the suite pins —
`bigint`, `Set`/`Map` by contents, nested key order, typed arrays and
`ArrayBuffer` bytewise, `RegExp`, `Date`, and cyclic values — with one
difference: `+0` and `-0` now compare **unequal** (`Object.is` semantics) where
the previous implementation treated them as equal (SameValueZero).

This makes the package **Node-only**: it now imports a `node:` builtin, so a
browser or edge bundle without a `node:util` shim will fail to resolve it.
14 changes: 8 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ cannot run against it. Measured — the reason is inline in

## Architecture

Thirteen source modules under `packages/entity/src` besides `index.ts`, split by
Twelve source modules under `packages/entity/src` besides `index.ts`, split by
what they own:

- **`entity.ts`** — the builder. `Entity(tag)(fields, options)` derives the
Expand Down Expand Up @@ -130,11 +130,13 @@ what they own:
keys and the tag — a bare-schema redeclaration used to drop the root's flags
silently. Built against a loosened `BuildEntity` passed in from `entity.ts`, so
this module imports no builder and there is no cycle.
- **`equal.ts`** — `deepEqual`, the primitive behind `equals`. Not
`JSON.stringify`: that **threw** on a `bigint` field, compared `Set`/`Map`/
typed-array fields with different contents as **equal**, and reported a
nested record as changed when only its key order differed. All three were
measured; `equal.spec.ts` pins them.
- **Equality** — `equals` is `node:util`'s **`isDeepStrictEqual`**, not `JSON.stringify` and
not a hand-rolled walk: serialising **threw** on a `bigint` field, compared
`Set`/`Map`/typed-array fields with different contents as **equal**, and
reported a nested record as changed when only its key order differed. All
three were measured, as was the cyclic-field stack overflow;
`equal.spec.ts` pins every one against the stdlib function. This import is
what makes the package **Node-only**.
- **`freeze.ts`** — `deepFreeze`, the runtime half of immutability. Freezes
and recurses into arrays and plain objects, freezes `Date` as a leaf, and
deliberately leaves `Map`/`Set`/class instances alone. Which _fields_ to skip
Expand Down
27 changes: 0 additions & 27 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,32 +281,5 @@ export default defineConfig({
keywords: "TypeScript, zod, domain-driven design, entity, immutability, Result",
}),
],
// WebSite JSON-LD for proper site name display in Google search
[
"script",
{ type: "application/ld+json" },
JSON.stringify({
"@context": "https://schema.org",
"@type": "WebSite",
name: "entity",
url: SITE_URL,
}),
],
// Organization JSON-LD for logo display in Google search
[
"script",
{ type: "application/ld+json" },
JSON.stringify({
"@context": "https://schema.org",
"@type": "Organization",
name: "entity",
url: SITE_URL,
logo: {
"@type": "ImageObject",
url: `${SITE_URL}logo.svg`,
},
sameAs: ["https://github.com/btravstack/entity"],
}),
],
],
});
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
"prepare": "lefthook install",
"release": "pnpm build && changeset publish",
"test": "turbo run test",
"test:types": "turbo run test:types",
"typecheck": "turbo run typecheck",
"version": "changeset version"
},
Expand Down
1 change: 0 additions & 1 deletion packages/entity/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@
"build": "tsdown src/index.ts --format cjs,esm --dts --clean",
"dev": "tsdown src/index.ts --format cjs,esm --dts --watch",
"test": "vitest run",
"test:types": "tsc --noEmit -p tsconfig.test-d.json",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json"
},
"devDependencies": {
Expand Down
9 changes: 6 additions & 3 deletions packages/entity/src/entity.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { isDeepStrictEqual } from "node:util";

import { fromSchema, type SchemaIssues } from "@unthrown/standard-schema";
import { Err, Ok, P, all, fromPromise, fromThrowable, type Result } from "unthrown";
import type { z } from "zod";

import type { BuildEntity } from "./base.js";
import { createBase, record } from "./base.js";
import { computed, type ComputedField } from "./computed.js";
import { deepEqual } from "./equal.js";
import { InvalidEntity } from "./errors.js";
import { field, isFieldSpec, type FieldSpec, type Flags } from "./field.js";
import { deepFreeze } from "./freeze.js";
Expand Down Expand Up @@ -355,11 +356,13 @@ export function Entity<Tag extends string>(tag: Tag) {
* Compares the projected data structurally, not by `JSON.stringify`:
* serialising threw on a `bigint` field, equated `Set`/`Map`/typed-array
* fields with different contents, and reported a nested record as changed
* when only its key order differed. See `equal.ts`.
* when only its key order differed. `node:util`'s `isDeepStrictEqual`
* handles all three, plus cycles — every case is pinned in
* `equal.spec.ts`. It is what makes this package Node-only.
*/
equals(other: unknown): boolean {
if (!(other instanceof Base)) return false;
return deepEqual(project(this), project(other));
return isDeepStrictEqual(project(this), project(other));
}

/**
Expand Down
3 changes: 2 additions & 1 deletion packages/entity/src/equal.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { isDeepStrictEqual as deepEqual } from "node:util";

import { expect, test } from "vitest";
import { z } from "zod";

import { deepEqual } from "./equal.js";
import { Entity } from "./index.js";

const Id = z.uuid().brand("Id");
Expand Down
172 changes: 0 additions & 172 deletions packages/entity/src/equal.ts

This file was deleted.

2 changes: 0 additions & 2 deletions packages/entity/src/field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,5 +90,3 @@ export const isFieldSpec = (v: unknown): v is FieldSpec<z.core.$ZodType, Flags>
Object.hasOwn(v, "schema") &&
Object.hasOwn(v, "flags") &&
!("_zod" in v);

export const schemaOf = (entry: unknown): unknown => (isFieldSpec(entry) ? entry.schema : entry);
20 changes: 8 additions & 12 deletions packages/entity/src/shape.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,23 @@
import { z } from "zod";

import { schemaOf } from "./field.js";
import { isFieldSpec } from "./field.js";
import type { Fields, SchemaOf, SchemasOf } from "./types.js";

/** A field is nominal if its inferred type is branded, or is already non-interchangeable. */
type Nominal = z.core.$brand<string | symbol> | boolean;

/**
* True when `Wide` is assignable to `Candidate` — i.e. `Candidate` is at
* least as wide as `Wide`. Wrapped in a tuple so union `Candidate`s are
* compared as a whole rather than distributed member-by-member.
*/
type IsAtLeastAsWideAs<Candidate, Wide> = [Wide] extends [Candidate] ? true : false;

/**
* A string/number literal union (e.g. a `z.enum(...)`) is narrow: the wide
* primitive it's drawn from is not assignable to it. Bare `string`/`number`
* are the wide primitives themselves and are rejected.
* are the wide primitives themselves and are rejected. Each test is
* tuple-wrapped so a union `T` is compared as a whole rather than distributed.
*/
type IsNarrowLiteral<T> = T extends string
? IsAtLeastAsWideAs<T, string> extends true
? [string] extends [T]
? false
: true
: T extends number
? IsAtLeastAsWideAs<T, number> extends true
? [number] extends [T]
? false
: true
: false;
Expand Down Expand Up @@ -100,7 +94,9 @@ type OnlyNominal<T extends Fields> = {

/** The only sanctioned way to declare a domain shape. */
export function shape<T extends Fields>(fields: T & OnlyNominal<T>): z.ZodObject<SchemasOf<T>> {
const unwrapped = Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, schemaOf(v)]));
const unwrapped = Object.fromEntries(
Object.entries(fields).map(([k, v]) => [k, isFieldSpec(v) ? v.schema : v]),
);
return z.object(unwrapped as SchemasOf<T>);
}

Expand Down
3 changes: 1 addition & 2 deletions packages/entity/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,7 @@ type ConstructedInstance<Tag extends string, S extends Fields, A extends Schemas
* (TS2509). `string & "Personal"` reduces to `"Personal"`, which is exactly
* what the variant needs.
*/
export type RootInstance<S extends Fields, A extends Schemas> = BaseInstance<S, A> &
DeepReadonly<OutputOf<S, A>> & { readonly _tag: string };
export type RootInstance<S extends Fields, A extends Schemas> = ConstructedInstance<string, S, A>;

/**
* Whatever the receiver's own class body added, carried **unmapped**.
Expand Down
1 change: 0 additions & 1 deletion packages/entity/src/union.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@ const discriminantValues = (member: UnionMember, discriminant: string): readonly
// form that makes the singular `.value` getter throw.
const values: unknown = schema?.values;
if (values instanceof Set) return [...values];
if (Array.isArray(values)) return values;

// `z.enum([...])` — `.options` is the declared member list.
const options: unknown = schema?.options;
Expand Down
3 changes: 0 additions & 3 deletions turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@
"typecheck": {
"dependsOn": ["build"]
},
"test:types": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"],
"cache": false
Expand Down
Loading