@bjnstnkvc/db 0.1.0
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/LICENSE +21 -0
- package/README.md +1290 -0
- package/dist/main.cjs +4414 -0
- package/dist/main.d.cts +1372 -0
- package/dist/main.d.ts +1372 -0
- package/dist/main.js +4349 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,1290 @@
|
|
|
1
|
+
# DB
|
|
2
|
+
|
|
3
|
+
A database layer for IndexedDB, with an API modelled on [Laravel's](https://laravel.com/docs/12.x/database): a `DB` class, a fluent query builder, a schema builder and forward-only migrations that run when your app boots.
|
|
4
|
+
|
|
5
|
+
The method names and their semantics follow Laravel closely enough that the docs are worth reading side by side, and each section below links the page it draws from. It is not a port: IndexedDB is a key-value store with no query language, so the places where behaviour has to differ are called out where they arise. This project is not affiliated with the Laravel project.
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- [Installation & setup](#installation--setup)
|
|
10
|
+
- [Configuration](#configuration)
|
|
11
|
+
- [Migrations](#migrations)
|
|
12
|
+
- [Seeding](#seeding)
|
|
13
|
+
- [Defining a schema](#defining-a-schema)
|
|
14
|
+
- [Querying](#querying)
|
|
15
|
+
- [Joins](#joins)
|
|
16
|
+
- [Grouping](#grouping)
|
|
17
|
+
- [Query plans](#query-plans)
|
|
18
|
+
- [Transactions](#transactions)
|
|
19
|
+
- [Events and the query log](#events-and-the-query-log)
|
|
20
|
+
- [Multiple tabs](#multiple-tabs)
|
|
21
|
+
- [Connections](#connections)
|
|
22
|
+
- [Storage quota](#storage-quota)
|
|
23
|
+
- [Reserved tables](#reserved-tables)
|
|
24
|
+
- [Exceptions](#exceptions)
|
|
25
|
+
- [Testing](#testing)
|
|
26
|
+
|
|
27
|
+
## Installation & setup
|
|
28
|
+
|
|
29
|
+
### NPM
|
|
30
|
+
|
|
31
|
+
You can install the package via npm:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install @bjnstnkvc/db
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
and then import it into your project
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { DB, Schema, Migration, type Blueprint } from '@bjnstnkvc/db';
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
### Configuration
|
|
46
|
+
|
|
47
|
+
Declare your connections once, at module scope, then migrate when the app boots:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { DB, Schema, Migration, type Blueprint } from '@bjnstnkvc/db';
|
|
51
|
+
|
|
52
|
+
interface User {
|
|
53
|
+
id: number;
|
|
54
|
+
name: string;
|
|
55
|
+
email: string;
|
|
56
|
+
age: number | null;
|
|
57
|
+
role: string;
|
|
58
|
+
created_at: Date | null;
|
|
59
|
+
updated_at: Date | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
class CreateUsersTable extends Migration {
|
|
63
|
+
/**
|
|
64
|
+
* Run the migration.
|
|
65
|
+
*/
|
|
66
|
+
override async up(): Promise<void> {
|
|
67
|
+
await Schema.create('users', (table: Blueprint): void => {
|
|
68
|
+
table.id();
|
|
69
|
+
table.string('name');
|
|
70
|
+
table.string('email').unique();
|
|
71
|
+
table.integer('age').nullable().index();
|
|
72
|
+
table.string('role').default('member').index();
|
|
73
|
+
table.timestamps();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
DB.configure({
|
|
79
|
+
default : 'app',
|
|
80
|
+
connections: {
|
|
81
|
+
app: {
|
|
82
|
+
database : 'app',
|
|
83
|
+
migrations: [CreateUsersTable],
|
|
84
|
+
seeders : [UserSeeder],
|
|
85
|
+
strict : true,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await DB.migrate('app');
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`User` is your own interface describing a row of the table. Nothing in this package generates it,
|
|
94
|
+
and every example below passes it as `DB.table<User>('users')` so the builder can type its
|
|
95
|
+
constraints, its return values and its aggregate keys.
|
|
96
|
+
|
|
97
|
+
| Option | Meaning |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| `default` | The connection used when none is named |
|
|
100
|
+
| `connections[name].database` | The IndexedDB database name |
|
|
101
|
+
| `connections[name].migrations` | Ordered migration classes. Their order **is** the schema version. |
|
|
102
|
+
| `connections[name].seeders` | Ordered seeder classes, run by `DB.seed(name)`. See [Seeding](#seeding). |
|
|
103
|
+
| `connections[name].strict` | Defaults to `true`. Nullability violations and uncoercible values throw. `false` writes `null` instead. |
|
|
104
|
+
|
|
105
|
+
`DB.migrate(name)` is idempotent. It opens the database at the version your migrations ask for, and
|
|
106
|
+
when that already matches, nothing runs. Calling it on every boot is the intended usage, and there
|
|
107
|
+
is no "has this been migrated?" check for you to write.
|
|
108
|
+
|
|
109
|
+
The connection name is **required** here. Every method whose subject is the connection itself names
|
|
110
|
+
it rather than falling back to the default, since a silent fallback would migrate, seed or delete the
|
|
111
|
+
wrong database. That covers `migrate`, `seed`, `fresh`, `status`, `disconnect` and `purge`. The
|
|
112
|
+
table-level helpers still default, because there the subject is the table:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
await DB.migrate('app');
|
|
116
|
+
await DB.migrate('reporting');
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Migrations
|
|
120
|
+
|
|
121
|
+
A migration declares `up()` and nothing else:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
class AddRoleToUsersTable extends Migration {
|
|
125
|
+
/**
|
|
126
|
+
* Run the migration.
|
|
127
|
+
*/
|
|
128
|
+
override async up(): Promise<void> {
|
|
129
|
+
await Schema.table('users', (table: Blueprint): void => {
|
|
130
|
+
table.string('role').default('member');
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Adding a column **with** a `default()` backfills every existing record. Without one, existing
|
|
137
|
+
records are left alone.
|
|
138
|
+
|
|
139
|
+
#### Migrations are forward-only
|
|
140
|
+
|
|
141
|
+
IndexedDB versions cannot decrease, so there is no `down()`, no `rollback()` and no batches. To
|
|
142
|
+
start over, `DB.fresh(name)` deletes the database and replays every migration.
|
|
143
|
+
|
|
144
|
+
Migrations may only ever be **appended**. Reordering them, or removing one that already ran, throws
|
|
145
|
+
`MigrationMismatchException` rather than corrupting the schema.
|
|
146
|
+
|
|
147
|
+
The recorded name defaults to the class name, so a bundler that mangles class names will look like a
|
|
148
|
+
reordered list. If you minify with class-name mangling, override `name()`:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
class CreateUsersTable extends Migration {
|
|
152
|
+
/**
|
|
153
|
+
* Get the name of the migration.
|
|
154
|
+
*/
|
|
155
|
+
override name(): string {
|
|
156
|
+
return 'create_users_table';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Run the migration.
|
|
161
|
+
*/
|
|
162
|
+
override async up(): Promise<void> {
|
|
163
|
+
// ...
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
#### What a migration may await
|
|
169
|
+
|
|
170
|
+
A migration runs inside the version-change transaction, and IndexedDB commits a transaction the
|
|
171
|
+
moment its request queue drains. So a migration may **only** await operations from this package.
|
|
172
|
+
Awaiting `Schema.*` and `DB.table(...)` is safe. Awaiting a `fetch`, a timer, or any other promise
|
|
173
|
+
ends the transaction, and the next schema call throws `MigrationTransactionClosedException`.
|
|
174
|
+
|
|
175
|
+
If you need data from the network, that is what a seeder is for. See [Seeding](#seeding).
|
|
176
|
+
|
|
177
|
+
#### Migration status
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
await DB.status('app');
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Resolves to one entry per registered migration:
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
[
|
|
187
|
+
{ migration: 'CreateUsersTable', ran: true, at: '2026-08-27T21:00:00.000Z' },
|
|
188
|
+
{ migration: 'AddRoleToUsersTable', ran: false, at: null }
|
|
189
|
+
]
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`DB.status(name)` never migrates as a side effect, so you can call it before `DB.migrate(name)` to
|
|
193
|
+
see what is pending.
|
|
194
|
+
|
|
195
|
+
> Modelled on Laravel's [Migrations](https://laravel.com/docs/12.x/migrations). These only run
|
|
196
|
+
> forward, and they are registered in the connection config rather than discovered from a directory.
|
|
197
|
+
|
|
198
|
+
### Seeding
|
|
199
|
+
|
|
200
|
+
> **Use this for development, demos and tests, not for state your app ships and users edit.** A
|
|
201
|
+
> seeder cannot tell a row the user deleted from a row it never wrote, so re-running one puts back
|
|
202
|
+
> data the user removed on purpose. Recording that a seeder ran does not fix it. See
|
|
203
|
+
> [why seeding fits development better](#why-seeding-fits-development-better) for what to do instead.
|
|
204
|
+
|
|
205
|
+
Seeding is a separate step from migrating, and deliberately so. A migration runs inside the version
|
|
206
|
+
change transaction and therefore cannot await a `fetch`. A seeder runs outside it, so it can await
|
|
207
|
+
anything at all, which makes it the right home for any seed data that comes off the network.
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import { DB, Seeder } from '@bjnstnkvc/db';
|
|
211
|
+
|
|
212
|
+
class UserSeeder extends Seeder {
|
|
213
|
+
/**
|
|
214
|
+
* Seed the database.
|
|
215
|
+
*/
|
|
216
|
+
override async run(): Promise<void> {
|
|
217
|
+
const fetched: User[] = await (await fetch('/users.json')).json();
|
|
218
|
+
|
|
219
|
+
await DB.table<User>('users').insert(fetched);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Register the seeders on the connection and run them when you want to:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
await DB.seed('app');
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Resolves to the names of the seeders that ran:
|
|
231
|
+
|
|
232
|
+
```
|
|
233
|
+
['UserSeeder']
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`DB.seed(name)` opens the connection first, which migrates it, so the tables a seeder writes to are
|
|
237
|
+
guaranteed to exist. The connection name is required for the same reason it is on `migrate`.
|
|
238
|
+
|
|
239
|
+
#### Which connection a seeder writes to
|
|
240
|
+
|
|
241
|
+
For the duration of the run, the connection being seeded **stands in as the default**. So a seeder
|
|
242
|
+
registered on `reporting` that calls `DB.table('users')` writes to `reporting`, not to the
|
|
243
|
+
configured default, and the previous default is restored when the run finishes or fails. Laravel's
|
|
244
|
+
[`SeedCommand`](https://laravel.com/docs/12.x/seeding#running-seeders) does the same thing.
|
|
245
|
+
|
|
246
|
+
Naming a connection explicitly still wins, so a seeder may reach across:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
await DB.connection('app').table<User>('users').insert({ name: 'Alice' });
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
One consequence worth knowing: while a seed run is in flight, `DB.table(...)` anywhere in the app
|
|
253
|
+
resolves to the connection being seeded. Seeding at boot, before the rest of the app starts, keeps
|
|
254
|
+
that from mattering.
|
|
255
|
+
|
|
256
|
+
#### Seeders are not recorded
|
|
257
|
+
|
|
258
|
+
Unlike migrations, nothing records that a seeder ran. Every call to `DB.seed(name)` runs every
|
|
259
|
+
registered seeder again, which matches [Laravel](https://laravel.com/docs/12.x/seeding) and keeps
|
|
260
|
+
the surface small.
|
|
261
|
+
|
|
262
|
+
This is the one place where a browser differs from a server in a way that bites. On a server
|
|
263
|
+
`db:seed` is a command someone runs. In an app, boot happens on every refresh. Migrations are safe
|
|
264
|
+
there, since the database is already at the version its migrations ask for and nothing runs. Seeding
|
|
265
|
+
has no such guard, so a seeder inserting two rows leaves four after the second refresh and six after
|
|
266
|
+
the third.
|
|
267
|
+
|
|
268
|
+
There are two ways to handle it, and they answer different questions.
|
|
269
|
+
|
|
270
|
+
**Seed only a database that has never been migrated.** `DB.status` reads the stored version without
|
|
271
|
+
migrating, so it can be asked before `DB.migrate` whether this is a first run:
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
const status: MigrationStatus[] = await DB.status('app');
|
|
275
|
+
const fresh: boolean = status.every((entry: MigrationStatus): boolean => !entry.ran);
|
|
276
|
+
|
|
277
|
+
await DB.migrate('app');
|
|
278
|
+
|
|
279
|
+
if (fresh) {
|
|
280
|
+
await DB.seed('app');
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Do not use `DB.migrate`'s return value for this. It reports the migrations that call ran, so it is
|
|
285
|
+
non-empty for an existing user whenever you add a table, and they would be seeded again. Note also
|
|
286
|
+
that this seeds a new database only, so a seeder you add later never reaches anyone who already has
|
|
287
|
+
the app.
|
|
288
|
+
|
|
289
|
+
**Or write seeders that do not care how often they run.** This is the better answer for reference
|
|
290
|
+
data, and it keeps working when you add a seeder later. `upsert` against a unique index, or
|
|
291
|
+
`insertOrIgnore`, makes a second run a no-op:
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
class UserSeeder extends Seeder {
|
|
295
|
+
/**
|
|
296
|
+
* Seed the database.
|
|
297
|
+
*/
|
|
298
|
+
override async run(): Promise<void> {
|
|
299
|
+
await DB.table<User>('users').upsert([
|
|
300
|
+
{ email: 'admin@example.com', name: 'Admin' },
|
|
301
|
+
], 'email');
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Seeding is also not atomic across seeders. They run one after another, and a failure in the third
|
|
307
|
+
leaves the first two committed. A seeder that needs all-or-nothing opens its own transaction:
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
class UserSeeder extends Seeder {
|
|
311
|
+
/**
|
|
312
|
+
* Seed the database.
|
|
313
|
+
*/
|
|
314
|
+
override async run(): Promise<void> {
|
|
315
|
+
await DB.transaction(async (transaction: Transaction): Promise<void> => {
|
|
316
|
+
await transaction.table<User>('users').insert({ name: 'Alice' });
|
|
317
|
+
await transaction.table('posts').insert({ user_id: 1, title: 'Hello' });
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
#### Why seeding fits development better
|
|
324
|
+
|
|
325
|
+
Everything above makes a seeder safe to run repeatedly. None of it makes a seeder safe to run
|
|
326
|
+
against data a user owns, and that limit is structural rather than a gap in this package.
|
|
327
|
+
|
|
328
|
+
A seeder cannot distinguish a row the user deleted from a row it never wrote, because both are
|
|
329
|
+
simply absent. An idempotent seeder therefore puts back whatever the user removed.
|
|
330
|
+
|
|
331
|
+
The examples below share these shapes. `settings` holds the configuration, `seeds` records which
|
|
332
|
+
seeder last ran and what it wrote, and `dismissed` records the keys the user removed on purpose:
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
interface Setting {
|
|
336
|
+
id: number;
|
|
337
|
+
key: string;
|
|
338
|
+
value: string;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
interface Seed {
|
|
342
|
+
seeder: string;
|
|
343
|
+
digest: string;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
interface Dismissed {
|
|
347
|
+
key: string;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
type Default = Omit<Setting, 'id'>;
|
|
351
|
+
|
|
352
|
+
const DEFAULTS: Default[] = [
|
|
353
|
+
{ key: 'theme', value: 'dark' },
|
|
354
|
+
{ key: 'locale', value: 'en' },
|
|
355
|
+
];
|
|
356
|
+
|
|
357
|
+
const DIGEST: string = 'v1';
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Those three are your tables, so a migration creates them like any other. This package creates only
|
|
361
|
+
`migrations` and `schema`, which is why those two names are [reserved](#reserved-tables) and nothing
|
|
362
|
+
else is made for you:
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
class CreateSettingsTables extends Migration {
|
|
366
|
+
/**
|
|
367
|
+
* Run the migration.
|
|
368
|
+
*/
|
|
369
|
+
override async up(): Promise<void> {
|
|
370
|
+
await Schema.create('settings', (table: Blueprint): void => {
|
|
371
|
+
table.id();
|
|
372
|
+
table.string('key').unique();
|
|
373
|
+
table.string('value');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
await Schema.create('seeds', (table: Blueprint): void => {
|
|
377
|
+
table.string('seeder').primary();
|
|
378
|
+
table.string('digest');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
await Schema.create('dismissed', (table: Blueprint): void => {
|
|
382
|
+
table.string('key').primary();
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
The indexes are not decoration. `upsert` needs its conflict target to be the key path or a unique
|
|
389
|
+
index, so `key` on `settings` is unique and `seeder` and `key` are the key paths of the other two.
|
|
390
|
+
Without them each `upsert` below would throw `SchemaException`.
|
|
391
|
+
|
|
392
|
+
Suppose a seeder installs those defaults, and records the digest so it re-runs only when they
|
|
393
|
+
actually change:
|
|
394
|
+
|
|
395
|
+
```ts
|
|
396
|
+
class ConfigSeeder extends Seeder {
|
|
397
|
+
/**
|
|
398
|
+
* Seed the database.
|
|
399
|
+
*/
|
|
400
|
+
override async run(): Promise<void> {
|
|
401
|
+
const seen: Seed | null = await DB.table<Seed>('seeds').find('ConfigSeeder');
|
|
402
|
+
|
|
403
|
+
if (seen !== null && seen.digest === DIGEST) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
await DB.table<Setting>('settings').upsert(DEFAULTS, 'key');
|
|
408
|
+
await DB.table<Seed>('seeds').upsert([{ seeder: 'ConfigSeeder', digest: DIGEST }], 'seeder');
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
That holds up until the next time the defaults change:
|
|
414
|
+
|
|
415
|
+
```
|
|
416
|
+
boot 1, seeder v1 ['locale', 'theme']
|
|
417
|
+
boot 2, unchanged ['locale', 'theme']
|
|
418
|
+
user deletes locale ['theme']
|
|
419
|
+
boot 3, seeder v2 ['currency', 'locale', 'theme']
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Adding `currency` re-ran the seeder, and `locale` came back with it. A ledger only defers the
|
|
423
|
+
problem to the next release, which is why this package does not ship one.
|
|
424
|
+
|
|
425
|
+
There are two ways out, and neither of them is a seeder.
|
|
426
|
+
|
|
427
|
+
**Keep the defaults in code.** Store only what the user changed, and merge when reading:
|
|
428
|
+
|
|
429
|
+
```ts
|
|
430
|
+
async function settings(): Promise<Record<string, string>> {
|
|
431
|
+
const defaults: Record<string, string> = Object.fromEntries(
|
|
432
|
+
DEFAULTS.map((row: Default): [string, string] => [row.key, row.value]),
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
const overrides: Record<string, string> = await DB.table<Setting>('settings').pluck<string>('value', 'key');
|
|
436
|
+
|
|
437
|
+
return { ...defaults, ...overrides };
|
|
438
|
+
}
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
Deleting is then an explicit override rather than an absent row, so nothing can resurrect it, and a
|
|
442
|
+
new default ships with the app instead of needing a data migration. The cost is that defaults are
|
|
443
|
+
not rows, so a query cannot filter or join across them.
|
|
444
|
+
|
|
445
|
+
**Or record what the user dismissed.** Keep the rows in the table, and have the seeder skip anything
|
|
446
|
+
the user removed on purpose:
|
|
447
|
+
|
|
448
|
+
```ts
|
|
449
|
+
class ConfigSeeder extends Seeder {
|
|
450
|
+
/**
|
|
451
|
+
* Seed the database.
|
|
452
|
+
*/
|
|
453
|
+
override async run(): Promise<void> {
|
|
454
|
+
const dismissed: string[] = await DB.table<Dismissed>('dismissed').pluck<string>('key');
|
|
455
|
+
const wanted: Default[] = DEFAULTS.filter((row: Default): boolean => !dismissed.includes(row.key));
|
|
456
|
+
|
|
457
|
+
await DB.table<Setting>('settings').upsert(wanted, 'key');
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
Your delete handler writes to `dismissed` as well as removing the row. The seeder is then free to
|
|
463
|
+
run on every boot, because the user's intent is recorded rather than inferred. A `source` column
|
|
464
|
+
marking which rows the seeder owns pairs well with this, so a seeder never overwrites something the
|
|
465
|
+
user authored.
|
|
466
|
+
|
|
467
|
+
Seeding stays the right tool where nobody has edited the data yet: fixtures in tests, demo data
|
|
468
|
+
behind a developer menu, and one-shot imports where deleting a row carries no meaning.
|
|
469
|
+
|
|
470
|
+
#### Rebuilding from scratch
|
|
471
|
+
|
|
472
|
+
`DB.fresh(name)` deletes the database and replays the migrations. Pass `{ seed: true }` to seed it
|
|
473
|
+
afterwards as well, the way [`migrate:fresh --seed`](https://laravel.com/docs/12.x/migrations#refreshing-the-database)
|
|
474
|
+
does in Laravel:
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
await DB.fresh('app');
|
|
478
|
+
await DB.fresh('app', { seed: true });
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
> Modelled on Laravel's [Database: Seeding](https://laravel.com/docs/12.x/seeding), down to the
|
|
482
|
+
> seeded connection standing in as the default for the duration of the run.
|
|
483
|
+
|
|
484
|
+
### Defining a schema
|
|
485
|
+
|
|
486
|
+
IndexedDB stores whole objects and enforces only a key path, `autoIncrement` and indexes. Column
|
|
487
|
+
types are recorded as metadata and enforced by this package at write time.
|
|
488
|
+
|
|
489
|
+
| Blueprint | Effect |
|
|
490
|
+
| --- | --- |
|
|
491
|
+
| `table.id()` | `keyPath: 'id'`, `autoIncrement: true` |
|
|
492
|
+
| `table.uuid('id').primary()` | `keyPath: 'id'`, no autoIncrement |
|
|
493
|
+
| `table.string` / `integer` / `float` / `boolean` / `date` / `datetime` / `json` | Column metadata |
|
|
494
|
+
| `table.decimal('price', 2)` | Column metadata, stored as a whole number of the smallest unit |
|
|
495
|
+
| `table.enum('role', Role)` | Column metadata, checked at write time. Takes a list, an enum or a constant object |
|
|
496
|
+
| `.nullable()` | Metadata, enforced at write time |
|
|
497
|
+
| `.default(value)` | Applied at write time, and backfilled when added to an existing table |
|
|
498
|
+
| `.primary()` | Makes the column the key path. At most one per table. |
|
|
499
|
+
| `.index()` | `createIndex('users_name_index', 'name')` |
|
|
500
|
+
| `.unique()` | `createIndex('users_email_unique', 'email', { unique: true })` |
|
|
501
|
+
| `table.index(['a', 'b'])` | Compound index |
|
|
502
|
+
| `.multiEntry()` | One index entry per array element |
|
|
503
|
+
| `table.timestamps()` | Nullable `created_at` / `updated_at`, filled automatically |
|
|
504
|
+
|
|
505
|
+
Altering a table also supports `dropColumn`, `renameColumn`, `dropIndex` and `Schema.rename`. The key
|
|
506
|
+
path may not be dropped or renamed, because IndexedDB fixes it when the store is created.
|
|
507
|
+
|
|
508
|
+
```ts
|
|
509
|
+
await Schema.table('users', (table: Blueprint): void => {
|
|
510
|
+
table.dropColumn('legacy');
|
|
511
|
+
table.renameColumn('name', 'full_name');
|
|
512
|
+
table.dropIndex('users_age_index');
|
|
513
|
+
table.index(['full_name']);
|
|
514
|
+
});
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
`Schema.rename` is implemented as create-copy-drop, so it is O(n) in the number of records.
|
|
518
|
+
|
|
519
|
+
#### Fixed point columns hold their smallest unit
|
|
520
|
+
|
|
521
|
+
`table.decimal` records a scale and stores the value as a plain integer counting the smallest unit
|
|
522
|
+
that scale describes. A price with two places is written as 1999, not 19.99:
|
|
523
|
+
|
|
524
|
+
```ts
|
|
525
|
+
interface Product {
|
|
526
|
+
id: number;
|
|
527
|
+
name: string;
|
|
528
|
+
price: number;
|
|
529
|
+
weight: number;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
await Schema.create('products', (table: Blueprint): void => {
|
|
533
|
+
table.id();
|
|
534
|
+
table.string('name');
|
|
535
|
+
table.decimal('price');
|
|
536
|
+
table.decimal('weight', 3);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
await DB.table<Product>('products').insert({ name: 'Keyboard', price: 1999, weight: 1250 });
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
Writing a fractional value throws, because rounding it silently is how money goes missing:
|
|
543
|
+
|
|
544
|
+
```ts
|
|
545
|
+
await DB.table<Product>('products').insert({ name: 'Keyboard', price: 19.99 });
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
```
|
|
549
|
+
TypeError: A decimal column stores a whole number of its smallest unit, so [19.99] cannot be
|
|
550
|
+
written. Scale it first, as in Math.round(19.99 * 100).
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
The reason for the integer is that JavaScript has one number type and it is a float, so 0.1 + 0.2
|
|
554
|
+
is not 0.3. Every sum, every `orderBy` against an index and every `between` range would inherit
|
|
555
|
+
that error. An integer number of pence has none of it, and IndexedDB orders integers exactly.
|
|
556
|
+
|
|
557
|
+
Scale on the way in and format on the way out. The declared scale is metadata, so a formatter can
|
|
558
|
+
read it back from `Schema.getColumns` rather than hardcoding the same 100 in two places:
|
|
559
|
+
|
|
560
|
+
```ts
|
|
561
|
+
const columns: ColumnSchema[] = await Schema.getColumns('products');
|
|
562
|
+
const places: number = columns.find((column: ColumnSchema): boolean => column.name === 'price')!.places!;
|
|
563
|
+
|
|
564
|
+
const money = (minor: number): string => (minor / 10 ** places).toFixed(places);
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
A loose connection rounds instead of throwing, in keeping with every other coercion.
|
|
568
|
+
|
|
569
|
+
#### Enumerated columns are checked on the way in
|
|
570
|
+
|
|
571
|
+
`table.enum` stores a string and refuses anything outside the declared list:
|
|
572
|
+
|
|
573
|
+
```ts
|
|
574
|
+
await Schema.create('users', (table: Blueprint): void => {
|
|
575
|
+
table.id();
|
|
576
|
+
table.string('email').unique();
|
|
577
|
+
table.enum('role', ['admin', 'editor', 'member']).default('member');
|
|
578
|
+
table.enum('tier', ['free', 'paid']).nullable();
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
await DB.table<User>('users').insert({ email: 'john@example.com', role: 'owner' });
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
```
|
|
585
|
+
CheckConstraintViolationException: Column [role] of table [users] does not accept [owner].
|
|
586
|
+
It accepts [admin, editor, member].
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
The check runs on `insert`, `update` and `upsert`, and applies to a nullable column too: null is
|
|
590
|
+
accepted, an undeclared value is not. A loose connection writes null instead of throwing, so a
|
|
591
|
+
non-nullable enumerated column still reports the problem as
|
|
592
|
+
`NotNullConstraintViolationException`.
|
|
593
|
+
|
|
594
|
+
Declaring one over an empty list throws `SchemaException` at migration time, since nothing could
|
|
595
|
+
ever be written to it.
|
|
596
|
+
|
|
597
|
+
The list can come from a TypeScript string enum or an `as const` object instead, which keeps the
|
|
598
|
+
values in one place and lets the compiler check them at the call site:
|
|
599
|
+
|
|
600
|
+
```ts
|
|
601
|
+
enum Role {
|
|
602
|
+
Admin = 'admin',
|
|
603
|
+
Editor = 'editor',
|
|
604
|
+
Member = 'member',
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
await Schema.create('users', (table: Blueprint): void => {
|
|
608
|
+
table.enum('role', Role).default(Role.Member);
|
|
609
|
+
});
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
The column stores the enum's **values**, never its keys, so `Role.Admin` is written as `admin`. Two
|
|
613
|
+
members sharing a value collapse to one, since a duplicate would otherwise reach anything rendering
|
|
614
|
+
the column.
|
|
615
|
+
|
|
616
|
+
A **numeric** enum is refused. TypeScript compiles one to an object carrying a reverse mapping, so
|
|
617
|
+
its runtime values are both the names and the numbers, and there is no string form worth storing:
|
|
618
|
+
|
|
619
|
+
```ts
|
|
620
|
+
enum Status {
|
|
621
|
+
Draft,
|
|
622
|
+
Live,
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
table.enum('status', Status);
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
```
|
|
629
|
+
SchemaException: Column [status] of table [items] is enumerated over a numeric enum, which has no
|
|
630
|
+
string form to store. Give the enum string values, or use integer() instead.
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
The declared values are metadata, so a form can read them back rather than repeating the list:
|
|
634
|
+
|
|
635
|
+
```ts
|
|
636
|
+
const columns: ColumnSchema[] = await Schema.getColumns('users');
|
|
637
|
+
const roles: string[] = columns.find((column: ColumnSchema): boolean => column.name === 'role')!.values!;
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
TypeScript is not involved in the check. Narrow the column to a union on your row type if you want
|
|
641
|
+
the compiler to help as well:
|
|
642
|
+
|
|
643
|
+
```ts
|
|
644
|
+
interface User {
|
|
645
|
+
// ...
|
|
646
|
+
role: 'admin' | 'editor' | 'member';
|
|
647
|
+
}
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
#### Schema outside a migration
|
|
651
|
+
|
|
652
|
+
`Schema.create`, `Schema.table`, `Schema.drop`, `Schema.dropIfExists` and `Schema.rename` need the
|
|
653
|
+
version-change transaction, so they only run **inside a migration** and throw `SchemaException`
|
|
654
|
+
anywhere else. This is a real divergence from Laravel, where
|
|
655
|
+
[`Schema::create()`](https://laravel.com/docs/12.x/migrations#creating-tables) works from anywhere.
|
|
656
|
+
|
|
657
|
+
The read side works anywhere:
|
|
658
|
+
|
|
659
|
+
```ts
|
|
660
|
+
await Schema.hasTable('users');
|
|
661
|
+
await Schema.hasColumn('users', 'email');
|
|
662
|
+
await Schema.getTables();
|
|
663
|
+
await Schema.getColumns('users');
|
|
664
|
+
await Schema.getIndexes('users');
|
|
665
|
+
await Schema.connection('reporting').hasTable('reports');
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
> Modelled on Laravel's [Migrations: Tables](https://laravel.com/docs/12.x/migrations#tables).
|
|
669
|
+
> Column types are metadata this package enforces at write time, since IndexedDB stores whole objects
|
|
670
|
+
> and checks nothing itself.
|
|
671
|
+
|
|
672
|
+
### Querying
|
|
673
|
+
|
|
674
|
+
Every chained method returns the builder straight away. Only a terminal returns a promise.
|
|
675
|
+
|
|
676
|
+
```ts
|
|
677
|
+
const users: User[] = await DB.table<User>('users')
|
|
678
|
+
.where('age', '>=', 18)
|
|
679
|
+
.whereIn('role', ['admin', 'owner'])
|
|
680
|
+
.whereNotNull('email')
|
|
681
|
+
.orderBy('created_at', 'desc')
|
|
682
|
+
.limit(10)
|
|
683
|
+
.get();
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
#### Constraints
|
|
687
|
+
|
|
688
|
+
`where` takes four forms: a column and a value for an implicit `=`, a column with an explicit
|
|
689
|
+
operator, an object of column-value pairs, and a closure that opens a nested group.
|
|
690
|
+
|
|
691
|
+
```ts
|
|
692
|
+
DB.table<User>('users')
|
|
693
|
+
.where('name', 'John')
|
|
694
|
+
.where('age', '>=', 18)
|
|
695
|
+
.where({ role: 'admin', age: 30 })
|
|
696
|
+
.where((query: Builder<User>): void => {
|
|
697
|
+
query.where('age', 25).orWhere('name', 'Jane');
|
|
698
|
+
})
|
|
699
|
+
.orWhere('role', 'owner')
|
|
700
|
+
.whereNot('role', 'guest')
|
|
701
|
+
.whereIn('role', ['admin', 'owner'])
|
|
702
|
+
.whereNotIn('role', ['guest'])
|
|
703
|
+
.whereNull('age')
|
|
704
|
+
.whereNotNull('email')
|
|
705
|
+
.whereBetween('age', [18, 65])
|
|
706
|
+
.whereNotBetween('age', [0, 17])
|
|
707
|
+
.whereLike('name', 'Jo%')
|
|
708
|
+
.whereNotLike('name', 'Test%');
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
Every one of these has an `or` form too: `orWhere`, `orWhereIn`, `orWhereNotIn`, `orWhereNull`,
|
|
712
|
+
`orWhereNotNull`, `orWhereBetween`, `orWhereNotBetween`, `orWhereLike`, `orWhereNotLike` and
|
|
713
|
+
`orWhereColumn`, so a disjunction no longer needs a nested closure.
|
|
714
|
+
|
|
715
|
+
Operators: `=`, `==`, `===`, `!=`, `<>`, `!==`, `<`, `>`, `<=`, `>=`, `like`, `not like`. `==` is
|
|
716
|
+
loose and `===` is strict.
|
|
717
|
+
|
|
718
|
+
Constraints follow SQL's three-valued logic: a comparison against `null` is unknown, and negating
|
|
719
|
+
unknown leaves it unknown. So a record whose `age` is `null` satisfies neither
|
|
720
|
+
`whereBetween('age', [18, 65])` nor `whereNotBetween('age', [18, 65])`. Only `whereNull` matches it.
|
|
721
|
+
|
|
722
|
+
`like` and `not like` take SQL's wildcards, where `%` matches any run of characters and `_` matches
|
|
723
|
+
exactly one. Both are case insensitive, both cross newlines, and a backslash escapes a wildcard so
|
|
724
|
+
`'100\\%'` matches a literal percent. Everything else in the pattern is a literal, so a pattern full
|
|
725
|
+
of regular expression syntax matches only itself.
|
|
726
|
+
|
|
727
|
+
The pattern is matched by a direct scan rather than a regular expression, which matters if your
|
|
728
|
+
patterns come from a search box. A regular expression compiled from `%%%%%` backtracks over every
|
|
729
|
+
way of splitting the value between the wildcards, and that is exponential in their number. The scan
|
|
730
|
+
walks the value once per wildcard instead, so a hostile or careless pattern costs time in proportion
|
|
731
|
+
to its length rather than freezing the tab.
|
|
732
|
+
|
|
733
|
+
#### Shaping
|
|
734
|
+
|
|
735
|
+
```ts
|
|
736
|
+
DB.table<User>('users')
|
|
737
|
+
.select('name', 'email')
|
|
738
|
+
.distinct()
|
|
739
|
+
.orderBy('name')
|
|
740
|
+
.latest('created_at')
|
|
741
|
+
.oldest('created_at')
|
|
742
|
+
.limit(10)
|
|
743
|
+
.offset(20)
|
|
744
|
+
.forPage(2, 15)
|
|
745
|
+
.when(role, (query: Builder<User>, value: unknown): void => query.where('role', value))
|
|
746
|
+
.tap((query: Builder<User>): void => query.where('active', true))
|
|
747
|
+
.clone()
|
|
748
|
+
.dump();
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
`select()` projects in memory after the fetch. IndexedDB always returns whole records, so it shapes
|
|
752
|
+
the result rather than saving any work.
|
|
753
|
+
|
|
754
|
+
#### Terminals
|
|
755
|
+
|
|
756
|
+
```ts
|
|
757
|
+
await DB.table<User>('users').get();
|
|
758
|
+
await DB.table<User>('users').first();
|
|
759
|
+
await DB.table<User>('users').firstOrFail();
|
|
760
|
+
await DB.table<User>('users').find(1);
|
|
761
|
+
await DB.table<User>('users').findOrFail(1);
|
|
762
|
+
await DB.table<User>('users').value('email');
|
|
763
|
+
await DB.table<User>('users').pluck('email');
|
|
764
|
+
await DB.table<User>('users').pluck('email', 'name');
|
|
765
|
+
await DB.table<User>('users').exists();
|
|
766
|
+
await DB.table<User>('users').doesntExist();
|
|
767
|
+
await DB.table<User>('users').count();
|
|
768
|
+
await DB.table<User>('users').sum('age');
|
|
769
|
+
await DB.table<User>('users').avg('age');
|
|
770
|
+
await DB.table<User>('users').min('age');
|
|
771
|
+
await DB.table<User>('users').max('age');
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
| Terminal | Resolves to |
|
|
775
|
+
| --- | --- |
|
|
776
|
+
| `get()` | `T[]` |
|
|
777
|
+
| `first()` | `T` or `null` |
|
|
778
|
+
| `firstOrFail()` | `T`, or throws `RecordsNotFoundException` |
|
|
779
|
+
| `find(key)` | `T` or `null`, by point lookup on the key path |
|
|
780
|
+
| `findOrFail(key)` | `T`, or throws `RecordsNotFoundException` |
|
|
781
|
+
| `value(column)` | The column of the first matching record, or `null` |
|
|
782
|
+
| `pluck(column)` | `V[]` in result order |
|
|
783
|
+
| `pluck(column, key)` | `Record<string, V>`, keyed by a second column |
|
|
784
|
+
| `exists()` / `doesntExist()` | `boolean` |
|
|
785
|
+
| `count()` | `number` |
|
|
786
|
+
| `sum(column)` | `number` |
|
|
787
|
+
| `avg(column)` / `min(column)` / `max(column)` | `number` or `null` when nothing matched |
|
|
788
|
+
| `sole()` | `T`, or throws `RecordsNotFoundException` / `MultipleRecordsFoundException` |
|
|
789
|
+
| `paginate(page?, perPage?)` | `{ data, total, perPage, currentPage, lastPage }` |
|
|
790
|
+
|
|
791
|
+
`min` and `max` read the answer straight off the index when the column has one and the query is
|
|
792
|
+
unconstrained, so they cost one cursor rather than a full scan.
|
|
793
|
+
|
|
794
|
+
`paginate` gives you the totals a pager needs, which `forPage` cannot, and counts what the query
|
|
795
|
+
matches rather than what the page returns:
|
|
796
|
+
|
|
797
|
+
```ts
|
|
798
|
+
const page = await DB.table<User>('users').orderBy('name').paginate(2, 15);
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
```
|
|
802
|
+
{ data: [ ... ], total: 132, perPage: 15, currentPage: 2, lastPage: 9 }
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
`chunk` and `each` walk the result a page at a time, and stop early when the callback returns
|
|
806
|
+
`false`:
|
|
807
|
+
|
|
808
|
+
```ts
|
|
809
|
+
await DB.table<User>('users').orderBy('id').chunk(100, async (records: User[], page: number): Promise<void> => {
|
|
810
|
+
await send(records);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
await DB.table<User>('users').each((user: User, index: number): void => {
|
|
814
|
+
console.log(index, user.name);
|
|
815
|
+
});
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
#### Writes
|
|
819
|
+
|
|
820
|
+
```ts
|
|
821
|
+
await DB.table<User>('users').insert({ name: 'John', email: 'john@example.com' });
|
|
822
|
+
await DB.table<User>('users').insert([{ /* ... */ }, { /* ... */ }]);
|
|
823
|
+
await DB.table<User>('users').insertGetId({ name: 'John', email: 'john@example.com' });
|
|
824
|
+
|
|
825
|
+
await DB.table<User>('users').where('role', 'member').update({ role: 'owner' });
|
|
826
|
+
await DB.table<User>('users').updateOrInsert({ email: 'john@example.com' }, { name: 'John' });
|
|
827
|
+
|
|
828
|
+
await DB.table<User>('users').upsert([{ email: 'john@example.com', name: 'John' }], 'email');
|
|
829
|
+
await DB.table<User>('users').upsert([{ /* ... */ }], 'email', ['name']);
|
|
830
|
+
|
|
831
|
+
await DB.table<User>('users').where('id', 1).increment('visits');
|
|
832
|
+
await DB.table<User>('users').where('id', 1).decrement('credits', 5);
|
|
833
|
+
|
|
834
|
+
await DB.table<User>('users').where('role', 'guest').delete();
|
|
835
|
+
await DB.table<User>('users').truncate();
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
On insert, the connection applies declared defaults, fills `created_at`/`updated_at` when the table
|
|
839
|
+
declares `timestamps()`, coerces declared column types, and throws
|
|
840
|
+
`NotNullConstraintViolationException` for an absent non-nullable column. On update, only
|
|
841
|
+
`updated_at` is touched.
|
|
842
|
+
|
|
843
|
+
A violated unique index surfaces as `UniqueConstraintViolationException` naming the table and the
|
|
844
|
+
index, rather than a bare `DOMException`.
|
|
845
|
+
|
|
846
|
+
`upsert` requires its conflict target to be the key path or a unique index, because IndexedDB cannot
|
|
847
|
+
enforce anything else. Any other column throws `SchemaException`.
|
|
848
|
+
|
|
849
|
+
The key path may not be updated, so `update`, `upsert` and `increment` all refuse it.
|
|
850
|
+
|
|
851
|
+
`update` and `delete` honour `limit` and `offset` in the order the plan scans, which is index order
|
|
852
|
+
when an index drives the query and key order otherwise. Pair them with an indexed `orderBy` if you
|
|
853
|
+
need a defined order.
|
|
854
|
+
|
|
855
|
+
> Modelled on Laravel's [Database: Query Builder](https://laravel.com/docs/12.x/queries). The method
|
|
856
|
+
> names and their semantics match, and every terminal is asynchronous because IndexedDB is.
|
|
857
|
+
|
|
858
|
+
### Joins
|
|
859
|
+
|
|
860
|
+
```ts
|
|
861
|
+
const rows = await DB.table('users')
|
|
862
|
+
.join('posts', 'users.id', '=', 'posts.user_id')
|
|
863
|
+
.where('users.name', 'John')
|
|
864
|
+
.orderBy('posts.created_at', 'desc')
|
|
865
|
+
.get();
|
|
866
|
+
```
|
|
867
|
+
|
|
868
|
+
`join`, `leftJoin`, `rightJoin` and `crossJoin` are available. The operator may be left implicit:
|
|
869
|
+
|
|
870
|
+
```ts
|
|
871
|
+
await DB.table('users').join('posts', 'users.id', 'posts.user_id').get();
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
For more than one condition, pass a closure:
|
|
875
|
+
|
|
876
|
+
```ts
|
|
877
|
+
await DB.table('users')
|
|
878
|
+
.join('posts', (join: Join): void => {
|
|
879
|
+
join.on('users.id', '=', 'posts.user_id').on('posts.published', '=', 'users.active');
|
|
880
|
+
})
|
|
881
|
+
.get();
|
|
882
|
+
```
|
|
883
|
+
|
|
884
|
+
#### The row is flat, and collisions clobber
|
|
885
|
+
|
|
886
|
+
A joined row is flattened the way SQL hands it back, so a column present on both tables keeps the
|
|
887
|
+
value from the table joined later:
|
|
888
|
+
|
|
889
|
+
```ts
|
|
890
|
+
const row = await DB.table('users').join('posts', 'users.id', '=', 'posts.user_id').first();
|
|
891
|
+
```
|
|
892
|
+
|
|
893
|
+
```
|
|
894
|
+
{ id: 3, name: 'John', user_id: 2, title: 'Hello' }
|
|
895
|
+
```
|
|
896
|
+
|
|
897
|
+
That `id` is `posts.id`. Since `table.timestamps()` gives every table a `created_at` and an
|
|
898
|
+
`updated_at`, collisions are the norm rather than the exception on a join. `select` with an alias is
|
|
899
|
+
how both sides survive:
|
|
900
|
+
|
|
901
|
+
```ts
|
|
902
|
+
const rows = await DB.table('users')
|
|
903
|
+
.join('posts', 'users.id', '=', 'posts.user_id')
|
|
904
|
+
.select('users.id as user_id', 'posts.id as post_id', 'posts.title')
|
|
905
|
+
.get();
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
```
|
|
909
|
+
[
|
|
910
|
+
{ user_id: 1, post_id: 1, title: 'Hello' }
|
|
911
|
+
]
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
`as` works on any query, joined or not.
|
|
915
|
+
|
|
916
|
+
#### Ambiguous columns are rejected, not guessed
|
|
917
|
+
|
|
918
|
+
Once a join is in play, a bare column name that two tables share cannot be resolved, so it throws
|
|
919
|
+
`SchemaException` naming the tables rather than silently picking one:
|
|
920
|
+
|
|
921
|
+
```ts
|
|
922
|
+
await DB.table('users').join('posts', 'users.id', '=', 'posts.user_id').where('id', 1).get();
|
|
923
|
+
```
|
|
924
|
+
|
|
925
|
+
```
|
|
926
|
+
SchemaException: Column [id] is ambiguous across tables [users, posts]. Qualify it, as in [users.id].
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
A bare column only one table has still resolves, so `where('title', 'Hello')` is fine. Naming a
|
|
930
|
+
table the query does not join, or a column no table has, throws in the same way.
|
|
931
|
+
|
|
932
|
+
#### A left join nulls the missing side
|
|
933
|
+
|
|
934
|
+
Every column of the unmatched table comes back `null`, as in SQL, which makes the usual
|
|
935
|
+
find-the-orphans query work:
|
|
936
|
+
|
|
937
|
+
```ts
|
|
938
|
+
await DB.table('users')
|
|
939
|
+
.leftJoin('posts', 'users.id', '=', 'posts.user_id')
|
|
940
|
+
.whereNull('posts.id')
|
|
941
|
+
.get();
|
|
942
|
+
```
|
|
943
|
+
|
|
944
|
+
#### What joins cost, and what they do not support
|
|
945
|
+
|
|
946
|
+
IndexedDB has no join, so every one is performed in memory. A single equality condition uses a hash
|
|
947
|
+
join, and anything else falls back to a nested loop. The `where` clauses still narrow each table through
|
|
948
|
+
the planner, but the join itself reads both sides in full, so memory is proportional to the tables
|
|
949
|
+
involved. That is fine at the data volumes a browser holds, and worth knowing before joining two
|
|
950
|
+
large tables.
|
|
951
|
+
|
|
952
|
+
Joined queries are **read-only**. `update`, `delete`, `insert` and `upsert` are not supported through
|
|
953
|
+
a join. `orderBy` on a joined query always sorts in memory, since the row is synthesised and no index
|
|
954
|
+
covers it, and `chunk` slices the materialised result rather than walking keys.
|
|
955
|
+
|
|
956
|
+
`whereColumn` compares two columns of the same row, and is available on any query:
|
|
957
|
+
|
|
958
|
+
```ts
|
|
959
|
+
await DB.table('users').whereColumn('updated_at', '>', 'created_at').get();
|
|
960
|
+
```
|
|
961
|
+
|
|
962
|
+
> Modelled on Laravel's [Query Builder: Joins](https://laravel.com/docs/12.x/queries#joins). Rows
|
|
963
|
+
> stay flat as they do in Laravel, and the join itself runs in memory because IndexedDB has none.
|
|
964
|
+
|
|
965
|
+
### Grouping
|
|
966
|
+
|
|
967
|
+
Laravel spells aggregates as [raw SQL](https://laravel.com/docs/12.x/queries#raw-methods), which has
|
|
968
|
+
nothing to hand a string to here. So the aggregates are named in an object instead, and the alias
|
|
969
|
+
becomes the key:
|
|
970
|
+
|
|
971
|
+
```ts
|
|
972
|
+
const rows = await DB.table<User>('users')
|
|
973
|
+
.where('active', true)
|
|
974
|
+
.groupBy('role')
|
|
975
|
+
.aggregate({
|
|
976
|
+
total : { count: '*' },
|
|
977
|
+
oldest: { max: 'age' },
|
|
978
|
+
})
|
|
979
|
+
.having('total', '>', 5)
|
|
980
|
+
.orderBy('total', 'desc')
|
|
981
|
+
.get();
|
|
982
|
+
```
|
|
983
|
+
|
|
984
|
+
Resolves to one row per group, carrying the grouped columns and the aggregates:
|
|
985
|
+
|
|
986
|
+
```
|
|
987
|
+
[
|
|
988
|
+
{ role: 'member', total: 12, oldest: 61 },
|
|
989
|
+
{ role: 'admin', total: 7, oldest: 44 }
|
|
990
|
+
]
|
|
991
|
+
```
|
|
992
|
+
|
|
993
|
+
Because the alias is an object key rather than a string inside an expression, the result type is
|
|
994
|
+
inferred rather than cast. That row is typed `{ role: string; total: number; oldest: number | null }`,
|
|
995
|
+
and reading a column you did not group or aggregate is a compile error.
|
|
996
|
+
|
|
997
|
+
| Aggregate | Meaning |
|
|
998
|
+
| --- | --- |
|
|
999
|
+
| `{ count: '*' }` | The number of records in the group, always a `number` |
|
|
1000
|
+
| `{ count: 'column' }` | The number of records whose column is not null |
|
|
1001
|
+
| `{ sum: 'column' }` | The total, `0` for a group with no values |
|
|
1002
|
+
| `{ avg: 'column' }` | The mean, `null` for a group with no values |
|
|
1003
|
+
| `{ min: 'column' }` / `{ max: 'column' }` | The extreme, `null` for a group with no values |
|
|
1004
|
+
|
|
1005
|
+
Group by several columns by passing several names:
|
|
1006
|
+
|
|
1007
|
+
```ts
|
|
1008
|
+
await DB.table<User>('users').groupBy('team', 'role').aggregate({ total: { count: '*' } }).get();
|
|
1009
|
+
```
|
|
1010
|
+
|
|
1011
|
+
`aggregate()` is optional. Grouping with nothing aggregated gives you one row per distinct
|
|
1012
|
+
combination, which is what `distinct()` does over the same columns.
|
|
1013
|
+
|
|
1014
|
+
#### having, ordering and paging apply to groups
|
|
1015
|
+
|
|
1016
|
+
`having` and `orHaving` filter the grouped rows, and take the same operators as `where`. They can
|
|
1017
|
+
name either a grouped column or an aggregate alias, since by then both are just columns on the row.
|
|
1018
|
+
|
|
1019
|
+
`orderBy`, `limit` and `offset` on a grouping apply to **groups**, not records. Any ordering or
|
|
1020
|
+
paging set before `groupBy` is dropped, because paging records before grouping them is almost never
|
|
1021
|
+
what you meant:
|
|
1022
|
+
|
|
1023
|
+
```ts
|
|
1024
|
+
await DB.table<User>('users')
|
|
1025
|
+
.groupBy('role')
|
|
1026
|
+
.aggregate({ total: { count: '*' } })
|
|
1027
|
+
.orderBy('total', 'desc')
|
|
1028
|
+
.limit(3)
|
|
1029
|
+
.get();
|
|
1030
|
+
```
|
|
1031
|
+
|
|
1032
|
+
Grouping happens in memory after the records are fetched, so the planner still applies to the
|
|
1033
|
+
`where` clauses that select them, and a grouped query reports the plan of that underlying fetch.
|
|
1034
|
+
|
|
1035
|
+
> Modelled on Laravel's [Query Builder: Grouping](https://laravel.com/docs/12.x/queries#groupby-having),
|
|
1036
|
+
> with the aggregates named in a typed object instead of raw SQL.
|
|
1037
|
+
|
|
1038
|
+
### Query plans
|
|
1039
|
+
|
|
1040
|
+
The builder does not fetch everything and filter in memory. It compiles your constraints into an
|
|
1041
|
+
IndexedDB key range over one index, plus a residual predicate applied while cursoring:
|
|
1042
|
+
|
|
1043
|
+
```ts
|
|
1044
|
+
await DB.table<User>('users').where('id', 1).explain();
|
|
1045
|
+
await DB.table<User>('users').where('email', 'a@b.c').explain();
|
|
1046
|
+
await DB.table<User>('users').where('role', 'admin').explain();
|
|
1047
|
+
```
|
|
1048
|
+
|
|
1049
|
+
Each resolves to a description of the plan chosen:
|
|
1050
|
+
|
|
1051
|
+
```
|
|
1052
|
+
'key'
|
|
1053
|
+
'index:users_email_unique'
|
|
1054
|
+
'scan'
|
|
1055
|
+
```
|
|
1056
|
+
|
|
1057
|
+
- One index only. IndexedDB has no index intersection, so the planner picks the most selective
|
|
1058
|
+
candidate: the key path, then a unique index, then a plain index.
|
|
1059
|
+
- `orderBy` on a single indexed, **non-nullable** column cursors that index, which lets `limit`
|
|
1060
|
+
short-circuit the scan. Nullable columns are excluded because an IndexedDB index drops records
|
|
1061
|
+
with no value for its key path, which would silently lose rows.
|
|
1062
|
+
- When a range and an order want different indexes, the range wins and the sort happens in memory.
|
|
1063
|
+
- Any top-level `orWhere` forces a full scan.
|
|
1064
|
+
- `count()` with no residual constraints uses `count()` on the store or index, reading no records.
|
|
1065
|
+
|
|
1066
|
+
### Transactions
|
|
1067
|
+
|
|
1068
|
+
```ts
|
|
1069
|
+
await DB.transaction(async (transaction: Transaction): Promise<void> => {
|
|
1070
|
+
const id: IDBValidKey = await transaction.table<User>('users').insertGetId({ name: 'John' });
|
|
1071
|
+
|
|
1072
|
+
await transaction.table('posts').insert({ user_id: id, title: 'Hello' });
|
|
1073
|
+
});
|
|
1074
|
+
```
|
|
1075
|
+
|
|
1076
|
+
Throwing inside the callback aborts the transaction and rethrows your error.
|
|
1077
|
+
|
|
1078
|
+
By default the transaction covers every table, since the callback's reach is unknowable up front.
|
|
1079
|
+
Narrow it when you care:
|
|
1080
|
+
|
|
1081
|
+
```ts
|
|
1082
|
+
await DB.transaction(async (transaction: Transaction): Promise<void> => {
|
|
1083
|
+
await transaction.table<User>('users').insert({ name: 'John' });
|
|
1084
|
+
}, { tables: ['users'] });
|
|
1085
|
+
```
|
|
1086
|
+
|
|
1087
|
+
A nested `DB.transaction` **joins** the one already running. IndexedDB has no savepoints, so there is
|
|
1088
|
+
no partial rollback.
|
|
1089
|
+
|
|
1090
|
+
There is no `beginTransaction()` / `commit()` / `rollBack()`. A manually held IndexedDB transaction
|
|
1091
|
+
commits behind your back the first time you await anything outside it, so offering that API would be
|
|
1092
|
+
offering a trap. The same rule as migrations applies here: the callback may only await operations
|
|
1093
|
+
from this package.
|
|
1094
|
+
|
|
1095
|
+
> Modelled on Laravel's [Database: Transactions](https://laravel.com/docs/12.x/database#database-transactions).
|
|
1096
|
+
> The tables have to be declared up front, because an IndexedDB transaction fixes its scope when it
|
|
1097
|
+
> opens.
|
|
1098
|
+
|
|
1099
|
+
### Events and the query log
|
|
1100
|
+
|
|
1101
|
+
```ts
|
|
1102
|
+
DB.onQueryExecuted((event: QueryExecuted): void => {
|
|
1103
|
+
console.log(event.plan, event.duration, event.records);
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
DB.listen('migration-started', (event: MigrationStarted): void => console.log(event.migration));
|
|
1107
|
+
DB.listen('query', listener, { once: true });
|
|
1108
|
+
DB.forget('query', listener);
|
|
1109
|
+
```
|
|
1110
|
+
|
|
1111
|
+
Available events: `query`, `transaction-beginning`, `transaction-committed`,
|
|
1112
|
+
`transaction-rolled-back`, `migrations-started`, `migration-started`, `migration-ended`,
|
|
1113
|
+
`migrations-ended`, `no-pending-migrations`, `seeding-started`, `seeder-started`, `seeder-ended`,
|
|
1114
|
+
`seeding-ended`, `database-blocked`.
|
|
1115
|
+
|
|
1116
|
+
A connection with no seeders announces nothing, so `seeding-started` firing always means at least
|
|
1117
|
+
one seeder is about to run.
|
|
1118
|
+
|
|
1119
|
+
Listeners are **persistent by default**, with an opt-in `{ once: true }`. This is a deliberate
|
|
1120
|
+
departure from `@bjnstnkvc/local-storage`, where every listener fires exactly once.
|
|
1121
|
+
|
|
1122
|
+
```ts
|
|
1123
|
+
DB.enableQueryLog();
|
|
1124
|
+
|
|
1125
|
+
await DB.table<User>('users').where('role', 'admin').get();
|
|
1126
|
+
|
|
1127
|
+
DB.getQueryLog();
|
|
1128
|
+
```
|
|
1129
|
+
|
|
1130
|
+
Resolves to one entry per query that ran while the log was enabled:
|
|
1131
|
+
|
|
1132
|
+
```
|
|
1133
|
+
[
|
|
1134
|
+
{ connection: 'app', table: 'users', plan: 'scan', duration: 2.41, records: 7 }
|
|
1135
|
+
]
|
|
1136
|
+
```
|
|
1137
|
+
|
|
1138
|
+
Because `plan` is on every entry, the log is enough to spot a query that scans a whole table.
|
|
1139
|
+
|
|
1140
|
+
Durations come from `performance.now()`, so they are sub-millisecond. `disableQueryLog()` stops
|
|
1141
|
+
recording but keeps what was already recorded, and the log survives client-side navigation, so only
|
|
1142
|
+
`flushQueryLog()` empties it.
|
|
1143
|
+
|
|
1144
|
+
```ts
|
|
1145
|
+
DB.flushQueryLog();
|
|
1146
|
+
DB.disableQueryLog();
|
|
1147
|
+
```
|
|
1148
|
+
|
|
1149
|
+
`DB.logging()` then returns `false`, and `DB.getQueryLog()` an empty array.
|
|
1150
|
+
|
|
1151
|
+
> Modelled on Laravel's [Database: Listening for Query Events](https://laravel.com/docs/12.x/database#listening-for-query-events),
|
|
1152
|
+
> with the same enable, get and flush surface, dispatched as a browser event.
|
|
1153
|
+
|
|
1154
|
+
### Multiple tabs
|
|
1155
|
+
|
|
1156
|
+
IndexedDB is shared across tabs, which produces two situations worth handling:
|
|
1157
|
+
|
|
1158
|
+
- Another tab holds an older version open, blocking an upgrade. The connection emits
|
|
1159
|
+
`database-blocked` and rejects with `DatabaseBlockedException`, so you can ask the user to close
|
|
1160
|
+
the other tabs.
|
|
1161
|
+
- Another tab upgrades the database. The connection closes its own handle so it does not block that
|
|
1162
|
+
upgrade. If the other tab is running newer code with more migrations, this tab can no longer open
|
|
1163
|
+
the database and reports `MigrationMismatchException`, so reload the page.
|
|
1164
|
+
|
|
1165
|
+
### Connections
|
|
1166
|
+
|
|
1167
|
+
```ts
|
|
1168
|
+
DB.connection();
|
|
1169
|
+
DB.connection('reporting');
|
|
1170
|
+
DB.disconnect('app');
|
|
1171
|
+
DB.purge('app');
|
|
1172
|
+
```
|
|
1173
|
+
|
|
1174
|
+
| Call | Effect |
|
|
1175
|
+
| --- | --- |
|
|
1176
|
+
| `connection()` | The default connection |
|
|
1177
|
+
| `connection(name)` | A named connection, cached after the first resolve |
|
|
1178
|
+
| `disconnect(name)` | Close the handle, leaving the connection registered so the next query reopens it |
|
|
1179
|
+
| `purge(name)` | Close it and drop it, so the next resolve rebuilds it from configuration |
|
|
1180
|
+
|
|
1181
|
+
> Modelled on Laravel's [Database: Multiple Connections](https://laravel.com/docs/12.x/database#using-multiple-database-connections),
|
|
1182
|
+
> resolved by name and cached, with one IndexedDB database behind each.
|
|
1183
|
+
|
|
1184
|
+
### Storage quota
|
|
1185
|
+
|
|
1186
|
+
A browser gives each origin a finite storage budget, and a write that exceeds it fails. This is the
|
|
1187
|
+
likeliest failure a client-side database hits in production, and it has no equivalent in a server
|
|
1188
|
+
database, so it is worth handling explicitly.
|
|
1189
|
+
|
|
1190
|
+
The platform reports it as a bare `DOMException` whose message says nothing about the fix. This
|
|
1191
|
+
package names it instead:
|
|
1192
|
+
|
|
1193
|
+
```ts
|
|
1194
|
+
try {
|
|
1195
|
+
await DB.table<User>('users').insert(records);
|
|
1196
|
+
} catch (error) {
|
|
1197
|
+
if (error instanceof QuotaExceededException) {
|
|
1198
|
+
const { usage, quota } = await DB.estimate();
|
|
1199
|
+
|
|
1200
|
+
console.warn(`Using ${usage} of ${quota} bytes.`);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
```
|
|
1204
|
+
|
|
1205
|
+
`QuotaExceededException` is raised from any operation the quota stops, including one that aborts a
|
|
1206
|
+
transaction, so a single `catch` around a transaction covers everything inside it.
|
|
1207
|
+
|
|
1208
|
+
#### Asking not to be evicted
|
|
1209
|
+
|
|
1210
|
+
Browsers evict an origin's IndexedDB under storage pressure. If that happens, your migrations replay
|
|
1211
|
+
against an empty database on the next boot and the data is simply gone. `DB.persist()` asks the
|
|
1212
|
+
browser to exempt this origin:
|
|
1213
|
+
|
|
1214
|
+
```ts
|
|
1215
|
+
await DB.persist(); // true when the browser agreed
|
|
1216
|
+
await DB.persisted(); // true when this origin is already exempt
|
|
1217
|
+
```
|
|
1218
|
+
|
|
1219
|
+
Whether the request is granted is up to the browser and depends on things like whether the site is
|
|
1220
|
+
installed or has engagement history. Any app storing data it cares about should ask at boot.
|
|
1221
|
+
|
|
1222
|
+
`DB.estimate()` wraps `navigator.storage.estimate()`, and reports `{}` where the Storage Manager is
|
|
1223
|
+
not available rather than throwing.
|
|
1224
|
+
|
|
1225
|
+
### Reserved tables
|
|
1226
|
+
|
|
1227
|
+
`migrations` and `schema` are reserved. A migration that tries to create either throws
|
|
1228
|
+
`ReservedTableException`. Column metadata is read from `schema` once per connection and cached in
|
|
1229
|
+
memory, so writes inside a narrowed transaction still get their defaults.
|
|
1230
|
+
|
|
1231
|
+
### Exceptions
|
|
1232
|
+
|
|
1233
|
+
Every exception extends `Error` and sets its own `name`, so `instanceof` and the stack both read
|
|
1234
|
+
true. All of them are exported from the package root.
|
|
1235
|
+
|
|
1236
|
+
| Exception | Thrown when |
|
|
1237
|
+
| --- | --- |
|
|
1238
|
+
| `CheckConstraintViolationException` | A write gives an enumerated column a value it does not accept |
|
|
1239
|
+
| `ConnectionNotConfiguredException` | A connection is resolved under a name `DB.configure` never declared |
|
|
1240
|
+
| `DatabaseBlockedException` | Another tab holds the database open at an older version, so the upgrade cannot start |
|
|
1241
|
+
| `MigrationMismatchException` | The recorded migration list is not a prefix of the registered one, so one was removed, renamed or reordered |
|
|
1242
|
+
| `MigrationTransactionClosedException` | A migration awaited something outside this package, letting the versionchange transaction commit early |
|
|
1243
|
+
| `MultipleRecordsFoundException` | `sole()` matched more than one record |
|
|
1244
|
+
| `NotNullConstraintViolationException` | A non-nullable column is written as null, or is absent with no default |
|
|
1245
|
+
| `QuotaExceededException` | The origin's storage quota stopped the operation |
|
|
1246
|
+
| `RecordsNotFoundException` | `firstOrFail()`, `sole()` or `findOrFail()` matched nothing |
|
|
1247
|
+
| `ReservedTableException` | A migration tries to create `migrations` or `schema` |
|
|
1248
|
+
| `SchemaException` | A schema or query call the shape of the database cannot support |
|
|
1249
|
+
| `TableNotFoundException` | A query or schema read names a table the database does not have |
|
|
1250
|
+
| `UniqueConstraintViolationException` | A write collides with a unique index, named in the message |
|
|
1251
|
+
|
|
1252
|
+
`SchemaException` is the broad one, so here is every case that raises it:
|
|
1253
|
+
|
|
1254
|
+
- `Schema.create`, `table`, `drop`, `dropIfExists` or `rename` called outside a migration
|
|
1255
|
+
- `Schema.create` on a table that already exists, or `Schema.rename` onto a name already taken
|
|
1256
|
+
- dropping or renaming the key path, which IndexedDB fixes when the store is created
|
|
1257
|
+
- declaring the same column, or the same index name, twice on one blueprint, or adding a column
|
|
1258
|
+
that the table already has
|
|
1259
|
+
- declaring more than one primary column, including `.primary()` alongside `table.id()`
|
|
1260
|
+
- dropping or renaming a column, or dropping an index, that does not exist on the table
|
|
1261
|
+
- declaring an enumerated column over an empty list of values
|
|
1262
|
+
- `upsert` whose conflict target is neither the key path nor a unique index
|
|
1263
|
+
- `update`, `upsert`, `increment` or `decrement` touching the key path
|
|
1264
|
+
- a qualified column naming a table the query does not join
|
|
1265
|
+
- an unqualified column that is ambiguous across the tables a join reads
|
|
1266
|
+
- a column that exists on none of the tables the query reads
|
|
1267
|
+
- reading a table inside `DB.transaction` that the transaction did not declare
|
|
1268
|
+
|
|
1269
|
+
## Testing
|
|
1270
|
+
|
|
1271
|
+
IndexedDB does not exist in Node, so point your test setup at
|
|
1272
|
+
[`fake-indexeddb`](https://www.npmjs.com/package/fake-indexeddb):
|
|
1273
|
+
|
|
1274
|
+
In `tests/setup.ts`:
|
|
1275
|
+
|
|
1276
|
+
```ts
|
|
1277
|
+
import 'fake-indexeddb/auto';
|
|
1278
|
+
```
|
|
1279
|
+
|
|
1280
|
+
And in `vitest.config.ts`:
|
|
1281
|
+
|
|
1282
|
+
```ts
|
|
1283
|
+
export default defineConfig({
|
|
1284
|
+
test: {
|
|
1285
|
+
setupFiles: ['./tests/setup.ts'],
|
|
1286
|
+
},
|
|
1287
|
+
});
|
|
1288
|
+
```
|
|
1289
|
+
|
|
1290
|
+
Give each test file its own database name so the suites do not share state.
|