@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/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.