@trebired/store
v1.0.2
Published
Reusable generic entity store for Bun and Node.js with typed entity registries, SQLite, scoped JSONB storage, modes, request loaders, cache invalidation, and sub-entity reads.
Maintainers
Readme
@trebired/store
Reusable generic entity store for Bun and Node.js applications, with typed entity registries, SQLite and scoped JSONB storage, mode enrichment, request-scoped loaders, cache invalidation, and host-defined sub-entity reads.
@trebired/store is the generic Trebired package for hosts that already know their entities but should not keep rebuilding the same persistence, context scoping, read modes, cache, and enriched-record safety layer.
It owns:
- entity registry resolution
- high-level store runtime bootstrap
- PostgreSQL pool, query, and schema/table initialization
- SQLite query and table/index initialization
- scoped entity reads and writes
- storage adapter contracts
- PostgreSQL JSONB storage
- SQLite JSON storage
- raw, full, and named mode output
- private field redaction
- enriched-record write protection
- optional L1/L2 cache orchestration
- request-scoped loaders with
AsyncLocalStorage - discriminator-based record views over shared physical entities
- bulk removal and generic orphan/duplicate repair helpers
- boot reconciliation, declarative hydration, and runtime memo caching
- generic sub-entity execution
- stable result/error envelopes
- optional Trebired logger-adapter integration
It stays intentionally generic.
It does not know about products, deployments, repositories, routes, frontend panels, permissions, or any host-specific business entities.
Install
Runtime support: Bun 1+ and Node.js 18+.
npm install @trebired/storeFor the PostgreSQL adapter:
npm install pgSQLite does not add a native runtime dependency. Pass a compatible SQLite database/client, such as Bun SQLite, to the adapter or runtime.
Quick Start
Define the host-owned entity registry:
import {
createMemoryStorageAdapter,
createStore,
defineEntityRegistry,
} from "@trebired/store";
const entities = defineEntityRegistry({
documents: {
table: "documents",
storage: "memory",
context: ["workspaceId"],
aliases: ["document", "docs"],
metadata: {
owner: "content",
},
privateFields: {
token: "documents:private",
},
modes: {
summary: {
enrich: "documents.summary",
},
},
},
});
const store = createStore({
entities,
storages: {
memory: createMemoryStorageAdapter(),
},
enrichers: {
"documents.summary": (record) => ({
id: record.id,
title: record.title,
}),
},
});Write records through the package API:
await store.entity.write.put("documents", {
workspaceId: "workspace_1",
}, {
id: "doc_1",
title: "Store extraction",
token: "secret",
});Read scoped records:
const document = await store.entity.read.by("document", {
id: "doc_1",
}, {
workspaceId: "workspace_1",
}, {
mode: "summary",
});All public operations return explicit Trebired-style result objects with ok, error_code, message, data, and optional details.
Logging
Logging is optional and uses @trebired/logger-adapter, matching the other Trebired packages.
const store = createStore({
entities,
storages,
logger: console,
});Hosts can also pass loggerAdapter for a custom sink. The package emits generic store lifecycle/read/write/cache events only; it does not know about app routes, pages, permissions, or business objects.
Core API
The high-level factory is createStoreRuntime(options).
import {
computed,
countBy,
createRedisMemoAdapter,
createStoreRuntime,
relation,
} from "@trebired/store";
const redisMemo = createRedisMemoAdapter({
getJson: redis.getJson,
setJson: redis.setJson,
del: redis.del,
incr: redis.incr,
publishJson: redis.publishJson,
subscribeJson: redis.subscribeJson,
});
const runtime = createStoreRuntime({
entities: {
items: {
table: "items",
aliases: ["item"],
required: ["workspaceId"],
private: {
token: "items:private",
},
metadata: {
owner: "runtime",
},
modes: {
detail: {
// Concise mode hook maps strip the "with-" prefix before loading hooks.
// This loads hooks named "label" and "audit".
"with-label": true,
hooks: {
"with-label": true,
"with-audit": true,
},
with: {
owner: relation({
entity: "owners",
id: "owner_id",
mode: "raw",
assign: "owner",
}),
children: countBy({
entity: "children",
foreignKey: "item_id",
localKey: "id",
assign: {
total: "children_total",
},
}),
url: computed((record, api) => ({
url: api.url(record),
})),
},
},
},
},
},
postgres: {
databaseUrl: process.env.DATABASE_URL,
schema: "public",
resultMode: "envelope",
logOperations: true,
slowQueryMs: 250,
pool: {
max: 10,
idleTimeoutMs: 30_000,
connectionTimeoutMs: 5_000,
statementTimeoutMs: 15_000,
},
indexes: [
{
table: "items",
expression: "(record->>'status')",
},
],
migrations: [
async ({ query }) => {
const result = await query("select $1", ["migration-ok"]);
if (result.ok === false) throw new Error(result.message);
},
],
metrics(event) {
console.log(event.operation, event.elapsedMs);
},
},
modes: {
hookRoot: new URL("./entities/", import.meta.url),
hookFileConvention: "entity/with/name",
legacyHookAdapter({ hook }) {
return legacyHooks[hook];
},
},
boot: {
context: {
now_iso: new Date().toISOString(),
},
environment: {
developerMode: process.env.NODE_ENV === "development",
splitDev: process.env.SPLIT_DEV === "1",
},
fixes: [
{
entity: "items",
actions: [
{
if: {
field: "status",
equals: "starting",
},
set: {
status: "stopped",
stopped_at: {
ctx: "now_iso",
},
},
unset: [
"runtime.pid",
],
},
],
},
],
rewrites: {
items: {
normalize(record) {
return record;
},
},
},
followUps: {
"items.ensure": async ({ record }) => {
console.log(record.id);
},
},
},
memo: {
l1: {
maxEntries: 500,
ttlMs: 30_000,
},
l2: redisMemo,
redis: redisMemo,
},
subEntities: {
"items.children": {
kind: "provider",
validateContext(ctx) {
return ctx.item_id ? { ok: true, ctx } : { ok: false };
},
async list(ctx, options, api) {
return api.readAll("children", ctx, options).then((res) => res.data || []);
},
async count(ctx) {
return Number(ctx.item_id ? 1 : 0);
},
},
},
events: {
onWrite({ entity, operation }) {
console.log(entity, operation);
},
},
});The runtime exposes the store surface directly:
export const entity = runtime.entity;
export const cache = runtime.cache;
export const records = runtime.records;
export const repair = runtime.repair;
export const subEntity = runtime.subEntity;
export const memo = runtime.memo;
export const onBoot = runtime.onBoot;Runtime Postgres:
- creates a pool from explicit
databaseUrland pool options, or uses an injected client for tests and custom hosts - redacts database URLs in logs
- validates application queries for empty SQL, comments, multiple statements, placeholder order, and inline read/write literals
- supports
resultMode: "envelope"for{ ok, rows, rowCount }success and structured{ ok: false, error_code, message }failures - logs pool creation, first connection, pool waits, pool errors, slow queries, query failures, and optional operation logs
- calls an optional query metrics callback
- creates the schema, JSONB entity tables, default GIN indexes, extra expression indexes, and safe migration hooks
- wires the package PostgreSQL JSONB adapter internally
Runtime SQLite:
- uses an injected generic SQLite database/client, or an optional dynamic Bun SQLite
path - stores one table per entity with
id text primary keyandrecord text not null - stores records as JSON strings and filters/sorts with SQLite JSON functions where possible
- validates table names, JSON path fields, sort fields, and generated SQL
- supports
where,options.where,byIds, scoped context,scope: "all",limit,sort, and native bulk removal - supports
resultMode: "envelope"for structured query validation failures - logs slow queries and optional operation logs through
@trebired/logger-adapter - calls an optional query metrics callback
- creates SQLite-backed entity tables, expression indexes, and safe migration hooks during
runtime.onBoot() - wires the package SQLite JSON adapter internally
import { Database } from "bun:sqlite";
import { createStoreRuntime } from "@trebired/store";
const database = new Database("store.sqlite");
const runtime = createStoreRuntime({
sqlite: {
database,
},
entities: {
users: {
table: "users",
storage: "sqlite",
context: ["tenant_id"],
},
},
});
await runtime.onBoot();Runtime registry normalization supports concise host-owned entity definitions. required becomes context, private becomes privateFields, and mode hooks such as "with-profile": true load hook name "profile". Unknown fields can be preserved as opaque metadata, but the package does not interpret display or presentation metadata.
If an entity declares storage, that value is respected. If storage is omitted, runtime defaults to postgres when Postgres is configured, otherwise sqlite when SQLite is configured, otherwise memory. When Postgres and SQLite are both configured, declare storage on each entity that should not use the Postgres default.
Runtime boot reconciliation supports generic fixes with if, if_all, nested field paths, equals, equals_any, gt, set, set_if_missing, unset, rewrite, after, run_after_on_match, skip_in_developer_mode, and skip_in_split_dev. Boot values can resolve from context with { ctx: "now_iso" }. The package queues and runs host-owned follow-up callbacks, records structured outcomes in RuntimeBootResult.followUps, and leaves the callback behavior outside the package.
For host code that has many boot rules, the package exports reusable boot builders:
import {
bootFollowUpWhen,
bootResetStatus,
bootRewrite,
bootSetIfMissing,
bootTruthyCondition,
defineBootFix,
mergeBootOptions,
} from "@trebired/store";
const boot = mergeBootOptions({
fixes: [
defineBootFix("items", [
bootRewrite(),
bootSetIfMissing({ owner: "system" }),
bootResetStatus(["running", "starting"], "stopped", {
unset: ["runtime.pid"],
}),
bootFollowUpWhen("items.ensure", [
{ field: "status", equals: "stopped" },
bootTruthyCondition("runtime.policy.auto_start"),
]),
]),
],
});Boot follow-up dispatching can also be package-owned while handlers remain host-owned:
import {
createBootFollowUpDispatcher,
readBootBoolean,
} from "@trebired/store";
const followUps = createBootFollowUpDispatcher({
logger: console,
group: "trebired.store.boot",
guards: {
readyTarget: {
timeoutMs: 30_000,
pollMs: 250,
async resolveTarget({ record }) {
return typeof record.target_id === "string" ? record.target_id : "";
},
async isReady(targetId) {
return checkTargetReady(targetId);
},
onWaitMessage: "Waiting for boot follow-up target.",
onReadyMessage: "Boot follow-up target became ready.",
onTimeoutMessage: "Boot follow-up target did not become ready in time.",
},
},
handlers: {
"items.ensure": {
guard: "readyTarget",
policy: {
field: "runtime.policy.ensure_on_boot",
fallback: false,
},
async run({ call, entity, record, config, api }) {
await ensureRecord(record, config);
return api.succeeded({ call, entity });
},
},
"items.audit": async ({ record, api }) => {
if (!readBootBoolean(record, "runtime.policy.audit_on_boot")) {
return api.skipped();
}
return auditRecord(record);
},
},
});
const boot = {
fixes: [
defineBootFix("items", [
bootFollowUpWhen("items.ensure", {
field: "status",
equals: "ready",
}),
]),
],
followUps,
};The dispatcher owns call lookup, unknown-call skipped outcomes, exception handling, optional boolean policy checks, readiness polling, generic log metadata, and structured success/failure/skipped envelopes. followUpCount is the number of queued follow-ups; followUpsRunCount is the number of non-skipped outcomes.
Boot rewrite builders remove repeated normalization boilerplate:
import {
bootRecord,
booleanPolicyDefaults,
createBootRewriter,
customTransform,
defaultStatus,
objectField,
stringAliases,
stringField,
uniqueStringArrayField,
} from "@trebired/store";
const normalize = createBootRewriter({
items: bootRecord([
stringField("name", {
fallbackFrom: "id",
prefix: "#",
}),
objectField("metadata"),
stringAliases("created_at", ["createdAt", "recorded_at"]),
stringAliases("created_by", ["createdBy"]),
uniqueStringArrayField("tags", ["tagIds"]),
defaultStatus("stopped"),
booleanPolicyDefaults("runtime.policy", {
ensure_on_boot: true,
retry_on_failure: true,
}),
customTransform((record) => hostNormalize(record)),
]),
});
const boot = {
rewrites: {
normalize,
},
};Transforms preserve unknown fields, return a new object, support nested dot paths, normalize aliases/defaults/arrays/objects, and allow host-specific normalization through customTransform(...).
Runtime memo exposes:
runtime.memo.get(...)runtime.memo.set(...)runtime.memo.run(...)runtime.memo.invalidateEntity(...)runtime.memo.inspectRead(...)runtime.memo.keyForRead(...)runtime.memo.entityVersion(...)
Stable memo keys ignore request/runtime-only fields such as req, res, meta, frontend, cacheBypass, and cache.
Provider-backed virtual sub-entities can be declared under subEntities with kind: "provider" and are routed through runtime.entity.read.all/by/count/hasAny(...).
For lower-level integrations, createStore(options) remains available.
Direct SQLite adapter usage:
import { Database } from "bun:sqlite";
import {
createSqliteJsonStorageAdapter,
createStore,
defineEntityRegistry,
} from "@trebired/store";
const database = new Database(":memory:");
const entities = defineEntityRegistry({
users: {
table: "users",
storage: "sqlite",
context: ["tenant_id"],
},
});
const sqlite = createSqliteJsonStorageAdapter({
database,
});
const store = createStore({
entities,
storages: {
sqlite,
},
});
await sqlite.ensureReadyFor?.({
definition: entities.users,
name: "users",
});
await store.entity.write.put("users", {
tenant_id: "tenant_1",
}, {
email: "[email protected]",
id: "user_1",
});Entity reads:
store.entity.read.all(entity, context, options?)store.entity.read.by(entity, where, context, options?)store.entity.read.count(entity, context, options?)store.entity.read.hasAny(entity, context, options?)
context may be null or undefined; the package normalizes those values to {}. Non-object contexts such as arrays, dates, strings, numbers, booleans, and functions return store-invalid-context results. Entities with required context keys still fail required-context validation when the normalized context is missing those keys.
all, count, and hasAny accept options.where with the same validation as read.by(...):
const activeDocuments = await store.entity.read.all("documents", {
workspaceId: "workspace_1",
}, {
where: {
status: "active",
},
});
const hasGoldDocuments = await store.entity.read.hasAny("documents", {
workspaceId: "workspace_1",
}, {
where: {
meta: {
tier: "gold",
},
},
});Entity writes:
store.entity.write.put(entity, context, record, options?)store.entity.write.by(entity, where, context, patch, options?)store.entity.write.remove(entity, context, id, options?)store.entity.write.removeMany(entity, ids, context?, options?)
Record views:
store.records(entity, views)
Sub-entity reads:
store.subEntity.list(name, parentWhere, context, options?)store.subEntity.by(name, parentWhere, where, context, options?)store.subEntity.count(name, parentWhere, context, options?)
Entity Definitions
Hosts define the registry. The package only understands the generic contract:
const entities = defineEntityRegistry({
documents: {
table: "documents",
storage: "postgres",
context: ["workspaceId"],
aliases: ["document", "docs"],
metadata: {
owner: "content",
},
privateFields: {
token: "documents:private",
},
modes: {
summary: {
enrich: "documents.summary",
},
},
},
});Helpers:
defineEntityRegistry(registry)resolveEntityName(registry, nameOrAlias)resolveEntityDefinition(registry, nameOrAlias)resolveEntityMetadata(registry, nameOrAlias)
Resolution supports the canonical key, singular/plural forms, and explicit aliases.
Record Views
Record views are discriminator-filtered helpers over one physical entity. They are useful when a host stores several generic row shapes in one table but does not want to rebuild local wrapper APIs.
const records = store.records("documents", {
item: {
kind: "item",
defaults: {
status: "open",
},
normalize: (row) => ({
...row,
title: String(row.title || "Untitled"),
}),
sort: [
"priority:asc",
"recorded_at:desc",
],
},
target: {
kind: "target",
uniqueBy: [
"item_id",
"server_id",
],
},
});The default discriminator field is kind. Set discriminatorField on a view to use another field.
View reads automatically add the discriminator to where and push where, sort, and limit into storage where the adapter supports it:
const openItems = await records.item.list({
context,
limit: 20,
where: {
status: "open",
},
});
const item = await records.item.byId("item_1", {
context,
});View writes apply defaults, run normalize, and enforce the discriminator before writing:
await records.item.put({
id: "item_1",
title: "Reusable state",
}, {
context,
});
await records.item.patch({
id: "item_1",
}, {
priority: 2,
}, {
context,
});Unique upserts read by the configured unique fields, preserve an existing id when found, and write the normalized merged record:
await records.target.upsertUnique({
id: "target_1",
item_id: "item_1",
server_id: "server_1",
status: "current",
}, {
context,
});Record views use the same entity write paths as the core API, so enriched-record safeguards remain mandatory.
Storage Adapters
Adapters implement:
allbybyIdscounthasAnyputremove- optional
removeMany ensureReadyFor
The package includes createPostgresJsonbStorageAdapter(...).
PostgreSQL records are stored as:
id text primary key,
record jsonb not nullThe adapter validates SQL identifiers and placeholder order, scopes reads by required context, applies required context on write, supports scope: "all" with optional where, supports byIds, supports sort and limit, supports native bulk delete, and accepts an explicit client. It does not read environment files or host configuration.
import { Pool } from "pg";
import { createPostgresJsonbStorageAdapter } from "@trebired/store";
const postgres = createPostgresJsonbStorageAdapter({
client: new Pool({
connectionString: process.env.DATABASE_URL,
}),
schema: "public",
});Modes And Private Fields
Reads support:
rawfull- any named mode from the entity definition
Raw mode returns stored records without enrichment or private-field filtering.
Non-raw reads redact private fields unless includePrivate explicitly allows them. Named modes can use host-owned enrichers:
const store = createStore({
entities,
storages,
enrichers: {
"documents.summary": (record) => ({
id: record.id,
title: record.title,
}),
},
});For mode definitions made of reusable hooks, build the registry from entity config:
import { createModeEnricherRegistry } from "@trebired/store";
const entities = defineEntityRegistry({
documents: {
table: "documents",
storage: "postgres",
modes: {
detail: {
hooks: {
"with-owner": true,
"with-url": true,
},
},
},
},
});
const enrichers = createModeEnricherRegistry({
entities,
getStore: () => store,
loadHook({ hook }) {
return hooks[hook];
},
});Hooks run sequentially in definition order and receive a read-only API:
const hooks = {
async "with-owner"(record, api, context) {
const owner = await api.readById("owners", String(record.ownerId), context.context, {
mode: "raw",
});
return {
...record,
ownerName: owner.data?.name,
recorded_at: api.recorded_at,
};
},
};Enriched-Record Safety
Non-raw read results are marked as enriched records and deep-frozen before they are returned.
Every enriched record receives:
- an enumerable
__store_enriched: truemarker, so JSON-serialized enriched data remains detectable - an internal non-enumerable runtime brand plus
WeakSettracking, so same-object marker deletion or hiding attempts are rejected - a recursive freeze across nested objects and arrays, so enriched records are output-only
If persisted JSONB data already contains the enriched marker, reads fail with store-enriched-marker-persisted.
If write input contains or has been tracked as enriched, writes fail with store-enriched-record.
Use mode: "raw" when the host needs stored data for a write path:
const raw = await store.entity.read.by("documents", {
id: "doc_1",
}, context, {
mode: "raw",
});There is no option to disable this guard, no auto-repair path, and no exported helper that strips, bypasses, repairs, or ignores the marker.
Cache
Enable caching with cache: true or a cache config:
const store = createStore({
entities,
storages,
cache: {
enabled: true,
l2: redisLikeAdapter,
},
});The cache provides:
- L1 memory cache
- optional L2 adapter
- entity versioning
- key tracking per entity
- invalidation per entity
- in-flight read de-duplication
- optional read cache metadata
Cache keys ignore request/frontend/runtime-only context fields and include only data that changes read results.
Cache keys include entity, operation, mode, scoped context, where/input, scope, private-field unlocks, and entity version.
Writes and removes clear request loaders and invalidate the affected entity cache. Hosts can also invalidate generic entity cache directly:
store.cache.invalidateEntity("documents");Repair Helpers
The generic repair API cleans up relationships between record views without host-side SQL:
const summary = await store.repair.orphansAndDuplicates({
child: records.target,
parent: records.item,
childParentKey: "item_id",
uniqueBy: [
"item_id",
"server_id",
],
keep: "freshest",
freshnessFields: [
"recorded_at",
"last_seen_at",
"applied_at",
"removed_at",
],
context,
});It scans parent and child views in raw mode, finds children whose parent id is missing, finds duplicate children by uniqueBy, keeps the freshest duplicate, deletes invalid rows through removeMany, and returns counts for scanned, deleted, remaining, and skipped work.
Request Context
Request helpers use AsyncLocalStorage and are framework-neutral:
import {
getStoreRequestContext,
getOrCreateRequestLoader,
runWithStoreRequestContext,
} from "@trebired/store";
await runWithStoreRequestContext({
requestId: "req_1",
}, async () => {
const loader = getOrCreateRequestLoader("documents:all", () => new Map());
const request = getStoreRequestContext();
console.log(request?.meta.requestId);
return loader;
});Exports:
runWithStoreRequestContextgetStoreRequestContextgetOrCreateRequestLoadergetOrCreateRequestValueclearRequestEntityLoaders
Framework bindings should live as small host adapters.
Sub-Entities
Sub-entities are host-defined child collections read from a parent entity:
const store = createStore({
entities,
storages,
subEntities: {
comments: {
parent: "documents",
childKey: "comments",
identityField: "id",
sourceMode: "raw",
},
},
});
const comments = await store.subEntity.list("comments", {
id: "doc_1",
}, {
workspaceId: "workspace_1",
});A sub-entity can define context validation, list/by/count implementations, and enrichment hooks. The package owns the execution contract; hosts own concrete definitions.
Errors
Generic error codes include:
store-entity-not-foundstore-sub-entity-not-foundstore-invalid-contextstore-missing-idstore-invalid-idstore-invalid-wherestore-invalid-modestore-enriched-recordstore-enriched-marker-persistedstore-sql-identifierstore-sql-placeholderstore-storage-error
The core package does not include frontend-specific, route-specific, or exception-format adapters.
