fix(db): use native booleans instead of B() integer coercion

PG rejects integer 0/1 for BOOLEAN columns. Replaced all B() calls
with native JS booleans — works for both SQLite (coerces true→1,
false→0) and PG (native BOOLEAN). Removed B() import and PG adapter
coercion hack.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mitchell R 2026-05-23 12:55:04 +02:00
parent 5cefa04a45
commit 6e3a893421
No known key found for this signature in database
2 changed files with 19 additions and 34 deletions

View file

@ -59,19 +59,6 @@ export class PgAdapter implements DbAdapter {
return out; return out;
} }
private coerceParams(params: ReadonlyArray<SqlValue>): unknown[] {
return params.map((v) => {
if (v === 0 || v === 1) {
// Could be integer or boolean. PG is strict about boolean columns
// receiving integer values. We can't know the column type here, but
// the `pg` driver accepts JS booleans for both INTEGER and BOOLEAN
// columns, so converting 0/1 to false/true is always safe.
return v === 1;
}
return v;
});
}
private async runner<T>(fn: (c: PoolClient) => Promise<T>): Promise<T> { private async runner<T>(fn: (c: PoolClient) => Promise<T>): Promise<T> {
if (this.currentTxClient) return fn(this.currentTxClient); if (this.currentTxClient) return fn(this.currentTxClient);
const client = await this.pool.connect(); const client = await this.pool.connect();
@ -81,9 +68,8 @@ export class PgAdapter implements DbAdapter {
async run(sql: string, params: ReadonlyArray<SqlValue> = []): Promise<RunResult> { async run(sql: string, params: ReadonlyArray<SqlValue> = []): Promise<RunResult> {
const pgSql = this.rewriteSql(sql); const pgSql = this.rewriteSql(sql);
const pgParams = this.coerceParams(params);
return this.runner(async (c) => { return this.runner(async (c) => {
const res = await c.query(pgSql, pgParams); const res = await c.query(pgSql, params as unknown[]);
let lastInsertRowid = 0n; let lastInsertRowid = 0n;
// If the caller added RETURNING id, pluck it. // If the caller added RETURNING id, pluck it.
if (res.rows.length > 0 && res.rows[0] && "id" in res.rows[0]) { if (res.rows.length > 0 && res.rows[0] && "id" in res.rows[0]) {
@ -98,18 +84,16 @@ export class PgAdapter implements DbAdapter {
async get<T = Row>(sql: string, params: ReadonlyArray<SqlValue> = []): Promise<T | undefined> { async get<T = Row>(sql: string, params: ReadonlyArray<SqlValue> = []): Promise<T | undefined> {
const pgSql = this.rewriteSql(sql); const pgSql = this.rewriteSql(sql);
const pgParams = this.coerceParams(params);
return this.runner(async (c) => { return this.runner(async (c) => {
const res = await c.query(pgSql, pgParams); const res = await c.query(pgSql, params as unknown[]);
return (res.rows[0] as T | undefined); return (res.rows[0] as T | undefined);
}); });
} }
async all<T = Row>(sql: string, params: ReadonlyArray<SqlValue> = []): Promise<T[]> { async all<T = Row>(sql: string, params: ReadonlyArray<SqlValue> = []): Promise<T[]> {
const pgSql = this.rewriteSql(sql); const pgSql = this.rewriteSql(sql);
const pgParams = this.coerceParams(params);
return this.runner(async (c) => { return this.runner(async (c) => {
const res = await c.query(pgSql, pgParams); const res = await c.query(pgSql, params as unknown[]);
return res.rows as T[]; return res.rows as T[];
}); });
} }

View file

@ -81,7 +81,7 @@ import {
rowToSetupState, rowToSetupState,
rowToUser, rowToUser,
} from "./mappers.js"; } from "./mappers.js";
import { B, J, isoIn, isoNow, j } from "./util.js"; import { J, isoIn, isoNow, j } from "./util.js";
type NotifyFn = ( type NotifyFn = (
table: string, table: string,
@ -189,12 +189,13 @@ export class Repository {
const role: UserRole = input.role ?? "operator"; const role: UserRole = input.role ?? "operator";
const result = await this._run( const result = await this._run(
`INSERT INTO users (username, password_hash, role, is_active, must_change_password) `INSERT INTO users (username, password_hash, role, is_active, must_change_password)
VALUES (?, ?, ?, 1, ?)`, VALUES (?, ?, ?, ?, ?)`,
[ [
input.username, input.username,
input.password_hash, input.password_hash,
role, role,
B(Boolean(input.must_change_password)), true,
Boolean(input.must_change_password),
], ],
); );
const id = Number(result.lastInsertRowid); const id = Number(result.lastInsertRowid);
@ -213,7 +214,7 @@ export class Repository {
} }
if ("totp_enabled" in patch) { if ("totp_enabled" in patch) {
cols.push("totp_enabled = ?"); cols.push("totp_enabled = ?");
vals.push(B(Boolean(patch.totp_enabled))); vals.push(Boolean(patch.totp_enabled));
} }
if ("totp_secret_encrypted" in patch) { if ("totp_secret_encrypted" in patch) {
cols.push("totp_secret_encrypted = ?"); cols.push("totp_secret_encrypted = ?");
@ -225,7 +226,7 @@ export class Repository {
} }
if ("must_change_password" in patch) { if ("must_change_password" in patch) {
cols.push("must_change_password = ?"); cols.push("must_change_password = ?");
vals.push(B(Boolean(patch.must_change_password))); vals.push(Boolean(patch.must_change_password));
} }
if ("failed_login_count" in patch) { if ("failed_login_count" in patch) {
cols.push("failed_login_count = ?"); cols.push("failed_login_count = ?");
@ -241,7 +242,7 @@ export class Repository {
} }
if ("is_active" in patch) { if ("is_active" in patch) {
cols.push("is_active = ?"); cols.push("is_active = ?");
vals.push(B(Boolean(patch.is_active))); vals.push(Boolean(patch.is_active));
} }
if (cols.length === 0) return; if (cols.length === 0) return;
vals.push(id); vals.push(id);
@ -270,7 +271,7 @@ export class Repository {
input.id, input.id,
input.user_id, input.user_id,
input.csrf_token, input.csrf_token,
B(input.totp_pending), Boolean(input.totp_pending),
input.user_agent, input.user_agent,
input.ip_address, input.ip_address,
input.expires_at, input.expires_at,
@ -295,7 +296,7 @@ export class Repository {
async setSessionTotpPending(id: string, pending: boolean): Promise<void> { async setSessionTotpPending(id: string, pending: boolean): Promise<void> {
await this._run("UPDATE sessions SET totp_pending = ? WHERE id = ?", [ await this._run("UPDATE sessions SET totp_pending = ? WHERE id = ?", [
B(pending), pending,
id, id,
]); ]);
} }
@ -573,7 +574,7 @@ export class Repository {
input.priority ?? "normal", input.priority ?? "normal",
input.cooling_timeout_seconds ?? null, input.cooling_timeout_seconds ?? null,
J(input.preload_camera_ids ?? []), J(input.preload_camera_ids ?? []),
B(input.resets_idle_timer ?? true), Boolean(input.resets_idle_timer ?? true),
], ],
); );
const id = Number(result.lastInsertRowid); const id = Number(result.lastInsertRowid);
@ -590,7 +591,7 @@ export class Repository {
if (k === "id" || k === "display_id") continue; // display_id deprecated if (k === "id" || k === "display_id") continue; // display_id deprecated
sets.push(`${k} = ?`); sets.push(`${k} = ?`);
if (k === "preload_camera_ids" || k === "regions") vals.push(J(v)); if (k === "preload_camera_ids" || k === "regions") vals.push(J(v));
else if (typeof v === "boolean") vals.push(B(v)); else if (typeof v === "boolean") vals.push(v);
else vals.push(v === undefined ? null : v); else vals.push(v === undefined ? null : v);
} }
if (sets.length === 0) return; if (sets.length === 0) return;
@ -936,7 +937,7 @@ export class Repository {
const cam = rowToCamera(existing as Record<string, unknown>); const cam = rowToCamera(existing as Record<string, unknown>);
await this._run( await this._run(
`UPDATE cameras SET name = ?, cloud_stream_url = ?, cloud_stream_type = ?, enabled = ? WHERE id = ?`, `UPDATE cameras SET name = ?, cloud_stream_url = ?, cloud_stream_type = ?, enabled = ? WHERE id = ?`,
[input.name, input.cloud_stream_url, input.cloud_stream_type, B(input.enabled), cam.id], [input.name, input.cloud_stream_url, input.cloud_stream_type, Boolean(input.enabled), cam.id],
); );
void this.notify("cameras", "update", cam.id); void this.notify("cameras", "update", cam.id);
return (await this.getCameraById(cam.id))!; return (await this.getCameraById(cam.id))!;
@ -946,7 +947,7 @@ export class Repository {
(name, type, cloud_account_id, cloud_vendor_camera_id, cloud_stream_url, cloud_stream_type, enabled) (name, type, cloud_account_id, cloud_vendor_camera_id, cloud_stream_url, cloud_stream_type, enabled)
VALUES (?, 'cloud', ?, ?, ?, ?, ?)`, VALUES (?, 'cloud', ?, ?, ?, ?, ?)`,
[input.name, input.cloud_account_id, input.cloud_vendor_camera_id, [input.name, input.cloud_account_id, input.cloud_vendor_camera_id,
input.cloud_stream_url, input.cloud_stream_type, B(input.enabled)], input.cloud_stream_url, input.cloud_stream_type, Boolean(input.enabled)],
); );
const id = Number(result.lastInsertRowid); const id = Number(result.lastInsertRowid);
void this.notify("cameras", "create", id); void this.notify("cameras", "create", id);
@ -1017,7 +1018,7 @@ export class Repository {
input.encoding ?? null, input.encoding ?? null,
input.framerate ?? null, input.framerate ?? null,
input.bitrate_kbps ?? null, input.bitrate_kbps ?? null,
B(Boolean(input.is_discovered)), Boolean(input.is_discovered),
], ],
); );
const id = Number(result.lastInsertRowid); const id = Number(result.lastInsertRowid);
@ -1829,7 +1830,7 @@ export class Repository {
input.topic, input.topic,
input.property_op, input.property_op,
J(input.payload), J(input.payload),
B(input.forwarded_to_nodered), Boolean(input.forwarded_to_nodered),
], ],
); );
return Number(result.lastInsertRowid); return Number(result.lastInsertRowid);
@ -2099,7 +2100,7 @@ export class Repository {
if (k === "id" || k === "created_at") continue; if (k === "id" || k === "created_at") continue;
sets.push(`${k} = ?`); sets.push(`${k} = ?`);
if (k === "capabilities") vals.push(J(v)); if (k === "capabilities") vals.push(J(v));
else if (typeof v === "boolean") vals.push(B(v)); else if (typeof v === "boolean") vals.push(v);
else vals.push(v === undefined ? null : v); else vals.push(v === undefined ? null : v);
} }
if (sets.length === 0) return; if (sets.length === 0) return;