Conversation
- Configure ReScript to emit ESM for Bun compatibility - Set in-source compilation to keep .res and .res.js files alongside each other - Add rescript-schema as build dependency for runtime validation - Set suffix to .res.js to distinguish compiled ReScript files This enables the ReScript compiler to output Bun-compatible JavaScript while maintaining a clear project structure.
- Remove: better-sqlite3 (Bun.sql built-in), tsx (Bun native), TypeScript - Add: rescript (compiler), rescript-schema (validation) - Update scripts: bun --watch for dev, rescript build for compilation - Keep: Express (works on Bun), cors, @types/express for FFI bindings This creates a minimal, focused dependency tree optimized for ReScript+Bun. No runtime overhead from TypeScript compilation or alternative SQLite drivers.
- Bind bun:sqlite Database and prepared statement types
- Expose: open, prepare, all, get, run for query execution
- Include transaction support: begin, commit, rollback
- Use abstract types (database, statement) to prevent accidental misuse
- Parameters passed as spread (...array<'b>) for flexibility
- run() returns {changes, lastInsertRowid} for mutation feedback
These bindings replace better-sqlite3 and expose Bun's built-in high-performance
SQLite interface. The abstract types ensure type safety at the Bun boundary.
Instead of a generic QueryBuilder with type parameters, implement explicit, straightforward query functions: - selectAll, findById, findByCategory, findByName, findWithPagination - insertItem, updateItem, deleteById - Utility: exists, count Each function: - Has a concrete signature (no type params) - Uses Result<T, string> for error handling - Handles parameters explicitly (no dynamic binding) - Returns typed itemRow (not generic 'a) This approach: ✓ Eliminates abstraction complexity ✓ Makes all query logic visible and debuggable ✓ Easier to review and refactor ✓ Can scale to CategoryQueryBuilder, OrderQueryBuilder, etc. if needed ✓ No phantom types or advanced generics to maintain Boundary: All Bun.sqlite calls are here. Business logic calls these functions.
- Item record: id, name, description, categoryId, createdAt, updatedAt - fromRow: converts QueryBuilder.itemRow to domain Item type - createTableSQL: SQL to initialize items table with FK to categories - createCategoryIndexSQL: index for efficient category lookups This entity layer keeps domain types separate from database representation, making it easy to evolve the model independently of the DB schema.
Replace Zod with rescript-schema for fully type-safe validation: - createItemSchema: validates name (3-100 chars), optional description, categoryId - updateItemSchema: all fields optional (partial updates) - itemResponseSchema: response DTO matching API contract - validateCreateItem, validateUpdateItem: parse and validate request bodies - toResponse: convert Item entity to API response Key difference from Zod: S.Output.t<typeof schema> is PROVEN correct by the type checker—there's no escape hatch like z.any() or zod.coerce(). Every validation path is type-safe.
Replace AppError class with exhaustive error variants: - NotFound, ValidationError, Conflict, Unauthorized, Forbidden, Internal - statusCode: maps error to HTTP status - message: converts error to user-facing string - toResponse: creates JSON error response Compiler benefits: ✓ Exhaustive pattern matching—impossible to miss error cases ✓ No null checks or type guards needed ✓ Clear error propagation through Result<T, AppError> ✓ Every possible failure mode is explicit in the type system This eliminates entire classes of runtime errors that plagued the class-based approach.
Business logic layer: - Query ops: getAll, getOne, getByCategory, search - Mutations: create, update, delete - All return Result<T, AppError> — no exceptions escape - DB dependency injected via setDatabase() - Exhaustive error handling on every QueryBuilder call Key patterns: ✓ getDb() validates DB is initialized before any query ✓ Result.flatMap chains operations with error propagation ✓ Impossible to accidentally return null or undefined ✓ Each error case is explicit and catchable ✓ Service is pure — only side effect is DB I/O Next layer (HTTP) will unwrap Results and convert to responses.
- initialize: opens database, creates schema, injects DB into services - destroy: closes connection and cleans up - getInstance, getDatabase: accessors for DB reference Flow on startup: 1. AppDataSource.initialize() -> opens data.db 2. Runs PRAGMA foreign_keys = ON 3. Executes Item.createTableSQL and indexes 4. Calls ItemService.setDatabase(db) to inject DB 5. Sets isInitialized = true On shutdown: 1. AppDataSource.destroy() -> db->close() 2. Clears dataSource reference This pattern ensures: ✓ Single DB connection shared across services ✓ Schema is created once on startup ✓ Clean shutdown on process termination ✓ Easy to add more entities (Category, Order, etc.)
Bind only what we use: - Application creation, routing (GET/POST/PUT/PATCH/DELETE) - Request: method, path, params, query, body - Response: status, json, send, setHeader - Middleware: cors, json parser - Error handler type for error middleware This is a minimal surface that covers our current needs. If we need more (e.g., sessions, auth middleware), we add bindings incrementally. Alternative: Switch to Bun.serve later for better performance. For now, Express is lower-risk and enables us to test business logic first.
Handlers for all CRUD operations: - list: GET /rest/items (all items) - get: GET /rest/items/:id (single item) - create: POST /rest/items (new item with validation) - update: PUT /rest/items/:id (partial or full update) - delete: DELETE /rest/items/:id (remove item) Each handler: ✓ Parses request (params, body) ✓ Validates input via ItemDto schemas ✓ Calls ItemService (business logic) ✓ Handles Result<T, AppError> ✓ Maps error to HTTP status code ✓ Returns JSON response Error handling is exhaustive: - Request parsing errors -> 400 - Validation errors -> 400 - Not found -> 404 - Internal errors -> 500 No exceptions escape to the error middleware.
Express router with CRUD endpoints: - GET / -> list all items - GET /:id -> fetch single item - POST / -> create item - PUT /:id -> update item - DELETE /:id -> delete item All routes bound to ItemsController handlers. Router is mounted at /rest/items in the main app. This is the final layer before the entry point.
- Initialize Express app with middleware (cors, json parser) - Await AppDataSource.initialize() to set up database and schema - Mount ItemsRouter at /rest/items - Install error handler middleware - Listen on port 3001 - Handle graceful shutdown (Ctrl+C, SIGTERM) Flow on startup: 1. Express app created 2. Middleware registered 3. Database initialized (schema created, services injected) 4. Routes mounted 5. Error middleware installed 6. Server listening Flow on shutdown: 1. SIGINT/SIGTERM caught 2. AppDataSource.destroy() closes DB connection 3. Process exits cleanly Application is now fully functional with ReScript + Bun + Express.
Documents the complete TypeScript → Bun + ReScript migration: - Architectural changes (concrete QueryBuilder, type-safe errors, Result<T,E>) - Project structure and layer organization - Commit sequence (13 atomic, bottom-up commits) - Testing instructions - Design decisions and trade-offs - Potential gotchas and mitigations This serves as both documentation and a review guide for understanding each commit's purpose and how layers connect.
- Ignore ReScript compiled output (*.res.js, _build/) - Ignore SQLite database files (*.db, *.sqlite, *.sqlite3) - Ignore Bun lock file (bun.lockb) - Keep existing Node/IDE ignores Prevents compiled ReScript and database snapshots from being committed.
Provides: - Quick visual overview of all 14 commits - Architectural comparison table (TypeScript vs ReScript) - Full end-to-end request flow with code examples - Architecture diagram showing layer stack - How to review and test - Key ReScript patterns used - Performance characteristics - Known limitations (all intentional) - Pre-merge checklist Serves as the entry point for understanding the complete redesign.
Remove: - express (replace with Bun.serve) - cors (use Response headers directly) - @types/express, @types/cors, @types/node (no longer needed) - eslint and TypeScript tooling (ReScript provides safety) Keep: - rescript (compiler) - rescript-schema (validation) Dependencies: ✓ Bun.sqlite - built-in ✓ Bun.serve - built-in ✓ JSON parsing - built-in ✓ URL routing - hand-rolled in ReScript Result: Zero external dependencies for runtime or HTTP handling. Only rescript-schema for validation (no replacements available).
Replace Express entirely with Bun's native HTTP server: - request: Fetch API Request type - response: Fetch API Response type - fetchHandler: async function that takes request -> response - json: Create JSON response with CORS headers - empty: Create 204 No Content response - corsPreflightResponse: Handle OPTIONS preflight Benefits: ✓ Built-in to Bun (no npm install) ✓ Fetch API standard (portable code) ✓ CORS headers set directly (no middleware) ✓ Zero external dependencies ✓ Significantly faster than Express Design: - Router logic (URL parsing) is in ReScript - This module only handles HTTP primitives - Response construction includes CORS headers by default
Route matching: - parseRoute: match URL patterns (/rest/items/:id) against paths - Extract parameters from URLs - Support GET, POST, PUT, DELETE, PATCH, OPTIONS Router: - register: add route + handler - dispatch: find matching route and execute handler - Handles CORS preflight (OPTIONS) automatically Convenience: - router.get/post/put/delete/patch helpers Benefits: ✓ 100% ReScript (no external router library) ✓ Explicit route matching logic (easy to debug) ✓ CORS built-in (no middleware needed) ✓ Lightweight (only code we need) ✓ Type-safe parameter extraction Future: Add pattern matching improvements, catch-all routes, etc.
All handlers now use Fetch API directly: - request is Fetch API Request - response is Fetch API Response - No Express types or middleware Helpers: - parseJsonBody: async parse with error handling - getIdParam: extract and validate ID from URL Handlers: - list, get, create, update, delete - All return json() or empty() responses with CORS headers - All handle Result<T, E> exhaustively Benefits: ✓ Pure standard Fetch API ✓ No Express-specific code ✓ No type adapter layer needed ✓ Easier to port to other Bun patterns later Response building uses BunServer.json/empty which include CORS headers.
Replaced by Router.res (pure ReScript) and Bun.serve (native HTTP). Old module no longer needed.
Replaced by BunServer.res (Bun.serve native bindings). Express dependency completely removed from package.json.
Application flow: 1. Initialize database (AppDataSource) 2. Create router and register routes 3. Define fetch handler (request -> response) 4. Start Bun.serve with fetch handler 5. Handle graceful shutdown What's gone: - Express app initialization - Middleware setup (cors, json) - app.listen(...) What's new: - Router.create() and Router.get/post/etc - handleRequest: async fetch handler - BunServer.serve(options) with fetch function URL routing entirely in ReScript (Router.dispatch). No external HTTP framework. Bun.serve handles all network I/O. Dependencies: ✓ Bun (built-in) ✓ ReScript (compiler) ✓ rescript-schema (validation) ✓ NOTHING ELSE This is the simplest, fastest HTTP server we can have in Bun.
Removed dependencies: - express - express-async-errors - cors - Any other external HTTP middleware Reason: Replaced by Bun.serve native API and ReScript routing. Final dependencies: - bun (runtime + HTTP server) - rescript (compiler) - rescript-schema (validation only) DevDependencies: - @types/bun (TypeScript types for IDE/tooling) Scripts: - dev: bun run src/index.res (direct execution) - build: rescript build -to-js (compile to JavaScript) - start: bun dist/index.js (run compiled JS) - db:* scripts via bun (database utilities) This is the absolute minimum to run a REST API. No framework bloat. No middleware boilerplate. Everything in ReScript, optimized for Bun.
Defines: - Item.t: full entity with id and timestamps - Item.createInput: DTO for POST /items - Item.updateInput: DTO for PATCH /items/:id These replace the TypeScript DTOs (item.dto.ts). Used by: - ItemsController.res (handlers) - ItemService.res (business logic) - Schemas.res (validation) Type-safe across the entire API stack.
Defines: - itemCreateSchema: validates POST /items body - itemUpdateSchema: validates PATCH /items/:id body - parseCreateInput: parse + validate JSON - parseUpdateInput: parse + validate JSON - formatErrors: convert errors to string for responses This is the single source of truth for all input validation. Replaces scattered TypeScript validation decorators. Type-safe parsing: Result<input, errors> Zero runtime overhead: compiles to optimized JS.
Defines: - NotFound: 404 - ValidationError: 400 (with error list) - Conflict: 409 - Internal: 500 Functions: - toStatus: error → HTTP status - toMessage: error → string message - toResponse: error → HTTP response Compiles to minimal JavaScript. No exceptions or try/catch in business logic. Errors flow as Result types through the system.
Defines: - deps type: interface for data access - mockList/get/create/update/delete: in-memory defaults - default: service instance Dependency Injection allows: - Testing: inject mock deps - Flexibility: swap database implementations - Clarity: explicit dependencies Will be populated by AppDataSource.ts later. Controllers receive deps and call service methods.
Handlers (type: request -> promise<response>): - list: GET /items - get: GET /items/:id - create: POST /items - update: PATCH /items/:id - delete: DELETE /items/:id Inline parsing: - readBody: extract request body - getIdParam: parse route param to int - Schemas.parse*: validate JSON Error handling: - All errors flow through Result types - AppError.toResponse: convert to HTTP response Dependency Injection: - Controllers use ItemService.default (injected deps) - Services are polymorphic (can be mocked) No exceptions. Pure functions. Type-safe.
Functions: - extractParams: parse /items/:id from path - matchRoute: match METHOD + PATH to handler - dispatch: main entry point for request routing Supports: - GET /items - GET /items/:id - POST /items - PATCH /items/:id - DELETE /items/:id Pattern matching: - Static paths: exact match - Dynamic params: :id extracted to params dict - Returns Option (Some route, None if no match) Type-safe handler dispatch. Zero regex. Pure string matching.
…cture - bindings/Bun.res: minimal FFI — serve, sqlite, URL.pathname, req.json() - core/AppError.res: error variants + toResponse (adds BadRequest) - core/Result.res: ROP combinators — map, flatMap, fromOption - core/Item.res: type t + createInput + updateInput + toJson (single serializer) - core/Schemas.res: modern S.schema API, schema IS the type, single file - db/Db.res: open bun:sqlite + run migrations - db/ItemRepo.res: findAll, findById, insert, patch, delete — db threaded as param - api/ItemService.res: DI record wired to ItemRepo, ROP pipelines - api/ItemsController.res: uniform handler type, -> pipelines, zero duplication - api/Router.res: URL.pathname fix, pattern match dispatch - api/Server.res: Bun.serve + graceful shutdown - Index.res: db init -> server start
- db/Schema_.res: DSL-style table definitions (inspired by rescript-sql) - db/Schema.res: typed row/insert/update shapes derived from Schema_ - db/Db.res: updated DDL — categories + items with FK - db/CategoryRepo.res: findByItemId - db/ItemRepo.res: findAll(search,limit), insert, insertMany, replace, replaceMany, deleteMany - core/Category.res: type t + toJson - core/Item.res: add categoryId field - core/Schemas.res: replaceInput, bulkReplaceInput, bulkDeleteInput, S.union poly body - api/ItemService.res: full op set - api/ServiceRegistry.res: wire CategoryRepo - api/ItemsController.res: all 8 handlers - api/Router.res: /rest prefix, /items/:id/categories, bulk routes - Readme.md: accurate stack, full endpoint tables, env vars
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.