ilana-orm 1.0.17 → 1.0.19

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
@@ -24,6 +24,7 @@ A fully-featured, Eloquent-style ORM for Node.js with automatic TypeScript suppo
24
24
  - [Schema Builder](#schema-builder)
25
25
  - [Transactions](#transactions)
26
26
  - [Advanced Features](#advanced-features)
27
+ - [Supabase](#supabase)
27
28
  - [Complete API Reference](#complete-api-reference)
28
29
  - [TypeScript Support](#typescript-support)
29
30
  - [Performance & Best Practices](#performance--best-practices)
@@ -447,6 +448,9 @@ module.exports = {
447
448
  extension: "ts",
448
449
  },
449
450
 
451
+ // SQL query logging — logs every query with bound values and execution time
452
+ logging: process.env.NODE_ENV === "development",
453
+
450
454
  // Debugging
451
455
  debug: process.env.NODE_ENV === "development",
452
456
 
@@ -790,47 +794,33 @@ export default class User extends Model {
790
794
 
791
795
  ### UUID Primary Keys
792
796
 
793
- **JavaScript:**
794
797
  ```javascript
795
798
  class User extends Model {
796
799
  static table = 'users';
797
- static keyType = 'string';
800
+ static keyType = 'uuid';
798
801
  static incrementing = false;
799
802
  }
800
803
 
801
- module.exports = User;
802
-
803
- // Usage
804
- const user = await User.create({
805
- name: 'John Doe',
806
- email: 'john@example.com',
807
- });
808
- // user.id will be a generated UUID
809
- ````
804
+ const user = await User.create({ name: 'John Doe', email: 'john@example.com' });
805
+ // user.id → "550e8400-e29b-41d4-a716-446655440000"
806
+ ```
810
807
 
811
- **TypeScript:**
808
+ ### ULID Primary Keys
812
809
 
813
- ```typescript
814
- export default class User extends Model {
815
- protected static table = "users";
816
- protected static keyType = "string" as const;
817
- protected static incrementing = false;
810
+ ULIDs are 26-character sortable identifiers — URL-safe, lexicographically ordered by creation time:
818
811
 
819
- // Attributes
820
- id!: string; // UUID primary key
821
- name!: string;
822
- email!: string;
812
+ ```javascript
813
+ class Order extends Model {
814
+ static table = 'orders';
815
+ static keyType = 'ulid';
816
+ static incrementing = false;
823
817
  }
824
818
 
825
- // Usage
826
- const user = await User.create({
827
- name: "John Doe",
828
- email: "john@example.com",
829
- });
830
- // user.id will be a generated UUID
819
+ const order = await Order.create({ total: 49.99 });
820
+ // order.id "01J3X7KQZB8YTPNMCHW4RSVFGE"
831
821
  ```
832
822
 
833
- ````
823
+ Use `char(26)` for the column type in migrations.
834
824
 
835
825
  ### Attribute Casting
836
826
 
@@ -1518,11 +1508,11 @@ const users = await User.all();
1518
1508
 
1519
1509
  // Find by primary key
1520
1510
  const user = await User.find(1);
1521
- const user = await User.findOrFail(1); // Throws if not found
1511
+ const user = await User.findOrFail(1); // Throws ModelNotFoundException if not found
1522
1512
 
1523
1513
  // First record
1524
1514
  const user = await User.first();
1525
- const user = await User.firstOrFail(); // Throws if not found
1515
+ const user = await User.firstOrFail(); // Throws ModelNotFoundException if not found
1526
1516
 
1527
1517
  // Create or find
1528
1518
  const user = await User.firstOrCreate(
@@ -1545,11 +1535,11 @@ const users = await User.all();
1545
1535
 
1546
1536
  // Find by primary key
1547
1537
  const user = await User.find(1);
1548
- const user = await User.findOrFail(1); // Throws if not found
1538
+ const user = await User.findOrFail(1); // Throws ModelNotFoundException if not found
1549
1539
 
1550
1540
  // First record
1551
1541
  const user = await User.first();
1552
- const user = await User.firstOrFail(); // Throws if not found
1542
+ const user = await User.firstOrFail(); // Throws ModelNotFoundException if not found
1553
1543
 
1554
1544
  // Create or find
1555
1545
  const user = await User.firstOrCreate(
@@ -4022,6 +4012,222 @@ const users = await User.query().on("reporting_db").get();
4022
4012
 
4023
4013
  ````
4024
4014
 
4015
+ ## Debugging
4016
+
4017
+ ### Query Logging
4018
+
4019
+ Enable SQL query logging in `ilana.config.js`:
4020
+
4021
+ ```javascript
4022
+ // ilana.config.js
4023
+ module.exports = {
4024
+ default: 'mysql',
4025
+ logging: process.env.NODE_ENV === 'development', // logs all queries in dev
4026
+ connections: { ... }
4027
+ };
4028
+ ```
4029
+
4030
+ Or toggle programmatically:
4031
+
4032
+ ```javascript
4033
+ import { Database } from 'ilana-orm';
4034
+
4035
+ Database.enableLogging(); // turn on
4036
+ Database.disableLogging(); // turn off
4037
+ ```
4038
+
4039
+ Output:
4040
+ ```
4041
+ [IlanaORM] select * from "users" where "role" = 'admin' order by "created_at" desc limit 10 — 3ms
4042
+ [IlanaORM] select * from "posts" where "user_id" in (1, 2, 3) — 1ms
4043
+ ```
4044
+
4045
+ Inspect a query without executing it:
4046
+
4047
+ ```javascript
4048
+ const sql = User.query().where('role', 'admin').toSql();
4049
+ console.log(sql); // select * from "users" where "role" = 'admin'
4050
+ ```
4051
+
4052
+ ### ModelNotFoundException
4053
+
4054
+ `findOrFail()`, `firstOrFail()`, and `sole()` throw a `ModelNotFoundException` — a named error class you can catch specifically:
4055
+
4056
+ ```javascript
4057
+ import { ModelNotFoundException } from 'ilana-orm';
4058
+
4059
+ // In a route
4060
+ app.get('/users/:id', async (req, res) => {
4061
+ const user = await User.findOrFail(req.params.id);
4062
+ res.json(user);
4063
+ });
4064
+
4065
+ // Global Express error handler
4066
+ app.use((err, req, res, next) => {
4067
+ if (err instanceof ModelNotFoundException) {
4068
+ return res.status(404).json({ message: err.message });
4069
+ // "User with id 99 not found"
4070
+ }
4071
+ res.status(500).json({ message: 'Server error' });
4072
+ });
4073
+ ```
4074
+
4075
+ Properties: `err.message`, `err.model` (class name), `err.id` (the id passed to `findOrFail`).
4076
+
4077
+ Call `err.toResponse()` to get a plain `{ status: 404, message }` object suitable for any HTTP framework:
4078
+
4079
+ ```javascript
4080
+ app.get('/users/:id', async (req, res) => {
4081
+ try {
4082
+ return res.json(await User.findOrFail(req.params.id));
4083
+ } catch (err) {
4084
+ if (err instanceof ModelNotFoundException) {
4085
+ const { status, message } = err.toResponse();
4086
+ return res.status(status).json({ message });
4087
+ }
4088
+ throw err;
4089
+ }
4090
+ });
4091
+ ```
4092
+
4093
+ ### Column Expressions with `F()`
4094
+
4095
+ Reference a column's current value in an update — no raw SQL, no race conditions, no need to fetch first:
4096
+
4097
+ ```javascript
4098
+ import { F } from 'ilana-orm';
4099
+
4100
+ await Post.query().where('id', postId).update({ views: F('views').plus(1) });
4101
+ await Product.query().where('id', id).update({ stock: F('stock').minus(quantity) });
4102
+ ```
4103
+
4104
+ Available: `.plus(n)`, `.minus(n)`, `.times(n)`, `.divide(n)`.
4105
+
4106
+ ### Bulk Restore
4107
+
4108
+ Restore many soft-deleted records at once via the query builder:
4109
+
4110
+ ```javascript
4111
+ await User.query().onlyTrashed().where('role', 'admin').restore();
4112
+ // UPDATE users SET deleted_at = NULL WHERE role = 'admin' AND deleted_at IS NOT NULL
4113
+ ```
4114
+
4115
+ ### Enum Helpers
4116
+
4117
+ Define possible values for enum columns and get auto-generated `isX()` / `makeX()` helpers on every instance:
4118
+
4119
+ ```javascript
4120
+ class User extends Model {
4121
+ static enums = {
4122
+ role: ['user', 'moderator', 'admin'],
4123
+ status: ['active', 'suspended'],
4124
+ };
4125
+ }
4126
+
4127
+ const user = await User.find(1);
4128
+ user.isAdmin(); // true / false
4129
+ await user.makeAdmin(); // sets role = 'admin' and saves
4130
+ user.isSuspended(); // true / false
4131
+ ```
4132
+
4133
+ ### Strict Loading
4134
+
4135
+ Throw an error when an unloaded relation is accessed — catches N+1 problems at development time:
4136
+
4137
+ ```javascript
4138
+ class Post extends Model {
4139
+ static strictLoading = true;
4140
+ }
4141
+
4142
+ const posts = await Post.all(); // no .with('comments')
4143
+ posts[0].relations.comments; // throws: 'comments' was not eager loaded on Post
4144
+ ```
4145
+
4146
+ ### Touch
4147
+
4148
+ Automatically update a parent's `updated_at` whenever the child saves:
4149
+
4150
+ ```javascript
4151
+ class Comment extends Model {
4152
+ static touches = ['post'];
4153
+
4154
+ post() { return this.belongsTo('Post', 'post_id'); }
4155
+ }
4156
+
4157
+ await comment.save(); // also bumps posts SET updated_at = NOW() WHERE id = comment.post_id
4158
+ ```
4159
+
4160
+ ### Plain Object Results with `values()`
4161
+
4162
+ Return raw plain objects instead of model instances — faster for read-heavy endpoints where you don't need model methods:
4163
+
4164
+ ```javascript
4165
+ const users = await User.query().select('id', 'name', 'email').values();
4166
+ // [{ id: 1, name: 'John', email: 'john@example.com' }, ...]
4167
+ ```
4168
+
4169
+ ### Vector / Semantic Search (pgvector)
4170
+
4171
+ Semantic search matches by **meaning**, not exact keywords. It works by converting text into a list of numbers called an **embedding** using an AI embedding model, storing those numbers in the database, and finding records whose embeddings are mathematically closest to a search query.
4172
+
4173
+ **Requires:** PostgreSQL + pgvector extension. An external embedding model (OpenAI, Cohere, Ollama, etc.) is also required — IlanaORM does not include one. Does **not** work with MySQL or SQLite.
4174
+
4175
+ ```javascript
4176
+ // 1. Enable the extension in a migration
4177
+ await schema.enableVectorExtension();
4178
+
4179
+ // 2. Add a vector column
4180
+ await schema.table('posts', table => {
4181
+ table.specificType('embedding', 'vector(1536)'); // dimensions must match your model
4182
+ });
4183
+
4184
+ // 3. Configure the model
4185
+ class Post extends Model {
4186
+ static embeddingColumn = 'embedding';
4187
+ static embeddingProvider = async (text) => {
4188
+ // Must return Promise<number[]> — plug in any embedding API
4189
+ const res = await openai.embeddings.create({ model: 'text-embedding-ada-002', input: text });
4190
+ return res.data[0].embedding;
4191
+ };
4192
+ }
4193
+
4194
+ // 4. Store embeddings when creating records
4195
+ await Post.create({
4196
+ title: 'JavaScript tips',
4197
+ body: '...',
4198
+ embedding: JSON.stringify(await Post.embeddingProvider('JavaScript tips ...')),
4199
+ });
4200
+
4201
+ // 5. Search — converts text to vector, queries by similarity
4202
+ const posts = await Post.search('javascript performance tips', { limit: 5 });
4203
+
4204
+ // Or search by a raw vector you already have
4205
+ const posts = await Post.nearestTo(myVector, { distance: 'cosine', limit: 10 });
4206
+
4207
+ // Each result has a .distance attribute (lower = more similar)
4208
+ posts.forEach(p => console.log(p.title, p.distance));
4209
+ ```
4210
+
4211
+ Distance options: `'cosine'` (default, `<=>`), `'l2'` (`<->`), `'inner'` (`<#>`).
4212
+
4213
+ ### Edge Runtime
4214
+
4215
+ Import from `ilana-orm/edge` to skip the Node.js `fs`/`path` auto-loader. Required for Cloudflare Workers, Deno, Bun, and Next.js edge routes:
4216
+
4217
+ ```javascript
4218
+ import { Model, Database } from 'ilana-orm/edge';
4219
+
4220
+ // Must configure explicitly — no auto-loading of ilana.config.js
4221
+ Database.configure({
4222
+ default: 'pg',
4223
+ connections: {
4224
+ pg: { client: 'pg', connection: { connectionString: process.env.DATABASE_URL } },
4225
+ },
4226
+ });
4227
+
4228
+ const users = await User.all();
4229
+ ```
4230
+
4025
4231
  ## TypeScript Support
4026
4232
 
4027
4233
  ### Type-Safe Models
@@ -4074,6 +4280,32 @@ const user = await User.factory().create({
4074
4280
  });
4075
4281
  ```
4076
4282
 
4283
+ ## Supabase
4284
+
4285
+ Supabase is a hosted PostgreSQL platform. IlanaORM works with it out of the box using the `pg` driver — no special setup required.
4286
+
4287
+ ```javascript
4288
+ // ilana.config.js
4289
+ const { Database } = require('ilana-orm');
4290
+
4291
+ Database.configure({
4292
+ default: 'pg',
4293
+ connections: {
4294
+ pg: {
4295
+ client: 'pg',
4296
+ connection: {
4297
+ connectionString: process.env.DATABASE_URL, // from Supabase dashboard
4298
+ ssl: { rejectUnauthorized: false }, // required for Supabase
4299
+ },
4300
+ },
4301
+ },
4302
+ });
4303
+ ```
4304
+
4305
+ All features work including migrations, relations, soft deletes, and vector search (Supabase has pgvector built in).
4306
+
4307
+ For serverless or edge deployments with Supabase, use the [connection pooler URL](https://supabase.com/docs/guides/database/connecting-to-postgres) (port `6543`) and the `ilana-orm/edge` entry point for Cloudflare Workers or Next.js edge routes.
4308
+
4077
4309
  ## Performance & Best Practices
4078
4310
 
4079
4311
  ### Query Optimization
@@ -4249,12 +4481,18 @@ User.updateOrCreate(search, update); // Update or create
4249
4481
  User.firstOrNew(search, create); // Find or new instance
4250
4482
  User.upsert(data, unique, update); // Upsert records
4251
4483
 
4252
- // Deletion methods
4253
- User.destroy(ids); // Delete by IDs
4484
+ // Deletion & seeding
4485
+ User.destroy(ids); // Delete by IDs (soft-delete aware)
4486
+ User.truncate(); // Delete all rows in the table
4487
+ User.seed(n); // Create n records using the registered factory
4488
+ User.prune(); // Delete all records matching static prunable()
4254
4489
  User.withTrashed(); // Include soft deleted
4255
4490
  User.onlyTrashed(); // Only soft deleted
4256
4491
  User.withoutTrashed(); // Exclude soft deleted
4257
4492
 
4493
+ // Events
4494
+ User.withoutEvents(async () => { ... }); // Run without firing events
4495
+
4258
4496
  // Configuration
4259
4497
  User.getTableName(); // Get table name
4260
4498
  User.getPrimaryKey(); // Get primary key
@@ -4262,6 +4500,7 @@ User.getKeyType(); // Get key type
4262
4500
  User.getIncrementing(); // Get incrementing flag
4263
4501
  User.getConnectionName(); // Get connection name
4264
4502
  User.generateUuid(); // Generate UUID
4503
+ User.generateUlid(); // Generate ULID
4265
4504
 
4266
4505
  // Events
4267
4506
  User.creating(callback); // Before creating
@@ -4297,6 +4536,10 @@ user.update(attributes); // Update model
4297
4536
  user.delete(); // Delete model
4298
4537
  user.forceDelete(); // Force delete (ignores softDeletes)
4299
4538
  user.restore(); // Restore soft deleted
4539
+ user.fresh(); // Re-fetch from DB and return new instance
4540
+ user.is(other); // Check if two instances are the same record
4541
+ user.isNot(other); // Inverse of is()
4542
+ user.replicate(except?); // Clone as unsaved record (excludes PK + timestamps)
4300
4543
 
4301
4544
  // Attributes
4302
4545
  user.fill(attributes); // Mass assign (respects fillable/guarded)
@@ -4435,6 +4678,18 @@ query.findOrFail(id); // Find or throw
4435
4678
  query.pluck(column); // Get column values
4436
4679
  query.exists(); // Check existence
4437
4680
  query.doesntExist(); // Check non-existence
4681
+ query.sole(); // Get exactly one result — throws if zero or more than one
4682
+ query.tap(callback); // Run a callback for debugging without breaking the chain
4683
+ query.values(); // Return plain objects instead of model instances
4684
+ ```
4685
+
4686
+ #### Soft Deletes (QueryBuilder)
4687
+
4688
+ ```javascript
4689
+ query.withTrashed(); // include soft-deleted records
4690
+ query.onlyTrashed(); // only soft-deleted records
4691
+ query.withoutTrashed(); // exclude soft-deleted (default)
4692
+ query.restore(); // bulk-restore matched soft-deleted records
4438
4693
  ```
4439
4694
 
4440
4695
  #### Pagination
@@ -4469,9 +4724,10 @@ query.upsert(data, uniqueBy, update);
4469
4724
  query.with(...relations);
4470
4725
  query.withConstraints(relation, callback);
4471
4726
  query.withCount(...relations); // adds relation_count subquery column per model
4472
- query.whereHas(relation, callback); // WHERE EXISTS subquery
4473
- query.doesntHave(relation); // WHERE NOT EXISTS subquery
4474
- query.whereDoesntHave(relation, callback); // WHERE NOT EXISTS with constraint
4727
+ query.has(relation, operator?, count?); // WHERE EXISTS, or count-based (e.g. has('posts', '>', 5))
4728
+ query.whereHas(relation, callback); // WHERE EXISTS with constraint subquery
4729
+ query.doesntHave(relation); // WHERE NOT EXISTS subquery
4730
+ query.whereDoesntHave(relation, callback); // WHERE NOT EXISTS with constraint
4475
4731
  ```
4476
4732
 
4477
4733
  #### Locking