litedbmodel 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,32 +1,84 @@
1
1
  # litedbmodel
2
2
 
3
- A lightweight, SQL-friendly TypeScript ORM for PostgreSQL, MySQL, and SQLite.
4
-
5
3
  [![npm version](https://img.shields.io/npm/v/litedbmodel.svg)](https://www.npmjs.com/package/litedbmodel)
6
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
5
 
6
+ litedbmodel is a lightweight, SQL-friendly TypeScript ORM for PostgreSQL, MySQL, and SQLite.
7
+ It is designed for production systems where you care about **predictable SQL**, **explicit performance control**, and **operational safety** (replication lag, N+1, accidental full scans).
8
+
9
+
8
10
  ## Philosophy
9
11
 
10
- **SQL is not the enemy.** Most ORMs hide SQL behind complex abstractions, making debugging harder and limiting what you can do. litedbmodel takes a different approach:
12
+ ### SQL is not the enemy opacity is.
13
+ Most ORMs hide SQL behind abstractions that are hard to debug and hard to tune.
14
+ litedbmodel keeps SQL visible and controllable: generated queries are intentionally simple, and complex cases use real SQL via `query()` / `execute()`.
15
+
16
+ ### Make performance the default, not a post-mortem
17
+ - Lazy relations are supported, but **N+1 is prevented automatically** via batch loading.
18
+ - Per-parent limiting is done at the **SQL level** (efficient “top-N per group” patterns).
19
+ - Write operations default to **no RETURNING** for throughput; request PKs via `PkeyResult` only when needed.
20
+
21
+ ### Production safety over convenience magic
22
+ - Supports **reader/writer routing** for read replicas and replication-lag-aware reads.
23
+ - Write operations (`create/createMany, update/updateMany, delete`) require an explicit **transaction boundary**.
24
+ - Configurable **hard limits** detect accidental over-fetching early.
11
25
 
12
- - **Predictable** — Generated queries are simple, readable, and exactly what you'd write by hand
13
- - **Type-Safe** Results map to typed model instances with full IDE support
14
- - **Real SQL When Needed** Complex queries and DB-specific optimizations use actual SQL via `query()`, not a proprietary DSL
15
- - **Model-Centric** — Same model serves list/detail views; relations load on-demand with automatic batch loading
26
+ ### Refactoring-friendly, without sacrificing SQL control
27
+ - Column references are **symbol-based** (`Model.column`) so IDE rename/find-references work.
28
+ - Conditions are **type-safe tuples** (`[Column, value]`), and an ESLint plugin catches mistakes TS cannot.
16
29
 
17
30
  > See [Design Philosophy](./docs/BENCHMARK-NESTED.md#litedbmodels-design-philosophy) for detailed comparison with query-centric ORMs.
18
31
 
19
- ## Features
32
+ ## Key Features
33
+
34
+ ### SQL Control & Modeling
35
+ - Predictable generated SQL (readable, hand-written style)
36
+ - Raw SQL escape hatch: `Model.query()` / `DBModel.execute()`
37
+ - Query-based models for complex reads (aggregations, JOINs, CTEs)
38
+
39
+ ### Performance by Default
40
+ - Transparent N+1 prevention (automatic batch loading for lazy relations)
41
+ - SQL-level per-parent limit for relations
42
+ - Subqueries: IN/EXISTS with correlated conditions
43
+
44
+ ### Operational Readiness
45
+ - Reader/Writer separation with sticky-writer reads after transactions (replication-lag-aware)
46
+ - Transactions with retry options (e.g., deadlock retry)
47
+ - Safety guards: `findHardLimit` / `hasManyHardLimit`
48
+
49
+ ### Developer Experience
50
+ - Symbol-based columns + tuple conditions for refactoring safety
51
+ - Declarative `SKIP` pattern for optional fields/conditions
52
+ - Middleware for cross-cutting concerns (logging, auth, tenant isolation)
53
+ - Multi-database support (portable tuple API; raw SQL is dialect-dependent)
20
54
 
21
- - **Symbol-Based Columns** `Model.column` enables IDE "Find References" and "Rename Symbol"
22
- - **Type-Safe Conditions** — Compile-time validation with `[Column, value]` tuples
23
- - **Query-Based Models** — Define models backed by complex SQL (aggregations, JOINs, CTEs)
24
- - **Subquery Support** IN/NOT IN/EXISTS/NOT EXISTS with correlated subqueries
25
- - **Declarative SKIP** Conditional fields without if-statements
26
- - **Transparent N+1 Prevention** Batch loading for lazy relations (library's job, not yours)
27
- - **Raw SQL Escape** `Model.query()` and `DBModel.execute()` when you need full control (DB-specific syntax is your responsibility)
28
- - **Middleware** Cross-cutting concerns (logging, auth, tenant isolation)
29
- - **Multi-Database** — PostgreSQL, MySQL, SQLite with config-only switching (Tuple API and relations are portable; raw SQL is dialect-dependent)
55
+ ## When litedbmodel is a good fit
56
+
57
+ Choose litedbmodel if you:
58
+ - Build **large or high-throughput** services where SQL tuning and explain plans matter
59
+ - Want ORM ergonomics, but refuse to lose the ability to write/own SQL
60
+ - Operate with **read replicas** and care about replication lag and routing rules
61
+ - Need safe defaults against “oops, loaded 10M rows” and N+1 regressions
62
+ - Prefer a model-centric approach (list/detail + relations) with predictable behavior
63
+
64
+ ## When it may NOT be a good fit
65
+
66
+ litedbmodel may be a poor fit if you:
67
+ - Want a “fully abstracted” ORM that hides SQL entirely
68
+ - Prefer a query-builder DSL as the primary interface (rather than SQL/tuple conditions)
69
+ - Need database-agnostic portability for complex raw SQL (dialect differences are real)
70
+
71
+ ## Non-goals
72
+
73
+ Non-goals are deliberate trade-offs to keep SQL predictable and operations safe.
74
+ litedbmodel is intentionally **not** trying to be a “do-everything” ORM.
75
+
76
+ - **100% database-agnostic SQL**: complex queries are expected to use real SQL, and SQL dialect differences are real.
77
+ - **Migrations as a built-in feature**: schema migrations are out of scope (use your preferred migration tool).
78
+ - **Hiding SQL behind a large abstraction layer**: we prioritize predictable SQL over a fully abstracted API.
79
+ - **Automatic eager-loading everywhere**: relations are lazy by default; performance characteristics should stay explicit and controllable.
80
+
81
+ ---
30
82
 
31
83
  ## Installation
32
84
 
@@ -66,16 +118,20 @@ DBModel.setConfig({
66
118
  });
67
119
 
68
120
  // 3. CRUD operations
69
- const user = await User.create([
121
+ await User.create([
70
122
  [User.name, 'John'],
71
123
  [User.email, 'john@example.com'],
72
124
  ]);
125
+ await User.update([[User.id, 1]], [[User.name, 'Jane']]);
126
+ await User.delete([[User.is_active, false]]);
73
127
 
128
+ // Read operations
74
129
  const users = await User.find([[User.is_active, true]]);
75
130
  const john = await User.findOne([[User.email, 'john@example.com']]);
76
131
 
77
- await User.update([[User.id, 1]], [[User.name, 'Jane']]);
78
- await User.delete([[User.is_active, false]]);
132
+ // With returning: true → get PkeyResult for re-fetching
133
+ const created = await User.create([...], { returning: true });
134
+ const [newUser] = await User.findById(created);
79
135
  ```
80
136
 
81
137
  ---
@@ -159,6 +215,218 @@ Use explicit type decorators when auto-inference isn't sufficient:
159
215
 
160
216
  ---
161
217
 
218
+ ## CRUD Operations
219
+
220
+ ### PkeyResult Type
221
+
222
+ Write operations can optionally return a `PkeyResult` object:
223
+
224
+ ```typescript
225
+ interface PkeyResult {
226
+ key: Column[]; // Key column(s) used to identify rows
227
+ values: unknown[][]; // 2D array of key values
228
+ }
229
+
230
+ // Single PK example
231
+ { key: [User.id], values: [[1], [2], [3]] }
232
+
233
+ // Composite PK example
234
+ { key: [TenantUser.tenant_id, TenantUser.id], values: [[1, 100], [1, 101]] }
235
+ ```
236
+
237
+ **Default behavior:** `returning: false` — returns `null` for better performance.
238
+ **With `returning: true`:** Returns `PkeyResult` with affected primary keys.
239
+
240
+ > **Note:** `PkeyResult.key` always contains primary key column(s), regardless of `keyColumns` used in `updateMany`.
241
+
242
+ ### create / createMany
243
+
244
+ ```typescript
245
+ // Default: returns null (no RETURNING)
246
+ await User.create([
247
+ [User.name, 'John'],
248
+ [User.email, 'john@example.com'],
249
+ ]);
250
+
251
+ // With returning: true → returns PkeyResult
252
+ const result = await User.create([
253
+ [User.name, 'John'],
254
+ [User.email, 'john@example.com'],
255
+ ], { returning: true });
256
+ // result: { key: [User.id], values: [[1]] }
257
+
258
+ // Multiple records
259
+ const result = await User.createMany([
260
+ [[User.name, 'John'], [User.email, 'john@example.com']],
261
+ [[User.name, 'Jane'], [User.email, 'jane@example.com']],
262
+ ], { returning: true });
263
+ // result: { key: [User.id], values: [[1], [2]] }
264
+
265
+ // Fetch created records if needed
266
+ const [user] = await User.findById(result);
267
+ ```
268
+
269
+ ### update / updateMany
270
+
271
+ ```typescript
272
+ // Default: returns null (no RETURNING)
273
+ await User.update(
274
+ [[User.status, 'pending']], // conditions
275
+ [[User.status, 'active']], // values
276
+ );
277
+
278
+ // With returning: true → returns PkeyResult
279
+ const result = await User.update(
280
+ [[User.status, 'pending']],
281
+ [[User.status, 'active']],
282
+ { returning: true }
283
+ );
284
+ // result: { key: [User.id], values: [[1], [2], [3]] }
285
+
286
+ // Bulk update with different values per row
287
+ const result = await User.updateMany([
288
+ [[User.id, 1], [User.name, 'John'], [User.email, 'john@example.com']],
289
+ [[User.id, 2], [User.name, 'Jane'], [User.email, 'jane@example.com']],
290
+ ], { keyColumns: [User.id], returning: true });
291
+ // result: { key: [User.id], values: [[1], [2]] }
292
+
293
+ // Fetch updated records if needed
294
+ const users = await User.findById(result);
295
+ ```
296
+
297
+ **Generated SQL for updateMany:**
298
+
299
+ | Database | SQL |
300
+ |----------|-----|
301
+ | PostgreSQL | `UPDATE ... FROM UNNEST($1::int[], $2::text[], ...) AS v(...) WHERE t.id = v.id` |
302
+ | MySQL 8.0.19+ | `UPDATE ... JOIN (VALUES ROW(?, ?, ?), ...) AS v(...) ON ... SET ...` |
303
+ | SQLite 3.33+ | `WITH v(...) AS (VALUES (...), ...) UPDATE ... FROM v WHERE ...` |
304
+
305
+ ### delete
306
+
307
+ ```typescript
308
+ // Default: returns null (no RETURNING)
309
+ await User.delete([[User.is_active, false]]);
310
+
311
+ // With returning: true → returns PkeyResult
312
+ const result = await User.delete([[User.is_active, false]], { returning: true });
313
+ // result: { key: [User.id], values: [[4], [5]] }
314
+ ```
315
+
316
+ ### findById
317
+
318
+ Fetch records by primary key. Accepts `PkeyResult` format for efficient batch loading:
319
+
320
+ ```typescript
321
+ // Single record
322
+ const [user] = await User.findById({ values: [[1]] });
323
+
324
+ // Multiple records
325
+ const users = await User.findById({ values: [[1], [2], [3]] });
326
+
327
+ // Composite PK
328
+ const [entry] = await TenantUser.findById({
329
+ values: [[1, 100]] // [tenant_id, id]
330
+ });
331
+
332
+ // Use with update/delete result
333
+ const result = await User.update(...);
334
+ const users = await User.findById(result);
335
+ ```
336
+
337
+ **Generated SQL:**
338
+
339
+ | Database | Single PK | Composite PK |
340
+ |----------|-----------|--------------|
341
+ | PostgreSQL | `WHERE id = ANY($1::int[])` | `WHERE (col1, col2) IN (SELECT * FROM UNNEST(...))` |
342
+ | MySQL | `WHERE id IN (?, ?, ?)` | `JOIN (VALUES ROW(...), ...) AS v ON ...` |
343
+ | SQLite | `WHERE id IN (?, ?, ?)` | `WITH v AS (VALUES ...) ... JOIN v ON ...` |
344
+
345
+ ### Upsert (ON CONFLICT)
346
+
347
+ ```typescript
348
+ // Insert or ignore
349
+ await User.create(
350
+ [[User.name, 'John'], [User.email, 'john@example.com']],
351
+ { onConflict: User.email, onConflictIgnore: true }
352
+ );
353
+
354
+ // Insert or update
355
+ await User.create(
356
+ [[User.name, 'John'], [User.email, 'john@example.com']],
357
+ { onConflict: User.email, onConflictUpdate: [User.name] }
358
+ );
359
+
360
+ // Composite unique key
361
+ await UserPref.create(
362
+ [[UserPref.user_id, 1], [UserPref.key, 'theme'], [UserPref.value, 'dark']],
363
+ { onConflict: [UserPref.user_id, UserPref.key], onConflictUpdate: [UserPref.value] }
364
+ );
365
+ ```
366
+
367
+ ### Behavior Notes
368
+
369
+ #### PkeyResult Semantics
370
+
371
+ | Aspect | Behavior |
372
+ |--------|----------|
373
+ | **Order** | Matches database `RETURNING` order (not guaranteed across DBs; `findById(result)` order is also unspecified) |
374
+ | **update result** | Contains PKs of **matched rows** (rows matching WHERE condition, regardless of whether values actually changed) |
375
+ | **delete result** | Contains PKs of **deleted rows** |
376
+ | **Duplicates** | No duplicates (each row appears once; MySQL pre-SELECT uses `DISTINCT`) |
377
+ | **Empty result** | `{ key: [...], values: [] }` when no rows affected (not `null`) |
378
+
379
+ > **Note:** For MySQL (no `RETURNING`), when `returning: true`:
380
+ > - `update`/`delete`: Executes pre-SELECT (with `DISTINCT`) to get PKs, then executes the operation (2 queries in same transaction)
381
+ > - `updateMany`: Executes update, then SELECT to get PKs of affected rows (2 queries in same transaction)
382
+ > - When `returning: false` (default): Single query, returns `null`
383
+
384
+ #### Batch Limits
385
+
386
+ `createMany` and `updateMany` do **not** auto-split large batches. Users are responsible for chunking:
387
+
388
+ ```typescript
389
+ // Recommended: chunk large batches (DB-dependent limits)
390
+ const BATCH_SIZE = 1000; // Adjust based on your DB and row size
391
+ for (let i = 0; i < rows.length; i += BATCH_SIZE) {
392
+ const chunk = rows.slice(i, i + BATCH_SIZE);
393
+ await User.updateMany(chunk, { keyColumns: [User.id] });
394
+ }
395
+ ```
396
+
397
+ | Database | Practical Limits |
398
+ |----------|------------------|
399
+ | PostgreSQL | ~32,767 parameters per query |
400
+ | MySQL | `max_allowed_packet` (default 64MB), ~65,535 placeholders |
401
+ | SQLite | 999 variables (compile-time `SQLITE_MAX_VARIABLE_NUMBER`) |
402
+
403
+ #### updateMany keyColumns Contract
404
+
405
+ | Requirement | Description |
406
+ |-------------|-------------|
407
+ | **Must be unique** | `keyColumns` must uniquely identify rows (primary key or unique constraint) |
408
+ | **Must exist in rows** | Every row must include all `keyColumns` |
409
+ | **Non-key columns** | Columns not in `keyColumns` become `SET` clause values |
410
+
411
+ ```typescript
412
+ // ✅ Valid: keyColumns is primary key
413
+ await User.updateMany([
414
+ [[User.id, 1], [User.name, 'John']],
415
+ ], { keyColumns: [User.id] });
416
+
417
+ // ✅ Valid: keyColumns is unique constraint
418
+ await User.updateMany([
419
+ [[User.email, 'john@example.com'], [User.name, 'John']],
420
+ ], { keyColumns: [User.email] }); // If email has UNIQUE constraint
421
+
422
+ // ❌ Invalid: keyColumns missing from row
423
+ await User.updateMany([
424
+ [[User.name, 'John']], // Missing User.id!
425
+ ], { keyColumns: [User.id] });
426
+ ```
427
+
428
+ ---
429
+
162
430
  ## Type-Safe Conditions
163
431
 
164
432
  Conditions use `[Column, value]` tuples for compile-time validation. For operators, use `${Model.column}` in template literals—the ESLint plugin catches incorrect column references.
@@ -291,30 +559,6 @@ await User.find([
291
559
 
292
560
  ---
293
561
 
294
- ## Upsert (ON CONFLICT)
295
-
296
- ```typescript
297
- // Insert or ignore
298
- await User.create(
299
- [[User.name, 'John'], [User.email, 'john@example.com']],
300
- { onConflict: User.email, onConflictIgnore: true }
301
- );
302
-
303
- // Insert or update
304
- await User.create(
305
- [[User.name, 'John'], [User.email, 'john@example.com']],
306
- { onConflict: User.email, onConflictUpdate: [User.name] }
307
- );
308
-
309
- // Composite unique key
310
- await UserPref.create(
311
- [[UserPref.user_id, 1], [UserPref.key, 'theme'], [UserPref.value, 'dark']],
312
- { onConflict: [UserPref.user_id, UserPref.key], onConflictUpdate: [UserPref.value] }
313
- );
314
- ```
315
-
316
- ---
317
-
318
562
  ## Relation Decorators
319
563
 
320
564
  Define relations declaratively with type-safe decorators:
@@ -514,6 +758,7 @@ flowchart TD
514
758
  create["create()"]
515
759
  createMany["createMany()"]
516
760
  update["update()"]
761
+ updateMany["updateMany()"]
517
762
  delete["delete()"]
518
763
  end
519
764
 
@@ -547,6 +792,7 @@ flowchart TD
547
792
  create --> execute
548
793
  createMany --> execute
549
794
  update --> execute
795
+ updateMany --> execute
550
796
  delete --> execute
551
797
 
552
798
  query --> execute
@@ -556,7 +802,7 @@ flowchart TD
556
802
  ```
557
803
 
558
804
  **Middleware hooks:**
559
- - **Method-level**: `find`, `findOne`, `findById`, `count`, `create`, `createMany`, `update`, `delete`
805
+ - **Method-level**: `find`, `findOne`, `findById`, `count`, `create`, `createMany`, `update`, `updateMany`, `delete`
560
806
  - **Instantiation-level**: `query` — returns model instances from raw SQL
561
807
  - **SQL-level**: `execute` — intercepts ALL SQL queries (SELECT, INSERT, UPDATE, DELETE)
562
808
 
package/dist/DBModel.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  import { DBBoolValue, DBNullValue, DBNotNullValue, DBImmediateValue, DBSubquery, DBExists } from './DBValues';
5
5
  import { type ConditionObject } from './DBConditions';
6
6
  import { DBHandler, type DBConfig, type DBConnection } from './DBHandler';
7
- import type { SelectOptions, InsertOptions, UpdateOptions, DeleteOptions, TransactionOptions, LimitConfig, DBConfigOptions } from './types';
7
+ import type { SelectOptions, InsertOptions, UpdateOptions, DeleteOptions, UpdateManyOptions, TransactionOptions, LimitConfig, DBConfigOptions, PkeyResult } from './types';
8
8
  import { type Column, type OrderSpec, type CVs, type Conds, type CondsOf, type OrCondOf, type ColumnsOf } from './Column';
9
9
  import type { MiddlewareClass, ExecuteResult } from './Middleware';
10
10
  import { type KeyPair, type CompositeKeyPairs } from './decorators';
@@ -305,6 +305,11 @@ export declare abstract class DBModel {
305
305
  * @internal
306
306
  */
307
307
  protected static _createInstance<T extends typeof DBModel>(this: T, row: Record<string, unknown>): InstanceType<T>;
308
+ /**
309
+ * Build PkeyResult from rows containing primary key values
310
+ * @internal
311
+ */
312
+ protected static _buildPkeyResult(rows: Record<string, unknown>[], pkeyColumns?: Column[]): PkeyResult;
308
313
  /** Boolean TRUE value */
309
314
  static readonly true: DBBoolValue;
310
315
  /** Boolean FALSE value */
@@ -694,18 +699,32 @@ export declare abstract class DBModel {
694
699
  */
695
700
  static findOne<T extends typeof DBModel>(this: T, conditions: CondsOf<T>, options?: SelectOptions): Promise<InstanceType<T> | null>;
696
701
  /**
697
- * Find record by primary key
698
- * @param id - Primary key value (or object for composite keys)
702
+ * Find records by primary key using PkeyResult format.
703
+ * Efficiently fetches multiple records by their primary keys.
704
+ *
705
+ * @param pkeyResult - Object with `values` property containing 2D array of PK values
699
706
  * @param options - Query options
700
- * @returns Model instance or null
707
+ * @returns Array of model instances (empty array if no matches)
701
708
  *
702
709
  * @example
703
710
  * ```typescript
704
- * const user = await User.findById(123);
705
- * const entry = await Entry.findById({ user_id: 1, date: '2024-01-01' });
711
+ * // Single record
712
+ * const [user] = await User.findById({ values: [[1]] });
713
+ *
714
+ * // Multiple records
715
+ * const users = await User.findById({ values: [[1], [2], [3]] });
716
+ *
717
+ * // Composite PK
718
+ * const [entry] = await TenantUser.findById({
719
+ * values: [[1, 100]] // [tenant_id, id]
720
+ * });
721
+ *
722
+ * // Use with write operation result
723
+ * const result = await User.update(..., { returning: true });
724
+ * const users = await User.findById(result);
706
725
  * ```
707
726
  */
708
- static findById<T extends typeof DBModel>(this: T, id: unknown, options?: SelectOptions): Promise<InstanceType<T> | null>;
727
+ static findById<T extends typeof DBModel>(this: T, pkeyResult: Pick<PkeyResult, 'values'>, options?: SelectOptions): Promise<InstanceType<T>[]>;
709
728
  /**
710
729
  * Count records using type-safe condition tuples.
711
730
  *
@@ -723,68 +742,124 @@ export declare abstract class DBModel {
723
742
  * Value types are validated at compile time.
724
743
  *
725
744
  * @param pairs - Array of [Column, value] tuples
726
- * @param options - Insert options
727
- * @returns Created model instance
745
+ * @param options - Insert options (returning: true to get PkeyResult)
746
+ * @returns null by default, or PkeyResult if returning: true
728
747
  *
729
748
  * @example
730
749
  * ```typescript
731
- * const user = await User.create([
750
+ * // Default: returns null
751
+ * await User.create([
732
752
  * [User.name, 'John'],
733
753
  * [User.email, 'john@test.com'],
734
- * [User.is_active, true],
735
754
  * ]);
755
+ *
756
+ * // With returning: true → PkeyResult
757
+ * const result = await User.create([
758
+ * [User.name, 'John'],
759
+ * [User.email, 'john@test.com'],
760
+ * ], { returning: true });
761
+ * const [user] = await User.findById(result);
736
762
  * ```
737
763
  */
738
- static create<T extends typeof DBModel, P extends readonly (readonly [Column<any, any>, any])[]>(this: T, pairs: P & CVs<P>, options?: InsertOptions<InstanceType<T>>): Promise<InstanceType<T>>;
764
+ static create<T extends typeof DBModel, P extends readonly (readonly [Column<any, any>, any])[]>(this: T, pairs: P & CVs<P>, options?: InsertOptions<InstanceType<T>>): Promise<PkeyResult | null>;
739
765
  /**
740
766
  * Create multiple records using type-safe column-value tuples.
741
767
  *
742
768
  * @param pairsArray - Array of tuple arrays
743
- * @param options - Insert options
744
- * @returns Created model instances
769
+ * @param options - Insert options (returning: true to get PkeyResult)
770
+ * @returns null by default, or PkeyResult if returning: true
745
771
  *
746
772
  * @example
747
773
  * ```typescript
748
- * const users = await User.createMany([
774
+ * // Default: returns null
775
+ * await User.createMany([
749
776
  * [[User.name, 'John'], [User.email, 'john@test.com']],
750
777
  * [[User.name, 'Jane'], [User.email, 'jane@test.com']],
751
778
  * ]);
779
+ *
780
+ * // With returning: true → PkeyResult
781
+ * const result = await User.createMany([
782
+ * [[User.name, 'John'], [User.email, 'john@test.com']],
783
+ * [[User.name, 'Jane'], [User.email, 'jane@test.com']],
784
+ * ], { returning: true });
785
+ * const users = await User.findById(result);
752
786
  * ```
753
787
  */
754
- static createMany<T extends typeof DBModel>(this: T, pairsArray: readonly (readonly (readonly [Column<any, any>, any])[])[], options?: InsertOptions<InstanceType<T>>): Promise<InstanceType<T>[]>;
788
+ static createMany<T extends typeof DBModel>(this: T, pairsArray: readonly (readonly (readonly [Column<any, any>, any])[])[], options?: InsertOptions<InstanceType<T>>): Promise<PkeyResult | null>;
755
789
  /**
756
790
  * Update records using type-safe column-value tuples.
757
791
  * Value types are validated at compile time.
758
792
  *
759
793
  * @param conditions - Array of condition tuples for WHERE clause
760
794
  * @param values - Array of [Column, value] tuples for SET clause
761
- * @param options - Update options
762
- * @returns Updated model instances
795
+ * @param options - Update options (returning: true to get PkeyResult)
796
+ * @returns null by default, or PkeyResult if returning: true
763
797
  *
764
798
  * @example
765
799
  * ```typescript
800
+ * // Default: returns null
766
801
  * await User.update(
767
- * [[User.id, 1]], // conditions
768
- * [ // values
769
- * [User.name, 'Jane'],
770
- * [User.email, 'jane@test.com'],
771
- * ],
802
+ * [[User.id, 1]],
803
+ * [[User.name, 'Jane']],
772
804
  * );
805
+ *
806
+ * // With returning: true → PkeyResult
807
+ * const result = await User.update(
808
+ * [[User.status, 'pending']],
809
+ * [[User.status, 'active']],
810
+ * { returning: true }
811
+ * );
812
+ * const users = await User.findById(result);
773
813
  * ```
774
814
  */
775
- static update<T extends typeof DBModel, V extends readonly (readonly [Column<any, any>, any])[]>(this: T, conditions: CondsOf<T>, values: V & CVs<V>, options?: UpdateOptions): Promise<InstanceType<T>[]>;
815
+ static update<T extends typeof DBModel, V extends readonly (readonly [Column<any, any>, any])[]>(this: T, conditions: CondsOf<T>, values: V & CVs<V>, options?: UpdateOptions): Promise<PkeyResult | null>;
776
816
  /**
777
817
  * Delete records matching conditions
778
818
  * @param conditions - Filter conditions
779
- * @param options - Delete options
780
- * @returns Deleted model instances
819
+ * @param options - Delete options (returning: true to get PkeyResult)
820
+ * @returns null by default, or PkeyResult if returning: true
781
821
  *
782
822
  * @example
783
823
  * ```typescript
824
+ * // Default: returns null
784
825
  * await User.delete([[User.is_active, false]]);
826
+ *
827
+ * // With returning: true → PkeyResult
828
+ * const result = await User.delete([[User.is_active, false]], { returning: true });
829
+ * // result: { key: [User.id], values: [[4], [5]] }
785
830
  * ```
786
831
  */
787
- static delete<T extends typeof DBModel>(this: T, conditions: CondsOf<T>, options?: DeleteOptions): Promise<InstanceType<T>[]>;
832
+ static delete<T extends typeof DBModel>(this: T, conditions: CondsOf<T>, options?: DeleteOptions): Promise<PkeyResult | null>;
833
+ /**
834
+ * Update multiple records with different values per row.
835
+ * Uses efficient bulk update strategies (UNNEST for PostgreSQL, VALUES for MySQL/SQLite).
836
+ *
837
+ * @param rows - Array of [Column, value][] tuples, each representing one row's values
838
+ * @param options - Options including keyColumns to identify rows
839
+ * @returns null by default, or PkeyResult if returning: true
840
+ *
841
+ * @example
842
+ * ```typescript
843
+ * // Default: returns null
844
+ * await User.updateMany([
845
+ * [[User.id, 1], [User.name, 'John'], [User.email, 'john@example.com']],
846
+ * [[User.id, 2], [User.name, 'Jane'], [User.email, 'jane@example.com']],
847
+ * ], { keyColumns: [User.id] });
848
+ *
849
+ * // With returning: true → PkeyResult
850
+ * const result = await User.updateMany([
851
+ * [[User.id, 1], [User.name, 'John']],
852
+ * [[User.id, 2], [User.name, 'Jane']],
853
+ * ], { keyColumns: [User.id], returning: true });
854
+ * const users = await User.findById(result);
855
+ * ```
856
+ */
857
+ static updateMany<T extends typeof DBModel>(this: T, rows: readonly (readonly (readonly [Column<any, any>, any])[])[], options: UpdateManyOptions): Promise<PkeyResult | null>;
858
+ /**
859
+ * Infer PostgreSQL type from JavaScript value
860
+ * @internal
861
+ */
862
+ private static _inferPgType;
788
863
  /**
789
864
  * Execute raw SQL query.
790
865
  *
@@ -958,30 +1033,6 @@ export declare abstract class DBModel {
958
1033
  * ```
959
1034
  */
960
1035
  static createDBBase(config: DBConfig, options?: DBConfigOptions): typeof DBModel;
961
- /**
962
- * Save this instance (INSERT if new, UPDATE if exists)
963
- * @param properties - Properties to save (uses all properties if not specified)
964
- * @returns true if successful
965
- *
966
- * @example
967
- * ```typescript
968
- * const user = new User();
969
- * user.name = 'John';
970
- * user.email = 'john@example.com';
971
- * await user.save();
972
- * ```
973
- */
974
- save(properties?: Record<string, unknown>): Promise<boolean>;
975
- /**
976
- * Delete this instance from the database
977
- * @returns true if successful
978
- *
979
- * @example
980
- * ```typescript
981
- * await user.destroy();
982
- * ```
983
- */
984
- destroy(): Promise<boolean>;
985
1036
  /**
986
1037
  * Reload this instance from the database
987
1038
  * @param forUpdate - If true, lock the row for update