# @sdflc/backend-helpers

> Set of helper functions and classes to simplify building backend apps within @sdflc ecosystem

Latest version **1.0.139** (published 2026-09-10) · MIT license · 0 weekly downloads

## Install

```sh
npm install @sdflc/backend-helpers
pnpm add @sdflc/backend-helpers
yarn add @sdflc/backend-helpers
bun add @sdflc/backend-helpers
```

## Health

**Score 70/100 (B)** — status: active.

Positive: has types; esm support; no vulnerabilities; recently updated; high maintenance score; high quality score.

Warnings: low downloads.

## Facts

| | |
|---|---|
| Version | 1.0.139 |
| Published | 2026-09-10 |
| First published | 2021-07-02 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | bundled |
| Module format | ESM + CommonJS |
| Dependencies | 16 |
| Unpacked size | 1.2 MB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| Author | Sergey Demidenko |
| Maintainers | dcerge |

## Links

- npm: https://www.npmjs.com/package/@sdflc/backend-helpers
- npm.io page: https://npm.io/package/@sdflc/backend-helpers

## Dependencies (16)

- [pg](https://npm.io/package/pg.md) ^8.20.0
- [knex](https://npm.io/package/knex.md) ^3.2.6
- [uuid](https://npm.io/package/uuid.md) ^13.0.0
- [dayjs](https://npm.io/package/dayjs.md) ^1.11.20
- [redis](https://npm.io/package/redis.md) ^5.11.0
- [bcrypt](https://npm.io/package/bcrypt.md) ^6.0.0
- [lodash](https://npm.io/package/lodash.md) ^4.17.23
- [checkit](https://npm.io/package/checkit.md) ^0.7.0
- [bluebird](https://npm.io/package/bluebird.md) ^3.7.2
- [dataloader](https://npm.io/package/dataloader.md) ^2.2.3
- [check-types](https://npm.io/package/check-types.md) ^11.2.3
- [dotenv-safe](https://npm.io/package/dotenv-safe.md) ^9.1.0
- [@sdflc/utils](https://npm.io/package/@sdflc/utils.md) ^1.0.28
- [jsonwebtoken](https://npm.io/package/jsonwebtoken.md) ^9.0.3
- [@sdflc/api-helpers](https://npm.io/package/@sdflc/api-helpers.md) ^1.1.16
- [json-stable-stringify](https://npm.io/package/json-stable-stringify.md) ^1.3.0

## Recent versions

- 1.0.139 (latest) — 2026-09-10
- 1.0.138 — 2026-09-09
- 1.0.137 — 2026-09-03
- 1.0.136 — 2026-07-21
- 1.0.135 — 2026-04-30
- 1.0.134 — 2026-04-19
- 1.0.133 — 2026-04-19
- 1.0.132 — 2026-04-17
- 1.0.131 — 2026-04-17
- 1.0.130 — 2026-04-17
- 1.0.129 — 2026-04-17
- 1.0.128 — 2026-04-17
- 1.0.127 — 2026-04-06
- 1.0.126 — 2026-04-06
- 1.0.125 — 2026-04-05
- … 122 more at https://npm.io/package/@sdflc/backend-helpers/versions

## README

# @sdflc/backend-helpers

A comprehensive backend framework for Node.js applications providing database access, authentication, caching, IP-based rate limiting, GraphQL resolver helpers, REST controllers, CLI utilities, and migration tools.

---

## Table of contents

- [Installation](#installation)
- [Requirements](#requirements)
- [Architecture overview](#architecture-overview)
- [Quick start](#quick-start)
- [Configuration](#configuration)
  - [buildConfig](#buildconfig)
  - [buildKnexConfig](#buildknexconfig)
- [Application setup](#application-setup)
  - [Logger](#logger)
  - [Redis client](#redis-client)
  - [Knex handle](#knex-handle)
  - [Context](#context)
  - [Express app](#express-app)
- [Core concepts](#core-concepts)
  - [ApiContext](#apicontext)
  - [BaseCore](#basecore)
  - [BaseGateway](#basegateway)
  - [BaseController](#basecontroller)
  - [BaseRestApiCrudController](#baserestapicrudcontroller)
  - [BaseExternalApiGateway](#baseexternalapigatewayx)
- [IP rate limiting](#ip-rate-limiting)
  - [How it works](#how-it-works)
  - [Setup](#setup)
  - [Global tier](#global-tier)
  - [Core-level rules](#core-level-rules)
  - [Action-level rules](#action-level-rules)
  - [Per-call override](#per-call-override)
  - [Disabling rate limiting](#disabling-rate-limiting)
  - [Resetting a counter after success](#resetting-a-counter-after-success)
  - [Custom limiter](#custom-limiter)
- [Database](#database)
  - [Database class](#database-class)
  - [DataCache and custom loaders](#datacache-and-custom-loaders)
  - [RedisDataLoader](#redisdataloader)
- [Authentication](#authentication)
  - [JWT token helpers](#jwt-token-helpers)
- [GraphQL helpers](#graphql-helpers)
  - [buildDefaultResolvers](#builddefaultresolvers)
- [CLI utilities](#cli-utilities)
- [Migration and seeding utilities](#migration-and-seeding-utilities)
  - [DbMigratorAndSeeder](#dbmigratorandseeder)
  - [dbHelpers](#dbhelpers)
- [Field helpers](#field-helpers)
- [Validation helpers](#validation-helpers)
  - [commonValidators](#commonvalidators)
  - [UUID helpers](#uuid-helpers)
  - [UUID rule factories](#uuid-rule-factories)
- [Utility functions](#utility-functions)
  - [downloadFile](#downloadfile)
  - [passwordHelpers](#passwordhelpers)
- [Constants](#constants)
- [Interfaces reference](#interfaces-reference)

---

## Installation

```bash
npm install @sdflc/backend-helpers
```

---

## Requirements

- Node.js >= 22
- TypeScript >= 5.7
- PostgreSQL or MSSQL (via Knex)

---

## Architecture overview

The library implements a simplified variation of **Clean Architecture** — a layered approach where each layer has a single, well-defined responsibility and dependencies only flow inward.

```
Application layer   →   GraphQL resolvers / REST controllers / CLI
                        Accept requests from the outside world and optionally
                        transform inputs before passing them to cores.

Business layer      →   Core classes (BaseCore subclasses)
                        Manage transactions, handle request-level authentication,
                        validation, IP rate limiting, enforce business rules,
                        and normalise responses into a consistent OpResult shape.

Data layer          →   Gateway classes (BaseGateway subclasses)
                        Perform CRUD operations on database tables, run complex
                        queries with joins and filters, communicate with
                        external APIs and third-party services.
```

Each inbound request — whether from an HTTP client, a GraphQL operation, or a CLI command — creates a fresh **ApiContext** that carries the database connection, all gateway instances, all core instances, and request-scoped data (userId, spaceId, headers, etc.).

**Key invariants:**

- Gateways and cores are instantiated **per-request** and must never be reused across requests
- The data layer (gateways) has no knowledge of HTTP, GraphQL, or authentication — it only handles data
- The business layer (cores) has no knowledge of transport details — it receives plain args and returns OpResults
- The application layer (controllers/resolvers/CLI) has no direct database access — it goes through cores

This separation means the same core logic is accessible identically from a GraphQL resolver, a REST endpoint, and a CLI command — as demonstrated throughout this documentation.

---

## Quick start

The typical project structure:

```
src/
  config.ts               ← environment config via buildConfig()
  knexConfig.ts           ← Knex config via buildKnexConfig()
  knexHandle.ts           ← Knex instance for migrations/seeds/CLI
  logger.ts               ← shared logger instance
  redisClient.ts          ← optional Redis client
  context.ts              ← per-request ApiContext factory
  app.ts                  ← Express + Apollo setup
  index.ts                ← server entry point
  core/
    AppCore.ts            ← project-level base core
    NoteCore.ts           ← resource-specific core
  gateways/
    NoteGw.ts             ← resource-specific gateway
  app/
    graphql/
      types/              ← GraphQL type definitions
      resolvers/          ← GraphQL resolvers
    restapi/              ← REST controllers
    cli/
      index.ts            ← CLI entry point
```

---

## Configuration

### buildConfig

Loads environment variables from a `.env` file and returns a flat camelCase config object. Boolean strings (`'true'`/`'1'` → `true`, `'false'`/`'0'` → `false`) are coerced automatically.

```typescript
// src/config.ts
import { buildConfig } from '@sdflc/backend-helpers';

const config = buildConfig(__dirname, {
  envFile: '../.env',
  envFileExample: '../.env.example',
  defaultNodeEnv: 'development',
  allowEmptyValues: true,
});

export default {
  ...config,
  // Post-process any values that need further transformation:
  currencies: (config.allowedCurrencies || '').split(','),
};
```

```bash
# .env.example — documents all required variables
NODE_ENV=development
DB_TYPE=pg
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp
DB_SCHEMA=public
DB_USER=postgres
DB_PASSWORD=secret
DB_POOL_MIN=0
DB_POOL_MAX=10
JWT_SECRET=change-me
LOG_LEVEL=debug
REDIS_HOST=
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_SSL=false
CACHE_TTL=300
COOKIE_NAME=token
FINGERPRINT_COOKIE_NAME=fp
INTERSERVICE_API_KEY=
```

---

### buildKnexConfig

Builds a Knex configuration object for the current environment. The library handles port coercion, MSSQL connection options, and PostgreSQL `searchPath` automatically — no manual post-processing needed.

```typescript
// src/knexConfig.ts
import { buildKnexConfig } from '@sdflc/backend-helpers';
import config from './config';

const knexConfig = buildKnexConfig(__dirname, {
  ...config,
  migrationsFolder: 'database/migrations',
  seedsFolder: 'database/seeds',
  migrationsTemplateFileName: 'migrations-template.ts',
  useSchemaForMigrationsTable: true,
});

export default knexConfig;
```

Built-in behaviours:

- All query results are post-processed to camelCase
- All identifiers are converted to snake_case
- Database port is coerced to `Number`
- `connectionTimeout: 30000` is set by default
- MSSQL `connection.options` added automatically when `dbType === 'mssql'`
- PostgreSQL `searchPath` set automatically when `dbType === 'pg'` and `dbSchema` is provided
- `debug` is forced to `false` in production

#### Available options

| Option                                  | Type      | Description                                             |
| --------------------------------------- | --------- | ------------------------------------------------------- |
| `dbType`                                | `string`  | `'pg'` or `'mssql'` (default: `'pg'`)                   |
| `dbHost/Port/Name/Schema/User/Password` | `string`  | Connection parameters                                   |
| `dbPoolMin/Max`                         | `number`  | Pool size (defaults: `0`/`50`)                          |
| `dbPool`                                | `object`  | Full tarn pool config (overrides min/max)               |
| `migrationsFolder`                      | `string`  | Path to migrations directory                            |
| `seedsFolder`                           | `string`  | Seeds directory (all environments)                      |
| `seedsFolderDev/Prod`                   | `string`  | Environment-specific seeds directories                  |
| `migrationsTable`                       | `string`  | Migrations tracking table name                          |
| `migrationsTemplateFileName`            | `string`  | Template for `knex migrate:make`                        |
| `useSchemaForMigrationsTable`           | `boolean` | Scope migrations table to schema                        |
| `wrapIdentifierOverrideMap`             | `object`  | Override snake_case conversion for specific identifiers |

---

## Application setup

### Logger

```typescript
// src/logger.ts
import { Logger } from '@sdflc/utils';
import config from './config';

export const logger = new Logger({ level: config.logLevel });
```

---

### Redis client

```typescript
// src/redisClient.ts
import { createRedisClient } from '@sdflc/backend-helpers';
import config from './config';
import { logger } from './logger';

// Returns null if redisHost is not configured — safe to pass to DataCache
export const redisClient = config.redisHost
  ? createRedisClient({
      redisHost: config.redisHost,
      redisPort: config.redisPort,
      redisPassword: config.redisPassword,
      redisSsl: config.redisSsl,
      connectionTimeout: 5000,
      pingInterval: 5000,
      onConnecting: () => logger.log('Redis connecting'),
      onConnected: () => logger.log('Redis connected'),
      onDisconnected: () => logger.log('Redis disconnected'),
      onReconnecting: () => logger.log('Redis reconnecting'),
      onError: (err) => logger.error('Redis error:', err),
    })
  : null;

// Call redisClient.connect() at startup — see app.ts and cli/index.ts
```

> The client is created but **not connected** — call `await redisClient.connect()` during app or CLI startup. This allows the client to be created early and connected at a controlled point.

The same `redisClient` instance is used for both DataLoader caching (via gateways) and IP rate limiting (via `createContextDefault`). Pass it once — the library routes it to both uses automatically.

---

### Knex handle

Used directly for migrations and seeds (separate from the per-request `Database` instance).

```typescript
// src/knexHandle.ts
import knex from 'knex';
import knexConfig from './knexConfig';

export default knex({
  ...knexConfig,
  pool: {
    min: 2,
    max: 20,
    acquireTimeoutMillis: 30000,
    createTimeoutMillis: 30000,
    destroyTimeoutMillis: 5000,
    idleTimeoutMillis: 30000,
    reapIntervalMillis: 1000,
    createRetryIntervalMillis: 200,
  },
  acquireConnectionTimeout: 30000,
});
```

---

### Context

The context factory creates a fresh `ApiContext` for every request. All gateways and cores are instantiated here. The same factory is used by GraphQL, REST, and CLI.

```typescript
// src/context.ts
import { createContextDefault } from '@sdflc/backend-helpers';
import { STATUSES } from '@sdflc/utils';
import dayjs from 'dayjs';

import config from './config';
import * as cores from './core';
import * as gateways from './gateways';
import knexConfig from './knexConfig';
import { redisClient } from './redisClient';
import { logger } from './logger';

export const createContext = async (args: any) => {
  return createContextDefault({
    req: args.req,
    dbSchema: config.dbSchema,
    cores,
    gateways,
    knexConfig,
    isProduction: config.isProduction,
    logger,
    doAuth: true,
    interserviceApiKey: config.interserviceApiKey,
    jwtSecret: config.jwtSecret,
    verifyTokenArgs: {
      ignoreExpiration: false,
      ignoreNotBefore: false,
    },
    // Pass redisClient to enable IP rate limiting automatically.
    // An IpRateLimiter instance is constructed once here and shared
    // across all cores for the lifetime of this request.
    redisClient,
    // Optional: cap every IP at 300 requests per minute across all cores.
    globalRateLimit: { limit: 300, windowSec: 60 },
    // Provide extra props to every gateway constructor
    getGatewayContext: () => ({
      redisClient,
      cacheTtl: config.cacheTtl,
      activeStatuses: [STATUSES.ACTIVE],
    }),
    // Extend context with app-specific fields after construction
    extendContext: ({ context }: any) => {
      context.nowUtc = dayjs().utc().toDate();
      context.space = {
        cookieName: config.cookieName,
        fingerprintCookieName: config.fingerprintCookieName,
      };
    },
  });
};
```

#### `createContextDefault` options

All options from the original factory plus the new rate-limiting options:

| Option            | Type                           | Description                                                                                                                                                                  |
| ----------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `redisClient`     | `any \| null`                  | Redis client for the built-in `IpRateLimiter`. If provided and no `rateLimiter` is set, an `IpRateLimiter` is constructed automatically and placed on `context.rateLimiter`. |
| `rateLimiter`     | `RateLimiterInterface \| null` | Custom rate-limiter instance. Overrides `redisClient`. Set to `null` to disable rate limiting explicitly.                                                                    |
| `globalRateLimit` | `RateLimitRule`                | Service-wide rule applied as the global tier to every `runAction` call across all cores.                                                                                     |

---

### Express app

```typescript
// src/app.ts
import express from 'express';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import http from 'http';
import dayjs from 'dayjs';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@as-integrations/express5';
import { buildSubgraphSchema } from '@apollo/subgraph';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import {
  ApolloServerPluginUsageReportingDisabled,
  ApolloServerPluginInlineTraceDisabled,
} from '@apollo/server/plugin/disabled';
import { OpResult, OP_RESULT_CODES } from '@sdflc/api-helpers';
import { HEADERS } from '@sdflc/backend-helpers';

import typeDefs from './app/graphql/types';
import resolvers from './app/graphql/resolvers';
import { createContext } from './context';
import config from './config';
import * as controllers from './app/restapi';
import { redisClient } from './redisClient';
import { isOpResult } from './utils';
import packageJson from '../package.json';

const corsOptions = {
  origin: (_origin: any, callback: any) => callback(null, true),
  credentials: true,
};

const configureApp = async (app: any) => {
  app.disable('x-powered-by');

  app.use('/api/webhooks/stripe', express.raw({ type: 'application/json' }));
  app.use(express.json({ limit: config.maxReqBodySize }));
  app.use(express.urlencoded({ limit: config.maxReqBodySize, extended: true }));
  app.use(cookieParser());
  app.use(cors(corsOptions));

  // Health check
  app.get('/', (req: any, res: any) => {
    res.json(
      new OpResult({
        data: {
          version: packageJson.version,
          name: packageJson.name,
          utcDateTime: dayjs().toISOString(),
          remoteIp: req.headers[HEADERS.IP_ADDRESS] || req.socket?.remoteAddress,
        },
      }).toJS(),
    );
  });

  // Attach context to non-GraphQL requests
  app.use(async (req: any, _res: any, next: any) => {
    if (req.path !== '/graphql') {
      req.context = await createContext({ req });
    }
    next();
  });

  // Register REST controllers
  Object.keys(controllers).forEach((key) => {
    const controller = new (controllers as any)[key]();
    controller.init({ app });
  });

  const httpServer = http.createServer(app);

  const apolloServer = new ApolloServer({
    schema: buildSubgraphSchema([{ typeDefs, resolvers: resolvers as any }]),
    introspection: !config.isProduction,
    plugins: [
      ApolloServerPluginDrainHttpServer({ httpServer }),
      ApolloServerPluginUsageReportingDisabled(),
      ApolloServerPluginInlineTraceDisabled(),
    ],
  });

  if (redisClient) {
    await redisClient.connect();
  }

  await apolloServer.start();

  app.use(config.graphqlUrl, cors(corsOptions), expressMiddleware(apolloServer, { context: createContext }));

  // 404 handler
  app.use((req: any, res: any) => {
    res
      .status(404)
      .json(
        new OpResult()
          .addError('', `API entry point not supported: '${req.originalUrl}'`, OP_RESULT_CODES.EXCEPTION)
          .toJS(),
      );
  });

  // Global error handler
  app.use((err: any, _req: any, res: any, _next: any) => {
    if (isOpResult(err)) {
      res.status(500).json(err.toJS());
    } else if (typeof err === 'string') {
      res.status(500).json(new OpResult().addError('', err, OP_RESULT_CODES.FAILED).toJS());
    } else if (err instanceof Error) {
      res.status(500).json(new OpResult().addError('', err.message, OP_RESULT_CODES.FAILED).toJS());
    } else {
      res
        .status(500)
        .json(
          new OpResult()
            .setData(err)
            .addError('', 'An error has occurred when processing this request', OP_RESULT_CODES.FAILED)
            .toJS(),
        );
    }
  });

  return httpServer;
};

export default configureApp;
```

---

## Core concepts

### ApiContext

The request context object shared across all layers. Created once per request — or once per CLI invocation.

| Field                | Type                    | Description                                                                                     |
| -------------------- | ----------------------- | ----------------------------------------------------------------------------------------------- |
| `requestId`          | `string`                | UUID generated per request                                                                      |
| `cores`              | `Record<string, any>`   | All registered core instances (camelCase keys)                                                  |
| `gateways`           | `Record<string, any>`   | All registered gateway instances (camelCase keys)                                               |
| `usedKeyPrefixes`    | `Record<string, any>`   | Tracks gateway key prefixes to detect collisions                                                |
| `db`                 | `Database`              | Shared database instance                                                                        |
| `req` / `res`        | `any`                   | Express request/response objects                                                                |
| `headers`            | `Record<string, any>`   | Raw request headers                                                                             |
| `cookies`            | `Record<string, any>`   | Parsed request cookies                                                                          |
| `spaceId`            | `string?`               | From request headers or JWT token                                                               |
| `apiKey`             | `string?`               | From `x-api-key` header                                                                         |
| `managementApiKey`   | `string?`               | From management API key header                                                                  |
| `ipAddress`          | `string?`               | Client IP address — used by the rate limiter                                                    |
| `authorization`      | `string?`               | Bearer token stripped from Authorization header                                                 |
| `fingerprint`        | `string?`               | CSRF fingerprint from cookie or header                                                          |
| `acceptLanguage`     | `string?`               | Raw `Accept-Language` header value                                                              |
| `languages`          | `string[]?`             | Parsed language preference list                                                                 |
| `requestedLang`      | `string?`               | Resolved language for the request (default `'en'`)                                              |
| `userAgent`          | `string?`               | `User-Agent` header                                                                             |
| `referer`            | `string?`               | `Referer` header                                                                                |
| `authorizationToken` | `string?`               | Raw authorization token passed explicitly                                                       |
| `isExternalDb`       | `boolean?`              | True when a pre-built `db` was passed in                                                        |
| `userId`             | `string?`               | Populated after successful authentication                                                       |
| `accountId`          | `string?`               | Populated after successful authentication                                                       |
| `roleId`             | `string?`               | Populated after successful authentication                                                       |
| `isSuperAdmin`       | `boolean?`              | True when `roleId === 'superadmin'`                                                             |
| `lang`               | `string?`               | Resolved language from token or header                                                          |
| `rateLimiter`        | `RateLimiterInterface?` | App-level rate-limiter singleton — set by `createContextDefault` when `redisClient` is provided |
| `globalRateLimit`    | `RateLimitRule?`        | Service-wide rate-limit rule applied to all cores                                               |

---

### BaseCore

The base class for all business logic. The `AppCore` pattern lets you add project-wide helpers once and extend it for every resource.

```typescript
// src/core/AppCore.ts
import { BaseCore, BaseCorePropsInterface } from '@sdflc/backend-helpers';

class AppCore extends BaseCore {
  constructor(props: BaseCorePropsInterface) {
    super(props);
  }

  // Add project-wide helpers available to all core subclasses
  public now(): Date {
    return new Date();
  }
}

export { AppCore };
```

```typescript
// src/core/NoteCore.ts
import { OP_RESULT_CODES, OpResult } from '@sdflc/api-helpers';
import { BaseCorePropsInterface, BaseCoreActionsInterface, BaseCoreValidatorsInterface } from '@sdflc/backend-helpers';

import { AppCore } from './AppCore';
import { validators } from './validators/noteValidators';

class NoteCore extends AppCore {
  constructor(props: BaseCorePropsInterface) {
    super({
      ...props,
      gatewayName: 'noteGw',
      name: 'Note',
      doAuth: true,
      doingWhat: {
        list: 'listing notes',
        get: 'getting a note',
        getMany: 'getting multiple notes',
        create: 'creating a note',
        update: 'updating a note',
        remove: 'removing a note',
        removeMany: 'removing multiple notes',
      },
      // Rate limiting — tighten create, leave reads unrestricted
      rateLimit: { limit: 60, windowSec: 60 },
      rateLimitOnAction: {
        list: false,
        get: false,
        create: { limit: 10, windowSec: 60 },
      },
    });
  }

  // ... validators, hooks, etc.
}

export { NoteCore };
```

#### Per-action authentication

```typescript
// Require auth globally but allow public listing:
new NoteCore({
  doAuth: true,
  doAuthOnAction: { list: false },
  ...props,
});
```

#### Soft authentication (`tryAuth`)

For public endpoints that behave differently when a user is authenticated (e.g. show "saved" state):

```typescript
return this.runAction({
  args,
  tryAuth: true, // populate context if token present, never fail
  hasTransaction: false,
  doingWhat: 'listing public notes',
  action: async (args, opt) => {
    const isAuthenticated = !!this.getContext().userId;
    // proceed with or without an authenticated user
  },
});
```

#### Before/after hooks

| Hook               | Called by                 | Purpose                                        |
| ------------------ | ------------------------- | ---------------------------------------------- |
| `beforeList`       | `list()`                  | Transform/scope filter args                    |
| `afterList`        | `list()`                  | Transform result array                         |
| `beforeGet`        | `get()`                   | Validate/transform id args                     |
| `afterGet`         | `get()`                   | Enforce ownership, transform single result     |
| `beforeGetMany`    | `getMany()`               | Transform ids args                             |
| `afterGetMany`     | `getMany()`               | Filter/transform multiple results              |
| `beforeCreate`     | `create()`                | Enrich params (inject userId, accountId, etc.) |
| `afterCreate`      | `create()`                | Transform created records                      |
| `beforeUpdate`     | `update()`                | Transform params, inject ownership into where  |
| `afterUpdate`      | `update()`                | Transform updated records, send notifications  |
| `beforeRemove`     | `remove()`                | Inject ownership into where clause             |
| `afterRemove`      | `remove()`                | Post-removal side effects                      |
| `beforeRemoveMany` | `removeMany()`            | Inject ownership into each where clause        |
| `afterRemoveMany`  | `removeMany()`            | Post-removal side effects                      |
| `processItemOnIn`  | All before-mutation hooks | Normalise inbound data                         |
| `processItemOnOut` | All after hooks           | Shape outbound data                            |

---

#### Action-scoped request store

When you need to share data between a `before*` hook and its corresponding `after*` hook — for example, fetching a record once before an update and reusing it for audit logging afterward — `BaseCore` provides a typed key-value store scoped to a single action invocation. A fresh store is created for every `runAction` call, so there is no bleed between `list()`, `create()`, etc., even on the same core instance.

Use the three protected helpers from any `before*`, `after*`, `validate`, or `action` handler:

| Method             | Signature                          | Description                                                               |
| ------------------ | ---------------------------------- | ------------------------------------------------------------------------- |
| `storeSet<T>`      | `(opt, key, value) → T`            | Stores a value; returns it as a pass-through                              |
| `storeGet<T>`      | `(opt, key) → T \| undefined`      | Retrieves a typed value; `undefined` when absent                          |
| `storeGetOrSet<T>` | `(opt, key, factory) → Promise<T>` | Returns stored value or calls `factory()`, stores, and returns the result |

Define keys as constants to catch typos at compile time:

```typescript
// src/core/ProductCore.ts
const STORE = {
  ORIGINAL_RECORD: 'originalRecord',
  PARENT_CATEGORY: 'parentCategory',
} as const;

class ProductCore extends AppCore {
  async beforeUpdate(params: any, opt: BaseCoreActionsInterface): Promise<any> {
    // Fetch the current record once; storeGetOrSet avoids a second DB hit if
    // another hook already loaded it.
    const current = await this.storeGetOrSet(opt, STORE.ORIGINAL_RECORD, () =>
      this.getGateway().get(opt.args?.where?.id),
    );

    this.logger.debug(`Updating product, original slug was '${current?.slug}'`);
    return this.processItemOnIn(params, opt);
  }

  async afterUpdate(items: any[], opt: BaseCoreActionsInterface): Promise<any> {
    const original = this.storeGet<{ slug: string }>(opt, STORE.ORIGINAL_RECORD);

    if (original?.slug && items[0]?.slug !== original.slug) {
      this.logger.log(`Product slug changed from '${original.slug}' to '${items[0]?.slug}'; updating references`);
      // trigger side effects — redirect rules, search index update, etc.
    }

    return Promise.all(items.map((item) => this.processItemOnOut(item, opt)));
  }
}
```

**Notes:**

- Prefer `storeGetOrSet` for anything fetched from the database — it guarantees at most one DB call per action invocation regardless of how many hooks reference the same key.
- The store is also available inside the `validate` function and the inline `action` handler passed to `runAction()` via the `opt` argument.
- The store is not shared between a parent call and a nested same-request call — each `runAction` invocation gets its own independent `Map`.
- `storeGet` uses `Map.has()` internally, so storing `null` or `false` is treated as a hit, not a miss.

---

### BaseGateway

The base class for all database-backed data access. One gateway per table.

```typescript
// src/gateways/NoteGw.ts
import { BaseGateway, BaseGatewayPropsInterface } from '@sdflc/backend-helpers';

class NoteGw extends BaseGateway {
  constructor(props: BaseGatewayPropsInterface) {
    super({
      ...props,
      table: 'note',
      keyPrefix: 'note',
      hasStatus: true,
      hasCreatedAt: true,
      hasUpdatedAt: true,
      hasRemovedAt: true, // soft-delete
      hasCreatedBy: true,
      hasUpdatedBy: true,
      hasUserId: true,
      selectFields: ['note.*'],
      idField: 'note.id',
      idFieldUpdateRemove: 'id',
      defaultSorting: [{ name: 'created_at', order: 'desc' }],
    });
  }

  async onListFilter(query: any, filterParams: any): Promise<void> {
    await super.onListFilter(query, filterParams);

    const { userId, title } = filterParams || {};

    if (userId) {
      query.where('note.user_id', userId);
    }

    if (title) {
      query.whereILike('note.title', `%${title}%`);
    }
  }

  async onUpdateFilter(query: any, whereParams: any): Promise<number> {
    let count = await super.onUpdateFilter(query, whereParams);

    if (whereParams?.userId != null) {
      count++;
      query.where({ user_id: whereParams.userId });
    }

    return count;
  }
}

export { NoteGw };
```

#### Gateway config flags

| Flag                   | Type                  | Description                                                                                                                                                     |
| ---------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `table`                | `string`              | Database table name                                                                                                                                             |
| `idField`              | `string`              | Primary key for SELECT/loader — table-qualify for joined queries (default: `id`)                                                                                |
| `idFieldAs`            | `string`              | Alias for the id column when it differs from the lookup key                                                                                                     |
| `idFieldUpdateRemove`  | `string`              | Primary key for UPDATE/DELETE WHERE — must be unqualified                                                                                                       |
| `statusField`          | `string`              | Column name for the status field (default: `'status'`). Set to `'Status_ID'` etc. on legacy tables — affects all status WHERE clauses and the soft-delete patch |
| `setFields`            | `string \| string[]`  | Conflict target field(s) for the `set()` upsert operation                                                                                                       |
| `mergeFields`          | `string[]`            | Explicit field list for the ON CONFLICT MERGE clause                                                                                                            |
| `hasStatus`            | `boolean`             | Status filtering and REMOVED guard                                                                                                                              |
| `hasCreatedAt`         | `boolean`             | Auto-set `created_at` on insert                                                                                                                                 |
| `hasUpdatedAt`         | `boolean`             | Auto-set `updated_at` on update                                                                                                                                 |
| `hasRemovedAt`         | `boolean`             | Soft-delete via `removed_at` timestamp                                                                                                                          |
| `hasRemovedAtStr`      | `boolean`             | Also writes a formatted string timestamp to `removed_at_str`                                                                                                    |
| `hasCreatedBy`         | `boolean`             | Auto-set `created_by` from `context.userId` on insert                                                                                                           |
| `hasUpdatedBy`         | `boolean`             | Auto-set `updated_by` from `context.userId` on update                                                                                                           |
| `hasRemovedBy`         | `boolean`             | Auto-set `removed_by` from `context.userId` on soft-delete                                                                                                      |
| `hasSpace`             | `boolean`             | Scopes reads/writes to `context.spaceId`                                                                                                                        |
| `hasUserId`            | `boolean`             | Sets `userId` from context on create                                                                                                                            |
| `hasVersion`           | `boolean`             | Optimistic locking via version field                                                                                                                            |
| `hasLang`              | `boolean`             | Language-scoped records                                                                                                                                         |
| `localizationForField` | `string`              | Field combined with `lang` to build a composite localisation id                                                                                                 |
| `hardRemove`           | `boolean`             | Hard DELETE instead of soft-delete                                                                                                                              |
| `noCache`              | `boolean`             | Bypass DataLoader cache                                                                                                                                         |
| `returnFields`         | `string[]`            | Columns in RETURNING clause (default: `['*']`)                                                                                                                  |
| `defaultSorting`       | `SortItemInterface[]` | Default ORDER BY when none provided                                                                                                                             |
| `filterByUserField`    | `string`              | Auto-scope list queries to `context.userId`                                                                                                                     |
| `fieldForKeyword`      | `string`              | Column to apply keyword ILIKE/LIKE search against                                                                                                               |
| `selectFields`         | `string[]`            | Columns selected when `useCache: false` (default: `['*']`)                                                                                                      |
| `activeStatuses`       | `number[]`            | Status values considered active for list filtering                                                                                                              |
| `insertChunkSize`      | `number`              | Records per INSERT batch — prevents DB parameter limit errors (default: `2500`)                                                                                 |
| `listFilters`          | `GatewayFilterRule[]` | Declarative WHERE rules for `list()` — see [Declarative filter rules](#declarative-filter-rules)                                                                |
| `updateFilters`        | `GatewayFilterRule[]` | Declarative WHERE rules for `update()`                                                                                                                          |
| `removeFilters`        | `GatewayFilterRule[]` | Declarative WHERE rules for `remove()`                                                                                                                          |
| `countFilters`         | `GatewayFilterRule[]` | Declarative WHERE rules for `count()` — falls back to `listFilters` when not set                                                                                |
| `nonCreatableFields`   | `string[]`            | Field names callers cannot set on create — stripped before INSERT. snake_case or camelCase both accepted                                                        |
| `nonUpdatableFields`   | `string[]`            | Field names callers cannot change on update — stripped before UPDATE. snake_case or camelCase both accepted                                                     |

> **Implicit write protection.** In addition to `nonCreatableFields` / `nonUpdatableFields`, the framework always strips a baseline set of fields the framework owns:
>
> - **On create** — `id`, `removedAt`, `removedBy` (the framework generates `id` via `generateId()`; removal markers only belong on the remove path)
> - **On update** — `id`, `createdAt`, `createdBy`, `removedAt`, `removedBy` (write-once and remove-only markers)
>
> This implicit stripping happens regardless of whether `nonCreatableFields` / `nonUpdatableFields` are set. Use those arrays only for additional resource-specific immutables (e.g. `partyId` on a junction table whose identity is fixed at creation time). |

#### Filter hooks

| Hook             | Signature                                             | Purpose                                                        |
| ---------------- | ----------------------------------------------------- | -------------------------------------------------------------- |
| `onListFilter`   | `(query, filterParams) => Promise<void>`              | Custom WHERE clauses for `list()`                              |
| `onUpdateFilter` | `(query, whereParams) => Promise<number>`             | Custom WHERE clauses for `update()` — return incremented count |
| `onRemoveFilter` | `(query, whereParams) => Promise<number>`             | Custom WHERE clauses for `remove()` — return incremented count |
| `onCountFilter`  | `(query, filterParams) => void`                       | Custom WHERE clauses for `count()`                             |
| `buildListQuery` | `(args: BuildListQueryArgsInterface) => Promise<any>` | Override to add JOINs and computed columns to list queries     |

Always call `super.onListFilter()` / `super.onUpdateFilter()` / `super.onRemoveFilter()` at the top of any override so base-class scoping (soft-delete, space, user) runs first.

**Soft-delete guards on writes.** When `hasRemovedAt: true`, the base implementations of `onUpdateFilter` and `onRemoveFilter` automatically add `WHERE table.removed_at IS NULL`. This guard is independent of `hasStatus` — it fires on tables that use `Removed_At` without the framework's standard `status` column (a common shape for PartyLabz tables, where `hasStatus` is `false` and the row's lifecycle is tracked through `Status_ID` + `Removed_At` instead).

The guard prevents two failure modes silently breaking writes:

- An `update()` on an already-removed row would otherwise produce a half-mutated record (new `updated_at` and `updated_by`, original `removed_at` still set).
- A `remove()` on an already-removed row would otherwise re-write `removed_at` and `removed_by` with a fresh timestamp, masking the fact that the row was removed earlier.
  **Configurable status column.** When `statusField` is set in the config (e.g. `statusField: 'Status_ID'`), every status-related WHERE clause in `onListFilter`, `onUpdateFilter`, `onRemoveFilter`, and the soft-delete patch in `transformOnRemove` uses that column name. Set this once on legacy tables that do not use the framework's default `status` column.

#### Field-level write protection

`BaseGateway` strips fields that callers should not be able to set from `params` before the INSERT or UPDATE statement runs. Two layers cooperate:

**Implicit framework-managed fields** are always stripped, regardless of configuration. These are columns the framework owns and either generates itself or sets only on a specific path.

| Path     | Always stripped                                          |
| -------- | -------------------------------------------------------- |
| `create` | `id`, `removedAt`, `removedBy`                           |
| `update` | `id`, `createdAt`, `createdBy`, `removedAt`, `removedBy` |

`removedAt` and `removedBy` are only included when their `has*` flags are set on the gateway. `id` is always stripped — `BaseGateway.generateId()` produces it.

**Gateway-configured immutables** are declared per-gateway via `nonCreatableFields` and `nonUpdatableFields`. Names can be supplied in `snake_case` (DB column form) or `camelCase` (VM/API form) — both spellings are stripped from the params object so callers can be consistent with whichever convention they prefer.

```typescript
class PartyGuestGw extends BaseGateway {
  constructor(props: BaseGatewayPropsInterface) {
    super({
      ...props,
      table: 'Party_Guest',
      hasStatus: false, // PartyLabz uses Status_ID
      statusField: 'Status_ID',

      // partyId / personId define the row's identity. Once a guest record
      // exists in a party, those values must never change — only the RSVP
      // state, notes, and audit fields evolve. Stripping them on update
      // ensures a malformed client payload cannot reassign a guest.
      nonUpdatableFields: ['partyId', 'personId'],
    });
  }
}
```

**Logging.** Every stripped field is logged at `debug` level with the table name and field name, so it is easy to confirm during troubleshooting that a "ignored" client value was, in fact, dropped by the framework rather than silently overwritten.

**Why this matters.** Without this protection, a request like `update(where, { id: 'evil', createdBy: 'attacker', myField: 'ok' })` would attempt to rewrite framework-owned columns. The framework strips `id` and `createdBy` automatically; the gateway author can extend the strip-list to cover any additional columns whose values must come from the server, not the client.

---

#### Declarative filter rules

For the common case of simple equality or array `IN` filters, declarative rules eliminate the need to override `onListFilter`, `onUpdateFilter`, `onRemoveFilter`, or `onCountFilter` at all. Rules are passed in the constructor props and applied automatically after the base-class scoping logic.

```typescript
class PartyGuestGw extends BaseGateway {
  private static readonly DOMAIN_FILTER_RULES = [
    { field: 'partyId', column: 'Party_Guest.Party_ID', type: 'array' as const },
    { field: 'rsvpStatus', column: 'Party_Guest.Rsvp_Status', type: 'array' as const },
    { field: 'wasInvited', column: 'Party_Guest.Invited_At', type: 'nullableBool' as const },
    { field: 'rsvpAt', column: 'Party_Guest.Rsvp_At', type: 'nullableDateRange' as const },
    { field: 'isStarred', column: 'Party_Guest.Is_Starred', type: 'bool' as const },
    {
      field: 'search',
      column: '',
      type: 'search' as const,
      columns: ['Person.First_Name', 'Person.Last_Name', 'Person.Email'],
    },
  ];

  constructor(props: BaseGatewayPropsInterface) {
    super({
      ...props,
      table: TABLES.PARTY_GUEST,
      listFilters: PartyGuestGw.DOMAIN_FILTER_RULES,
      updateFilters: PartyGuestGw.DOMAIN_FILTER_RULES,
      removeFilters: PartyGuestGw.DOMAIN_FILTER_RULES,
      // countFilters omitted — BaseGateway falls back to listFilters automatically
    });
  }
}
```

**Best practice:** define the rules once as a `private static readonly` constant and reference it from all four filter props. This ensures `list`, `update`, `remove`, and `count` always stay in sync without duplicating the field/column/type data.

#### Filter rule types

Each `GatewayFilterRule` has a `type` that determines the WHERE clause strategy:

| Type                | Behaviour                                                                                                                                                                                                                    |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `array`             | `whereIn(column, values)` for arrays; `where(column, value)` for scalars. Skipped when value is `null`/`undefined`/`''`.                                                                                                     |
| `eq`                | `where(column, value)`. Strict single-value equality — never expands to `whereIn`. Skipped when value is `null`/`undefined`.                                                                                                 |
| `bool`              | `where(column, true\|false)`. Fires only on strict `true` or `false`; `null`/`undefined` leaves the column unconstrained.                                                                                                    |
| `nullableBool`      | `true` → `whereNotNull(column)` / `false` → `whereNull(column)` / `null`/`undefined` → skipped. Use when a nullable datetime encodes presence (e.g. `invitedAt IS [NOT] NULL`).                                              |
| `dateRange`         | Reads `filter[field + 'From']` and `filter[field + 'To']`. Applies `>=` and/or `<=` bounds independently. The `field` value is the base name (e.g. `'rsvpAt'`); actual keys are `rsvpAtFrom` / `rsvpAtTo`.                   |
| `nullableDateRange` | Extends `dateRange` with a `filter[field + 'Null']` boolean: `true` → `whereNull` (from/to skipped); `false` → `whereNotNull` (from/to still applied); `null`/`undefined` → null-ness unconstrained.                         |
| `search`            | `LIKE`/`ILIKE` across one or more columns with OR semantics. Reads `filter[field]` (string). Requires the `columns` array on the rule. The operator is resolved per-database (`ilike` on PostgreSQL, `like` on MSSQL/MySQL). |

Rules with unsupported types on `updateFilters`/`removeFilters` (`search`, `dateRange`, `nullableDateRange`) are silently skipped with a warning — update and remove filters must be precise identifiers.

#### count()

Returns the count of non-removed rows matching a filter. Applies the same soft-delete, space, and user scoping as `list()`, then runs `onCountFilter()` and the declarative `countFilters` rules (falling back to `listFilters` when `countFilters` is not set).

```typescript
// In a core method:
const total = await this.gateway.count({ partyId: args.partyId, rsvpStatus: [1, 2] });
```

Gateways that already declare `listFilters` get `count()` for free with no additional configuration. Set `countFilters` explicitly only when count needs different scoping than list.

**Soft-delete guard order.** `count()` adds the soft-delete guard automatically based on the gateway's flags:

1. When `hasRemovedBy: true` → `WHERE table.removed_by IS NULL`
2. Otherwise, when `hasRemovedAt: true` → `WHERE table.removed_at IS NULL`
3. Otherwise — no soft-delete guard
   `Removed_By IS NULL` is preferred because, in this codebase, `Removed_By` and `Removed_At` are always set together on the remove path and `Removed_By` is the primary sentinel. The fallback to `Removed_At` covers tables that have a timestamp column but no author column (uncommon but valid).

To add custom WHERE logic beyond what declarative rules express, override `onCountFilter()`:

```typescript
protected onCountFilter(query: any, filterParams: any): void {
  if (filterParams?.accountId) {
    query.where(`${TABLES.PARTY}.${FIELDS.ACCOUNT_ID}`, filterParams.accountId);
  }
}
```

---

#### Join-variant gateways

For read operations that need columns from related tables alongside the primary table, create additional read-only gateway variants that add JOINs via `buildListQuery`. The base gateway remains the sole source of truth for mutations.

```typescript
// PartyGuestGw         — base gateway: all mutations, count(), plain reads
// PartyGuestWithMainPersonGw  — read-only: joins Person for name/email/phone
// PartyGuestWithFullPersonGw  — read-only: joins Person + avatar + extra fields

class PartyGuestWithMainPersonGw extends PartyGuestGw {
  constructor(props: BaseGatewayPropsInterface) {
    super({
      ...props,
      // Each variant must have a unique keyPrefix to prevent DataLoader
      // cache collisions between plain and joined result sets.
      keyPrefix: 'party-guest-with-person',
      selectFields: [
        `${TABLES.PARTY_GUEST}.*`,
        `${TABLES.PERSON}.${FIELDS.FIRST_NAME}`,
        `${TABLES.PERSON}.${FIELDS.EMAIL}`,
        `${TABLES.PERSON}.${FIELDS.PHONE}`,
      ],
    });
  }

  // Join logic lives in buildListQuery, not in list() itself.
  // This ensures the framework's cache/direct-query branching is respected —
  // when useCache is true, only ids are selected here and the DataLoader
  // hydrates records; the join only fires on the useCache: false path.
  protected async buildListQuery(args: BuildListQueryArgsInterface): Promise<any> {
    const query = await super.buildListQuery(args);

    query.leftJoin(TABLES.PERSON, `${TABLES.PERSON}.${FIELDS.ID}`, `${TABLES.PARTY_GUEST}.${FIELDS.PERSON_ID}`);

    return query;
  }
}
```

**Rules for join-variant gateways:**

- **Mutations always go through the base gateway** — join variants are strictly read-only
- **Each variant needs a unique `keyPrefix`** — prevents cross-contamination between plain and joined result sets in the DataLoader cache
- **Join logic belongs in `buildListQuery`**, not in `onListFilter` or `list()` — this respects the framework's `useCache` branching
- **`idField` must be table-qualified** in the base gateway (`Party_Guest.ID`) to prevent ambiguity in joined queries

---

### BaseController

Base class for custom Express REST controllers with consistent response handling.

```typescript
// src/app/restapi/EmbedController.ts
import { BaseController } from '@sdflc/backend-helpers';
import { OP_RESULT_CODES, OpResult } from '@sdflc/api-helpers';
import fs from 'fs';
import path from 'path';

class EmbedController extends BaseController {
  init(props: any): void {
    const { app } = props;
    app.get('/api/v1/embed.js', this.getEmbedScript.bind(this));
  }

  async getEmbedScript(req: any, res: any): Promise<void> {
    const scriptPath = path.join(__dirname, '../../public/api/v1/embed.js');

    if (!fs.existsSync(scriptPath)) {
      return res
        .status(404)
        .json(new OpResult().addError('', 'Embed script not found', OP_RESULT_CODES.NOT_FOUND).toJS());
    }

    const script = fs.readFileSync(scriptPath, 'utf8');
    res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
    res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*');
    res.send(script);
  }
}

export { EmbedController };
```

The `json()` wrapper handles all response shapes automatically:

- `OpResult` → uses `result.getHttpStatus()` and `result.toJS()`
- `{ redirectUrl }` → calls `res.redirect()`
- Plain object → `200` JSON
- `null`/`undefined` → `204 No Content`

---

### BaseRestApiCrudController

Registers standard CRUD routes automatically. Comma-separated `:id` values route to `getMany`/`removeMany` automatically.

```typescript
// src/app/restapi/NoteRestController.ts
import { BaseRestApiCrudController } from '@sdflc/backend-helpers';
import { createContext } from '../../context';

class NoteRestController extends BaseRestApiCrudController {
  constructor() {
    super({
      prefix: 'note',
      getContext: (req: any) => createContext({ req }),
      disableCreateMany: true,
      disableUpdateMany: true,
      disableSet: true,
    });
  }
}

// In app.ts:
const controller = new NoteRestController();
controller.init({ app, root: '/api/notes' });
```

Routes registered for `root = '/api/notes'`:

| Method   | Path                     | Core method             | Notes                       |
| -------- | ------------------------ | ----------------------- | --------------------------- |
| `GET`    | `/api/notes`             | `core.list(query)`      | Query string becomes filter |
| `GET`    | `/api/notes/:id`         | `core.get()`            | Single id                   |
| `GET`    | `/api/notes/id1,id2,id3` | `core.getMany()`        | Comma-separated ids         |
| `POST`   | `/api/notes`             | `core.create(body)`     |                             |
| `POST`   | `/api/notes/many`        | `core.createMany(body)` | Array body                  |
| `PUT`    | `/api/notes/:id`         | `core.update()`         |                             |
| `PUT`    | `/api/notes/many`        | `core.updateMany()`     |                             |
| `DELETE` | `/api/notes/:id`         | `core.remove()`         | Single id                   |
| `DELETE` | `/api/notes/id1,id2`     | `core.removeMany()`     | Comma-separated ids         |

---

### BaseExternalApiGateway

Base class for external API integrations. Credentials are injected at runtime after construction.

```typescript
// src/gateways/StripeGateway.ts
import { BaseExternalApiGateway } from '@sdflc/backend-helpers';
import Stripe from 'stripe';

interface StripeCredentials {
  secretKey: string;
}

class StripeGateway extends BaseExternalApiGateway {
  private client: Stripe | null = null;

  protected onCredentialsSet(): void {
    const { secretKey } = this.credentials as StripeCredentials;
    this.client = new Stripe(secretKey);
  }

  async chargeCard(amount: number, currency: string): Promise<any> {
    this.assertCredentials();
    return this.client!.paymentIntents.create({ amount, currency });
  }
}

// In a core method:
const gw = new StripeGateway({ baseUrl: 'https://api.stripe.com' });
const creds = await db.getCredentials(context.spaceId, 'stripe');
gw.setCredentials(creds); // accepts string or Record<string, any>
await gw.chargeCard(1000, 'usd');
```

---

## IP rate limiting

### How it works

Rate limiting is built into `BaseCore` and runs automatically on every `runAction` call — before the database transaction is opened and before authentication. This protects auth endpoints from brute-force without any DB overhead.

The implementation uses two independent tiers, both of which must pass for a request to proceed. Each tier maintains its own Redis counter keyed on `(space, scope, action, ip)`.

**Global tier** — a service-wide ceiling applied to every core and every action. Cannot be opted out by core or action configuration. Guards the whole service from a flood regardless of which endpoint is targeted.

**Core-or-action tier** — a more specific limit scoped to a core or a single action. Core and action rules are **mutually exclusive**: an action rule replaces the core rule for that action so that loosening a limit actually raises it — the core counter will not silently block first.

Resolution for the core-or-action tier (first defined value wins):

1. `CoreActionInterface.rateLimit` — per-call override in `runAction()`
2. `BaseCorePropsInterface.rateLimitOnAction[actionName]` — per-action props
3. `BaseCorePropsInterface.rateLimit` — core-level default
4. Nothing — tier is skipped

Using `false` at steps 1 or 2 disables the core-or-action tier for that action entirely so only the global tier applies. `false` never suppresses the global tier.

Blocked requests receive `OP_RESULT_CODES.TOO_MANY_REQUESTS` (429) with a human-readable message including the time until the window resets.

Redis errors are swallowed silently — the limiter allows requests through rather than taking down an endpoint. If Redis is not configured, rate limiting is completely skipped with zero overhead.

---

### Setup

The minimum to enable rate limiting is passing `redisClient` to `createContextDefault`. An `IpRateLimiter` instance is constructed once per request context and shared across all cores automatically — no per-core wiring needed.

```typescript
// src/context.ts
export const createContext = async (args: any) => {
  return createContextDefault({
    // ...existing options...
    redisClient, // enables rate limiting
    globalRateLimit: { limit: 300, windowSec: 60 }, // optional service-wide cap
  });
};
```

That is the only change needed to get rate limiting running across all cores. Per-core and per-action rules are additive on top.

---

### Global tier

Set `globalRateLimit` on the context to cap every IP across all cores and all actions. This is the flood guard — it runs regardless of what the core or action configures.

```typescript
createContextDefault({
  redisClient,
  globalRateLimit: { limit: 500, windowSec: 60 }, // 500 req/min per IP, service-wide
});
```

The global tier has no opt-out. If you need an endpoint to be completely unmetered, do not set `globalRateLimit`.

---

### Core-level rules

Add `rateLimit` to a core's constructor props to apply a default rule to every action in that core. Individual actions can override or disable it via `rateLimitOnAction`.

```typescript
class AuthCore extends AppCore {
  constructor(props: BaseCorePropsInterface) {
    super({
      ...props,
      rateLimit: { limit: 30, windowSec: 60 }, // 30 req/min for all auth actions
      rateLimitOnAction: {
        list: false, // no core-tier limit for list
        get: false, // no core-tier limit for get
        create: { limit: 5, windowSec: 300 }, // tighter for sign-up (replaces core rule)
      },
    });
  }
}
```

`rateLimitOnAction` values:

| Value           | Effect                                                         |
| --------------- | -------------------------------------------------------------- |
| `RateLimitRule` | Replaces the core rule for this action (action wins)           |
| `false`         | Disables the core-or-action tier for this action (global only) |
| `undefined`     | Falls back to the core-level `rateLimit` default               |

---

### Action-level rules

Pass `rateLimit` directly to `runAction()` for a one-off rule on a specific call. This is the highest-priority override and replaces any `rateLimitOnAction` or `rateLimit` configured in props for that call.

```typescript
return this.runAction({
  args,
  doAuth: true,
  hasTransaction: true,
  actionName: 'signIn',
  rateLimit: { limit: 5, windowSec: 300 }, // 5 attempts per 5 minutes
  doingWhat: 'signing in',
  action: async (args, opt) => {
    /* ... */
  },
});
```

---

### Disabling rate limiting

Set `rateLimit: false` at the call site or in `rateLimitOnAction` to disable the core-or-action tier for a specific action. The global tier still runs.

```typescript
// Disable core-tier for an internal helper action:
return this.runAction({
  args,
  rateLimit: false,
  hasTransaction: false,
  doingWhat: 'fetching internal config',
  action: async (args, opt) => {
    /* ... */
  },
});

// Disable core-tier for specific actions in props:
super({
  ...props,
  rateLimit: { limit: 60, windowSec: 60 },
  rateLimitOnAction: {
    list: false, // reads are unrestricted at core tier
    get: false,
  },
});
```

To disable rate limiting entirely for a core (including ignoring `context.rateLimiter`), pass `rateLimiter: null` in the core's props:

```typescript
super({
  ...props,
  rateLimiter: null, // this core is completely exempt from rate limiting
});
```

---

### Resetting a counter after success

After a successful sensitive operation (e.g. sign-in), you can reset the IP rate-limit counter so a legitimate user who was briefly throttled is not stuck waiting for the window to expire.

Call `this.resetRateLimit(actionName)` from any `BaseCore` subclass. The `actionName` must match the value used in the `runAction` call that incremented the counter — either the `actionName` field or the `rule.action` override if one was set. The method silently no-ops when no limiter is configured or when Redis is unavailable.

```typescript
public async signIn(args: any) {
  return this.runAction({
    args,
    actionName: 'signIn',
    rateLimit: { limit: 20, windowSec: 900 },
    action: async (args) => {
      // ... validate credentials ...

      if (!passwordValid) {
        return this.failure(OP_RESULT_CODES.UNAUTHORIZED, 'Username/Password pair is invalid');
      }

      // Successful sign-in — clear the failed-attempt counter so the user
      // is not stuck throttled until the 15-minute window expires.
      await this.resetRateLimit('signIn');

      // ... issue cookies, return user ...
    },
    hasTransaction: false,
    doingWhat: 'signing in a user',
  });
}
```

The optional second argument controls which tier's counter is reset. It defaults to `'core-action'`, which covers rules set via `rateLimit` on `runAction` or `rateLimitOnAction` / `rateLimit` in props. Pass `'global'` to reset the global tier counter instead.

```typescript
await this.resetRateLimit('signIn'); // resets the core-or-action counter (default)
await this.resetRateLimit('signIn', 'global'); // resets the global counter
```

---

### Custom limiter

To use a different rate-limiting backend (e.g. in-memory for tests, DynamoDB for Lambda), implement `RateLimiterInterface` and pass it via `rateLimiter` in `createContextDefault` or in per-core props.

```typescript
import { RateLimiterInterface, RateLimitCheckArgs, RateLimitResult } from '@sdflc/backend-helpers';

class NoOpRateLimiter implements RateLimiterInterface {
  async check(_args: RateLimitCheckArgs): Promise<RateLimitResult> {
    return { blocked: false, count: 0, ttlSec: -1 };
  }
  async reset(_args: Pick<RateLimitCheckArgs, 'action' | 'ip' | 'spaceId'>): Promise<void> {}
}

// In tests — disable all rate limiting:
createContextDefault({
  rateLimiter: new NoOpRateLimiter(),
  // no redisClient needed
});
```

---

## Database

### Database class

Wraps Knex with connection pool deduplication, transaction management, and schema support.

```typescript
import { Database } from '@sdflc/backend-helpers';

const db = new Database({
  knexConfig: { client: 'pg', connection: { host, port, user, password, database } },
  dbSchema: 'public',
  logger,
});

// Read utility with filtering, mapping, and computed fields:
const userMap = await db.read({
  table: 'user',
  selectFields: ['id', 'email', 'first_name', 'last_name'],
  where: { status: 100 },
  mapBy: 'id',
  calculated: [{ name: 'displayName', value: (row: any) => `${row.firstName} ${row.lastName}` }],
});

// Transactions are managed automatically by BaseCore.runAction()
await db.startTransaction();
try {
  await db.commitTransaction();
} catch {
  await db.rollbackTransaction();
}
```

---

### DataCache and custom loaders

`DataCache` is the default per-request DataLoader-backed cache. You can provide a **custom loader** when you need non-standard fetching — for example enriching records with a JOIN.

```typescript
// src/gateways/NoteGw.ts
import { BaseGateway, BaseGatewayPropsInterface } from '@sdflc/backend-helpers';

class NoteGw extends BaseGateway {
  constructor(props: BaseGatewayPropsInterface) {
    super({
      ...props,
      table: 'note',
      keyPrefix: 'note',
      hasStatus: true,
      // statusField: 'Status_ID',     // ← uncomment for legacy tables that don't use 'status'
      hasCreatedAt: true,
      hasUpdatedAt: true,
      hasRemovedAt: true, // soft-delete
      hasCreatedBy: true,
      hasUpdatedBy: true,
      hasUserId: true,
      selectFields: ['note.*'],
      idField: 'note.id',
      idFieldUpdateRemove: 'id',
      defaultSorting: [{ name: 'created_at', order: 'desc' }],

      // Declarative write protection — these fields are stripped from
      // the params object before INSERT / UPDATE runs. id, audit, and
      // removal fields are stripped automatically by the framework, so
      // these arrays are for additional resource-specific immutables only.
      nonCreatableFields: ['accountId'], // server-derived, never client-supplied
      nonUpdatableFields: ['userId'], // ownership cannot change after creation
    });
  }

  async onListFilter(query: any, filterParams: any): Promise<void> {
    await super.onListFilter(query, filterParams);

    const { userId, title } = filterParams || {};

    if (userId) {
      query.where('note.user_id', userId);
    }

    if (title) {
      query.whereILike('note.title', `%${title}%`);
    }
  }

  // BaseGateway.onUpdateFilter already adds the soft-delete guard
  // (WHERE removed_at IS NULL) when hasRemovedAt is true, so we don't
  // need to add it here. Just call super and add resource-specific scoping.
  async onUpdateFilter(query: any, whereParams: any): Promise<number> {
    let count = await super.onUpdateFilter(query, whereParams);

    if (whereParams?.userId != null) {
      count++;
      query.where({ user_id: whereParams.userId });
    }

    return count;
  }
}

export { NoteGw };
```

You can also swap loaders at runtime:

```typescript
// Swap to a Redis-backed cache:
gateway.setLoaderConfig({ redisClient, cacheTtl: 600 });

// Replace with a fully custom loader:
gateway.setLoader({
  load: (id: any) => fetchFromExternalAPI(id),
  loadMany: (ids: any[]) => Promise.all(ids.map(fetchFromExternalAPI)),
  clear: (_key: any) => gateway,
  clearAll: () => gateway,
  prime: (_key: any, _value: any) => gateway,
});
```

---

### RedisDataLoader

Used internally by `DataCache` when a `redisClient` is provided. Enable it for all gateways by passing `redisClient` via `getGatewayContext()`:

```typescript
getGatewayContext: () => ({
  redisClient,
  cacheTtl: 300,
}),
```

---

## Authentication

### JWT token helpers

```typescript
import { createUserToken, verifyUserToken } from '@sdflc/backend-helpers';

// Create a token with fingerprint (CSRF protection):
const result = createUserToken(
  { userId: 'user-uuid', accountId: 'account-uuid', roleId: 'admin', spaceId: 'space-id', lang: 'en' },
  process.env.JWT_SECRET!,
  { expiresIn: '7d', useFingerprint: true },
);

if (result.didSucceed()) {
  const { token, fingerprint } = result.getDataFirst();
  res.cookie('fp', fingerprint, { httpOnly: true, secure: true, sameSite: 'strict' });
  res.json({ token });
}

// Verify a token (called automatically by BaseCore.authenticate()):
const verifyResult = verifyUserToken(token, process.env.JWT_SECRET!, {
  algorithms: ['HS256'],
  audience: context.spaceId,
  issuer: 'my-service',
  fingerprint: req.cookies.fp,
});

if (verifyResult.didFail()) {
  return res.status(401).json(verifyResult.toJS());
}
```

---

## GraphQL helpers

### buildDefaultResolvers

Generates a standard set of GraphQL resolvers for a resource.

```typescript
// src/app/graphql/resolvers/noteResolvers.ts
import { buildDefaultResolvers } from '@sdflc/backend-helpers';

const resolvers = buildDefaultResolvers({
  prefix: 'note',
  core: 'noteCore',
  disableCreateMany: true,
  disableUpdateMany: true,
  disableSet: true,
  types: {
    Note: {
      async __resolveReference(reference: any, context: any) {
        if (!reference.id) return null;
        return context.gateways.noteGw.get(reference.id);
      },
      userCreated(parent: any, _args: any, _context: any) {
        return { __typename: 'User', id: parent.createdBy };
      },
    },
  },
});

export default resolvers;
```

```typescript
// src/app/graphql/types/noteTypes.ts
const typeDefs = `#graphql
  type Note @key(fields: "id") {
    id:          ID
    title:       String
    body:        String
    userId:      ID
    createdAt:   String
    updatedAt:   String
    userCreated: User
  }

  type NoteResult implements OpResult {
    code:   Int!
    errors: [Error!]
    data:   [Note!]
  }

  input NoteInput {
    title: String!
    body:  String
  }

  input NoteFilter {
    id:            [ID]
    title:         String
    searchKeyword: String
  }

  input NoteWhereInput {
    id: ID!
  }

  type Query {
    noteList(filter: NoteFilter, params: PaginationAndSorting): NoteResult
    noteGet(id: ID): NoteResult
    noteGetMany(ids: [ID]): NoteResult
  }

  type Mutation {
    noteCreate(params: NoteInput): NoteResult
    noteUpdate(where: NoteWhereInput, params: NoteInput): NoteResult
    noteRemove(where: NoteWhereInput): NoteResult
    noteRemoveMany(where: [NoteWhereInput]): NoteResult
  }
`;

export default typeDefs;
```

Generated resolvers:

| Resolver              | Type     | Core method             |
| --------------------- | -------- | ----------------------- |
| `${prefix}List`       | Query    | `core.list(args)`       |
| `${prefix}Get`        | Query    | `core.get(args)`        |
| `${prefix}GetMany`    | Query    | `core.getMany(args)`    |
| `${prefix}Create`     | Mutation | `core.create(args)`     |
| `${prefix}CreateMany` | Mutation | `core.createMany(args)` |
| `${prefix}Update`     | Mutation | `core.update(args)`     |
| `${prefix}UpdateMany` | Mutation | `core.updateMany(args)` |
| `${prefix}Set`        | Mutation | `core.set(args)`        |
| `${prefix}Remove`     | Mutation | `core.remove(args)`     |
| `${prefix}RemoveMany` | Mutation | `core.removeMany(args)` |

---

## CLI utilities

Because all business logic lives in core classes, the same operations available over HTTP and GraphQL are equally accessible from a CLI command — with no duplication. The CLI simply constructs a context with synthetic request headers and calls core methods directly.

### Project setup

Add a `cmd` script to `package.json` that points at your CLI entry point:

```json
{
  "scripts": {
    "cmd": "ts-node -r tsconfig-paths/register --project ./tsconfig.json ./src/app/cli/index.ts",
    "build": "cross-env NODE_ENV=production tsc -p ./tsconfig.json",
    "start": "node dist/src/index.js",
    "start:dev": "nodemon --exec \"ts-node -r tsconfig-paths/register --project ./tsconfig.json ./src/index.ts\" -e ts",
    "lint": "eslint */**/*.{js,ts,tsx} --quiet --fix",
    "test": "jest --config jest.config.ts",
    "create-migration": "knex migrate:make -x ts --knexfile ./src/knexConfig.ts",
    "migrations": "knex migrate:latest --knexfile ./src/knexConfig.ts",
    "migration-down": "knex migrate:down --knexfile ./src/knexConfig.ts",
    "rollback-migration": "knex migrate:rollback --knexfile ./src/knexConfig.ts",
    "seed": "knex seed:run --knexfile ./src/knexConfig.ts",
    "reset-db": "npm run cmd -- reset"
  }
}
```

Usage:

```bash
npm run cmd -- reset            # wipe DB, run migrations, seed
npm run cmd -- recalculate      # trigger a recalculation job
npm run cmd -- fetch-recalls    # fetch data from an external API
npm run cmd -- --help           # list all available commands
```

### CLI entry point

```typescript
// src/app/cli/index.ts
import yargs from 'yargs';
import { HEADERS, dbReset } from '@sdflc/backend-helpers';

import { createContext } from '../../context';
import config from '../../config';
import knexHandle from '../../knexHandle';
import { logger } from '../../logger';
import { redisClient } from '../../redisClient';

const getContext = () =>
  createContext({
    res: {},
    req: {
      socket: { remoteAddress: 'LOCAL' },
      headers: {
        [HEADERS.SPACE_ID]: 'develop',
        [HEADERS.API_KEY]: config.interserviceApiKey,
        [HEADERS.ACCOUNT_ID]: 'system',
        [HEADERS.USER_ID]: 'system',
      },
    },
  });

const resetDb = () => {
  dbReset(knexHandle, config).then(() => knexHandle.destroy());
};

const recalculateStats = async () => {
  const context = await getContext();

  try {
    logger.log('Recalculating stats...');
    await context.cores.noteCore.recalculateAll({});
    logger.log('Done.');
  } finally {
    process.exit(0);
  }
};

const syncExternalData = async () => {
  const context = await getContext();

  try {
    if (redisClient) {
      await redisClient.connect();
    }

    logger.log('Syncing data from external API...');
    const result = await context.cores.noteCore.syncFromExternal({
      batchSize: 100,
      maxRecords: 10000,
    });
    logger.log('Sync result:', result?.data);
    logger.log('Done.');
  } finally {
    process.exit(0);
  }
};

yargs(process.argv.splice(2))
  .command('reset', 'Wipes the database, runs all migrations, and seeds', () => {}, resetDb)
  .command('recalculate', 'Recalculates all stats and derived data', () => {}, recalculateStats)
  .command('sync', 'Syncs records from the external API', () => {}, syncExternalData)
  .strict()
  .help('h')
  .parse();
```

### Key points

**Context reuse** — the same `createContext()` factory used by GraphQL and REST is used here with a synthetic `req` object. All authentication, gateway initialisation, Redis, and rate-limiting wiring happens identically.

**Interservice API key** — CLI commands bypass JWT auth by providing `interserviceApiKey` in the synthetic headers.

**Redis** — connect `redisClient` before calling core methods that require it. Rate limiting is also active for CLI calls if `redisClient` is configured — this is usually desirable but can be bypassed by passing `rateLimiter: null` to `createContextDefault` in your CLI's `createContext` factory if needed.

**Process exit** — CLI commands should call `process.exit(0)` in a `finally` block to close open database connections and Redis clients cleanly.

**Database reset** — `dbReset` operates directly on a Knex handle without going through the context.

---

## Validation helpers

### commonValidators

Pre-built validators aware of the core's `idType` setting.

```typescript
// src/core/validators/noteValidators.ts
import Checkit from 'checkit';
import {
  BaseCoreActionsInterface,
  validateList,
  validateCreate as validateCreateCommon,
  validateUpdate as validateUpdateCommon,
} from '@sdflc/backend-helpers';

const rulesList = new Checkit({
  title: [{ rule: 'string', message: 'Title should be a string' }],
});

const rulesCreate = new Checkit({
  title: [
    { rule: 'required', message: 'Title is required' },
    { rule: 'string', message: 'Title should be a string' },
    { rule: 'maxLength:256', message: 'Title must be under 256 characters' },
  ],
  body: [{ rule: 'string', message: 'Body should be a string' }],
});

const rulesUpdate = new Checkit({
  title: [
    { rule: 'string', message: 'Title should be a string' },
    { rule: 'maxLength:256', message: 'Title must be under 256 characters' },
  ],
});

const validateCreate = async (args: any, opt: BaseCoreActionsInterface) => {
  const [checkResult] = await validateCreateCommon({ rules: rulesCreate })(args, opt);
  if (checkResult !== true) return [checkResult, {}];
  return [true, {}];
};

const validateUpdate = async (args: any, opt: BaseCoreActionsInterface) => {
  const [checkResult] = await validateUpdateCommon({ rules: rulesUpdate })(args, opt);
  if (checkResult !== true) return [checkResult, {}];
  return [true, {}];
};

export const validators = {
  list: validateList({ rules: rulesList }),
  create: validateCreate,
  update: validateUpdate,
};
```

---

### UUID helpers

UUID validation primitives that work with any UUID variant — including MSSQL `newsequentialid()` output, whose version nibble falls outside the RFC 4122 `[1-5]` range that `is-uuid` and Checkit's built-in `uuid` rule enforce.

```typescript
import { isValidUuid, UUID_REGEX, UUID_NIL } from '@sdflc/backend-helpers';

isValidUuid('a1b2c3d4-e5f6-7890-abcd-ef1234567890'); // true (standard v4)
isValidUuid('f1e2d3c4-b5a6-f789-abcd-ef1234567890'); // true (MSSQL newsequentialid)
isValidUuid(UUID_NIL); // true (nil UUID is accepted as a sentinel)
isValidUuid('not-a-uuid'); // false
isValidUuid(null); // false
```

**Why structural validation?** The library validates format only — correct hyphen placement, segment lengths, and hex characters — and is lenient on the version/variant bits. This makes it safe to use with:

- Standard RFC 4122 v4 UUIDs (`crypto.randomUUID`, `uuidv4`, etc.)
- MSSQL `newsequentialid()` sequential UUIDs
- The nil UUID, commonly used as a system-owned sentinel (e.g. `createdBy = UUID_NIL` means "created by the system")

`UUID_NIL` is exported as a constant so call sites that do need to distinguish nil from real UUIDs can do so explicitly (`value === UUID_NIL`).

---

### UUID rule factories

Reusable Checkit rule definitions for validating UUID fields. Each factory is parameterised by field name, so the same building blocks cover any entity — `id`, `fileId`, `accountId`, `tagIds`, and so on — without writing custom rules per resource.

All factories use `isValidUuid` internally, so they accept the full range of UUIDs described above.

| Factory                         | Use for                                                                       |
| ------------------------------- | ----------------------------------------------------------------------------- |
| `rulesRequiredUuid(field)`      | Required scalar UUID (mandatory foreign keys — `partyId` on create)           |
| `rulesOptionalUuid(field)`      | Optional scalar UUID (optional foreign keys)                                  |
| `rulesRequiredUuidArray(field)` | Required scalar-or-array of UUIDs, at least one present                       |
| `rulesOptionalUuidArray(field)` | Optional scalar-or-array of UUIDs (filter fields like `{ tagIds: [...] }`)    |
| `rulesOptionalIntArray(field)`  | Optional scalar-or-array of integers (companion rule for mixed filter shapes) |

Each factory returns a plain rule-definition object suitable for spreading into a Checkit rule set:

```typescript
import Checkit from 'checkit';
import {
  rulesRequiredUuid,
  rulesOptionalUuid,
  rulesRequiredUuidArray,
  rulesOptionalUuidArray,
  rulesOptionalIntArray,
} from '@sdflc/backend-helpers';

// Create rules — partyId is required, tagIds are optional
const rulesCreate = new Checkit({
  ...rulesRequiredUuid('partyId'),
  ...rulesOptionalUuidArray('tagIds'),
  name: [{ rule: 'required', message: 'Name is required' }, { rule: 'string' }],
});

// Filter rules — any combination may be present, but all must be valid UUIDs
const rulesList = new Checkit({
  ...rulesOptionalUuidArray('partyId'),
  ...rulesOptionalUuidArray('personId'),
  ...rulesOptionalIntArray('rsvpStatus'),
});

// Required multi-value field — scalar or array both accepted
const rulesBulkAction = new Checkit({
  ...rulesRequiredUuidArray('fileIds'),
});
```

**Input coercion:** the array factories use `lodash.castArray`, so a caller passing either `{ tagIds: 'uuid-1' }` or `{ tagIds: ['uuid-1', 'uuid-2'] }` is handled transparently — no need to pre-wrap scalars.

---

### filterValidUuids

A helper for sanitising input rather than rejecting it. Drops non-UUID entries from a list and logs each dropped value for diagnostics.

```typescript
import { filterValidUuids } from '@sdflc/backend-helpers';

// In a core method — sanitise a mixed list before a gateway call:
const validIds = filterValidUuids(args.ids, 'noteCore.archive', this.logger);

if (validIds.length === 0) {
  return this.success([]);
}

const records = await this.gateway.getMany(validIds);
```

Use this when skipping malformed entries is preferable to failing the whole operation — for example when enriching relations from a list that may contain stale or externally-sourced IDs. Use the rule factories instead when invalid input should cause a validation error.

---

## Migration and seeding utilities

### DbMigratorAndSeeder

```typescript
import { DbMigratorAndSeeder } from '@sdflc/backend-helpers';
import knexHandle from './knexHandle';
import config from './config';

const migrator = new DbMigratorAndSeeder(knexHandle, {
  dbType: config.dbType,
  dbSchema: config.dbSchema,
  runDbSeed: true,
});

await migrator.runMigrations();
await migrator.resetDb();
await migrator.cleanDb(['note', 'user']);

await migrator.runSeeds(__dirname, [['users'], ['notes', 'tags'], ['note_tags']]);
```

### dbHelpers

```typescript
import { dbListTables, dbClean, dbRunMigrations, dbReset } from '@sdflc/backend-helpers';

// Used directly in CLI commands:
await dbReset(knexHandle, config);

const tables = await dbListTables(knexHandle, { dbType: 'pg', dbSchema: 'public', skipTables: ['migrations'] });
await dbClean(tables, knexHandle, { dbSchema: 'public', dbType: 'pg' });
await dbRunMigrations(knexHandle, { runDbSeed: false });
```

---

## Field helpers

```typescript
import { dbFieldAddDefaults, addOrderNo, addUserId, defaultFields } from '@sdflc/backend-helpers';

exports.up = (knex: any) =>
  knex.schema.createTable('note', (table: any) => {
    table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
    table.text('title').notNullable();
    table.text('body');

    addUserId(table, { addIndex: true });
    addOrderNo(table, { addIndex: true });

    dbFieldAddDefaults(table, {
      addStatus: true,
      addVersion: true,
      addUserInfo: true,
    });
  });

// In a seed file:
const seedData = [{ id: 'seed-note-1', title: 'Hello world', ...defaultFields({ addUserInfo: true }) }];
```

---

## Utility functions

### downloadFile

```typescript
import { downloadFile } from '@sdflc/backend-helpers';

const result = await downloadFile({
  url: 'https://example.com/report.pdf',
  folder: '/tmp/downloads',
  fileName: 'my-report.pdf',
});

if (result.didSucceed()) {
  const { size, speed, durationMs } = result.getDataFirst();
  console.log(`Downloaded ${size} bytes at ${Math.round(speed)} B/s in ${durationMs}ms`);
}
```

### passwordHelpers

```typescript
import { getSaltAndHash, checkPassword } from '@sdflc/backend-helpers';

const { hash } = await getSaltAndHash(plaintextPassword, 12);
const isCorrect = await checkPassword(plaintextPassword, storedHash);
```

---

## Constants

```typescript
import { FIELDS, HEADERS, ORDER_BYS, ID_TYPES } from '@sdflc/backend-helpers';

// Primary key and standard columns
FIELDS.ID; // 'id'
FIELDS.STATUS; // 'status'
FIELDS.SPACE_ID; // 'space_id'
FIELDS.USER_ID; // 'user_id'
FIELDS.LANG; // 'lang'
FIELDS.ORDER_NO; // 'order_no'
FIELDS.VERSION; // 'version'

// Audit / timestamp columns
FIELDS.CREATED_AT; // 'created_at'
FIELDS.UPDATED_AT; // 'updated_at'
FIELDS.REMOVED_AT; // 'removed_at'
FIELDS.CREATED_BY; // 'created_by'
FIELDS.UPDATED_BY; // 'updated_by'
FIELDS.REMOVED_BY; // 'removed_by'  ← soft-delete sentinel used by BaseGateway.count()

HEADERS.AUTHORIZATION; // 'authorization'
HEADERS.SPACE_ID; // 'x-space-id'
HEADERS.API_KEY; // 'x-api-key'
HEADERS.FINGERPRINT; // 'x-fingerprint'
HEADERS.IP_ADDRESS; // 'x-real-ip'

ID_TYPES.UNKNOWN; // 0
ID_TYPES.INT; // 1
ID_TYPES.UUID; // 2
ID_TYPES.TEXT; // 3

ORDER_BYS.ASC; // 'asc'
ORDER_BYS.DESC; // 'desc'
```

---

## Interfaces reference

| Interface                                 | Description                                                                                                                                      |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ApiContext`                              | Request context object shape                                                                                                                     |
| `ApiContextInterface`                     | Arguments for `createApiContext()`                                                                                                               |
| `BaseCorePropsInterface`                  | Constructor props for `BaseCore` subclasses                                                                                                      |
| `BaseCoreRateLimitOnActionInterface`      | Per-action rate-limit rule map used in `BaseCorePropsInterface`                                                                                  |
| `BaseGatewayPropsInterface`               | Constructor props for `BaseGateway` subclasses — includes `statusField`, `nonCreatableFields`, `nonUpdatableFields`, and the filter rule arrays  |
| `BaseExternalApiGatewayPropsInterface`    | Constructor props for `BaseExternalApiGateway`                                                                                                   |
| `BaseRestApiCrudControllerPropsInterface` | Constructor props for `BaseRestApiCrudController`                                                                                                |
| `BuildDefaultResolvers`                   | Arguments for `buildDefaultResolvers()`                                                                                                          |
| `BuildListQueryArgsInterface`             | Arguments passed to `buildListQuery()` overrides: `{ filter, params, fieldsToUse, useCache }`                                                    |
| `BuildConfigOptions`                      | Options for `buildConfig()`                                                                                                                      |
| `DatabaseOptions`                         | Options for `Database` constructor                                                                                                               |
| `DatabaseReadInterface`                   | Arguments for `Database.read()`                                                                                                                  |
| `CacheInterface`                          | Config for `DataCache` (without request context)                                                                                                 |
| `DataCachePropsInterface`                 | Full props for `DataCache` including request context                                                                                             |
| `LoaderInterface`                         | Interface for custom loaders (`load`, `loadMany`, `clear`, `prime`)                                                                              |
| `GatewayFilterRule`                       | Single declarative filter rule: `{ field, column, type, columns? }` — used by `listFilters` / `updateFilters` / `removeFilters` / `countFilters` |
| `UUID_REGEX`                              | Structural UUID regex constant — format validation only, no version/variant enforcement                                                          |
| `UUID_NIL`                                | Nil UUID constant (`'00000000-0000-0000-0000-000000000000'`) — used as a system-owned sentinel                                                   |
| `GatewayFilterType`                       | Union of filter strategy names: `'array' \| 'eq' \| 'bool' \| 'nullableBool' \| 'dateRange' \| 'nullableDateRange' \| 'search'`                  |
| `SortItemInterface`                       | Single sort descriptor: `{ name: string, order: 'asc' \| 'desc' }`                                                                               |
| `RateLimitRule`                           | Rate-limit rule shape: `{ limit, windowSec, action? }`                                                                                           |
| `RateLimiterInterface`                    | Structural interface for custom rate-limiter implementations                                                                                     |
| `IpRateLimiter`                           | Built-in Redis-backed rate limiter; constructed automatically from `redisClient`                                                                 |
| `CoreActionInterface`                     | Arguments for `BaseCore.runAction()` including `tryAuth` and `rateLimit`                                                                         |
| `CreateUserTokenArgs`                     | Options for `createUserToken()`                                                                                                                  |
| `VerifyUserTokenArgs`                     | Options for `verifyUserToken()`                                                                                                                  |
| `UserTokenParams`                         | JWT payload structure                                                                                                                            |
| `DownloadFileArgs`                        | Arguments for `downloadFile()`                                                                                                                   |
| `DbFieldAddDefaultInterface`              | Options for `dbFieldAddDefaults()`                                                                                                               |
| `ListArgsInterface`                       | Standard list operation arguments                                                                                                                |

---
_Source: https://npm.io/package/@sdflc/backend-helpers · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
