npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

foldkit-entity

v0.7.0

Published

Domain structure for Foldkit Plus: an Entity's fields, relations, and derived members as typed values, and Selections of them.

Readme

foldkit-entity

A domain entity declared once as a typed value: its intrinsic fields, its relations to other entities, and the derived values consumers may read. Other packages interpret that declaration; this one only describes.

Status: declaration and selection. foldkit-remote registers Entities and reads Selections as they are. foldkit-remote-drizzle binds a related set to tables with bind, and foldkit-form builds a form from Entity.input, and foldkit-crud joins one to a Remote operation.

For the whole path in one runnable trace, domain to client to SQL, see examples/entity.

What it owns

foldkit-entity owns domain structure: which members an entity has, what kind each is, and which entity a relation points to.

It does not own:

  • Validation. entity.schema is an ordinary Effect Schema.Struct and stays the only validity rule.
  • Storage or fetching. A relation says "one Author", not "a foreign key" or "a join table". How a derived value is computed is the interpreter's business.
  • Operations. An Entity implies no create, update, or delete.
  • Application state. Nothing here touches a Model or dispatches a Message.

Mental model

Entity
 ├── fields      intrinsic values: the properties of entity.schema
 ├── relations   navigation edges: one / many of another Entity (Entity.relate)
 ├── derived     readable values an interpreter supplies
 └── members     all three under one namespace; keys never collide

interpreter ──(foldkit-metadata)──► Entity / member        attaches what it needs
interpreter ◄── reads fields, relations, derived, metadata

Each pipe step, and Entity.relate, returns a new frozen descriptor with the same identity. Two descriptors are the same entity when Entity.same(a, b), not when a === b.

Install

pnpm add effect foldkit-entity

Sixty seconds: describe one read

import { Schema } from 'effect'
import { Entity, type Selected } from 'foldkit-entity'

const Task = Entity.define('Task', Schema.Struct({
  id: Schema.String,
  title: Schema.String,
  done: Schema.Boolean,
}))
const TaskTitle = Entity.select(Task, { id: true, title: true })
type TaskTitle = Selected<typeof TaskTitle> // the value it reads
const value: TaskTitle = { id: 't1', title: 'Read the guide' }

Task describes the domain. TaskTitle describes one consumer's result shape; it does not query a database or allocate a cache. Its schema validates that shape when an interpreter produces data. A selection does not include done unless you select it.

Next, hand the Entity to Remote for client reads or bind it to Drizzle tables on the server. Declare operation input separately when adding a form: selecting readable fields does not grant permission to write them.

Relations and derived values

import { Schema } from 'effect'
import { Derived, Entity, Relation } from 'foldkit-entity'

const Author = Entity.define('Author', Schema.Struct({ id: Schema.String, name: Schema.String }))
const Comment = Entity.define('Comment', Schema.Struct({ id: Schema.String, body: Schema.String }))
const Post = Entity.define(
  'Post',
  Schema.Struct({ id: Schema.String, title: Schema.String, published: Schema.Boolean }),
).pipe(Entity.derived({ commentCount: Derived.make(Schema.Number) }))

const Blog = Entity.relate(
  { Author, Post, Comment },
  {
    Post: {
      author: Relation.one(Author),
      editor: Relation.one(Author, { optional: true }),
      comments: Relation.many(Comment),
    },
    Comment: { post: Relation.one(Post) },
  },
)

Blog.Post.schema // Schema.Struct of id, title, published: relations are not in it
Blog.Post.fields.title.schema // Schema.String
Blog.Post.relations.editor.optional // true
Blog.Post.derived.commentCount.schema // Schema.Number
Blog.Post.relations.comments.target() // Blog.Comment
Blog.Post.relations.comments.target().relations.post.target() // Blog.Post, fully typed
Object.keys(Blog.Post.members) // id, title, published, commentCount, author, editor, comments
  • Entity.define generates one Field per schema property and creates the identity. Calling it twice with the same name makes two different entities.
  • Derived.make states a readable value's schema and nothing about how it is produced. Entity.derived is a pipe step because it concerns one entity.
  • Entity.relate takes the entities and every relation between them, and returns the same entities with relations filled in. Use the returned ones (Blog.Post); the Post you passed in still has no relations.
  • Relation.one / Relation.many state what the owner sees. Post.author: one and Author.posts: many together are the familiar one-to-many; neither side names it.
  • target() returns the related entity, so relations can be followed from there, around a cycle too.

Entity.relate throws, and the types reject, a relation key that is already a member, an owner that is not in the first argument, and a target that is not in the first argument.

Why relations are declared in one step

Entities point at each other: a Post has Comments and a Comment has a Post. If each entity declared its own relations, Post would be typed in terms of Comment and Comment in terms of Post, and TypeScript cannot infer two constants that way. Declaring the relations over entities that already exist avoids the cycle, and lets relate check every target up front.

Ids of their own

An Entity is identified by its id field, and the id keeps the type you give it. Brand it, and a ref to an Author can no longer stand in for a Post's:

const AuthorId = Schema.String.pipe(Schema.brand('AuthorId'))
const PostId = Schema.String.pipe(Schema.brand('PostId'))

const Writer = Entity.define('Author', Schema.Struct({ id: AuthorId, name: Schema.String }))
const Article = Entity.define('Post', Schema.Struct({ id: PostId, title: Schema.String }))
const Press = Entity.relate({ Writer, Article }, { Article: { author: Relation.one(Writer) } })

type WriterId = import('foldkit-entity').IdOf<typeof Press.Writer> // AuthorId

const ByLine = Entity.select(Press.Article, { author: true })
// { author: EntityRef<'Author', AuthorId> }

Entity.input(Press.Article, Schema.Struct({ authorId: AuthorId }), {
  authorId: Relation.input(Press.Article.relations.author), // a PostId, or any text, is a type error
})
  • IdOf<E> is the type of the id field; EntityRef<Name, Id> carries it, and Relation.input takes it (Id | null for an optional one, a list for a many).
  • A ref's schema is the id's own, so a pattern or a check on the id holds for every ref to it.
  • An id is text. A ref travels and is stored as text, so an Entity whose id is a number (or has no id field) has refs with a plain string id: the number as text.
  • A Selection carries its Entity's id type, so foldkit-remote's Data.get(PostPage, id) takes a PostId and refuses an AuthorId, or plain text.
  • Nothing here is new API to opt into. An Entity with id: Schema.String types exactly as before.

Selecting a view

A Selection names the members a consumer wants and carries the schema of the value that results. It does not fetch that value; whoever produces it (a server adapter, a test, a form) decodes against selection.schema.

const AuthorOption = Entity.select(Blog.Author, { id: true, name: true })

const PostRow = Entity.select(Blog.Post, {
  title: true,
  commentCount: true,
  author: AuthorOption,
  editor: AuthorOption,
  comments: true,
})

type PostRow = Selected<typeof PostRow>
// {
//   title: string
//   commentCount: number
//   author: { id: string; name: string }
//   editor: { id: string; name: string } | null
//   comments: ReadonlyArray<{ entity: 'Comment'; id: string }>
// }

| Member | Select with | Yields | | --- | --- | --- | | Field, Derived | true | the member's own schema, checks included | | Relation | true | an EntityRef: { entity, id } | | Relation | a Selection of its target | that Selection's value | | many Relation | Entity.page(selection, window) | a page of that Selection's values: { items, hasNext, hasPrevious } |

A many relation yields an array, and an optional one is nullable. A Selection is an ordinary value, so AuthorOption above is declared once and reused. Nesting is always explicit, which is what keeps a cycle finite.

A page of a relation

A post may have ten thousand comments. Entity.page reads a many relation a page at a time:

const CommentBody = Entity.select(Blog.Comment, { body: true })

const PostWithComments = Entity.select(Blog.Post, {
  title: true,
  comments: Entity.page(CommentBody, { first: 10 }),
})
// { title: string, comments: { items: { body: string }[], hasNext: boolean, hasPrevious: boolean } }

The window is first or last, with after or before a cursor. A page is a view's shape, as neutral as "many means an array", so it is declared here. What a cursor is and how the rows are ordered belong to whoever fetches: the cursor is an opaque string an earlier answer handed out. Reading on is another Selection with another window.

An unknown member, a nested Selection on a field, a page of a one relation, and a Selection of the wrong Entity are type errors, and Entity.select throws for untyped callers.

Reading an operation's input

Experimental: the smallest mapping that several operation shapes and foldkit-form needed.

An Entity does not decide what may be written; an operation does (a Remote mutation, an RPC, a form). Entity.input takes that operation's input schema and says what each key means in terms of the Entity, so a consumer can find the field's metadata for a label, or the relation's target for a picker.

const CreatePostInput = Schema.Struct({
  title: Schema.String,
  authorId: Schema.String,
  notify: Schema.Boolean,
})

const CreatePost = Entity.input(Blog.Post, CreatePostInput, {
  authorId: Relation.input(Blog.Post.relations.author),
  notify: Entity.unmapped,
})

CreatePost.members.title // Blog.Post.fields.title: it names a field, so it maps itself
CreatePost.members.authorId.relation.target() // Blog.Author: what a picker chooses from
CreatePost.members.notify // Entity.unmapped: about the operation, not the Post
  • A key that names a field, with a value that fits it, maps itself. Only the present value has to fit: a key may be optional (a partial update) or admit null (clearing it), which is the input schema's rule to make.
  • Every other key needs an entry: a Field under another name, a relation's ids with Relation.input(relation), or Entity.unmapped. Nothing is inferred from a name like authorId.
  • An entry may name the member by its key: { headline: 'title', authorId: 'author' } is the title Field and Relation.input of author. It is the same reading, checked the same way; a name that is no field or relation is a type error.
  • Entity.fields(Post, 'id', 'title') is those fields' schemas, to spread into the input's struct, so the input keeps the field's rules without repeating Post.fields.title.schema per key.
  • Relation.input expects one id for a one, id | null for an optional one, and an array of ids for a many.
  • A derived member cannot be written, and a member of another Entity cannot be mapped. Both are type errors and throw.

An input that holds the target itself

A post created together with a new author carries the author, not an id. Relation.nested maps the key to the relation and to an input of its target:

const NewAuthor = Entity.input(Blog.Author, Schema.Struct({ name: Schema.String }))

const CreatePost = Entity.input(
  Blog.Post,
  Schema.Struct({ title: Schema.String, author: NewAuthor.schema }),
  { author: Relation.nested(Blog.Post.relations.author, NewAuthor) },
)

A one holds the nested input's value, a many a list of them; the nested input must be of the relation's target, and both are checked by type and at runtime. Entity.selectFor then loads what the nested input writes of the target, and Entity.valuesFor turns the loaded target back into nested values. What a nested write does (insert, update, replace the list) is the operation's handler to decide.

Showing what is there

An edit screen has to load the current values and turn them into input values. Both follow from the reading, so neither is written by hand:

const PostForEdit = Entity.selectFor(CreatePost)
// a Selection of `title` and `author`, the members the input writes; `author` as a ref

Entity.valuesFor(CreatePost, { title: 'Hello', author: { entity: 'Author', id: 'a1' } })
// { title: 'Hello', authorId: 'a1' }

selectFor selects every member the input writes, by the member's key, with each relation as refs. valuesFor reads such a value back under the input's keys: a field as it is, a relation as the id or ids of what it holds. A relation read without its ids (a nested Selection that left id out) fills nothing. An unmapped key appears in neither, since the Entity knows nothing about it.

The schema is an ordinary Schema.Struct, so declare it once and give the same value to the operation, for example Mutation.make('CreatePost', { Input: CreatePostInput, … }) in foldkit-remote.

Attaching metadata

An interpreter declares a foldkit-metadata key and attaches entries to the entity or to members. Entity core never reads them.

import { Metadata } from 'foldkit-metadata'

const Labels = Metadata.key<string>('my-admin/labels', {
  merge: labels => [...new Set(labels)],
  summarize: label => label,
})

const CmsPost = Blog.Post.pipe(
  Entity.annotate(Labels.of('Post')),
  Entity.annotateMembers({ title: Labels.of('Title'), author: Labels.of('Byline') }),
)

Labels.get(CmsPost.fields.title.metadata) // ['Title']
Entity.same(CmsPost, Blog.Post) // true: the metadata changed, the entity did not

Annotating again combines with what is there, using the key's own merge.

A schema's own words

A label or a help text written on a schema (annotate({ title, description })) is read with Words.of(schema), which gives each as an Option. Under Effect 4 a schema with checks resolves to its last check's annotations, which say what the check expects and carry no title, so Schema.String.annotate({ title: 'Name' }).check(Schema.isMinLength(1)) resolved directly has no title. Words.of falls back to the schema's own annotations. A form's labels, Crud's columns and the page Builder's inspector read words through it.

import { Schema } from 'effect'
import { Words } from 'foldkit-entity'

Words.of(Schema.String.annotate({ title: 'Name' }).check(Schema.isMinLength(1))).title
// Option.some('Name')

Saying something about a row: Expr

An Entity says what a domain has. An Expr says something about one row of it, as a value:

Why query semantics live in this package. They describe and interpret nothing, which is the line this package already draws: an Expr says which rows and fetches none of them, and the interpreters that fetch live elsewhere — foldkit-remote-drizzle compiles a body to SQL against a database.

evaluate is here rather than there because it is the reference semantics of the IR: what the operator definitions mean, operationally, over rows already in hand. A specification's reference implementation belongs with the specification, and it reaches nothing — no database, no transport, no Remote concept. The rule that keeps it honest is that it may depend on the IR and nothing else; the moment it wants one of those it has moved to the wrong place. Nothing that imports only Entity pays for them: they are ordinary consts in a sideEffects: false package, so a bundler drops them, and a package like foldkit-form imports only types from here in any case.

import { Expr, Order } from 'foldkit-entity'

const byTitle = Expr.eq(Blog.Post.fields.title, Expr.input('title', Schema.String))
const published = Expr.eq(Blog.Post.fields.published, true)
const newest = [Order.desc(Blog.Post.fields.title), Order.asc(Blog.Post.fields.id)]

Building one performs no work: it reads nothing, names no database, and runs no query. An interpreter compiles it — foldkit-remote-drizzle to SQL, an in-memory evaluator to a predicate over rows — which is what lets one query mean the same thing in more than one place.

A comparison coerces what it is given, so the common forms read as they mean: a field becomes a reference, a plain value becomes a literal, and an Expr is already one. What it will not do is compare a field to the wrong kind of value — Expr.eq(Blog.Post.fields.title, 42) is an error where it is written, rather than a row that never matches.

An input is a placeholder, not a value. A query's body is built once, so Expr.input('title', …) stands for whatever the query is given when it runs — there is nothing there yet to branch on. An InputExpr is an object, so a condition ? a : b over one is always truthy and decides itself once, forever. A query that depends on what it was passed says so with a comparison over the placeholder instead of a branch around it.

dependenciesOf(...) says which fields and inputs an expression reads and which operations it uses, so a planner knows what it needs and an interpreter can refuse a query it cannot run. Each field names its owner, the Entity's identity, as well as the Entity's name, so two Entities defined with the same name are two entries.

Every node is frozen as it is built, so a query is the value it was when Query.where checked it: nothing can swap a field or a literal in afterwards. A node may be shared, Expr.eq(n, n), and every walk over one visits each node once, so a deeply shared predicate costs its size, not its paths.

Asking a question that depends on an input, without branching on it

Since an input is a placeholder, a query cannot choose between two shapes based on what it was given. It does not need to: a question that looks like a branch is usually a comparison waiting to be written.

// A list that shows archived entries or unarchived ones, as the reader asks:
Expr.eq(Expr.isNotNull(Entry.fields.archivedAt), input.archived)

// A search box that filters when something is typed and not when nothing is:
Expr.contains(Entry.fields.label, input.search)

The first is "is-archived equals what you asked for". The second relies on everything containing the empty string. Both are one static body, and both ask exactly what archived ? … : … and search === '' ? … : … asked.

isNull and isNotNull are the same node with the answer absence gives flipped, so nothing has to negate a predicate to get the other.

contains is case-insensitive, which is what a search means — and which has to be said, not left to the interpreter: SQLite's like ignores case and Postgres's does not, so a body that left it open would mean two things. Folding is ASCII-only, since that is what lower does in SQLite without ICU: É does not match é, though each matches itself. Text holding a NUL character is refused by every interpreter here, since Postgres text cannot hold one and SQLite's like stops at it.

It searches text, and the operand is constrained to text. contains over a number would compile to lower(rank) like …, which SQLite coerces into an answer and Postgres rejects at runtime — so a non-text operand is a compile error where it is written rather than a surprise where it runs. Nullable text is allowed, because it is a real case with a real meaning:

Over a column that can be null it is not the same as no filter. A null contains nothing, not even the empty string, so its rows drop out. The column the CMS searches is declared not-null, which is what makes an empty search exactly everything there.

Which rows: Query

A Query is an Entity to read, the predicates every row must hold, and the order to read them in — composed with pipe, one immutable value per step:

const published = Query.where(Expr.eq(Blog.Post.fields.published, true))
const newest = Query.orderBy(Order.desc(Blog.Post.fields.title))

const recent = Query.from(Blog.Post).pipe(published, newest)
const oneOf = Query.from(Blog.Post).pipe(published, Query.where(byTitle))

published and newest are fragments: written once, piped into any query over the same Entity. Composing performs no work — no table is named, no connection opened, nothing read.

Two wheres conjoin and two orderBys append. Neither replaces what came before, so piping a fragment can only ever narrow a query, never silently undo part of it. An earlier ordering term stays the more significant one, which is what makes a later Query.orderBy(Order.asc(id)) a tie-breaker. Neither ever replaces what a fragment added.

The list of predicates is the conjunction — which is why no Expr.and exists. A query wanting three conditions writes three wheres. An and operator is only needed for a conjunction nested inside something else, and no query here has one yet.

A query reads one Entity, so a predicate or ordering term naming a different one is refused where it is piped: an interpreter would otherwise be asked for a column of a table it was never told to read. Entities are compared by identity, so two declared with the same name are two Entities here as everywhere else.

Saying what an interpreter can run

An Expr is only portable if the thing running it says what it runs. Query.unsupported(query, supported) names the operations a query needs that an interpreter does not have:

const missing = Query.unsupported(body, ['eq', 'isNull', 'isNotNull'])
if (missing.length > 0) throw new Error(`cannot run ${missing.join(', ')}`)

The refusal belongs to the interpreter, not here: one that compiles at registration and one that runs a body directly fail at different moments. What matters is that it refuses rather than skipping the operation — an interpreter that quietly drops a contains it cannot compile answers a different question in full confidence, and every test it does support still passes.

Reading one back

Query.show(query) renders a body as text, one clause per line:

Query.show(body)
// FROM Post
// WHERE Post.slug = $slug
//   AND Post.published = true
// ORDER BY Post.updatedAt DESC

For a person — an explanation, a diagnostic, a test asserting on a whole predicate at once — and never for an interpreter. It is close enough to SQL to read at a glance and unlike it everywhere that matters: an input is $slug rather than a bound parameter, contains is named rather than rendered as somebody's like, and no dialect's escaping or collation is implied. What actually ran is whatever that backend compiled, which is not this. Expr.show(node) does the same for one expression.

A Query says which rows. It does not say which fields — that is a Selection — and it does not say how many, whether absence is an error, or whether to watch for changes: those belong to the consumer doing the reading, not to the relation.

Only the operations a real query in this repository needs exist. The set grows from queries, not from what a database could express.

API

| Call | Meaning | | --- | --- | | Entity.define(name, struct) | A new Entity with a Field per property. | | Entity.relate(entities, { Owner: { key: Relation.one(Target) } }) | The entities with their relations declared; targets resolve to the returned entities. | | Entity.select(entity, { key: true or Selection }) | A Selection: what was selected (members) and the schema of the result. | | Entity.page(selection, { first, after } or { last, before }) | In a Selection, a many relation read as a page: items, hasNext, hasPrevious. | | Entity.fields(entity, ...keys) | The schemas of those fields, by key, to spread into an input's struct. | | Entity.input(entity, struct, mapping?) | Experimental. Which member each key of an operation's input writes. | | Relation.nested(relation, input) | In an input mapping: the key holds the target itself, written through input. | | Entity.selectFor(input) | The Selection of the members an input writes: what an edit screen loads. | | Entity.valuesFor(input, value) | The input values that reproduce a loaded value: fields as they are, refs as ids. | | Entity.derived({ key: Derived.make(schema) }) | Pipe step adding readable, externally supplied members. | | Entity.annotate(metadata) | Pipe step attaching metadata to the Entity. | | Entity.annotateMembers({ key: metadata }) | Pipe step attaching metadata to members by key. | | Entity.same(a, b) | Whether two descriptors are versions of one Entity. | | Selected<typeof selection> | Type: the value a Selection reads (its schema's Type). | | Words.of(schema) | A schema's title and description, each an Option, including a title given before a check. | | Entity.is(value) | Whether a value is an Entity descriptor. | | Expr.eq(left, right) | Two values are the same; a field or a plain value on either side is coerced, and a predicate may stand where a boolean is wanted. | | Expr.isNull(field) / Expr.isNotNull(field) | Whether a value is absent; one node, with the answer absence gives flipped. | | Expr.contains(field, search) | Whether text contains text, over a text or nullable-text operand only. Containing the empty string is everything, but a null contains nothing. | | Expr.field(field) | One field of one Entity, as a scalar. | | Expr.input(key, schema) | A value the query is given when it runs, as a placeholder. | | Expr.literal(value) | A constant. Comparisons coerce one, so this is rarely written. | | Order.asc(expr) / Order.desc(expr) | One term of an ordering, over a field or a scalar. | | dependenciesOf(...nodes) | The distinct fields, inputs, and operations those expressions use. | | Query.from(entity) | Every row of an Entity: the query each step narrows. | | Query.where(...predicates) | Pipe step keeping the rows those hold for; conjoins with what is there. | | Query.orderBy(...terms) | Pipe step reading in that order; appends after existing terms. | | Query.dependencies(query) | What the whole query reads: every predicate and ordering term. | | Query.unsupported(query, supported) | The operations it needs that an interpreter does not run. | | Query.show(query) / Expr.show(node) | The query or expression as readable text, for a person and not for an interpreter. |

Limits

  • The identifier is the field named id; no other field can be declared as it, because Remote keys its store by id.
  • A Selection has no filtering or ordering, and a page has no total; those belong to the interpreter that fetches.
  • Relations reach only the entities of one Entity.relate call; relating the result again adds relations but earlier targets keep pointing at the earlier result.
  • target() returns the entity as relate returned it. Metadata attached afterwards (CmsPost above) is on the new descriptor only, so annotate before relating when a target should carry it.
  • No registry: nothing checks that two different entities share a name.