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

@ticatec/keelson-core

v1.0.1

Published

Core database access, declarative transactions, dependency injection (BeanFactory), four-tier architecture (Service/Repository/DAO), dynamic query building, and pagination framework for Keelson.

Readme

@ticatec/keelson-core

中文文档 | English

A comprehensive Node.js database access framework providing robust abstractions for database connection management, SQL execution, declarative transaction handling, pagination, and dynamic query building.

Version License: MIT

This package was @ticatec/node-common-library. It is now @ticatec/keelson-core, starting again at 1.0.0 - a new name on npm is a new package with its own publish history. Migrating is a rename of the dependency and the import specifier; no export changed its name or signature. If you are coming from node-common-library 3.x, the behavioural changes that landed between 3.x and this release are listed under Migrating from node-common-library 3.x.

🌟 Features

  • Dual ESM / CommonJS: ships native ES modules and CommonJS from one package — Node picks the right format automatically based on your project
  • Multi-database Support: adapt to any database by implementing the DBConnection abstract class and the DBFactory interface (ready-made drivers: @ticatec/keelson-pg, @ticatec/keelson-mysql, @ticatec/keelson-dm)
  • Dialect-neutral SQL building: parameter placeholders are produced by the driver through getPlaceholder(index), so CommonSearchCriteria emits $1 on PostgreSQL and ? on MySQL / Dameng from the same builder code
  • Declarative Transactions: annotate service methods with @Transaction(propagation) — commit / rollback is handled by TransactionManager using AsyncLocalStorage, so the active connection is propagated without being passed around
  • Paginated Queries: built-in PaginationList result and the re-entrant CommonSearchCriteria dynamic query builder
  • SQL File Execution: executeSQLFile() strips comments and runs each statement, aborting on the first failure by default
  • Field Transformation: automatic underscore → camelCase, nested object hydration via "profile.isActive" aliases, explicit null preservation, and 1/0/T/F → boolean coercion
  • Lazy Bean Factory: beanFactory.register(name, Class) + beanFactory.createBean<T>(name) returns a singleton proxy that defers construction until first use, breaking circular-dependency cycles at module load time
  • Optimistic Locking: OptimisticLockException for concurrent-update conflict reporting

📦 Installation

pnpm add @ticatec/keelson-core @ticatec/logger-api reflect-metadata
# plus the driver for your database, e.g.
pnpm add @ticatec/keelson-pg pg

@ticatec/logger-api is a peer dependency — install it explicitly. It is a zero-dependency logging contract; it does not pull in a logging library. Add one only if you want structured output (see Logging).

The package is published as dual ESM/CJS. Consumers do not need any extra configuration — require() works in CommonJS projects and import works in ESM / TypeScript projects.

// ESM / TypeScript
import { DBManager, beanFactory, CommonService, CommonDAO } from '@ticatec/keelson-core';

// CommonJS
const { DBManager, beanFactory, CommonService, CommonDAO } = require('@ticatec/keelson-core');

Requirements

| | | | --- | --- | | Node.js | >= 18 | | Peer dependency | @ticatec/logger-api >= 1.0.0 | | Runtime dependency | reflect-metadata (required by the @Transaction decorator) |

🚀 Quick Start

1. Logging (optional)

Nothing to do — the framework logs against the @ticatec/logger-api contract, and without a provider installed records go to the console, filtered by LOG_LEVEL (default info).

To route them into a real logging library, install a provider once at startup. For pino, use the ready-made adapter:

import { initialize } from '@ticatec/logger-pino';

initialize({
    appenders: [
        { name: 'console', type: 'console', level: process.env.LOG_LEVEL || 'info' }
    ],
    loggers: {
        root: { level: process.env.LOG_LEVEL || 'info', appenders: ['console'] }
    }
});

For anything else, adapt it in a few lines with setLoggerProvider() — see the logger-api README.

Ordering does not matter. CommonDAO, CommonService, CommonRepository, CommonSearchCriteria and Beans all acquire their logger in the constructor, but a captured logger resolves the active provider on every write — so installing the provider after those objects exist works exactly as well.

2. Initialize the database manager

Use one of the driver packages, or implement DBFactory yourself (see Writing a custom driver). Call DBManager.init() exactly once at application startup.

import { DBManager } from '@ticatec/keelson-core';
import { initializePg } from '@ticatec/keelson-pg';

DBManager.init(initializePg({
    host: 'localhost',
    port: 5432,
    database: 'app',
    user: 'app',
    password: '***'
}));

On shutdown, release the pool through the factory:

await dbFactory.close();

3. Register DAOs, Repositories and Services

The default export beanFactory is a singleton registry. register(name, Class) does not instantiate the class — createBean(name) returns a lazy proxy whose underlying instance is built on first access and reused thereafter.

import { beanFactory } from '@ticatec/keelson-core';
import { UserDAO } from './dao/UserDAO';
import { UserRepository } from './repository/UserRepository';
import { UserService } from './service/UserService';

beanFactory.register('UserDAO', UserDAO);
beanFactory.register('UserRepository', UserRepository);
beanFactory.register('UserService', UserService);

For dynamic / code-split registration use Beans, which registers async loaders and resolves them into beanFactory in one pass:

import { Beans } from '@ticatec/keelson-core';

const beans = Beans.getInstance();
beans.register('UserDAO', () => import('./dao/UserDAO.js'));
beans.register('UserService', () => import('./service/UserService.js'));

await beans.load();   // each module's default export is registered into beanFactory

4. Create a DAO

CommonDAO exposes getDBConnection(), which returns the connection bound to the surrounding @Transaction context. Writing methods return the typed InsertResult<T> / UpdateResult<T> structures.

import { CommonDAO } from '@ticatec/keelson-core';
import type { InsertResult } from '@ticatec/keelson-core';

export class UserDAO extends CommonDAO {

    async createUser(user: User): Promise<InsertResult<User>> {
        const sql = 'INSERT INTO users (id, name, email) VALUES ($1, $2, $3) RETURNING *';
        return await this.executeInsertQuery<User>(sql, [this.genID(), user.name, user.email]);
    }

    async findUserById(id: string): Promise<User | null> {
        const sql = 'SELECT * FROM users WHERE id = $1';
        return await this.findByPK(sql, [id]);
    }

    async updateUser(user: User): Promise<number> {
        const sql = 'UPDATE users SET name = $1, email = $2 WHERE id = $3';
        const result = await this.executeUpdateQuery<User>(sql, [user.name, user.email, user.id]);
        return result.affectedRows;
    }

    async deleteUser(id: string): Promise<number> {
        const sql = 'DELETE FROM users WHERE id = $1';
        return await this.executeDeleteQuery(sql, [id]);
    }
}

The SQL you write is dialect-specific — $1, $2 on PostgreSQL, ? on MySQL and Dameng. Only CommonSearchCriteria generates placeholders for you.

5. Create a Repository

Repositories extend CommonRepository and coordinate DAOs via getDAOInstance<T>(name):

import { CommonRepository } from '@ticatec/keelson-core';
import type { UserDAO } from './dao/UserDAO';

export class UserRepository extends CommonRepository {
    async createUser(user: User): Promise<string> {
        const userDAO = this.getDAOInstance<UserDAO>('UserDAO');
        const result = await userDAO.createUser(user);
        if (result.affectedRows === 0 || result.record == null) {
            throw new Error('Failed to create user');
        }
        return result.record.id;
    }
}

6. Create a Service with @Transaction

Service methods are annotated with @Transaction(). Services extend CommonService and access repositories via getRepositoryInstance<T>(name):

import { CommonService, Transaction, Propagation } from '@ticatec/keelson-core';
import type { UserRepository } from './repository/UserRepository';

export class UserService extends CommonService {

    @Transaction()
    async registerUser(user: User): Promise<string> {
        const repo = this.getRepositoryInstance<UserRepository>('UserRepository');
        return await repo.createUser(user);
    }

    @Transaction(Propagation.REQUIRES_NEW)
    async writeAuditLog(entry: AuditEntry): Promise<void> {
        const repo = this.getRepositoryInstance<AuditRepository>('AuditRepository');
        await repo.append(entry);
    }
}

@Transaction only records metadata — the actual wrapping happens in the CommonService constructor. Putting the decorator on a class that does not extend CommonService has no effect.

7. Dynamic search with CommonSearchCriteria

Set the base SQL, base parameters and orderBy in the constructor; append dynamic conditions in buildDynamicQuery(). The constructor values are snapshotted as a baseline on first execution and restored before every subsequent run, so a single instance can be executed repeatedly without duplicating conditions.

import { CommonSearchCriteria } from '@ticatec/keelson-core';
import type { PaginationList } from '@ticatec/keelson-core';

const BASE_SQL = `
    SELECT u.id, u.name, u.email, u.created_at, p.is_active AS "profile.isActive"
      FROM users u
      LEFT JOIN profiles p ON p.user_id = u.id
     WHERE u.tenant_code = $1 AND u.deleted = false`;

class UserSearchCriteria extends CommonSearchCriteria {
    constructor(tenantCode: string, criteria?: any) {
        super(criteria);
        this.sql = BASE_SQL;            // baseline
        this.params = [tenantCode];     // baseline
        this.orderBy = 'ORDER BY u.created_at DESC';
        this.setBooleanFields('profile.isActive');
    }

    protected buildDynamicQuery(): void {
        this.addWildcardCriteria(this.criteria?.name, 'u.name');                       // '*' → LIKE, otherwise =
        this.addEqualsCriteria(this.criteria?.email, 'u.email');                       // exact match
        this.addRangeCriteria(this.criteria?.dateFrom, this.criteria?.dateTo, 'u.created_at');
    }
}

// inside a @Transaction or TransactionManager.execute context:
const result: PaginationList = await new UserSearchCriteria('TENANT_A', {
    name: 'John*',
    page: 1,
    pageSize: 20
}).paginationQuery(conn);

console.log(`Total: ${result.count}, Pages: ${result.pages}`);

Placeholders inside addXxxCriteria() are generated through the connection's getPlaceholder(index), so the same class works unchanged against PostgreSQL, MySQL and Dameng. Placeholders you write yourself inside BASE_SQL are your responsibility; use this.getPlaceholder(this.params.length) when appending raw fragments:

protected buildDynamicQuery(): void {
    if (this.criteria?.categoryPath) {
        this.params.push(`${this.criteria.categoryPath}%`);
        this.sql += ` AND pc.query_path LIKE ${this.getPlaceholder(this.params.length)}`;
    }
}

🏗️ Core Components

CommonDAO

Abstract base class for Data Access Objects. Members available to subclasses (all protected):

| Member | Purpose | | --- | --- | | getDBConnection() | Returns the connection bound to the surrounding transaction context; throws if there is none | | findFirst(sql, params?) | Executes a query and returns the first row or null | | findByPK(sql, params?) | Alias of findFirst | | listQuery(sql, params?) | Executes a query and returns all matching rows | | executeInsertQuery<T>(sql, params?) | Executes an INSERT, returns InsertResult<T> | | executeUpdateQuery<T>(sql, params?) | Executes an UPDATE, returns UpdateResult<T> | | executeDeleteQuery(sql, params?) | Executes a DELETE, returns the affected row count | | executePaginationQuery(criteria) | Runs a CommonSearchCriteria and returns PaginationList | | quickSearch<T>(sql, params?, pageNo?, rowCount?, booleanFields?) | Dialect-independent paginated query returning QuickSearchResult<T> | | executeCountSQL(sql, params, key?) | Runs a count(*) query and returns a number (key defaults to 'cc') | | genID() | 32-character UUID v7 without dashes — time-ordered, suitable as a primary key | | toBooleanInt(b) / toBooleanChar(b) | Write direction: formats a boolean for the column it is stored in - 1/0 and 'T'/'F' respectively. The read direction is DBConnection.getBoolean(value), or the booleanFields argument on a query | | logger | Logger scoped to the subclass name |

CommonRepository

Abstract base class for the repository tier — coordinates DAOs, assembles POs/DTOs.

| Member | Purpose | | --- | --- | | getDAOInstance<T>(name) | Returns the registered DAO proxy; throws a descriptive error when the bean is not registered | | genID() | 32-character UUID v7 without dashes | | genUUID() | Canonical 36-character UUID v7 with dashes | | logger | Logger scoped to the subclass name |

CommonService

Abstract service base class. The constructor walks the prototype chain and wraps every method annotated with @Transaction so the body runs inside TransactionManager.execute(propagation, …). Wrapping is idempotent — an inherited method that a parent class already wrapped is not wrapped twice.

| Member | Purpose | | --- | --- | | @Transaction(propagation?) | Method decorator that opens a transactional context | | getDBConnection() | Returns the connection of the current transaction | | getRepositoryInstance<T>(name) | Returns the registered repository proxy; throws when it is not registered | | logger | Logger scoped to the subclass name |

Transaction propagation

Defined in Propagation:

  • REQUIRED (default) — join the surrounding transaction, or start a new one if there is none. A context opened with NONE is not transactional, so a nested REQUIRED call starts a real transaction of its own rather than silently joining a non-transactional connection.
  • REQUIRES_NEW — always open an independent connection and transaction, suspending the surrounding context
  • NONE — run on a fresh, non-transactional connection (statements auto-commit)

For ad-hoc use without the decorator, call TransactionManager.execute(propagation, fn) directly:

import { TransactionManager, Propagation } from '@ticatec/keelson-core';

await TransactionManager.execute(Propagation.REQUIRED, async (conn) => {
    // conn is committed on resolve, rolled back on throw, and always closed
});

// Anywhere inside that callback:
const conn = TransactionManager.getCurrentConnection();   // DBConnection | undefined

DBConnection

Abstract class defining the database primitives a driver must implement, plus the shared query helpers the framework builds on top of them.

Provided by the base class (drivers inherit these):

  • Reads: find<T>() (single row), listQuery<T>() (many rows), executeCountSQL(), quickSearch<T>()
  • Criteria helpers: executePaginationSQL(criteria), queryByCriteria(criteria)
  • SQL files: executeSQLFile(path, options?)
  • Utilities: convertBooleanFields(data, fields), sanitizeParams(params), getRowSetLimitClause(rowCount, offset)
  • Result shaping: automatic underscore → camelCase, nested paths via "parent.child.field" aliases, explicit null preservation, boolean coercion

Must be implemented by a driver — see Writing a custom driver.

executeSQLFile(path, options?)

Reads a .sql file, strips /* … */ and -- … comments, splits on statement-terminating semicolons, and executes each statement in order.

await conn.executeSQLFile('./migrations/001_init.sql');
// default: { stopOnError: true, throwOnError: true } → aborts and throws on the first failure

const hasError = await conn.executeSQLFile('./migrations/001_init.sql', {
    stopOnError: false            // keep going, return true if anything failed
});

| Option | Default | Effect | | --- | --- | --- | | stopOnError | true | Stop at the first failing statement | | throwOnError | true | Throw an Error naming the file and the failing statement (only applies when stopOnError is true) |

This is a lightweight splitter for standard DDL/DML migrations. It does not parse PL/SQL blocks or semicolons inside string literals.

Boolean field auto-conversion

// Simple fields
const user = await conn.find(
    'SELECT * FROM users WHERE id = $1',
    [id],
    null,
    ['isActive', 'isVerified']          // converted in place
);

// Nested fields via SQL aliasing
const userWithProfile = await conn.find(
    `SELECT u.*, p.is_active AS "profile.isActive"
       FROM users u
       LEFT JOIN profiles p ON p.user_id = u.id
      WHERE u.id = $1`,
    [id],
    null,
    ['profile.isActive']                // walks the dotted path
);

CommonSearchCriteria / SearchCriteria

Dynamic query builder. CommonSearchCriteria is the base class; SearchCriteria is a thin backward-compatible alias — prefer CommonSearchCriteria in new code.

The constructor is protected constructor(criteria?: any), so the class can only be used through a subclass — declare your own public constructor and call super(criteria) from it.

It reads page and pageSize from the incoming criteria object and clamps them: page is forced to >= 1, pageSize to the range 1 … 1000 (default 25).

| Member | Purpose | | --- | --- | | buildDynamicQuery() | Abstract. Override to append and … clauses and push params | | addEqualsCriteria(value, field) | Appends field = <placeholder> when the value is non-empty | | addWildcardCriteria(text, field) | * wildcards → LIKE, otherwise = | | addRangeCriteria(from, to, field) | Appends field >= … and/or field < …; a Date upper bound is advanced to the start of the next day | | getPlaceholder(index) | Dialect placeholder for the active connection ($N / ?) | | escapePercentage(s) / replaceWildStar(s) | Escape \, % and _; replaceWildStar additionally maps * → % | | toWildSQL(s) / wrapLikeMatch(s) / includeStar(s) / isNotEmpty(v) | String helpers | | getNextDayStart(d) | Exclusive upper bound for date ranges — next local midnight, DST-safe | | setBooleanFields(...fields) | Sets which result fields to coerce to boolean | | getPostProcessor() | Override to return a per-row post-construction callback | | paginationQuery(conn) | Returns PaginationList — { count, hasMore, list, pages } | | query(conn) | Non-paginated run returning every matching row |

Re-entrancy. paginationQuery() and query() both snapshot sql, params and orderBy on first use and restore them before calling buildDynamicQuery() on every subsequent call. Set your base SQL and base parameters in the constructor; keep buildDynamicQuery() purely additive.

BeanFactory

beanFactory is the default-export singleton; the BeanFactory class is also exported if you need a separate registry.

import { beanFactory, BeanFactory } from '@ticatec/keelson-core';

beanFactory.register('UserDAO', UserDAO);

// Returns a Proxy, or undefined when nothing is registered under that name.
// The real instance is constructed on first property access and reused thereafter.
const userDAO = beanFactory.createBean<UserDAO>('UserDAO');

// An isolated registry, e.g. for tests
const testRegistry = new BeanFactory();

The proxy breaks circular dependencies: registering A and B that reference each other no longer triggers a ReferenceError at module load — neither is built until something actually calls a method on its proxy. A genuine construction cycle is detected and reported as Circular dependency detected: A -> B -> A.

DBManager

DBManager.init(factory);        // once at startup; subsequent calls are ignored
DBManager.getInstance();        // throws if init() has not been called
await DBManager.getInstance().connect();   // → DBConnection
DBManager.resetInstance();      // test hook

🔌 Writing a custom driver

A driver supplies two things: a DBConnection subclass and a DBFactory.

import { DBConnection, DBFactory, Field, FieldType } from '@ticatec/keelson-core';
import type { InsertResult, UpdateResult } from '@ticatec/keelson-core';

class MyDBConnection extends DBConnection {

    // --- dialect ---
    getPlaceholder(index: number): string { return `$${index}`; }   // '?' for MySQL / Dameng

    // --- transactions ---
    async beginTransaction(): Promise<void> { /* … */ }
    async commit(): Promise<void> { /* … */ }
    async rollback(): Promise<void> { /* … */ }
    async close(): Promise<void> { /* release back to the pool */ }

    // --- writes ---
    async executeUpdate(sql: string, params: any[]): Promise<number> { /* … */ }
    async insertRecord<T = any>(sql: string, params: any[]): Promise<InsertResult<T>> { /* … */ }
    async updateRecord<T = any>(sql: string, params: any[]): Promise<UpdateResult<T>> { /* … */ }
    async deleteRecord(sql: string, params: any[]): Promise<number> { /* … */ }

    // --- raw / reads (protected) ---
    protected async executeSQL(sql: string): Promise<any> { /* used by executeSQLFile */ }
    protected async fetchData(sql: string, params?: any[]): Promise<any> { /* … */ }

    // --- result introspection ---
    getFields(result: any): Array<Field> { /* … */ }
    protected getRowSet(result: any): Array<any> { /* the row array */ }
    protected getAffectRows(result: any): number { /* … */ }
    protected getFirstRow(result: any): any { /* mapped first row, or null */ }
}

class MyDBFactory implements DBFactory {
    async createDBConnection(): Promise<DBConnection> { /* … */ }
    async close(): Promise<void> { /* pool.end() */ }
}

Required members, at a glance:

| Member | Visibility | Notes | | --- | --- | --- | | getPlaceholder(index) | public | Abstract since 4.0.0. $N for PostgreSQL, ? for MySQL / Dameng | | beginTransaction() / commit() / rollback() / close() | public | close() releases the connection back to the pool | | executeUpdate() / insertRecord<T>() / updateRecord<T>() / deleteRecord() | public | The insert/update variants return InsertResult<T> / UpdateResult<T> | | executeSQL(sql) | protected | Raw execution path used by executeSQLFile() — must support DDL | | fetchData(sql, params?) | protected | Returns the driver's raw result; getRowSet / getFields read it | | getFields(result) | public | Field metadata | | getRowSet(result) | protected | The row array — resultToList() calls this, so it must return real rows | | getAffectRows(result) | protected | Affected row count | | getFirstRow(result) | protected | Mapped first row, or null | | DBFactory.close() | public | Required since 1.0.0 — shuts the pool down |

Optional overrides: getRowSetLimitClause(rowCount, offset) (defaults to limit N offset M), toCamel(name), splitFieldPath(field) (defaults to splitting on .; override this rather than setNestObj(), which carries the prototype-chain guard), buildFieldsMap(fields), setNestObj(obj, field, value), resultToList(result), getBoolean(value).

Helpers available to your driver: this.logger, this.safeLogMeta(sql, params) (logs the statement and the parameter count; the values only when KEELSON_LOG_SQL_PARAMS=true), this.sanitizeParams(params) (maps undefined → null), this.toCamel(name), this.getBoolean(value) (accepts true/false, 1/0, and '1', '0', 't', 'f', 'true', 'false' in any case).

🔧 Advanced Features

Batch processing

import type { BatchRecord, BatchRecords } from '@ticatec/keelson-core';

const batch: BatchRecords<User> = [
    { recNo: 1, data: { name: 'User 1', email: '[email protected]' }, error: null },
    { recNo: 2, data: { name: 'User 2', email: '[email protected]' }, error: null }
];

for (const record of batch) {
    try {
        await userDAO.createUser(record.data);
    } catch (err) {
        record.error = err;
    }
}

Bit-packed flags via BitsBoolean

import { BitsBoolean } from '@ticatec/keelson-core';

class UserPermissions extends BitsBoolean {
    constructor(value = 0) { super(value); }
    setCanRead(v: boolean)  { this.setBitValue(0, v); }
    getCanRead(): boolean   { return this.getBitValue(0); }
    setCanWrite(v: boolean) { this.setBitValue(1, v); }
    getCanWrite(): boolean  { return this.getBitValue(1); }
}

BitsBoolean.fromBooleanArray([true, false, true]);   // → 5 (max 31 elements)
new UserPermissions(5).toBooleanArray(3);            // → [true, false, true]

Bit positions are limited to 0 … 30; anything outside that range throws. All operations are unsigned 32-bit.

String utilities

import { StringUtils } from '@ticatec/keelson-core';

StringUtils.genID();               // 32-char UUID v7, no dashes
StringUtils.uuid();                // canonical UUID v7 with dashes
StringUtils.isEmpty('   ');        // true
StringUtils.isString('abc');       // true
StringUtils.isNumber('123');       // true
StringUtils.parseNumber('abc', 0); // 0 (fallback)
StringUtils.leftPad('45', '0', 4); // '0045'

genID() and uuid() return UUID v7. A v7 value carries a 48-bit millisecond timestamp in its leading bits and is monotonic within the same millisecond, so identifiers generated in a tight loop stay strictly increasing — and they keep that ordering after genID() strips the hyphens. That makes them well suited as database primary keys: inserts stay local in the B-tree index instead of scattering the way random v4 values do.

StringUtils.genID();   // 01a0b1ab1d6674899b3f9a4d28db4410
StringUtils.genID();   // 01a0b1ab1d6674899b3f9e1c0bb70631   ← sorts after the previous one

A v7 identifier discloses its creation time. Where that matters, or where the value must be unguessable (tokens, password-reset links), generate a random value yourself with node:crypto instead.

Logging

The library logs against @ticatec/logger-api, a zero-dependency contract. Which library actually receives the records is the application's choice — install a provider with setLoggerProvider(), or use the pino adapter in @ticatec/logger-pino. With no provider installed, output goes to the console at LOG_LEVEL (default info).

import { getLogger } from '@ticatec/keelson-core';
import type { Logger } from '@ticatec/keelson-core';

const log: Logger = getLogger('MyService');
log.info({ userId }, 'user logged in');

Every framework base class exposes a logger already scoped to the concrete subclass name, so you rarely need getLogger() directly. SQL is logged by the drivers through safeLogMeta(), which records the statement text and the parameter count but never the parameter values.

Logging and sensitive data

Every layer logs through the @ticatec/logger-api contract. Two things are deliberately kept out of the records:

SQL bind parameters. The parameters carry the real data flowing through every query - email addresses, password hashes, national IDs, card numbers. A DAO query logs the statement and the parameter count, never the values:

DEBUG [dao/OrderDAO] Executing find query {"sql":"select * from users where email=$1 and pwd=$2","paramCount":2}

Set KEELSON_LOG_SQL_PARAMS=true to include the values while debugging locally. It is read on every call, so it can be toggled at runtime, and only the exact string true enables it - 1 or yes will not. Never enable it in production: turning on debug logging would otherwise copy your entire database traffic into the log store, which is usually the most widely readable, longest-retained and most easily exported system you own.

The connection factory. DBManager.init(factory) records the factory's class name only. A DBFactory holds the full connection configuration, database password included.

Use sqlContext(sql, params) when you log a statement from your own driver or DAO; it applies the same rule.

import { getLogger, sqlContext } from '@ticatec/keelson-core';

const logger = getLogger('MyDriver', 'db');
logger.debug(sqlContext(sql, params), 'Executing statement');

📋 API Reference

Classes

  • DBManager — init(factory), getInstance(), connect(), resetInstance()
  • DBConnection — abstract driver base class
  • CommonDAO / CommonRepository / CommonService — the four-tier base classes
  • CommonSearchCriteria — dynamic query builder; protected constructor, extend it
  • SearchCriteria — deprecated. An empty subclass kept for backward compatibility (abstract class SearchCriteria extends CommonSearchCriteria {}). It adds no behaviour; extend CommonSearchCriteria directly in new code
  • TransactionManager — execute(propagation, fn), getCurrentConnection()
  • BeanFactory — registry class; beanFactory is the default singleton instance
  • Beans — getInstance(), register(name, loader), load()
  • BitsBoolean — bit-packed boolean flags
  • StringUtils — isEmpty, genID, uuid, leftPad, isString, isNumber, parseNumber

Decorators

  • @Transaction(propagation?: Propagation) — marks a method for declarative transaction handling. Defaults to Propagation.REQUIRED. It only records metadata via reflect-metadata; the CommonService constructor performs the actual wrapping, so the decorator has no effect on a class that does not extend CommonService.

Functions

  • sqlContext(sql: string, params?: any[]): object — builds the log context for a SQL statement: the statement and paramCount, with the parameter values included only when KEELSON_LOG_SQL_PARAMS=true. Use it whenever you log a statement.
  • getLogger(name: string, category?: string): Logger — re-exported from @ticatec/logger-api. Returns a logger that resolves the installed provider on every write, falling back to the console when there is none. name is conventionally the class or module name; category groups related loggers (the framework uses 'db', 'dao', 'service' and 'repository'). Never throws, and never requires prior initialisation.

Interfaces (type-only)

  • DBFactory — createDBConnection(): Promise<DBConnection>, close(): Promise<void>
  • InsertResult<T> — { affectedRows: number, record: T | null, insertId?: number | string | null }
  • UpdateResult<T> — { affectedRows: number, record: T | null }
  • PaginationList — { count, hasMore, list, pages }
  • QuickSearchResult<T> — { list: T[], hasMore: boolean }
  • ExecuteSQLFileOptions — { stopOnError?: boolean, throwOnError?: boolean }
  • Field — { name, type: FieldType, length? }
  • BaseDAO<T, K> — createNew, update, find by key
  • BaseCRUDDAO<T, K> — extends BaseDAO with remove
  • BatchRecord<T> / BatchRecords<T> — batch processing record shapes

Types (type-only)

  • PostConstructionFun — (obj: any) => void, used by find() / listQuery()
  • Logger — re-exported from @ticatec/logger-api; five level methods (trace, debug, info, warn, error)

Enums

  • FieldType — Text, Number, Date
  • Propagation — REQUIRED, REQUIRES_NEW, NONE

Errors

  • OptimisticLockException — extends Error; carries the conflicting entity on .entity
import { OptimisticLockException } from '@ticatec/keelson-core';

try {
    await userDAO.updateUser(user);
} catch (err) {
    if (err instanceof OptimisticLockException) {
        console.warn('concurrent edit on', err.entity);
    }
}

🔀 Migrating from node-common-library 3.x

| Change | Before (node-common-library 3.x) | After (keelson-core 1.0.0) | | --- | --- | --- | | insertRecord() / executeInsertQuery() | returned the inserted row (or a driver-specific value) | returns InsertResult<T> — read result.record, result.affectedRows, result.insertId | | updateRecord() / executeUpdateQuery() | returned the updated row (or a driver-specific value) | returns UpdateResult<T> — read result.record, result.affectedRows | | DBConnection.getPlaceholder(index) | did not exist | abstract — every driver must implement it | | DBFactory.close() | did not exist | required — every factory must implement it | | BeanFactory export | was bound to the singleton instance | is the class; use beanFactory for the singleton | | Logging | @ticatec/logger-wrapper (pino) as a peer dependency | @ticatec/logger-api — a zero-dependency contract. pino is now optional; install @ticatec/logger-pino (the renamed adapter) only if you want it | | StringUtils.genID() / uuid() | UUID v4 (crypto.randomUUID()) | UUID v7 — time-ordered and monotonic, suitable as a primary key | | NULL columns | dropped from mapped objects | preserved as null, consistently across find() and listQuery() | | DAO query logs | { sql, params } — every bind parameter value | { sql, paramCount }; values only with KEELSON_LOG_SQL_PARAMS=true | | DAO logger category | 'controller' | 'dao' — add a dao category to your logger config, or it falls back to root | | DBManager.init() log | the whole factory object, password included | the factory class name | | CommonDAO.getBoolean(b) / getBooleanValue(b) | boolean → 'T'/'F' and boolean → 1/0 | renamed to toBooleanChar(b) / toBooleanInt(b). The old getBoolean shared its name with DBConnection.getBoolean(value), which runs the other way; this.getBoolean(row.isActive) compiled and returned 'T'. No compatibility alias is kept - rename the calls | | DBConnection.getBoolean(value) | 'true' / 'false' fell through to truthiness, so 'false' read as true | recognises 'true' / 'false' (any case, trimmed) alongside 't' / 'f' and '1' / '0' |

Two more behaviours changed without changing a signature — worth re-reading if you depend on them:

  • Propagation.NONE no longer leaks a non-transactional context. A REQUIRED method called from inside a NONE context now opens a real transaction instead of silently joining the auto-commit connection.
  • CommonSearchCriteria is re-entrant. sql / params / orderBy set in the constructor are snapshotted and restored before each run, so executing the same instance twice no longer duplicates conditions or parameters.

Upgrading a call site usually looks like this:

// 3.x
const row = await this.executeInsertQuery(sql, params);
return row.id;

// 1.0.0
const result = await this.executeInsertQuery<User>(sql, params);
return result.record?.id;          // or result.insertId on MySQL / auto-increment tables

🛠️ Build & publish

| Script | What it does | | --- | --- | | pnpm build | Cleans lib/, compiles CommonJS to lib/cjs/, ESM to lib/esm/, copies the marker package.json files | | pnpm typecheck | tsc --noEmit against both tsconfig.cjs.json and tsconfig.esm.json | | pnpm test | Runs the Jest suite | | pnpm lint | ESLint over src/**/*.ts (also runs automatically via prebuild) | | pnpm clean | Removes lib/ | | pnpm publish:public | Publishes to npm with public access |

prepublishOnly runs typecheck && test && build, so a failing type check or test blocks publishing.

The dual build relies on the exports map in package.json: the . entry routes import to lib/esm/index.js and require to lib/cjs/index.js, with lib/cjs/index.d.ts providing types.

📝 Dependencies

| Package | Kind | Why | | --- | --- | --- | | reflect-metadata | dependency | Metadata storage for the @Transaction decorator | | uuid | dependency (^11.1.0) | UUID v7 generation for StringUtils.genID() / StringUtils.uuid() | | @ticatec/logger-api | peer dependency (>= 1.0.0) | Zero-dependency logging contract used by every base class |

uuid is pinned to the 11.x line because 12.x and 13.x are ESM-only, which would break this package's CommonJS build.

🤝 Contributing

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/AmazingFeature)
  3. Commit your changes (git commit -m 'Add some AmazingFeature')
  4. Push to the branch (git push origin feature/AmazingFeature)
  5. Open a Pull Request

📄 License

This project is licensed under the MIT License — see the LICENSE file for details.

👨‍💻 Author

Henry Feng — [email protected]

🔗 Links