flint-orm 0.3.0 → 0.4.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.
Files changed (5) hide show
  1. package/API.md +158 -317
  2. package/README.md +239 -134
  3. package/dist/index.js +288 -255
  4. package/package.json +43 -12
  5. package/src/cli.ts +0 -314
package/README.md CHANGED
@@ -1,238 +1,343 @@
1
- # flint-orm
1
+ # Flint ORM
2
2
 
3
- A minimal, type-safe SQLite ORM. One dialect, one runtime, no abstraction overhead.
3
+ A type-safe SQLite ORM for JavaScript. One schema, any driver.
4
4
 
5
- ## Why
5
+ ## Features
6
6
 
7
- Drizzle is great, but its execution layer (`drizzle-kit migrate`) wasn't built for dynamic, multi-tenant, per-request-connection contexts. Flint is narrower by design: SQLite/libSQL only, no multi-dialect abstraction, no pluggable drivers — just a query builder that does exactly what SQLite supports.
7
+ - **Driver-agnostic** use `bun:sqlite`, `better-sqlite3`, `@libsql/client`, `@tursodatabase/database`, or `@tursodatabase/sync`
8
+ - **Type-safe queries** — full TypeScript inference for results, inserts, and updates
9
+ - **Schema-first migrations** — define tables in code, generate and apply migrations
10
+ - **Fluent query builder** — chainable API for SELECT, INSERT, UPDATE, DELETE, and JOINs
11
+ - **Aggregate functions** — count, sum, avg, min, max with type inference
12
+ - **Zero runtime dependencies** on the core — drivers are opt-in per subpath
8
13
 
9
14
  ## Install
10
15
 
11
16
  ```bash
12
- npm install flint-orm
13
17
  bun add flint-orm
18
+ # or
19
+ npm install flint-orm
14
20
  ```
15
21
 
16
22
  ## Quick Start
17
23
 
18
24
  ```ts
19
- import { flint, table, text, integer, eq } from 'flint-orm';
25
+ import { flint } from 'flint-orm/bun-sqlite';
26
+ import { table, text, integer, date } from 'flint-orm/table';
27
+ import { eq, and } from 'flint-orm/expressions';
20
28
 
29
+ // Define schema
21
30
  const users = table('users', {
22
31
  id: text('id').primaryKey(),
23
32
  name: text('name').notNull(),
24
33
  email: text('email').unique(),
25
34
  age: integer('age'),
35
+ createdAt: date('created_at').defaultNow(),
26
36
  });
27
37
 
28
- const db = flint({ url: 'app.db' });
38
+ // Connect
39
+ const db = flint({ url: './app.db' });
29
40
 
30
41
  // Insert
31
- db.insert(users).values({ id: 'u1', name: 'Alice', email: 'alice@example.com' }).execute();
42
+ await db.insert(users).values({ id: 'u1', name: 'Alice', email: 'alice@example.com' }).execute();
32
43
 
33
44
  // Query
34
- const user = db.select().from(users).where(eq(users.id, 'u1')).single().execute();
35
- // { id: "u1", name: "Alice", email: "alice@example.com", age: null }
45
+ const adults = await db.select().from(users).where(eq(users.age, 18)).execute();
46
+ // ^? { id: string; name: string; email: string; age: number; createdAt: Date }[]
36
47
 
37
- // Update
38
- db.update(users).set({ name: 'Alice Updated' }).where(eq(users.id, 'u1')).execute();
39
-
40
- // Delete
41
- db.delete(users).where(eq(users.id, 'u1')).execute();
48
+ // Single row
49
+ const alice = await db.select().from(users).where(eq(users.id, 'u1')).single().execute();
50
+ // ^? { id: string; name: string; email: string; age: number; createdAt: Date } | null
42
51
  ```
43
52
 
44
- ## Features
53
+ ## Schema Definition
45
54
 
46
- ### Schema
55
+ ### Columns
47
56
 
48
57
  ```ts
49
- import { table, text, integer, boolean, json, date, real, index } from 'flint-orm';
58
+ import { table, text, integer, boolean, real, json, date, index } from 'flint-orm/table';
59
+
60
+ const posts = table('posts', {
61
+ id: text('id').primaryKey(),
62
+ title: text('title').notNull(),
63
+ body: text('body'),
64
+ published: boolean('published').default(false),
65
+ views: integer('views').default(0).autoIncrement(),
66
+ price: real('price'),
67
+ metadata: json('metadata').default({}),
68
+ createdAt: date('created_at').defaultNow(),
69
+ updatedAt: date('updated_at').onUpdateTimestamp(),
70
+ });
71
+ ```
50
72
 
73
+ ### Modifiers
74
+
75
+ - `.primaryKey()` — mark as primary key
76
+ - `.notNull()` — disallow NULL values
77
+ - `.unique()` — add unique constraint
78
+ - `.default(value)` — static default value
79
+ - `.defaultFn(fn)` — dynamic default (called on insert when value is omitted)
80
+ - `.references(target)` — foreign key reference
81
+ - `.onDelete(action)` — foreign key ON DELETE action (`cascade`, `set null`, `set default`, `restrict`, `no action`)
82
+ - `.onUpdate(action)` — foreign key ON UPDATE action (`cascade`, `set null`, `set default`, `restrict`, `no action`)
83
+ - `.autoIncrement()` — integer auto-increment (integer columns only)
84
+ - `.defaultNow()` — use `Date.now()` as default (date columns only)
85
+ - `.onUpdateTimestamp()` — always set to `Date.now()` on update (date columns only)
86
+
87
+ ### Indexes
88
+
89
+ ```ts
51
90
  const users = table(
52
91
  'users',
53
92
  {
54
93
  id: text('id').primaryKey(),
55
- name: text('name').notNull(),
56
- email: text('email').unique(),
57
- active: boolean('active').default(true),
58
- metadata: json('metadata'),
59
- createdAt: date('createdAt').defaultNow().onUpdate(),
60
- score: real('score'),
61
- age: integer('age'),
62
- orgId: text('orgId').references(orgs.id),
94
+ email: text('email'),
95
+ name: text('name'),
63
96
  },
64
- (t) => [index('idx_users_email').on(t.email).unique(), index('idx_users_org').on(t.orgId)],
97
+ (t) => [index('idx_users_email').on(t.email).unique(), index('idx_users_name').on(t.name)],
65
98
  );
66
99
  ```
67
100
 
68
- | Type | TS Type | SQLite | Modifiers |
69
- | --------- | --------- | ------- | ------------------------------------------------------------------------- |
70
- | `text` | `string` | TEXT | `.primaryKey()`, `.notNull()`, `.unique()`, `.default()`, `.references()` |
71
- | `integer` | `number` | INTEGER | `.autoIncrement()` |
72
- | `boolean` | `boolean` | INTEGER | Encodes 0/1 |
73
- | `json<T>` | `T` | TEXT | JSON encode/decode |
74
- | `date` | `Date` | INTEGER | `.defaultNow()`, `.onUpdate()` |
75
- | `real` | `number` | REAL | |
101
+ ### Type Inference
102
+
103
+ ```ts
104
+ import type { InferRow, InsertRow } from 'flint-orm/table';
105
+
106
+ type User = InferRow<typeof users>;
107
+ // { id: string; name: string; email: string | null; age: number | null; createdAt: Date }
108
+
109
+ type NewUser = InsertRow<typeof users>;
110
+ // { id: string; name: string; email?: string | null; age?: number | null; createdAt?: Date }
111
+ ```
112
+
113
+ `InsertRow` makes columns with auto-defaults (`integer`, `date`) optional.
76
114
 
77
- ### Query Builder
115
+ ## Queries
78
116
 
79
- Two-phase builders prevent invalid queries at compile time:
117
+ ### SELECT
80
118
 
81
119
  ```ts
82
- // SELECT
83
- db.select().from(users).columns(['id', 'name']).where(eq(users.active, true)).orderBy('name').limit(10).execute();
120
+ // All rows
121
+ const all = await db.select().from(users).execute();
84
122
 
85
- // INSERT
86
- db.insert(users).values({ id: 'u1', name: 'Alice' }).returning().execute();
123
+ // With conditions
124
+ const active = await db.select().from(users).where(eq(users.active, true)).execute();
87
125
 
88
- // UPSERT
89
- db.insert(users)
90
- .values({ id: 'u1', name: 'Alice' })
91
- .onConflictDoUpdate({ target: users.id, set: { name: 'Bob' } })
92
- .execute();
126
+ // Narrow columns
127
+ const names = await db.select().from(users).columns(['id', 'name']).execute();
128
+ // ^? { id: string; name: string }[]
93
129
 
94
- // UPDATE
95
- db.update(users).set({ name: 'Bob' }).where(eq(users.id, 'u1')).returning().execute();
130
+ // Single row
131
+ const user = await db.select().from(users).where(eq(users.id, 'u1')).single().execute();
132
+ // ^? { ... } | null
96
133
 
97
- // DELETE
98
- db.delete(users).where(eq(users.id, 'u1')).returning().execute();
134
+ // Ordering and pagination
135
+ const page = await db.select().from(users).orderBy('name', 'asc').limit(10).offset(20).execute();
136
+
137
+ // Distinct
138
+ const unique = await db.select().from(users).columns(['email']).distinct().execute();
99
139
  ```
100
140
 
101
- ### Joins
141
+ ### INSERT
102
142
 
103
143
  ```ts
104
- const orders = table('orders', {
105
- id: text('id').primaryKey(),
106
- userId: text('userId').references(users.id),
107
- total: integer('total').notNull(),
108
- });
144
+ // Single row
145
+ await db.insert(users).values({ id: 'u1', name: 'Alice', email: 'alice@example.com' }).execute();
146
+
147
+ // Multiple rows
148
+ await db
149
+ .insert(users)
150
+ .values([
151
+ { id: 'u1', name: 'Alice' },
152
+ { id: 'u2', name: 'Bob' },
153
+ ])
154
+ .execute();
109
155
 
110
- // Left join — nested result shape
111
- const result = db.leftJoin(orders).on(users).execute();
112
- // [{ id: "u1", name: "Alice", __children: [{ id: "o1", total: 100 }] }]
156
+ // Return inserted rows
157
+ const inserted = await db.insert(users).values({ id: 'u1', name: 'Alice' }).returning().execute();
158
+ // ^? { id: string; name: string; ... }[]
159
+
160
+ // Upsert
161
+ await db
162
+ .insert(users)
163
+ .values({ id: 'u1', name: 'Alice' })
164
+ .onConflictDoUpdate({ target: users.id, set: { name: 'Alice' } })
165
+ .execute();
113
166
 
114
- // Multi-join
115
- db.leftJoin(orders).on(users).leftJoin(orderItems).on(orders).execute();
167
+ // Ignore conflicts
168
+ await db.insert(users).values({ id: 'u1', name: 'Alice' }).onConflictDoNothing().execute();
116
169
  ```
117
170
 
118
- ### Aggregates
171
+ ### UPDATE
119
172
 
120
173
  ```ts
121
- db.count(users);
122
- db.sum(orders, orders.total);
123
- db.avg(orders, orders.total, eq(orders.userId, 'u1'));
174
+ await db.update(users).set({ name: 'Bob' }).where(eq(users.id, 'u1')).execute();
175
+
176
+ // Return updated rows
177
+ const updated = await db.update(users).set({ name: 'Bob' }).where(eq(users.id, 'u1')).returning().execute();
124
178
  ```
125
179
 
126
- ### Batch
180
+ ### DELETE
127
181
 
128
182
  ```ts
129
- db.batch([db.insert(orders).values({ id: 'o1', userId: 'u1', total: 100 }), db.update(users).set({ totalOrders: 1 }).where(eq(users.id, 'u1'))]);
183
+ await db.delete(users).where(eq(users.id, 'u1')).execute();
184
+
185
+ // Return deleted rows
186
+ const deleted = await db.delete(users).where(eq(users.id, 'u1')).returning().execute();
130
187
  ```
131
188
 
132
- ### Raw SQL
189
+ ### JOINs
133
190
 
134
191
  ```ts
135
- db.$client.prepare('SELECT * FROM users WHERE id = ?').all('u1');
192
+ // Auto-join via foreign key (posts.userId references users.id)
193
+ const postsWithAuthors = await db.leftJoin(users).on(posts).execute();
194
+
195
+ // Explicit join condition
196
+ const result = await db.leftJoin(users).on(posts, eq(posts.userId, users.id)).execute();
197
+
198
+ // Chain multiple joins
199
+ const complex = await db
200
+ .leftJoin(users)
201
+ .on(posts, eq(posts.userId, users.id))
202
+ .on(comments, eq(comments.postId, posts.id))
203
+ .where(eq(users.id, 'u1'))
204
+ .execute();
205
+
206
+ // Inner join
207
+ const inner = await db.innerJoin(users).on(posts).execute();
136
208
  ```
137
209
 
138
- ## Conditions
210
+ Join results are nested — each joined table's data appears under its table name:
139
211
 
140
212
  ```ts
141
- import { eq, neq, gt, gte, lt, lte, and, or, isIn, isNotIn, isNull, isNotNull, like, glob, between } from 'flint-orm';
142
-
143
- db.select()
144
- .from(users)
145
- .where(and(eq(users.active, true), gt(users.age, 18)))
146
- .execute();
213
+ // result shape: { id: string; name: string; posts: { id: string; title: string }[] }
147
214
  ```
148
215
 
149
- ## Types
216
+ ### Aggregates
150
217
 
151
218
  ```ts
152
- import type { InferRow, InsertRow } from 'flint-orm';
153
-
154
- type UserRow = InferRow<typeof users>;
155
- type UserInsert = InsertRow<typeof users>; // defaults are optional
219
+ const total = await db.count(users);
220
+ const activeCount = await db.count(users, eq(users.active, true));
221
+ const totalViews = await db.sum(posts, posts.views);
222
+ const avgAge = await db.avg(users, users.age);
223
+ const minAge = await db.min(users, users.age);
224
+ const maxAge = await db.max(users, users.age);
156
225
  ```
157
226
 
158
- ## Migration System
227
+ ### Batch (Transactions)
159
228
 
160
- ### CLI
229
+ ```ts
230
+ await db.batch([db.insert(users).values({ id: 'u1', name: 'Alice' }), db.insert(posts).values({ id: 'p1', userId: 'u1', title: 'Hello' })]);
231
+ ```
161
232
 
162
- ```bash
163
- # Generate migration from schema changes
164
- flint generate --name init_schema
233
+ ### Raw SQL
165
234
 
166
- # Preview SQL without writing files
167
- flint generate --preview
235
+ ```ts
236
+ import { sql } from 'flint-orm';
168
237
 
169
- # Apply pending migrations
170
- flint migrate
238
+ const expr = sql`name = ${'Alice'} AND age > ${18}`;
239
+ const result = await db.select().from(users).where(expr).execute();
171
240
 
172
- # Check status
173
- flint migrate --status
241
+ // Direct execution
242
+ await db.$run('CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)');
174
243
  ```
175
244
 
176
- ### Config
245
+ ## Conditions
246
+
247
+ All conditions are composable with `and()` and `or()`:
177
248
 
178
249
  ```ts
179
- // flint.config.ts
180
- import { defineConfig } from 'flint-orm/config';
250
+ import { eq, neq, gt, gte, lt, lte, and, or, isIn, isNotIn, isNull, isNotNull, like, glob, between } from 'flint-orm/expressions';
181
251
 
182
- export default defineConfig({
183
- schema: './src/schema',
184
- migrations: './flint',
185
- database: { url: './app.db' },
186
- });
187
- ```
252
+ // Equality
253
+ eq(users.name, 'Alice');
254
+ eq(posts.userId, users.id); // column-to-column comparison
188
255
 
189
- ### How It Works
256
+ // Comparisons
257
+ gt(users.age, 18);
258
+ gte(users.age, 18);
259
+ lt(users.age, 65);
260
+ lte(users.age, 65);
261
+ neq(users.id, 'excluded');
190
262
 
191
- 1. `flint generate` serializes your `table()` definitions, diffs against the last snapshot, and writes a migration folder with TypeScript operations
192
- 2. `flint migrate` reads pending migration folders, executes SQL via `batch()` (atomic), and records applied migrations in `__flint_migrations`
193
- 3. Tables are topologically sorted by foreign key dependencies (Kahn's algorithm) — referenced tables are created first
263
+ // Logical
264
+ and(eq(users.active, true), gt(users.age, 18));
265
+ or(eq(users.role, 'admin'), eq(users.role, 'moderator'));
194
266
 
195
- ### Programmatic
267
+ // Sets
268
+ isIn(users.id, ['u1', 'u2', 'u3']);
269
+ isNotIn(users.id, ['excluded']);
196
270
 
197
- ```ts
198
- import { generate, migrate, getMigrationStatus, serializeSchema, diffSchemas, generateSQL } from 'flint-orm/migration';
199
- ```
271
+ // Null checks
272
+ isNull(users.deletedAt);
273
+ isNotNull(users.email);
200
274
 
201
- ## CLI
275
+ // Pattern matching
276
+ like(users.name, 'A%'); // SQL LIKE (% = any characters, _ = single char)
277
+ glob(users.name, 'A*'); // SQL GLOB (* = any characters, ? = single char)
202
278
 
279
+ // Range
280
+ between(users.age, 18, 65);
203
281
  ```
204
- flint <command> [options]
205
282
 
206
- Commands:
207
- generate Generate a new migration from schema changes
208
- migrate Apply pending migrations
209
- status Show migration status
283
+ ## Migrations
210
284
 
211
- Options:
212
- --name, -n Migration name
213
- --preview, -p Show SQL without writing files
214
- --status Show which migrations are pending/applied
215
- ```
285
+ Flint uses schema-first migrations. Define your tables in code, and the CLI generates migration files from the diff.
216
286
 
217
- ## Error Handling
287
+ ### Setup
218
288
 
219
289
  ```ts
220
- import { FlintValidationError, FlintQueryError } from 'flint-orm';
290
+ // flint.config.ts
291
+ import { defineConfig } from 'flint-orm/config';
292
+
293
+ export default defineConfig({
294
+ driver: 'bun-sqlite',
295
+ database: {
296
+ url: './app.db',
297
+ },
298
+ schema: './src/schema',
299
+ migrations: './flint',
300
+ });
221
301
  ```
222
302
 
223
- | Error | When |
224
- | ---------------------- | ----------------------------- |
225
- | `FlintValidationError` | Invalid query construction |
226
- | `FlintQueryError` | Runtime SQL execution failure |
303
+ ### Generate
304
+
305
+ ```bash
306
+ flint generate # auto-detect changes
307
+ flint generate --name init # name the migration
308
+ flint generate --preview # dry run, show SQL without writing
309
+ ```
227
310
 
228
- ## Philosophy
311
+ ### Apply
229
312
 
230
- - **SQLite only** — no multi-dialect abstraction, no driver plugins
231
- - **Type-safe by construction** — two-phase builders, phantom types, `InferRow<T>`
232
- - **Immutable** every chain method returns a new instance
233
- - **No classes** functions and plain objects only
234
- - **Parameterized** — all queries use `?` placeholders, never string interpolation
313
+ ```bash
314
+ flint migrate # apply pending migrations
315
+ flint migrate --status # show applied vs pending
316
+ flint migrate --dry-run # show what would run
317
+ ```
235
318
 
236
- ## License
319
+ ### How It Works
237
320
 
238
- MIT
321
+ 1. `flint generate` serializes your `table()` definitions to JSON
322
+ 2. Diffs against the last migration's `state.json`
323
+ 3. Detects adds, drops, renames, and safe modifications
324
+ 4. Prompts to confirm potential renames
325
+ 5. Writes a migration folder with `migration.ts` (operations) + `state.json` (snapshot)
326
+ 6. `flint migrate` reads pending migrations and executes them in order
327
+
328
+ Unsafe changes (type changes, primary key changes, FK modifications, UNIQUE changes, DEFAULT removal) automatically emit a `rebuildTable` operation that recreates the table with the new schema and copies data safely within a transaction.
329
+
330
+ ## Subpath Imports
331
+
332
+ | Import | What's in it |
333
+ | -------------------------- | --------------------------------------------------------------- |
334
+ | `flint-orm/bun-sqlite` | `flint()` factory for bun:sqlite |
335
+ | `flint-orm/better-sqlite3` | `flint()` factory for better-sqlite3 |
336
+ | `flint-orm/libsql` | `flint()` factory for @libsql/client |
337
+ | `flint-orm/libsql-web` | `flint()` factory for @libsql/client/web |
338
+ | `flint-orm/turso` | `flint()` factory for @tursodatabase/database |
339
+ | `flint-orm/turso-sync` | `flint()` factory for @tursodatabase/sync |
340
+ | `flint-orm/table` | `table()`, column constructors, index builder, type utilities |
341
+ | `flint-orm/expressions` | `eq`, `and`, `or`, `gt`, `like`, and all condition helpers |
342
+ | `flint-orm/config` | `defineConfig()` |
343
+ | `flint-orm/migration` | `generate()`, `migrate()`, `serializeSchema()`, `diffSchemas()` |