import { ModelCollection } from "./modelCollection.js"; import { QueryBuilder } from "./queryBuilder.js"; import type { DatabaseAdapter, FieldDefinition, RelationshipDefinition } from "./types.js"; /** * Convert a snake_case name to camelCase. * Lowercases the input first so UPPERCASE DB column names (Firebird/Oracle) map correctly. */ export declare function snakeToCamel(name: string): string; /** * Convert a camelCase name to snake_case. */ export declare function camelToSnake(name: string): string; /** * Convert an in-memory field value to its database representation. * A "json" field serialises its object/array to a JSON string for the driver * (parity with the Python master's JSONField.to_db). A value that can't be * serialised (e.g. a circular reference or a BigInt) throws — save() builds * the row inside its try/catch, so it fails loud (rolls back, returns false, * records the cause). null/undefined and an already-serialised string pass * through untouched. Every other field type is returned as-is. */ export declare function toDbFieldValue(def: FieldDefinition | undefined, value: unknown): unknown; /** * Convert a database value to its in-memory representation for a field. * A "json" column comes back from the driver as a JSON string (SQLite TEXT, * MySQL JSON, PostgreSQL JSONB via the text protocol, MSSQL NVARCHAR); decode * it to the object/array the property expects (parity with the Python master's * JSONField parse-on-read). A value already an object/array is left untouched; * null stays null; a non-decodable string keeps its raw form. */ export declare function fromDbFieldValue(def: FieldDefinition | undefined, value: unknown): unknown; export declare class BaseModel { static tableName: string; static fields: Record; static softDelete?: boolean; static tableFilter?: string; static hasOne?: RelationshipDefinition[]; static hasMany?: RelationshipDefinition[]; static belongsTo?: RelationshipDefinition[]; static _db?: string; /** * When true, auto-generates fieldMapping entries from camelCase field names * to snake_case DB column names. Explicit fieldMapping entries always win. */ static autoMap: boolean; /** * Maps JS property names to database column names. * Example: { firstName: "first_name" } means the JS property `firstName` * corresponds to the database column `first_name`. * Properties not listed here use the property name as-is. */ static fieldMapping: Record; /** * When true, auto-generates CRUD routes for this model. * Models must explicitly opt-in by setting `static autoCrud = true;`. */ static autoCrud: boolean; /** Instance data */ [key: string]: unknown; /** Relationship cache for lazy loading */ private _relCache; /** * Cause of the most recent failed save(). null when the last save() * succeeded. Mirrors db.getError() so a caller that checks * `if (!(await model.save()))` can still recover the real cause via * `model.getError()` / `model.lastError` — the failure never vanishes * silently. Set by save() (validation message or driver error), cleared * to null on a successful save. */ lastError: string | null; constructor(data?: Record | string); /** * Get the database column name for a JS property. * Returns the mapped column name, or the property name if no mapping exists. */ static getDbColumn(prop: string): string; /** * Get all instance data converted to database column names. * Uses fieldMapping to translate JS property names to DB column names. */ getDbData(): Record; /** * Get the reverse mapping (DB column → JS property). * Flips fieldMapping so that { firstName: "first_name" } becomes { first_name: "firstName" }. */ static getReverseMapping(): Record; /** * Process any foreignKey field definitions on this model, auto-wiring: * - belongsTo entries on this model (strip _id from key → association name) * - hasMany entries on the referenced model via the module-level _fkRegistry * * Idempotent — safe to call multiple times. */ static _processForeignKeys(): void; /** * Merge any FK-registry-registered hasMany entries for this model. * Called before relationship resolution so the referenced model gets its has-many wired. */ static _applyFkRegistry(): void; /** * Create a fluent QueryBuilder pre-configured for this model's table and database. * * Usage: * const results = User.query().where("active = ?", [1]).orderBy("name").get(); * * @returns A QueryBuilder instance bound to this model's table and database. */ static query(): QueryBuilder; /** * Get the database adapter for this model. * If no adapter is registered, attempts auto-discovery from TINA4_DATABASE_URL. * SQLite URLs are initialised synchronously. Other engines require initDatabase() * to be called before first use. */ protected static getDb(): DatabaseAdapter; /** * Get the primary key field name (JS property name). */ protected static getPkField(): string; /** * EVERY primary-key field name, in declaration order. * * A key may span several columns. `getPkField()` returns only the FIRST and * is kept for the auto-increment paths, which are single-column by * definition. Anything that ADDRESSES a row must use this: keying on one * column of a composite key matches every row sharing that value, which is * the data-loss shape feature 4 removed from the raw write path below. */ protected static getPkFields(): string[]; /** A WHERE naming EVERY primary-key column, and its bound params. */ protected pkWhere(): { sql: string; params: unknown[]; }; /** * Get the primary key database column name (applies fieldMapping). */ protected static getPkColumn(): string; /** * Shared read tail for the collection-returning finders (where / all / select * / find filter-form / withTrashed). Runs the SAME two calls `db.fetch()` * makes — the page fetch AND the COUNT probe over the SAME base SQL — hydrates * the rows into model instances, and returns a ModelCollection carrying the * total (ADR-0064). * * The total is FREE: `probeTotal` is the exact `COUNT(*)` probe `db.fetch()` * already runs; the ORM used to discard it. ZERO extra queries beyond that one * probe. `sql` MUST NOT carry its own LIMIT/OFFSET — `adapterFetch` applies * limit/offset to the page, and the probe wraps the un-limited SQL so it counts * the WHOLE filtered set, not the page. */ protected static _collect(this: typeof BaseModel & (new (data?: Record) => T), sql: string, params: unknown[] | undefined, limit: number, offset: number, include?: string[]): Promise>; /** * Find a record by primary key. * @param id Primary key value. * @param include Optional array of relationship names to eager-load. */ static findById(this: new (data?: Record) => T, id: unknown, include?: string[]): Promise; /** * Create a new instance from data, save it, and return the saved instance. * * Canonical #3: if the underlying save() fails (validation errors or a * driver error), create() returns `false` — it does NOT hand back a * possibly-unsaved instance, so a failed insert can never masquerade as a * success. The failure cause is logged and available on the (discarded) * instance's getError() via the same path save() uses. * * Usage: * const user = User.create({ name: "Alice", email: "alice@example.com" }); * if (!(await User.create({ name: null }))) { ... } // save() failed -> false */ static create(this: new (data?: Record) => T, data?: Record): Promise; /** * Find record(s) by primary key, filter object, or all. * * Outlier C — overloaded on the first argument (parity with * Python/PHP/Ruby): * - number | string (scalar PK) → single instance (or null), like * findById(pk). `include` is accepted as the 2nd argument in this form. * - object (filter) → array of instances (AND-ed conditions). * - omitted → array of all records. * * Usage: * User.find(1) → User | null (PK lookup) * User.find(1, ["posts"]) → User | null (PK lookup + eager) * User.find({ name: "Alice" }) → [User, ...] * User.find({ age: 18 }, 10) → [User, ...] (limit 10) * User.find({}, 100, 0, "name ASC") → [User, ...] (with orderBy) * User.find() → all records */ static find(this: new (data?: Record) => T, pk: number | string, include?: string[]): Promise; static find(this: new (data?: Record) => T, filter?: Record, limit?: number, offset?: number, orderBy?: string, include?: string[]): Promise>; /** * Load a record into this instance via selectOne. * Returns true if found and loaded, false otherwise. */ /** * Load a record into this instance. * * Usage: * orm.id = 1; orm.load() — uses PK already set * orm.load("id = ?", [1]) — filter with params * orm.load("id = 1") — filter string * * Returns true if found, false otherwise. */ load(filter?: string, params?: unknown[], include?: string[]): Promise; /** * Find all records. * * BREAKING (3.13.95, parity): the signature is now * `all(limit?, offset?, include?, orderBy?)`. It NO LONGER accepts leading * `where`/`params`. * * Node was the sole outlier of the four. The master and the other two never * had a filter on `all()`: * Python all(limit=100, offset=0, include=None, order_by=None) * PHP all(int $limit = 100, int $offset = 0, ?array $include, ?string $orderBy) * Ruby all(limit: 100, offset: nil, order_by: nil, include: nil) * Node's extra leading parameters shifted every argument, so the same * positional call meant different things in different languages -- which is * precisely what the parity mandate exists to prevent. * * MIGRATION: a filtered read moves to `where()`, which already exists and * takes the conditions first: * before: User.all("age > ?", [28]) * after: User.where("age > ?", [28]) * TypeScript callers get a compile error (string is not assignable to number), * so the break is loud rather than silent. * * @param limit Max records (default 100, the shared cross-framework cap). * @param offset Records to skip (default 0). * @param include Relationship names to eager-load. * @param orderBy ORDER BY clause (e.g. "name ASC"). */ static all(this: new (data?: Record) => T, limit?: number, offset?: number, include?: string[], orderBy?: string): Promise>; /** * Query records with a WHERE clause. * Matches Python/PHP/Ruby where() API. * * @param conditions WHERE clause (e.g. "age > ? AND active = ?") * @param params Bind parameters * @param limit Max records (default 100) * @param offset Skip records (default 0) * @param include Relationship names to eager-load * @param orderBy ORDER BY clause (e.g. "name ASC") */ static where(this: new (data?: Record) => T, conditions: string, params?: unknown[], limit?: number, offset?: number, include?: string[], orderBy?: string): Promise>; /** * Save this instance (insert or update). Returns this on success (fluent * self), false on failure. * * Fails loud, never silent (the same principle db.execute() follows by * raising). On ANY failure path save() returns `false` — keeping the * contract callers rely on (`if (!(await model.save())) ...`) — but it also * (a) logs the real cause via Log.error with model/table context and * (b) records the cause on `this.lastError` so a caller can recover it after * the fact via getError() / lastError. It never throws and never changes the * `this | false` return shape. * * Two distinct failure paths, both loud: * - Validation (canonical #2): validate() runs FIRST. If it returns errors, * save() logs them, records them on lastError, and returns false WITHOUT * touching the database — an invalid model never reaches the driver. * - Database: a driver error (NOT NULL, duplicate PK, missing table, ...) is * rolled back, logged with the underlying cause, recorded on lastError, * and returns false — the cause is no longer swallowed silently. */ save(): Promise; /** * Return the cause of the most recent failed save(), or null. * * Mirrors db.getError(). After save() returns false — whether from * validation or a driver error — the real cause is retrievable here (and on * this.lastError) so a caller using the `if (!(await model.save()))` * contract can still surface it. Cleared to null on a successful save. */ getError(): string | null; /** * Delete this instance. Uses soft delete if configured. */ delete(): Promise; /** * Convert to plain object (dictionary). * @param include Optional array of relationship names to include (supports dot notation for nesting). * @param case_ Key casing: 'camel' (default, keys as-is) or 'snake' (convert via fieldMapping). */ toDict(include?: string[], case_?: "camel" | "snake"): Record; toFeature(geometryField?: string, include?: string[]): Record; static featureCollection(models: BaseModel[], geometryField?: string, include?: string[]): Record; /** * Convert to an associative object (alias for toDict). */ toAssoc(include?: string[], case_?: "camel" | "snake"): Record; /** * Convert to a plain object (alias for toDict). */ toObject(case_?: "camel" | "snake"): Record; /** * Convert to an array of values. */ toArray(): unknown[]; /** * Convert to a list (alias for toArray). */ toList(): unknown[]; /** * Convert to JSON string. * @param include Optional relationship names to include. */ toJson(include?: string[], case_?: "camel" | "snake"): string; /** * Validate this instance's values against the model's field definitions. * Returns an array of error strings (empty array means valid). */ validate(isUpdate?: boolean): string[]; /** * Generate and execute CREATE TABLE DDL from the model's field definitions. * Uses the adapter's createTable method if available, otherwise builds SQL directly. */ static createTable(): Promise; private static createSpatialIndexes; /** * Find a record by primary key or throw an error if not found. */ static findOrFail(this: new (data?: Record) => T, id: unknown): Promise; /** * Return true if a record with the given primary key exists. */ static exists(pkValue: unknown): Promise; /** * Every table a cached query touches: this model's table plus every FROM/JOIN * table in `sql`. A write to any of these busts the entry (CACHE-DEC-01). */ static _cacheTags(sql: string): string[]; /** * Run a raw SQL query with results cached by TTL. * * Invalidation (CACHE-DEC-01): the entry is tagged by every table the query * touches (this model's table plus any FROM/JOIN tables) in ONE process-wide * shared cache, so a write through the ORM (save/delete/forceDelete/restore) * to ANY of those tables busts it -- including a cross-table JOIN cached on a * different model. `ttl <= 0` means NO-CACHE: the query runs and the rows are * returned but nothing is stored, so every read hits the database. * * @param sql SQL query string. * @param params Bind parameters. * @param ttl Cache TTL in seconds (default 60; <= 0 = no-cache). * @param limit Max records to return (default 100). * @param offset Records to skip (default 0). * @param include Relationship names to eager-load on cache miss. */ static cached(this: new (data?: Record) => T, sql: string, params?: unknown[], ttl?: number, limit?: number, offset?: number, include?: string[]): Promise; /** * Invalidate every cached query that touches this model's table. * * Tag-scoped in the ORM layer (a cached JOIN on another model that reads * this table is busted too because it carries this table's tag; a query * that never touches this table is left intact), then cascaded to the * DB layer on this model's bound connection so an out-of-band write / * deliberate refresh / race-with-another-process cannot leave stale rows * in db.fetch()'s persistent cache. Called after every ORM write * (save/delete/forceDelete/restore) so a read-after-write never serves * a stale/deleted row (CACHE-DEC-01). PY-06-22 (3.13.105) added the * DB-layer cascade -- previously the two cache layers disagreed under * TINA4_AUTO_CACHING=true + TINA4_DB_CACHE=true. */ static clearCache(): void; /** * Execute a raw SQL SELECT and return results as model instances. */ static select(this: new (data?: Record) => T, sql: string, params?: unknown[], limit?: number, offset?: number): Promise>; static selectOne(this: new (data?: Record) => T, sql: string, params?: unknown[], include?: string[]): Promise; /** * Permanently delete this instance, bypassing soft delete. */ forceDelete(): Promise; /** * Restore a soft-deleted record. */ restore(): Promise; /** * Find records including soft-deleted ones. */ static withTrashed(this: new (data?: Record) => T, conditions?: string, params?: unknown[], limit?: number, offset?: number): Promise>; /** * Count records matching conditions (respects soft delete and table filter). */ static count(conditions?: string, params?: unknown[]): Promise; /** * Register a reusable query scope on the class. * * Usage: * User.scope("active", "active = ?", [1]); * const users = (User as any).active(); // calls where("active = ?", [1]) * const users = (User as any).active(10, 5); // with limit/offset */ static scope(name: string, filterSql: string, params?: unknown[]): void; /** * Load a has-one related model instance. */ hasOne(this: T, relatedClass: typeof BaseModel & (new (data?: Record) => R), foreignKey: string): Promise; /** * Load has-many related model instances. * * With no explicit `limit` this returns the WHOLE set (paged internally, like * the lazy accessor), never a silent row cap -- so an imperatively-loaded * has_many yields the SAME row count as the lazy path. An explicit `limit` * still pages. */ hasMany(this: T, relatedClass: typeof BaseModel & (new (data?: Record) => R), foreignKey: string, limit?: number, offset?: number): Promise; /** * Load the parent model this instance belongs to. */ belongsTo(this: T, relatedClass: typeof BaseModel & (new (data?: Record) => R), foreignKey: string): Promise; /** * Register a model class for lookup by name (used by eager loading). */ static _modelRegistry: Record; static registerModel(name: string, modelClass: typeof BaseModel): void; /** * Process foreignKey fields on every registered model so the cross-model * _fkRegistry (and each model's belongsTo/hasMany) is fully wired regardless * of which model was used first, then attach the lazy relationship accessors. * Idempotent — every step guards against duplicates. */ private static _processAllForeignKeys; /** * REL-NODE-AUTOWIRE-DEAD: attach a lazy-loading accessor for each declared * relationship (belongsTo/hasOne/hasMany) on this model's prototype, so * `post.author` / `author.posts` resolve on attribute access. The accessor is * async (Node lazy load) and caches into `_relCache` — the SAME cache eager * loading fills, so `toDict` stays consistent once a relation has been loaded. * Reuses the imperative belongsTo()/hasOne() path and the cross-model registry; * a soft-deleted child is excluded and the has-many read is uncapped. */ static _wireRelationshipAccessors(): void; /** * Lazy has-many read for a relationship accessor: excludes soft-deleted * children and returns the WHOLE set (adapterQuery is uncapped, so the tail is * never lost). Ordered by the child PK for a stable read. */ private static _loadHasManyLazy; /** * Resolve a model class by name from the registry. */ private static _resolveModel; /** * Eager load relationships for a collection of instances (prevents N+1). * @param instances Array of model instances. * @param include Array of relationship names (supports dot notation for nesting). */ static _eagerLoad(instances: BaseModel[], include: string[]): Promise; /** * Public alias for _eagerLoad. Eagerly loads relationships for a list of instances, * preventing N+1 queries. * * Usage: * const users = User.all(); * await User.eagerLoad(users, ["posts", "profile"]); * * @param instances Array of model instances to load relationships onto. * @param includeList Array of relationship names (supports dot notation for nesting). */ static eagerLoad(instances: BaseModel[], includeList: string[]): Promise; /** * Clear the relationship cache. */ clearRelCache(): void; }