TryGhost/Ghost · error · Error

Cannot insert without an id field

Error message

Cannot insert without an id field

What it means

Thrown by KnexPersistenceAdapter.insert() when the data object passed to insert has no 'id' field. Because MySQL doesn't support Knex's returning(), the adapter inserts the row then re-fetches it by id — so an id is mandatory on the payload. This is a factory-build contract requirement: any entity persisted via the Knex adapter must include an id in the built data.

Source

Thrown at e2e/data-factory/persistence/adapters/knex.ts:22

/**
 * Knex-based persistence adapter for direct database access
 */
export class KnexPersistenceAdapter implements PersistenceAdapter {
    private db: Knex;

    constructor(db: Knex) {
        this.db = db;
    }

    async insert<T>(entityType: string, data: T): Promise<T> {
        // entityType is the table name for Knex
        await this.db(entityType).insert(data);

        // MySQL doesn't support returning(), so we need to fetch the inserted record
        // Assuming the data has an 'id' field
        const id = (data as {id?: string}).id;
        if (!id) {
            throw new Error('Cannot insert without an id field');
        }

        return await this.findById<T>(entityType, id);
    }

    async update<T>(entityType: string, id: string, data: Partial<T>): Promise<T> {
        await this.db(entityType)
            .where('id', id)
            .update(data);

        return await this.findById<T>(entityType, id);
    }

    async delete(entityType: string, id: string): Promise<void> {
        await this.db(entityType)
            .where('id', id)
            .del();
    }

View on GitHub (pinned to 47d8b0e2ad)

Solutions

  1. Ensure the factory's build() generates an id (e.g. crypto.randomUUID() or a faker-based id) for every entity persisted via the Knex adapter.
  2. If the table uses DB-assigned auto-increment ids, the Knex adapter's insert path is the wrong abstraction — either switch to the API adapter or extend the adapter to read the last-inserted id.
  3. Run 'pnpm build' and 'pnpm test:types' after factory changes to catch missing id generation.
  4. Confirm the table's primary key is actually named 'id' (the adapter hardcodes the 'id' field name).

Example fix

// before: build() omits id, assuming DB auto-increment
build(options) {
    return {title: options.title ?? 'Post', status: 'draft'};
}

// after: generate an id so the Knex adapter can re-fetch
import {randomUUID} from 'crypto';
build(options) {
    return {id: options.id ?? randomUUID(), title: options.title ?? 'Post', status: 'draft'};
}
Defensive patterns

Strategy: validation

Validate before calling

import {randomUUID} from 'crypto';

function ensureId(data) {
    if (!data || data.id == null) {
        throw new Error('Cannot insert via Knex adapter without an id field. Generate one in build().');
    }
    return data;
}

// In the factory:
build(options) {
    return ensureId({id: options.id ?? randomUUID(), ...otherFields});
}

Type guard

function hasIdField(data) {
    return data != null && (typeof data.id === 'string' || typeof data.id === 'number') && data.id !== '';
}

Prevention

When it happens

Trigger: A factory's build() method produces an object without an id field (relying on auto-increment), then create() routes through KnexPersistenceAdapter.insert(). The adapter cannot re-fetch the inserted row because it has no id to query by, so it throws immediately after the raw insert.

Common situations: Factory was designed for the ApiPersistenceAdapter (where the server assigns the id) but reused with the Knex adapter without generating an id in build(); MySQL auto-increment assumed but the adapter needs an explicit id; a UUID/id generator was removed from build() during a refactor.

Related errors


AI-assisted analysis of TryGhost/Ghost@47d8b0e2ad (2026-08-13). Data as JSON: /api/errors/943433bf10628e08. Report an issue: GitHub.