Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **CLI migration commands for the main (auth/audit) connection.** The app runs the main connection as a separate
always-SQLite connection, but the migration CLI only managed the data connection. New `migration:run:main`,
`migration:generate:main`, `migration:show:main`, and `migration:revert:main` scripts (plus `:prod` variants) manage
it — needed when `MAIN_DATABASE_SYNCHRONIZE=false` disables boot auto-migration. Purely additive. (#364)

### Changed

- **`PUT /settings` now returns `501 Not Implemented` instead of a misleading `200`.** Settings are derived from
environment variables and consumed at boot (and `ConfigService` is immutable at runtime), so the previous handler
mutated an in-memory copy and reported success while persisting and applying nothing. The endpoint is now honest
about being read-only; `GET /settings` and the ADMIN guard are unchanged, and no dashboard flow uses the write. (#364)

### Fixed

- **Baileys reconnect no longer leaks the previous socket.** An internal (transient-drop) reconnect overwrote the live
socket without tearing the old one down, leaking its WebSocket and event listeners on every reconnect. The previous
socket is now detached and ended before its replacement is created. (#364)
- **Engine sessions keep operator config when the engine plugin fails to enable.** The engine config blob is now also
supplied at plugin construction, so `sessionDataPath`/`executablePath`/`authDir` still apply if a plugin fails to
enable before its `onLoad` runs (they previously dropped silently to defaults). The healthy path is unchanged. (#364)
- **Template names are unique per session.** A composite unique index makes resolve-by-name deterministic and rejects
duplicate names with `409 Conflict`; a migration losslessly de-duplicates any pre-existing collisions (keeps the
earliest, renames the rest) before adding the index. The `{{var}}`/`{var}` template-syntax split is unchanged and
still tracked in #69. (#364)
- **Container no longer crashes on browser-cleanup paths when `ps` is missing.** The production image is based on
`node:22-slim`, which omits the `ps` binary; cleanup code that shells out to `ps` (e.g. process-tree kills) fails
with `spawn ps ENOENT`, and that unhandled child-process error can take down the whole Node runtime. The image now
Expand Down
9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,14 @@
"migration:revert": "npm run typeorm -- migration:revert -d src/database/data-source.ts",
"migration:revert:prod": "npm run typeorm:prod -- migration:revert -d dist/database/data-source.js",
"migration:show": "npm run typeorm -- migration:show -d src/database/data-source.ts",
"migration:show:prod": "npm run typeorm:prod -- migration:show -d dist/database/data-source.js"
"migration:show:prod": "npm run typeorm:prod -- migration:show -d dist/database/data-source.js",
"migration:generate:main": "npm run typeorm -- migration:generate src/database/migrations-main/$npm_config_name -d src/database/data-source-main.ts",
"migration:run:main": "npm run typeorm -- migration:run -d src/database/data-source-main.ts",
"migration:run:main:prod": "npm run typeorm:prod -- migration:run -d dist/database/data-source-main.js",
"migration:revert:main": "npm run typeorm -- migration:revert -d src/database/data-source-main.ts",
"migration:revert:main:prod": "npm run typeorm:prod -- migration:revert -d dist/database/data-source-main.js",
"migration:show:main": "npm run typeorm -- migration:show -d src/database/data-source-main.ts",
"migration:show:main:prod": "npm run typeorm:prod -- migration:show -d dist/database/data-source-main.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1068.0",
Expand Down
33 changes: 33 additions & 0 deletions src/common/transformers/date.transformer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { DateTransformer } from './date.transformer';

// DateTransformer.to() resolves the DATA connection dialect from the global DATABASE_TYPE.
// Characterization tests lock the round-trip behavior (previously untested).
describe('DateTransformer (cross-DB date round-trip)', () => {
const original = process.env.DATABASE_TYPE;
afterEach(() => {
if (original === undefined) delete process.env.DATABASE_TYPE;
else process.env.DATABASE_TYPE = original;
});

it('from(): parses ISO strings to Date, passes a Date through, maps null to null', () => {
const parsed = DateTransformer.from('2026-06-20T10:00:00.000Z') as Date;
expect(parsed).toBeInstanceOf(Date);
expect(parsed.toISOString()).toBe('2026-06-20T10:00:00.000Z');

const now = new Date();
expect(DateTransformer.from(now)).toBe(now);
expect(DateTransformer.from(null)).toBeNull();
});

it('to(): stores an ISO string on SQLite and a native Date on Postgres; null stays null', () => {
const d = new Date('2026-06-20T10:00:00.000Z');

process.env.DATABASE_TYPE = 'sqlite';
expect(DateTransformer.to(d)).toBe('2026-06-20T10:00:00.000Z');

process.env.DATABASE_TYPE = 'postgres';
expect(DateTransformer.to(d)).toBe(d);

expect(DateTransformer.to(null)).toBeNull();
});
});
4 changes: 4 additions & 0 deletions src/common/transformers/date.transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { ValueTransformer } from 'typeorm';
* Cross-database date transformer.
* - SQLite stores as ISO string TEXT, transformer converts to/from Date
* - PostgreSQL stores as native timestamp, driver returns Date directly
*
* DATA CONNECTION ONLY. `to()` resolves the dialect from the global `DATABASE_TYPE`. Pair it
* only with data-connection entities; the always-SQLite MAIN connection (auth, audit) must not use
* it (see column-types.ts for the full rationale).
*/
export const DateTransformer: ValueTransformer = {
from: (value: string | Date | null): Date | null => {
Expand Down
28 changes: 28 additions & 0 deletions src/common/utils/column-types.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { jsonColumnType, dateColumnType } from './column-types';

// These helpers resolve the DATA connection dialect from the global DATABASE_TYPE env var.
// Characterization tests lock the resolved type strings so any future change to the resolution is
// caught (the helpers were previously untested).
describe('cross-DB column type helpers (data connection dialect)', () => {
const original = process.env.DATABASE_TYPE;
afterEach(() => {
if (original === undefined) delete process.env.DATABASE_TYPE;
else process.env.DATABASE_TYPE = original;
});

it('resolves Postgres types when DATABASE_TYPE=postgres', () => {
process.env.DATABASE_TYPE = 'postgres';
expect(jsonColumnType()).toBe('jsonb');
expect(dateColumnType()).toBe('timestamp');
});

it('resolves SQLite types when DATABASE_TYPE is sqlite or unset', () => {
process.env.DATABASE_TYPE = 'sqlite';
expect(jsonColumnType()).toBe('simple-json');
expect(dateColumnType()).toBe('text');

delete process.env.DATABASE_TYPE;
expect(jsonColumnType()).toBe('simple-json');
expect(dateColumnType()).toBe('text');
});
});
7 changes: 7 additions & 0 deletions src/common/utils/column-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
*
* PostgreSQL has native `jsonb` and `timestamp` types with better
* indexing and query performance.
*
* DATA CONNECTION ONLY. These resolve the dialect of the *data* connection from the global
* `DATABASE_TYPE` env var. Use them only on entities bound to the data connection. Entities on the
* MAIN connection (auth, audit) are ALWAYS SQLite — it is hardcoded `type: 'sqlite'` in
* app.module.ts regardless of DATABASE_TYPE — so they must hardcode `simple-json` / `datetime`
* (see audit-log.entity.ts) and must NOT call these helpers, or a Postgres deployment would emit a
* `jsonb`/`timestamp` column on the always-SQLite main DB.
*/

const isPostgres = (): boolean => process.env.DATABASE_TYPE === 'postgres';
Expand Down
5 changes: 4 additions & 1 deletion src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ export default () => ({
database: './data/main.sqlite',
// Schema management for the auth/audit DB. Default ON (zero-config first boot).
// Set MAIN_DATABASE_SYNCHRONIZE=false to manage schema via the main-owned migrations
// instead (migrationsRun then creates api_keys/audit_logs).
// instead (migrationsRun then creates api_keys/audit_logs). When disabled, run the
// main-connection migrations explicitly with `npm run migration:run:main` (or
// `migration:run:main:prod` for the compiled image) — the plain `migration:run` only
// manages the data connection.
synchronize: process.env.MAIN_DATABASE_SYNCHRONIZE !== 'false',
logging: process.env.DATABASE_LOGGING === 'true',
},
Expand Down
21 changes: 21 additions & 0 deletions src/database/data-source-main.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import mainDataSource from './data-source-main';

// The app runs the MAIN connection (auth + audit) as a separate always-SQLite connection. The
// default data-source.ts CLI only manages the DATA connection's migrations, so this standalone
// DataSource exists so the CLI can run/generate the main-owned migrations too.
describe('main CLI DataSource', () => {
it('targets the always-SQLite main connection', () => {
expect(mainDataSource.options.type).toBe('sqlite');
});

it('uses the main-owned migrations dir, not the data migrations dir', () => {
const migrations = (mainDataSource.options.migrations as string[]).join(' ');
expect(migrations).toContain('migrations-main');
});

it('covers the auth and audit entities (the main connection owns them)', () => {
const entities = (mainDataSource.options.entities as string[]).join(' ');
expect(entities).toContain('auth');
expect(entities).toContain('audit');
});
});
32 changes: 32 additions & 0 deletions src/database/data-source-main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { DataSource } from 'typeorm';
import { config } from 'dotenv';

// Load environment variables (mirrors data-source.ts).
config();

/**
* Standalone TypeORM CLI DataSource for the MAIN connection (auth + audit).
*
* The app runs the main connection as a separate, ALWAYS-SQLite connection (app.module.ts), distinct
* from the pluggable data connection. The default data-source.ts CLI only manages the data
* connection's migrations, so without this the CLI could not run/generate the main-owned migrations
* (migrations-main) — which matters the moment boot auto-migration is turned off
* (MAIN_DATABASE_SYNCHRONIZE=false), where the schema must be managed via the CLI instead.
*
* Mirrors the runtime main connection exactly: SQLite at ./data/main.sqlite, auth/audit entities,
* migrations-main. synchronize is always false here — the CLI manages schema via migrations.
*
* Usage: `npm run migration:run:main` (dev) / `migration:run:main:prod` (compiled).
*/
const mainDataSource = new DataSource({
type: 'sqlite',
// Hardcoded to match the runtime main path (configuration.ts), so the CLI and the app never target
// different main databases.
database: './data/main.sqlite',
entities: [__dirname + '/../modules/auth/**/*.entity{.ts,.js}', __dirname + '/../modules/audit/**/*.entity{.ts,.js}'],
migrations: [__dirname + '/migrations-main/*{.ts,.js}'],
synchronize: false,
logging: process.env.DATABASE_LOGGING === 'true',
});

export default mainDataSource;
42 changes: 42 additions & 0 deletions src/database/migrations/1781100000000-AddTemplateNameUnique.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

/**
* Enforces one template name per session (issue #69): resolve-by-name was nondeterministic because
* nothing stopped two templates in a session from sharing a name.
*
* Before adding the composite UNIQUE index, any pre-existing (sessionId, name) collisions are
* deduplicated LOSSLESSLY — the earliest row keeps the name, the rest are renamed to
* `<name>-dup-<id>` (id is a UUID, so the new name cannot collide; the name is left-trimmed so the
* result fits the varchar(100) column on PostgreSQL). No row is ever deleted.
*
* Hand-authored because `synchronize` is disabled for the `data` connection on PostgreSQL (and may
* be disabled on SQLite via DATABASE_SYNCHRONIZE=false). Idempotent: a re-run finds no duplicates
* and skips the existing index.
*/
export class AddTemplateNameUnique1781100000000 implements MigrationInterface {
name = 'AddTemplateNameUnique1781100000000';

public async up(queryRunner: QueryRunner): Promise<void> {
if (!(await queryRunner.hasTable('templates'))) return;

// Keep the earliest row per (sessionId, name) — createdAt ASC, id ASC as a stable tiebreak —
// and rename every other member of the group. substr(name,1,59)+'-dup-'+id(36) is <= 100 chars,
// so it never overflows the varchar(100) "name" column on PostgreSQL.
await queryRunner.query(
`UPDATE "templates" SET "name" = substr("name", 1, 59) || '-dup-' || "id" ` +
`WHERE "id" <> (` +
`SELECT t2."id" FROM "templates" t2 ` +
`WHERE t2."sessionId" = "templates"."sessionId" AND t2."name" = "templates"."name" ` +
`ORDER BY t2."createdAt" ASC, t2."id" ASC LIMIT 1)`,
);

await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_templates_session_name" ON "templates" ("sessionId", "name")`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
// Only the index is reversible; the lossless renames are intentionally left in place.
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_templates_session_name"`);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { DataSource } from 'typeorm';
import { AddTemplateNameUnique1781100000000 } from '../1781100000000-AddTemplateNameUnique';

describe('AddTemplateNameUnique migration', () => {
let ds: DataSource;

beforeEach(async () => {
ds = new DataSource({ type: 'sqlite', database: ':memory:' });
await ds.initialize();
// Minimal templates table mirroring the AddTemplates sqlite schema.
await ds.query(
`CREATE TABLE "templates" ("id" varchar PRIMARY KEY NOT NULL, "sessionId" varchar NOT NULL, ` +
`"name" varchar(100) NOT NULL, "body" text NOT NULL, "header" text, "footer" text, ` +
`"createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL)`,
);
});

afterEach(async () => {
await ds.destroy();
});

const insert = (id: string, sessionId: string, name: string, createdAt: string): Promise<unknown> =>
ds.query(
`INSERT INTO "templates" ("id","sessionId","name","body","header","footer","createdAt","updatedAt") ` +
`VALUES (?, ?, ?, 'body', NULL, NULL, ?, ?)`,
[id, sessionId, name, createdAt, createdAt],
);

it('creates the composite unique index and is idempotent', async () => {
const runner = ds.createQueryRunner();
const migration = new AddTemplateNameUnique1781100000000();

await migration.up(runner);
const index = (await runner.query(
`SELECT name FROM sqlite_master WHERE type='index' AND name='IDX_templates_session_name'`,
)) as unknown[];
expect(index).toHaveLength(1);

// Re-run must not throw (idempotent).
await expect(migration.up(runner)).resolves.toBeUndefined();
await runner.release();
});

it('deduplicates pre-existing (sessionId, name) collisions losslessly, keeping the earliest', async () => {
await insert('id-early', 'sess-1', 'welcome', '2026-01-01T00:00:00.000Z');
await insert('id-late', 'sess-1', 'welcome', '2026-02-01T00:00:00.000Z');
await insert('id-other', 'sess-1', 'promo', '2026-01-01T00:00:00.000Z');

const runner = ds.createQueryRunner();
await new AddTemplateNameUnique1781100000000().up(runner);

// No rows lost — all three bodies survive.
const all = (await runner.query(`SELECT id, name FROM "templates" ORDER BY id`)) as { id: string; name: string }[];
expect(all).toHaveLength(3);

const byId = Object.fromEntries(all.map(r => [r.id, r.name]));
expect(byId['id-early']).toBe('welcome'); // earliest keeps the clean name
expect(byId['id-late']).toBe('welcome-dup-id-late'); // later duplicate renamed losslessly
expect(byId['id-other']).toBe('promo'); // unrelated row untouched

// The unique index now rejects a fresh duplicate.
await expect(
runner.query(
`INSERT INTO "templates" ("id","sessionId","name","body","header","footer","createdAt","updatedAt") ` +
`VALUES ('id-new','sess-1','welcome','body',NULL,NULL,'2026-03-01T00:00:00.000Z','2026-03-01T00:00:00.000Z')`,
),
).rejects.toThrow();

await runner.release();
});

it('is a no-op when the templates table does not exist', async () => {
await ds.query(`DROP TABLE "templates"`);
const runner = ds.createQueryRunner();
await expect(new AddTemplateNameUnique1781100000000().up(runner)).resolves.toBeUndefined();
await runner.release();
});
});
Loading
Loading