DBDelve
Dive deep into your data. A fast, native, multi-engine database client written in Rust. Free and open source, no telemetry.
Primitives for hexagonal TypeScript.
Translation pending
Developer toolsTypeScript · JavaScriptGitHub @crobinson42OnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
Hexo Kit - typescript hexagonal and clean architecture software kit with end-to-end type-safety and excellent DX
Loading the full README.
Read the full README ↗Hexok is a small TypeScript kit for hexagonal software. You extend a primitive, and the compiler tells you what that primitive still requires. You wire objects together yourself.
Documentation: crobinson42.github.io/hexok.
npm i hexok
This repository includes an agent skill named hexok. It walks a feature from the user story through entities, use cases, events, adapters, the gateway, and where handlers run. Install it from GitHub. npx skills update refreshes that install from GitHub.
npx skills add crobinson42/hexok
npx skills update
import { Adapter, Entity, Errors, Event, EventCatalog, EventHandler, Mapper, Port, Schema, UseCase } from 'hexok'
| Primitive | What you extend | What the compiler requires |
|---|---|---|
Schema | Schema('User', zodSchema) | the token and the schema are arguments |
Entity | Entity('User', UserSchema) | create and set are already implemented; add rules as methods |
Port | Port('UserRepository') | the abstract methods you declare on the port |
Adapter | Adapter(UserRepository) | every abstract port method; override start / stop when you need them |
Mapper | Mapper('mongo.User', User, mongoUserSchema) | fromSource and toSource; adapters call toModel and fromModel |
UseCase | UseCase('user.create') | execute. Pass ports through the constructor. UseCase.context is the family factory |
Event | Event('user.created', UserSchema) | the token and the payload schema are arguments |
EventCatalog | EventCatalog('domain', { userCreated }) | the token and the event map are arguments |
EventHandler | EventHandler('on.user.created', UserCreated) | handle |
Errors | Errors('domain', { BlankName: { message } }) | each key is a factory; throw the error it returns |
Result, CodedError, and Standard Schema helpers ship next to the primitives. EventInstance<typeof DomainEvents> is the catalog's event objects. A use case publishes new UserCreated(...). EventMessage<typeof DomainEvents> is { key, payload } for an adapter. DomainEvents.message and DomainEvents.parse convert between them. Pass a catalog key as the second type argument to keep one entry. HTTP, gateways, and composition do not. A use case receives its ports in the constructor, and the application constructs that use case.
class DomainError extends Errors('domain', {
BlankName: { message: 'Name is blank' },
UserExists: { message: 'User already exists', data: z.object({ id: z.string() }) },
Unauthorized: { message: 'Unauthorized' },
}) {}
class User extends Entity('User', userSchema) {
rename(name: string): this {
if (name.trim() === '') throw DomainError.BlankName()
return this.set((draft) => {
draft.name = name
})
}
}
abstract class UserRepository extends Port('UserRepository') {
abstract get(id: string): Promise<User | null>
abstract save(user: User): Promise<void>
}
class InMemoryUsers extends Adapter(UserRepository) {
override async get(id: string): Promise<User | null> { /* ... */ }
override async save(user: User): Promise<void> { /* ... */ }
}
class CreateUser extends UseCase('user.create') {
constructor(private readonly deps: { users: UserRepository }) { super() }
async execute(input: { id: string; name: string; email: string }): Promise<User> {
const existing = await this.deps.users.get(input.id)
if (existing) throw DomainError.UserExists({ id: input.id })
const user = User.create(input)
await this.deps.users.save(user)
return user
}
}
const createUser = new CreateUser({ users: new InMemoryUsers() })
await createUser.execute({ id: '1', name: 'Ada', email: '[email protected]' })
The string you pass is the token. Its type is that string literal. A subclass does not redeclare it, and a different literal is not assignable over the top of it.
static abstract is not part of TypeScript, so a base class cannot force a subclass to fill in a static field. Hexok therefore takes the token and the schema as arguments of the primitive. Required behavior is an abstract instance method: omit execute, omit a port method on an adapter, or omit fromSource or toSource on a mapper, and the error is on that class.
UseCase.context returns a factory for one family. UseCase('user.create') is unchanged: execute(input), ports in the constructor. StandardSchemaV1 and InferSchema are exported from hexok. Zod and the other Standard Schema libraries satisfy StandardSchemaV1.
@hexok/typebox adapts a typebox 1 schematic to that same interface. typebox(schema) infers Static. typeboxDecode(schema) infers StaticDecode and decodes.
type ApiContext = { sessionId: string }
const ApiUseCase = UseCase.context<
ApiContext,
{ input: StandardSchemaV1; permission: string }
>()
.guard((call) => {
if (call.ctx.sessionId.length < 1 || call.spec.permission.length < 1) {
throw DomainError.Unauthorized()
}
})
.hooks({
preExecute: () => ({ started: Date.now() }),
postExecute(_call, state) {
void state.started
},
})
class FindUsers extends ApiUseCase('user.find', {
input: z.object({ query: z.string() }),
permission: 'users.read',
}) {
constructor(private readonly deps: { users: UserRepository }) { super() }
async execute(
ctx: UseCase.Ctx<typeof ApiUseCase>,
input: { query: string },
): Promise<User[]> {
return this.deps.users.search(input.query)
}
}
await new FindUsers({ users }).execute({ sessionId: 's' }, { query: 'ada' })
This is the beginning of the README. Read the rest at the original. Read the full README ↗