diff --git a/.gitignore b/.gitignore
index e9dc2fa..eabe4f2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,10 +5,13 @@ node_modules
out
dist
*.tgz
+*.sql
+
# code coverage
coverage
*.lcov
+*table.json
# logs
logs
@@ -32,3 +35,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config
.DS_Store
+
+# Documentation lives in its own repository (ElectronSz/stabilize-docs)
+stabilize-docs/
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..c73ea03
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "stabilize-cli"]
+ path = stabilize-cli
+ url = https://github.com/ElectronSz/stabilize-cli.git
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 247e927..946fb5a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,54 @@ All notable changes to this project will be documented in this file.
- Further features and improvements coming soon.
+## [2.2.1] - 2026-09-15
+
+Documentation-only release. No library code changed from 2.2.0.
+
+### Changed
+
+- **`README.md` now documents SQL Server.** 2.2.0 added Microsoft SQL Server as a first-class dialect but the README still described only PostgreSQL, MySQL and SQLite — there was not a single mention of SQL Server anywhere in it. Added a SQL Server badge, corrected the intro and the feature list, and documented that `DBType.MSSQL` uses the `mssql` v12 driver with T-SQL-specific query generation.
+- Corrected the CLI badge (was pinned at 2.1.0) and pointed the license badge at `stabilize-orm`; it previously linked to the `stabilize-cli` repository.
+
+## [2.2.0] - 2026-09-14
+
+### Added
+
+- **Microsoft SQL Server support** - SQL Server is now a first-class dialect alongside SQLite, MySQL and PostgreSQL. `DBType.MSSQL` selects the `mssql` driver (v12), and the placeholder rewriter maps the library's internal `?` placeholders to `@param0`-style named parameters automatically.
+ - T-SQL-specific query generation: `OUTPUT INSERTED.*` for reads-after-write, `OFFSET … FETCH` pagination with an `ORDER BY (SELECT NULL)` fallback when no ordering is supplied, and `MERGE INTO … USING … WHEN MATCHED / WHEN NOT MATCHED … OUTPUT INSERTED.*` for upserts.
+ - Schema generation uses `IF OBJECT_ID(…) IS NULL CREATE TABLE` in place of `CREATE TABLE IF NOT EXISTS`, and a `sys.indexes` probe in place of `CREATE INDEX IF NOT EXISTS`, neither of which SQL Server has.
+ - `poolStats()` reports borrowed / available / size for SQL Server connection pools.
+ - Connection strings use a comma between host and port: `Server=host,port;User Id=sa;Password=…;Database=…;TrustServerCertificate=true`.
+- **Model Relationships** - `OneToOne`, `ManyToOne`, `OneToMany` and `ManyToMany` are declared in the model configuration and eager-loaded either by passing `relations` to a finder or by chaining `.withRelations()` onto the query builder. Relations are resolved in batched follow-up queries rather than per-row.
+- **Many-to-many link management** - `attach()`, `detach()` and `sync()` edit a join table directly, without loading either side of the relationship into memory.
+- **`validateAll()`** - runs every validator on an entity and returns all failures at once, instead of throwing on the first one. `validate()` keeps its fail-fast behaviour.
+- **`findOrFail()` / `firstOrFail()`** - throw a `StabilizeError` with code `NOT_FOUND_ERROR` rather than returning `null`.
+- **Raw clause builders** - `.orderByRaw()`, `.groupByRaw()` and `.havingRaw()` accept expressions that are not bare column names.
+- **`auto-migrate.ts`** - a GORM-style `AutoMigrate` that creates missing tables, adds missing columns and adds missing indexes. It never drops columns and never alters column types - it only adds.
+- **Column encryption** - `utils/encryption.ts` exposes `encrypt()` / `decrypt()`; marking a column as encrypted transparently encrypts on write and decrypts on read, including for rows served from cache.
+- **Connection retry with exponential backoff and jitter** - `retryAttempts` (default 3) and `retryDelay` (default 1000ms) on the client config. Retries apply to read-only statements only; a failed write is never silently replayed.
+- **`auto-migrate.primary-key` and `client.retry` test suites**, plus eight integration suites (`sqlite`, `mysql`, `mariadb`, `postgres`, `mssql`, `models`, `relations`, `write-paths`) that run against real database servers and skip themselves when no server answers.
+
+### Fixed
+
+- **`after*` hooks and both delete hooks never ran.** `getHooks()` looked the model up via `proto.constructor`, but reads return plain rows from the driver, so the prototype was `Object` and the metadata lookup found nothing. The lookup now resolves the caller's model when one is passed, and hooks take a `model` argument for exactly this case. Any code that relied on `afterCreate` / `afterUpdate` / `afterDelete` firing will now see it fire.
+- **`getRepository()` opened a second, disconnected Redis connection on every call.** Repositories are now memoised per model, so repeated calls return the same instance instead of leaking a connection that nothing closed and `getCacheStats()` never counted.
+- **Cached `find()` results were written without their relations.** Rows are now hydrated and have relations loaded before being written to the cache, so a cache hit returns the same shape as a cache miss.
+- **`orderByRaw` / `groupByRaw` / `havingRaw` parameter handling**, and `OFFSET`-based pagination on dialects that require an `ORDER BY`.
+
+### Changed
+
+- `DBType` gains an `MSSQL` member. Since `DBType` is a string enum, existing persisted values are unaffected.
+- `mssql` (v12) is now a runtime dependency.
+
+### Known Issues
+
+- **SQL Server `IDENTITY` columns reject an explicit primary key.** `create({ id: 99 })` fails with *"Cannot insert explicit value for identity column in table 'x' when IDENTITY_INSERT is set to OFF"*, and `upsert({ id: 98 }, ["title"])` fails with *"Cannot update identity column 'id'"*. The cause is that the generated `MERGE` statement places the identity column in both the `UPDATE SET` list and the `INSERT` column list, and SQL Server permits neither. Letting the database assign the key works correctly on every path. A fix requires the upsert builder to skip identity columns in the `UPDATE SET` list and rely on `OUTPUT INSERTED.*` for the generated value; it is documented rather than patched here so that the behaviour change is not shipped silently in a minor release.
+- **`create()` with a JSON object throws on SQLite.** The SQLite write path binds values as given, while MySQL and SQL Server encode explicitly, so an object value reaches `bun:sqlite` unencoded and it rejects it with *"Binding expected string, TypedArray, boolean, number, bigint or null"*. Passing a pre-stringified value works on all three.
+- **Declared column `length`, `precision` and `scale` are not enforced** at write time - they appear in the generated DDL only, and SQLite ignores them entirely.
+- **`getValidators()` reports only `required` and `unique`**; other validators run during `validate()` but are not reflected in that summary.
+- **A `STRING` primary key is generated as `UUID PRIMARY KEY` on PostgreSQL** but as `VARCHAR(255)` / `NVARCHAR(255)` / `TEXT` on the other dialects, so the column types differ across dialects for the same model.
+
## [2.1.0] - 2026-04-02
### Added
diff --git a/README.md b/README.md
index 1c7fce0..a90d8a1 100644
--- a/README.md
+++ b/README.md
@@ -2,51 +2,55 @@
_A Modern, Type-Safe, and Expressive ORM for Bun_
-
-
-
-
-
-
+
+
+
-**Stabilize** is a lightweight, feature-rich ORM designed for performance and developer experience. It provides a unified, database-agnostic API for **PostgreSQL**, **MySQL**, and **SQLite**. Powered by a robust query builder, programmatic model definitions, automatic versioning, and a full-featured command-line interface, Stabilize is built to scale with your app.
+**Stabilize** is a lightweight, feature-rich ORM designed for performance and developer experience. It provides a unified, database-agnostic API for **PostgreSQL**, **MySQL/MariaDB**, **SQLite**, and **SQL Server**, plus a **MongoDB** document backend with the boundaries spelled out [below](#-mongodb). Powered by a robust query builder, programmatic model definitions, automatic versioning, and a full-featured command-line interface, Stabilize is built to scale with your app.
---
## 🚀 Features
-- **Unified API**: Write once, run on PostgreSQL, MySQL, or SQLite.
+- **Unified API**: Write once, run on PostgreSQL, MySQL/MariaDB, SQLite, or SQL Server.
+- **MongoDB Backend**: `DBType.MongoDB` runs the same repositories, relations, hooks and structured query builder against a document store, through the optional `mongodb` driver. Raw SQL, joins, unions, CTEs and raw clauses are refused with a `MONGO_UNSUPPORTED` error rather than mistranslated; the [boundaries](#-mongodb) are documented in full.
- **Programmatic Model Definitions**: Define models and columns using the `defineModel` API with the `DataTypes` enum for database-agnostic schemas.
- **Full-Featured CLI**: Generate models, manage migrations, seed data, and reset your database from the command line with [stabilize-cli](https://github.com/ElectronSz/stabilize-cli).
- **Automatic Migrations**: Generate database-specific SQL schemas directly from your model definitions.
+- **First-Class SQL Server Support**: `DBType.MSSQL` selects the `mssql` v12 driver, with T-SQL-specific query generation (`OUTPUT INSERTED.*`, `OFFSET … FETCH`, `MERGE` for upserts) and schema generation via `OBJECT_ID` / `sys.indexes` probes. Placeholders are rewritten automatically — you keep writing `?`.
- **Versioned Models & Time-Travel**: Enable versioning in your model configuration for automatic history tables and snapshot queries.
- **Retry Logic**: Automatic exponential backoff for database queries to handle transient connection issues.
-- **Connection Pooling**: Efficient connection management for PostgreSQL and MySQL.
+- **Connection Pooling**: Efficient connection management for PostgreSQL, MySQL, and SQL Server, with `poolStats()` reporting borrowed/available/size on SQL Server.
- **Transactional Integrity**: Built-in support for atomic transactions with automatic rollback on failure.
- **Advanced Query Builder**: Fluent, chainable API for building complex queries, including joins, filters, ordering, and pagination.
- **Pagination Helper**: Easily paginate any query with `.paginate(page, pageSize)` and get `{ data, total, page, pageSize }`.
- **Advanced Model Validation**: Enforce rules like `required`, `minLength`, `maxLength`, `pattern`, and custom validators—errors are thrown on invalid input.
-- **Model Relationships**: Define `OneToOne`, `ManyToOne`, `OneToMany`, and `ManyToMany` relationships in the model configuration.
+- **Model Relationships**: Define `OneToOne`, `ManyToOne`, `OneToMany`, and `ManyToMany` relationships in the model configuration, eager-loaded with `relations` or `.withRelations()`.
+- **Many-to-Many Link Management**: `attach()`, `detach()` and `sync()` edit a join table directly, without loading either side.
+- **validateAll**: Collect every validation failure at once, rather than only the first.
+- **findOrFail / firstOrFail**: Throw a `NOT_FOUND_ERROR` instead of returning `null`.
+- **Raw Clause Builders**: `.orderByRaw()`, `.groupByRaw()` and `.havingRaw()` for expressions that are not column names.
- **Soft Deletes**: Enable soft deletes in the model configuration for transparent "deleted" flags and safe row removal.
- **Lifecycle Hooks**: Define hooks in the model configuration or as class methods for lifecycle events like `beforeCreate`, `afterUpdate`, etc.
- **Pluggable Logging**: Includes a robust `StabilizeLogger` with support for file-based, rotating logs.
- **Custom Errors**: `StabilizeError` provides clear, consistent error handling.
- **Caching Layer**: Optional Redis-backed caching with `cache-aside` and `write-through` strategies.
+- **Column Encryption**: Mark a column with `encrypted: true` to transparently encrypt it on write and decrypt it on read with AES-256-GCM — including for rows served from cache.
- **Custom Query Scopes**: Define reusable query conditions (scopes) in models for simplified, reusable filtering logic.
- **Timestamps**: Automatically manage `createdAt` and `updatedAt` columns for tracking record creation and update times.
- **SQL Default Expressions**: Support database-side default expressions (e.g., `gen_random_uuid()`, `NOW()`) for columns using the `sqlDefault()` helper.
- **Nested Relations (Eager Loading)**: Load deeply nested relations using dot notation like `"roles.permissions"`.
-- **AutoMigrate with Index Management**: Automatically create, detect, and remove indexes and unique constraints during migration.
+- **AutoMigrate with Index Management**: Create missing tables, add missing columns, and add missing indexes and unique constraints in one pass. It is additive only — it never drops a column, never changes a column type, and never removes an index.
- **Advanced Query Builder Filters**: Chainable `.orWhere()`, `.whereIn()`, `.whereNotIn()`, `.whereNull()`, `.whereNotNull()`, `.whereBetween()`, `.groupBy()`, `.having()`, `.lock()` methods.
- **Optimistic Locking**: Add `optimisticLock: true` to a version column to automatically detect concurrent modification conflicts and throw `CONCURRENT_MODIFICATION` errors.
- **findAndCount**: Get paginated results with a total count in one call.
@@ -170,6 +174,138 @@ export const orm = new Stabilize(dbConfig, cacheConfig, loggerConfig);
---
+## 🍃 MongoDB
+
+`DBType.MongoDB` selects a document backend rather than a fifth SQL dialect. Its
+driver is the only optional dependency in the package, so install it alongside:
+
+```bash
+bun add mongodb
+```
+
+```typescript
+// config/database.ts
+import { DBType, type DBConfig } from "stabilize-orm";
+
+const dbConfig: DBConfig = {
+ type: DBType.MongoDB,
+ connectionString: process.env.MONGO_URL || "mongodb://localhost:27017/mydb",
+ // Optional. The fallback database for a URI that omits one from its path,
+ // which is how a mongo URI is usually written in development.
+ database: "mydb",
+ // Optional. Passed verbatim to the driver's `MongoClient` — `tls`,
+ // `authSource`, `maxPoolSize`, `retryWrites` and anything else the ORM has no
+ // opinion about.
+ mongoOptions: { maxPoolSize: 20 },
+};
+
+export default dbConfig;
+```
+
+Models, repositories, relations, hooks, versioning, soft deletes, validation,
+encryption, aggregates and transactions work as they do on SQL. A query is
+written with the query builder's *structured* methods — the ones that record a
+condition rather than SQL text:
+
+```typescript
+const users = await orm
+ .getRepository(User)
+ .find()
+ .whereEq("isActive", true)
+ .whereIn("role", ["admin", "editor"])
+ .orderBy("createdAt", "DESC")
+ .withRelations("roles")
+ .execute(orm.client);
+```
+
+`healthCheck()` pings the server. `poolStats()` returns
+`{ active: -1, idle: -1, total: -1 }`: the driver's pool is internal and
+per-server, so there is no honest number to report and the sentinel says so
+rather than inventing one.
+
+### What MongoDB cannot do
+
+A document store is not a SQL engine, and Stabilize refuses to guess where the
+two disagree. Every one of these is deliberate, and each is reported rather than
+silently mistranslated — a dropped `join()` would return the wrong rows with no
+error to notice.
+
+- **Raw SQL is refused.** `rawQuery()`, `rawExec()`, `query()` and `queryExec()`
+ throw a `StabilizeError` with code `MONGO_UNSUPPORTED`. Use the repository API
+ or the query builder's structured methods instead.
+
+ ```typescript
+ await orm.rawQuery("SELECT * FROM users WHERE age > ?", [25]);
+ // StabilizeError: Raw SQL is not available on MongoDB. ...
+ ```
+
+- **Joins, unions, CTEs and raw clauses throw.** `join()`, `innerJoin()`,
+ `leftJoin()`, `rightJoin()`, `fullJoin()`, `crossJoin()`, `union()`,
+ `unionAll()`, `with()`, `withRecursive()`, `whereRaw()`, `whereRef()`,
+ `whereExists()`, `whereNotExists()`, `selectRaw()`, `orderByRaw()`,
+ `groupByRaw()`, `having()`, `distinct()` and the SQL-text forms of
+ `where()`/`orWhere()`/`whereNot()` have no MongoDB equivalent. The throw
+ happens when the query is **executed**, not when the clause is added, and it
+ names every offending method at once:
+
+ ```typescript
+ await repo
+ .find()
+ .innerJoin("posts", "posts.user_id = users.id")
+ .whereRaw("LOWER(name) = 'ada'")
+ .execute(orm.client);
+ // StabilizeError: This query cannot be translated to MongoDB: innerJoin,
+ // whereRaw have no MongoDB equivalent. ... Use withRelations() for related
+ // documents, or run this query against a SQL backend.
+ ```
+
+ For a join, the replacement is `withRelations()`, which loads related
+ documents with batched reads rather than one statement:
+
+ ```typescript
+ await repo.find().withRelations("posts", "posts.comments").execute(orm.client);
+ ```
+
+- **`lock()` / `forUpdate()` is a no-op.** MongoDB has no row lock to map it
+ onto, so the clause is not rendered and the query runs unlocked rather than
+ failing. `lockForUpdate()` therefore reads the row without protecting it —
+ use `updateBy()` with a condition, or an optimistic lock column, for a
+ read-modify-write that has to be safe.
+
+- **`DECIMAL` is stored as a `double`.** MongoDB has no exact decimal unless the
+ caller supplies a `Decimal128`, so a `DECIMAL` column loses precision the way
+ a binary float does. For money, store the smallest unit as an `INTEGER`/`BIGINT`
+ or the value as a `STRING`.
+
+- **Auto-increment ids come from a counters collection — and they roll back.**
+ Ids are reserved by a `$inc` against `stabilize_counters`, keyed by collection
+ name, rather than by the server. Because that reservation runs inside the same
+ transaction as the write, an aborted transaction gives its ids back: the next
+ insert re-uses them. MySQL behaves the opposite way — InnoDB's auto-increment
+ counter is not transactional, so an aborted insert leaks the gap.
+
+- **Transactions need a replica set or a sharded cluster.** A standalone
+ `mongod` serves reads but rejects every transaction — and every repository
+ write (`create()`, `update()`, `delete()`, `bulkCreate()`, `upsert()` …) runs
+ inside one, so a standalone makes writes fail generally, not only
+ explicitly-transactional code. The client warns at connect time and reports
+ the failure as `TX_ERROR`:
+
+ ```typescript
+ // Standalone mongod, no replica set:
+ await repo.create({ name: "Ada" });
+ // StabilizeError (TX_ERROR): MongoDB transactions require a replica set or
+ // sharded cluster, and this server is a standalone. Every write goes through
+ // a transaction, so start the server with --replSet and run rs.initiate()
+ // (or point the connection at an existing replica set).
+ ```
+
+ Run a single-node replica set in development
+ (`rs.initiate()` on a `mongod --replSet rs0`) and every write path works
+ unchanged.
+
+---
+
## 🏗️ Models & Relationships
Define your tables as classes using the `defineModel` function. The `DataTypes` enum ensures database-agnostic schemas.
@@ -185,9 +321,9 @@ const User = defineModel({
tableName: "users",
versioned: true,
columns: {
- id: { type: DataTypes.Integer, required: true },
+ id: { type: DataTypes.INTEGER, required: true },
email: {
- type: DataTypes.String,
+ type: DataTypes.STRING,
length: 100,
required: true,
unique: true,
@@ -198,7 +334,9 @@ const User = defineModel({
type: RelationType.OneToMany,
target: () => UserRole,
property: "roles",
- foreignKey: "userId",
+ // The key lives on the target table, so the OneToMany side names it
+ // with inverseKey. `foreignKey` is accepted as a synonym here.
+ inverseKey: "userId",
},
],
hooks: {
@@ -247,9 +385,9 @@ Validation errors are thrown on create/update if data is invalid.
const User = defineModel({
tableName: "users",
columns: {
- id: { type: DataTypes.Integer, required: true },
+ id: { type: DataTypes.INTEGER, required: true },
email: {
- type: DataTypes.String,
+ type: DataTypes.STRING,
required: true,
unique: true,
minLength: 6,
@@ -258,13 +396,69 @@ const User = defineModel({
val.endsWith("@offbytesecure.com") ||
"Must use an @offbytesecure.com email",
},
- password: { type: DataTypes.String, minLength: 8 },
+ password: { type: DataTypes.STRING, minLength: 8 },
},
});
```
---
+## 🔐 Column Encryption
+
+Mark a column with `encrypted: true` and the ORM encrypts it on the way in and
+decrypts it on the way out. Your code keeps reading and writing ordinary
+strings; the ciphertext is only ever visible in the database.
+
+```typescript
+const User = defineModel({
+ tableName: "users",
+ columns: {
+ id: { type: DataTypes.INTEGER, required: true },
+ email: { type: DataTypes.STRING, required: true, unique: true },
+ nationalId: { type: DataTypes.STRING, encrypted: true },
+ },
+});
+
+await userRepository.create({
+ email: "lwazicd@icloud.com",
+ nationalId: "9001015800085", // stored as v2:::
+});
+
+const user = await userRepository.findOne(1);
+console.log(user.nationalId); // "9001015800085" — decrypted on read
+```
+
+Encryption uses AES-256-GCM, so a value that has been tampered with or truncated
+fails to decrypt rather than quietly returning corrupted plaintext. Values carry
+a `v2:` prefix that identifies the format. Encrypted columns are also decrypted
+when a row is served from cache, so a cache hit returns the same shape as a miss.
+
+### The encryption key
+
+The key is read from the `ORM_ENCRYPTION_KEY` environment variable on every call,
+so it can be set after your modules are imported:
+
+```bash
+# 32 bytes, or 64 hex characters
+export ORM_ENCRYPTION_KEY="$(openssl rand -hex 32)"
+```
+
+There is deliberately no default. **If `ORM_ENCRYPTION_KEY` is unset, reading or
+writing an encrypted column throws** rather than falling back to a built-in key.
+Earlier versions did fall back to a constant compiled into the package, which
+meant a deployment that never set the variable encrypted its columns with a value
+anyone who read the published source could reproduce. Failing loudly is the
+intended behaviour: it is a deployment mistake, not a runtime condition.
+
+If you have data written by one of those earlier versions, set the key to the
+legacy value `f71a3c8e9b12d5a49c0a3f98b1f2e46d` to keep reading it, then re-save
+those rows under a key of your own. Those rows use AES-CBC and are still read
+correctly; everything newly written uses GCM.
+
+---
+
+## ⏳ Versioning & Auditing
+
## ⏳ Versioning & Auditing
Enable automatic history tracking and time-travel queries by setting `versioned: true` in your model configuration.
@@ -281,8 +475,8 @@ const User = defineModel({
tableName: "users",
versioned: true,
columns: {
- id: { type: DataTypes.Integer, required: true },
- name: { type: DataTypes.String, length: 100 },
+ id: { type: DataTypes.INTEGER, required: true },
+ name: { type: DataTypes.STRING, length: 100 },
},
});
@@ -316,10 +510,10 @@ import { defineModel, DataTypes } from "stabilize-orm";
const User = defineModel({
tableName: "users",
columns: {
- id: { type: DataTypes.Integer, required: true },
- name: { type: DataTypes.String, length: 100 },
- createdAt: { type: DataTypes.DateTime },
- updatedAt: { type: DataTypes.DateTime },
+ id: { type: DataTypes.INTEGER, required: true },
+ name: { type: DataTypes.STRING, length: 100 },
+ createdAt: { type: DataTypes.DATETIME },
+ updatedAt: { type: DataTypes.DATETIME },
},
hooks: {
beforeCreate: (entity) => {
@@ -348,101 +542,67 @@ Supported hooks: `beforeCreate`, `afterCreate`, `beforeUpdate`, `afterUpdate`, `
## 💻 Command-Line Interface (CLI)
-Stabilize includes a powerful CLI for managing your workflow. See: [stabilize-cli on GitHub](https://github.com/ElectronSz/stabilize-cli)
-
-### Generating Files
-
-- **Generate a model**:
-
- ```bash
- stabilize-cli generate:model Product
- ```
-
-- **Generate a migration from a model**:
-
- ```bash
- stabilize-cli generate:migration User
- ```
-
-- **Generate a seed file**:
-
- ```bash
- stabilize-cli generate:seed InitialRoles
- ```
-
-- **Generate a REST API scaffold**:
- ```bash
- stabilize-cli generate:api User
- ```
-
-### Database & Migration Management
+Stabilize includes a powerful CLI with 31 commands. See: [stabilize-cli on GitHub](https://github.com/ElectronSz/stabilize-cli)
-- **Run all pending migrations**:
+### Generate
- ```bash
- stabilize-cli migrate
- ```
-
-- **Roll back the last migration**:
-
- ```bash
- stabilize-cli migrate:rollback
- ```
-
-- **Fresh migration (drop + re-migrate)**:
-
- ```bash
- stabilize-cli migrate:fresh --force
- ```
-
-- **Run all pending seeds (in dependency order)**:
-
- ```bash
- stabilize-cli seed
- ```
+```bash
+stabilize-cli generate:model User name:string email:string age:int --versioned # g:m
+stabilize-cli generate:migration User # g:mg
+stabilize-cli generate:seed User --count 10 # g:s
+stabilize-cli generate:api Product --prefix /v1 # g:a
+stabilize-cli generate:all Order userId:string total:decimal --count 20 # g:x
+stabilize-cli generate:test User # g:t
+```
-- **Check the status of migrations and seeds**:
+### Migrate
- ```bash
- stabilize-cli status
- ```
+```bash
+stabilize-cli migrate
+stabilize-cli migrate:rollback
+stabilize-cli migrate:fresh --force
+stabilize-cli migrate:status
+stabilize-cli migrate:pending
+stabilize-cli migrate:auto
+```
-- **Reset the database (drop, migrate, seed)**:
- ```bash
- stabilize-cli db:reset
- ```
+`migrate:auto` runs AutoMigrate against the live database — creating missing
+tables, adding missing columns and adding missing indexes. Like the library API
+it is additive only: it never drops a column and never changes a column type.
-### Backup & Restore
+### Database
-- **Backup the database**:
+```bash
+stabilize-cli db:drop --force
+stabilize-cli db:reset --force
+stabilize-cli db:truncate users --force
+stabilize-cli db:backup --output ./backups
+stabilize-cli db:restore backups/backup.db --force
+stabilize-cli db:tables
+stabilize-cli db:size
+stabilize-cli db:diff
+stabilize-cli db:console
+stabilize-cli db:table:info users
+```
- ```bash
- stabilize-cli db:backup
- ```
+### Model & Config
-- **Restore from a backup**:
- ```bash
- stabilize-cli db:restore backups/backup_20250101120000.db --force
- ```
+```bash
+stabilize-cli model:validate
+stabilize-cli model:info User
+stabilize-cli config:init --type postgres
+```
### Diagnostics
-- **Database size statistics**:
-
- ```bash
- stabilize-cli db:size
- ```
-
-- **Health check**:
-
- ```bash
- stabilize-cli health
- ```
-
-- **CLI info**:
- ```bash
- stabilize-cli info
- ```
+```bash
+stabilize-cli seed
+stabilize-cli status
+stabilize-cli health
+stabilize-cli health:json
+stabilize-cli query 'SELECT * FROM users LIMIT 5'
+stabilize-cli info
+```
---
@@ -520,11 +680,11 @@ import { orm } from "./db";
const User = defineModel({
tableName: "users",
columns: {
- id: { type: DataTypes.Integer, required: true },
- email: { type: DataTypes.String, length: 100, required: true },
- isActive: { type: DataTypes.Boolean, required: true },
- createdAt: { type: DataTypes.DateTime },
- updatedAt: { type: DataTypes.DateTime },
+ id: { type: DataTypes.INTEGER, required: true },
+ email: { type: DataTypes.STRING, length: 100, required: true },
+ isActive: { type: DataTypes.BOOLEAN, required: true },
+ createdAt: { type: DataTypes.DATETIME },
+ updatedAt: { type: DataTypes.DATETIME },
},
scopes: {
active: (qb) => qb.where("isActive = ?", true),
@@ -568,10 +728,10 @@ import { orm } from "./db";
const User = defineModel({
tableName: "users",
columns: {
- id: { type: DataTypes.Integer, required: true },
- email: { type: DataTypes.String, length: 100, required: true },
- createdAt: { type: DataTypes.DateTime },
- updatedAt: { type: DataTypes.DateTime },
+ id: { type: DataTypes.INTEGER, required: true },
+ email: { type: DataTypes.STRING, length: 100, required: true },
+ createdAt: { type: DataTypes.DATETIME },
+ updatedAt: { type: DataTypes.DATETIME },
},
timestamps: {
createdAt: "createdAt",
@@ -618,9 +778,9 @@ const User = defineModel({
tableName: "users",
softDelete: true,
columns: {
- id: { type: DataTypes.Integer, required: true },
- email: { type: DataTypes.String, length: 100, required: true },
- deletedAt: { type: DataTypes.DateTime, softDelete: true },
+ id: { type: DataTypes.INTEGER, required: true },
+ email: { type: DataTypes.STRING, length: 100, required: true },
+ deletedAt: { type: DataTypes.DATETIME, softDelete: true },
},
});
@@ -688,9 +848,9 @@ import { defineModel, DataTypes } from "stabilize-orm";
const User = defineModel({
tableName: "users",
columns: {
- id: { type: DataTypes.Integer, required: true },
- name: { type: DataTypes.String },
- version: { type: DataTypes.Integer, optimisticLock: true },
+ id: { type: DataTypes.INTEGER, required: true },
+ name: { type: DataTypes.STRING },
+ version: { type: DataTypes.INTEGER, optimisticLock: true },
},
});
@@ -730,9 +890,9 @@ const User = defineModel({
required: true,
defaultExpression: sqlDefault("gen_random_uuid()"),
},
- name: { type: DataTypes.String },
+ name: { type: DataTypes.STRING },
createdAt: {
- type: DataTypes.DateTime,
+ type: DataTypes.DATETIME,
defaultExpression: sqlDefault("NOW()"),
},
},
@@ -760,18 +920,118 @@ const results = await userRepository
.execute();
```
+### Raw Clauses
+
+`orderBy`, `groupBy` and `having` take a column name. When you need an
+expression instead, use the raw variants:
+
+```typescript
+const results = await orderRepository
+ .find()
+ .select("status", "COUNT(*) AS total")
+ .groupByRaw("strftime('%Y-%m', createdAt)")
+ .havingRaw("COUNT(*) > ?", 10)
+ .orderByRaw("CASE WHEN status = 'urgent' THEN 0 ELSE 1 END")
+ .execute(db.client);
+```
+
+`orderByRaw` takes an optional direction as its second argument
+(`orderByRaw("LENGTH(title)", "DESC")`). Raw and plain clauses compose, and raw
+parameters are bound in the order they appear.
+
---
-## 🔗 Nested Relations
+## 🔗 Relations
-Load deeply nested relations using dot notation:
+Relations are eager-loaded, one batched query per relation. A to-many relation
+comes back as an array (empty when there is nothing linked), a to-one relation
+as the row or `null`.
```typescript
const user = await userRepository.findOne(1, {
relations: ["roles", "roles.permissions"],
});
+// user.roles[0].permissions — nested paths use dot notation
```
+Relations can also be requested on the query builder, alongside `where`,
+`limit`, `orderBy` and `paginate`:
+
+```typescript
+const users = await userRepository
+ .find()
+ .where("isActive = ?", true)
+ .withRelations("roles", "roles.permissions")
+ .limit(10)
+ .execute(db.client);
+```
+
+`create`, `bulkCreate`, `findOne`, `findMany`, `findBy`, `findOneBy` and
+`findAndCount` all accept a `relations` option:
+
+```typescript
+const [user, post] = await userRepository.bulkCreate(
+ [{ email: "a@b.c" }, { email: "d@e.f" }],
+ { relations: ["roles"] },
+);
+```
+
+Related rows are read through the target model, so its soft-delete filter
+applies — a deleted child is not returned as part of its parent.
+
+### Managing Many-to-Many Links
+
+`attach`, `detach` and `sync` write the join table directly, so a many-to-many
+relation can be edited without loading and re-saving either side.
+
+```typescript
+await postRepository.attach(postId, "tags", [1, 2]); // links 1 and 2
+await postRepository.detach(postId, "tags", [2]); // unlinks 2
+await postRepository.detach(postId, "tags"); // unlinks everything
+
+// Makes the link set exactly [3, 4]: adds what is missing, removes what is not
+// in the list, leaves the rest alone.
+const { attached, detached } = await postRepository.sync(postId, "tags", [3, 4]);
+```
+
+All three are idempotent, accept a single id or an array, and return how many
+links they changed. `sync` runs in a transaction.
+
+---
+
+## 🥇 findOrFail / firstOrFail
+
+`findOne` and `first` return `null` on a miss. The `*OrFail` variants throw a
+`StabilizeError` with code `NOT_FOUND_ERROR` instead, so a miss cannot be
+mistaken for an empty result.
+
+```typescript
+const user = await userRepository.findOrFail(1); // never null
+const admin = await userRepository.firstOrFail({ role: "admin" });
+
+// Still loads relations
+const post = await postRepository.findOrFail(7, { relations: ["author"] });
+```
+
+---
+
+## 🛡️ validateAll
+
+`create` and `update` validate and throw on the **first** failure. When
+validating input from a form you usually want every failure at once:
+
+```typescript
+const errors = userRepository.validateAll({ email: "nope", name: "ab" });
+// ["Field email does not match pattern", "Field name too short"]
+
+if (errors.length) {
+ return res.status(422).json({ errors });
+}
+```
+
+Pass `true` as the second argument to skip the `required` rules, which is how
+an update validates a partial entity.
+
---
## 📊 Aggregation Queries
@@ -1084,7 +1344,7 @@ const id = generateUUID();
## 📑 License
-Licensed under the MIT License. See [LICENSE.md](./LICENSE.md) for details.
+Licensed under the MIT License. See [LICENSE](./LICENSE) for details.
---
@@ -1092,6 +1352,6 @@ Licensed under the MIT License. See [LICENSE.md](./LICENSE.md) for details.
Created with ❤️ by **ElectronSz**
-File last updated: 2026-04-02
+File last updated: 2026-09-15
diff --git a/auto-migrate.ts b/auto-migrate.ts
index af5ac64..81a28c1 100644
--- a/auto-migrate.ts
+++ b/auto-migrate.ts
@@ -1,86 +1,91 @@
/**
* @file auto-migrate.ts
- * @description Adds GORM-like AutoMigrate to Stabilize ORM with index and constraint management.
+ * @description GORM-like AutoMigrate: creates tables, adds missing columns, adds missing indexes.
+ * NEVER deletes columns or changes types - only adds.
*/
import { DBClient } from "./client";
-import { DBType, StabilizeError } from "./types";
-
-type ColumnType = "string" | "number" | "boolean" | "date" | "json";
-
-export interface ModelSchema {
- tableName: string;
- columns: Record<
- string,
- {
- type: ColumnType;
- primaryKey?: boolean;
- autoIncrement?: boolean;
- nullable?: boolean;
- default?: any;
- index?: string | boolean;
- unique?: boolean;
+import { DBType, DataTypes, StabilizeError } from "./types";
+import { MetadataStorage } from "./model";
+import {
+ createIndexIfNotExistsSQL,
+ createTableIfNotExistsSQL,
+ quoteIdentifier,
+} from "./migrations";
+import { mongoAutoMigrate } from "./mongo-schema";
+
+async function tableExists(db: DBClient, table: string): Promise {
+ switch (db.config.type) {
+ case DBType.SQLite: {
+ const rows = await db.query(
+ `SELECT name FROM sqlite_master WHERE type='table' AND name = '${table}'`,
+ );
+ return rows.length > 0;
}
- >;
- indexes?: Array<{
- name: string;
- columns: string[];
- unique?: boolean;
- }>;
-}
-
-function sqlType(
- type: ColumnType,
- dialect: "sqlite" | "mysql" | "postgres",
-): string {
- switch (dialect) {
- case "sqlite":
- return {
- string: "TEXT",
- number: "INTEGER",
- boolean: "INTEGER",
- date: "TEXT",
- json: "TEXT",
- }[type]!;
- case "mysql":
- return {
- string: "VARCHAR(255)",
- number: "INT",
- boolean: "TINYINT(1)",
- date: "DATETIME",
- json: "JSON",
- }[type]!;
- case "postgres":
- return {
- string: "TEXT",
- number: "INTEGER",
- boolean: "BOOLEAN",
- date: "TIMESTAMP",
- json: "JSONB",
- }[type]!;
+ case DBType.MySQL: {
+ const rows = await db.query(
+ `SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?`,
+ [table],
+ );
+ return rows.length > 0;
+ }
+ case DBType.Postgres: {
+ const rows = await db.query(
+ `SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename = $1`,
+ [table],
+ );
+ return rows.length > 0;
+ }
+ case DBType.MSSQL: {
+ const rows = await db.query(
+ `SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = ?`,
+ [table],
+ );
+ return rows.length > 0;
+ }
+ default:
+ return false;
}
}
async function getExistingColumns(
db: DBClient,
table: string,
-): Promise> {
+): Promise> {
+ const cols = new Map();
switch (db.config.type) {
- case DBType.SQLite:
- const rows = await db.query(`PRAGMA table_info(${table});`);
- return new Set(rows.map((r: any) => r.name));
- case DBType.MySQL:
- const cols = await db.query(`SHOW COLUMNS FROM \`${table}\`;`);
- return new Set(cols.map((c: any) => c.Field));
- case DBType.Postgres:
- const pgCols = await db.query(
- `SELECT column_name FROM information_schema.columns WHERE table_name = $1`,
+ case DBType.SQLite: {
+ const rows = await db.query(`PRAGMA table_info(${table})`);
+ for (const r of rows) cols.set(r.name, r.type);
+ break;
+ }
+ case DBType.MySQL: {
+ const rows = await db.query(`SHOW COLUMNS FROM \`${table}\``);
+ for (const r of rows) cols.set(r.Field, r.Type);
+ break;
+ }
+ case DBType.Postgres: {
+ const rows = await db.query(
+ `SELECT column_name, data_type FROM information_schema.columns WHERE table_name = $1`,
[table],
);
- return new Set(pgCols.map((c: any) => c.column_name));
- default:
- throw new StabilizeError("Unknown DB type", "MIGRATE_ERROR");
+ for (const r of rows) cols.set(r.column_name, r.data_type);
+ break;
+ }
+ case DBType.MSSQL: {
+ // Aliased to the names the rest of this function reads, so the SQLite and
+ // MySQL shapes above stay untouched.
+ const rows = await db.query(
+ `SELECT COLUMN_NAME AS name, DATA_TYPE AS type,
+ IS_NULLABLE AS nullable, COLUMN_DEFAULT AS default_value
+ FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = ?`,
+ [table],
+ );
+ for (const r of rows) cols.set(r.name, r.type);
+ break;
+ }
}
+ return cols;
}
async function getExistingIndexes(
@@ -90,10 +95,9 @@ async function getExistingIndexes(
const indexes = new Map();
switch (db.config.type) {
case DBType.SQLite: {
- const rows = await db.query(`PRAGMA index_list(${table});`);
+ const rows = await db.query(`PRAGMA index_list(${table})`);
for (const row of rows) {
- if (row.origin === "u" || row.origin === "c") continue;
- const info = await db.query(`PRAGMA index_info('${row.name}');`);
+ const info = await db.query(`PRAGMA index_info('${row.name}')`);
indexes.set(row.name, {
columns: info.map((i: any) => i.name),
unique: !!row.unique,
@@ -102,7 +106,7 @@ async function getExistingIndexes(
break;
}
case DBType.MySQL: {
- const rows = await db.query(`SHOW INDEX FROM \`${table}\`;`);
+ const rows = await db.query(`SHOW INDEX FROM \`${table}\``);
for (const row of rows) {
if (row.Key_name === "PRIMARY") continue;
if (!indexes.has(row.Key_name)) {
@@ -123,11 +127,32 @@ async function getExistingIndexes(
ORDER BY i.relname, a.attnum`,
[table],
);
+ for (const row of rows) {
+ if (!indexes.has(row.index_name)) {
+ indexes.set(row.index_name, { columns: [], unique: row.indisunique });
+ }
+ indexes.get(row.index_name)!.columns.push(row.column_name);
+ }
+ break;
+ }
+ case DBType.MSSQL: {
+ // `sys.index_columns` carries one row per indexed column, so walking it
+ // in `index_column_id` order rebuilds each index's column list. The
+ // primary key is excluded, matching the PostgreSQL query above.
+ const rows = await db.query(
+ `SELECT i.name AS index_name, i.is_unique AS is_unique, c.name AS column_name
+ FROM sys.indexes i
+ JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id
+ JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id
+ WHERE i.object_id = OBJECT_ID(?) AND i.is_primary_key = 0 AND i.name IS NOT NULL
+ ORDER BY i.name, ic.index_column_id`,
+ [table],
+ );
for (const row of rows) {
if (!indexes.has(row.index_name)) {
indexes.set(row.index_name, {
columns: [],
- unique: row.indisunique,
+ unique: !!row.is_unique,
});
}
indexes.get(row.index_name)!.columns.push(row.column_name);
@@ -138,189 +163,423 @@ async function getExistingIndexes(
return indexes;
}
-export function extractSchema(model: any): ModelSchema {
- if (!model.schema)
- throw new StabilizeError(
- `Model "${model.name}" is missing static schema property.`,
- "MIGRATE_ERROR",
- );
- if (!model.schema.tableName)
- throw new StabilizeError(
- `Model "${model.name}" schema missing tableName.`,
- "MIGRATE_ERROR",
- );
-
- return model.schema as ModelSchema;
+function mapType(
+ dataType: string,
+ dialect: "sqlite" | "mysql" | "postgres" | "mssql",
+): string {
+ const t = dataType.toUpperCase();
+ const map: Record> = {
+ sqlite: {
+ STRING: "TEXT",
+ TEXT: "TEXT",
+ INTEGER: "INTEGER",
+ BIGINT: "INTEGER",
+ FLOAT: "REAL",
+ DOUBLE: "REAL",
+ DECIMAL: "NUMERIC",
+ BOOLEAN: "INTEGER",
+ DATE: "TEXT",
+ DATETIME: "TEXT",
+ JSON: "TEXT",
+ UUID: "TEXT",
+ BLOB: "BLOB",
+ },
+ mysql: {
+ STRING: "VARCHAR(255)",
+ TEXT: "TEXT",
+ INTEGER: "INT",
+ BIGINT: "BIGINT",
+ FLOAT: "FLOAT",
+ DOUBLE: "DOUBLE",
+ DECIMAL: "DECIMAL(10,2)",
+ BOOLEAN: "TINYINT(1)",
+ DATE: "DATE",
+ DATETIME: "DATETIME",
+ JSON: "JSON",
+ UUID: "CHAR(36)",
+ BLOB: "BLOB",
+ },
+ postgres: {
+ STRING: "TEXT",
+ TEXT: "TEXT",
+ INTEGER: "INTEGER",
+ BIGINT: "BIGINT",
+ FLOAT: "REAL",
+ DOUBLE: "DOUBLE PRECISION",
+ DECIMAL: "DECIMAL(10,2)",
+ BOOLEAN: "BOOLEAN",
+ DATE: "DATE",
+ DATETIME: "TIMESTAMP",
+ JSON: "JSONB",
+ UUID: "UUID",
+ BLOB: "BYTEA",
+ },
+ mssql: {
+ STRING: "NVARCHAR(255)",
+ TEXT: "NVARCHAR(MAX)",
+ INTEGER: "INT",
+ BIGINT: "BIGINT",
+ FLOAT: "REAL",
+ DOUBLE: "FLOAT",
+ DECIMAL: "DECIMAL(10,2)",
+ BOOLEAN: "BIT",
+ DATE: "DATE",
+ DATETIME: "DATETIME2",
+ JSON: "NVARCHAR(MAX)",
+ UUID: "UNIQUEIDENTIFIER",
+ BLOB: "VARBINARY(MAX)",
+ },
+ };
+ // The fallback is a text column, and T-SQL's `TEXT` is both deprecated and
+ // unusable in most expressions, so SQL Server falls back to `NVARCHAR(MAX)`.
+ return map[dialect]?.[t] || (dialect === "mssql" ? "NVARCHAR(MAX)" : "TEXT");
}
-function buildCreateTable(
- schema: ModelSchema,
- dialect: "sqlite" | "mysql" | "postgres",
-) {
- const parts: string[] = [];
-
- for (const [col, meta] of Object.entries(schema.columns)) {
- let sql = `"${col}" ${sqlType(meta.type, dialect)}`;
-
- if (meta.primaryKey) sql += " PRIMARY KEY";
-
- if (meta.autoIncrement) {
- sql +=
- dialect === "mysql"
- ? " AUTO_INCREMENT"
- : dialect === "postgres"
- ? " GENERATED ALWAYS AS IDENTITY"
- : " AUTOINCREMENT";
- }
-
- if (!meta.nullable) sql += " NOT NULL";
-
- if (meta.default !== undefined)
- sql += ` DEFAULT ${JSON.stringify(meta.default)}`;
-
- parts.push(sql);
- }
-
- return `CREATE TABLE IF NOT EXISTS "${schema.tableName}" (${parts.join(", ")})`;
+/**
+ * The dialect's unbounded text type, used for the generated timestamp and
+ * history columns that `createTableFromMeta` and the history table add.
+ */
+function textType(dialect: "sqlite" | "mysql" | "postgres" | "mssql"): string {
+ return dialect === "mssql" ? "NVARCHAR(255)" : "TEXT";
}
-function buildAddColumn(
- table: string,
- col: string,
- meta: any,
- dialect: "sqlite" | "mysql" | "postgres",
-): string {
- return `ALTER TABLE "${table}" ADD COLUMN "${col}" ${sqlType(
- meta.type,
- dialect,
- )} ${meta.nullable ? "" : "NOT NULL"}`;
+/**
+ * Resolves a column's declared type to its name. `type` is either a
+ * `DataTypes` enum member (the documented form) or a raw string.
+ */
+function resolveTypeName(type: any): string {
+ return typeof type === "string" ? type : (DataTypes[type] ?? "TEXT");
}
-function buildCreateIndex(
- table: string,
- indexName: string,
- columns: string[],
- unique: boolean,
- dialect: "sqlite" | "mysql" | "postgres",
-): string {
- const uniqueStr = unique ? "UNIQUE " : "";
- const colStr = columns.map((c) => `"${c}"`).join(", ");
- return `CREATE ${uniqueStr}INDEX IF NOT EXISTS "${indexName}" ON "${table}" (${colStr})`;
+/** Whether a resolved type name is an integer, and so can auto-increment. */
+function isIntegerType(typeName: string): boolean {
+ return typeName.toUpperCase() === "INTEGER" || typeName.toUpperCase() === "BIGINT";
}
-function buildDropIndex(
- indexName: string,
- dialect: "sqlite" | "mysql" | "postgres",
+function getAutoIncrementPK(
+ dialect: "sqlite" | "mysql" | "postgres" | "mssql",
): string {
- if (dialect === "mysql") {
- return `ALTER TABLE DROP INDEX \`${indexName}\``;
+ switch (dialect) {
+ case "mysql":
+ return "INT AUTO_INCREMENT PRIMARY KEY";
+ case "postgres":
+ return "SERIAL PRIMARY KEY";
+ case "mssql":
+ return "INT IDENTITY(1,1) PRIMARY KEY";
+ default:
+ return "INTEGER PRIMARY KEY AUTOINCREMENT";
}
- return `DROP INDEX IF EXISTS "${indexName}"`;
-}
-
-function generateIndexName(
- table: string,
- columns: string[],
- suffix: string = "idx",
-): string {
- return `${table}_${columns.join("_")}_${suffix}`;
}
+/**
+ * GORM-style AutoMigrate.
+ *
+ * - Creates table if it doesn't exist
+ * - Adds missing columns (never deletes or changes type)
+ * - Creates missing indexes
+ * - Creates history table if model is versioned
+ *
+ * Usage:
+ * ```
+ * await orm.autoMigrate([User, Post, Comment]);
+ * ```
+ */
export async function autoMigrate(
db: DBClient,
models: any | any[],
): Promise {
const list = Array.isArray(models) ? models : [models];
+
+ // Returned before the `dialect` union below is computed, so no SQL path is
+ // reachable for a MongoDB client. MongoDB has no `ADD COLUMN` and no
+ // `CREATE TABLE`; its equivalent is a collection plus a validator, and it
+ // lives in `mongo-schema` rather than in a branch of this function.
+ if (db.config.type === DBType.MongoDB) {
+ return mongoAutoMigrate(db, list);
+ }
+
+ // Every identifier this file emits goes through `quoteIdentifier`, so the
+ // DDL parses on MySQL-family servers (backticks) as well as the rest (`"`).
+ // @see quoteIdentifier for why the two spellings are not interchangeable.
+ const q = (name: string) => quoteIdentifier(name, db.config.type);
const dialect =
db.config.type === DBType.SQLite
? "sqlite"
: db.config.type === DBType.MySQL
? "mysql"
- : "postgres";
+ : db.config.type === DBType.MSSQL
+ ? "mssql"
+ : "postgres";
for (const model of list) {
- const schema = extractSchema(model);
+ // Try MetadataStorage first (defineModel), fall back to model.schema
+ const meta = MetadataStorage.getModelMetadata(model);
+ const tableName = meta?.tableName || model.schema?.tableName;
+
+ if (!tableName) {
+ throw new StabilizeError(
+ `Model is missing tableName. Use defineModel() or add static schema.`,
+ "MIGRATE_ERROR",
+ );
+ }
- const existing = await getExistingColumns(db, schema.tableName);
- const existingIndexes = await getExistingIndexes(db, schema.tableName);
+ const exists = await tableExists(db, tableName);
- if (existing.size === 0) {
- const createSQL = buildCreateTable(schema, dialect);
- await db.migrationQuery(createSQL);
+ if (!exists) {
+ // Create table from defineModel metadata
+ if (meta) {
+ await createTableFromMeta(db, meta, tableName, dialect);
+ } else {
+ // Legacy schema format
+ await createTableFromSchema(db, model.schema, tableName, dialect);
+ }
} else {
- for (const [col, meta] of Object.entries(schema.columns)) {
- if (!existing.has(col)) {
- const alter = buildAddColumn(schema.tableName, col, meta, dialect);
- await db.migrationQuery(alter);
+ // Add missing columns
+ const existingCols = await getExistingColumns(db, tableName);
+ if (meta) {
+ for (const [key, col] of Object.entries(meta.columns)) {
+ const colName = (col as any).name || key;
+ if (!existingCols.has(colName)) {
+ const sqlType = mapType(
+ typeof (col as any).type === "string"
+ ? (col as any).type
+ : DataTypes[(col as any).type],
+ dialect,
+ );
+ const notNull = (col as any).required ? " NOT NULL" : "";
+ const defaultVal =
+ (col as any).defaultValue !== undefined
+ ? ` DEFAULT ${JSON.stringify((col as any).defaultValue)}`
+ : "";
+ const unique = (col as any).unique ? " UNIQUE" : "";
+ // `ADD COLUMN` is the spelling SQLite, MySQL and PostgreSQL share;
+ // T-SQL's grammar is `ADD `, with no `COLUMN` keyword.
+ const addColumn =
+ db.config.type === DBType.MSSQL ? "ADD" : "ADD COLUMN";
+ await db.migrationQuery(
+ `ALTER TABLE ${q(tableName)} ${addColumn} ${q(colName)} ${sqlType}${notNull}${defaultVal}${unique}`,
+ );
+ }
}
}
}
- const desiredIndexes = new Map<
- string,
- { columns: string[]; unique: boolean }
- >();
-
- for (const [col, meta] of Object.entries(schema.columns)) {
- if (meta.index && typeof meta.index === "string") {
- desiredIndexes.set(meta.index, {
- columns: [col],
- unique: !!meta.unique,
- });
- } else if (meta.index === true) {
- const idxName = generateIndexName(schema.tableName, [col]);
- desiredIndexes.set(idxName, { columns: [col], unique: false });
- }
- if (meta.unique && !meta.primaryKey) {
- const idxName = generateIndexName(schema.tableName, [col], "uniq");
- desiredIndexes.set(idxName, { columns: [col], unique: true });
+ // Create indexes. This read is what makes the statements below idempotent
+ // on MySQL and MariaDB, which have no `IF NOT EXISTS` clause to put on a
+ // `CREATE INDEX` and so cannot enforce it themselves — the name check here
+ // is the whole of the guarantee. @see createIndexIfNotExistsSQL.
+ const existingIndexes = await getExistingIndexes(db, tableName);
+ if (meta) {
+ for (const [key, col] of Object.entries(meta.columns)) {
+ const colName = (col as any).name || key;
+ if ((col as any).unique && key !== "id") {
+ const idxName = `${tableName}_${colName}_uniq`;
+ if (!existingIndexes.has(idxName)) {
+ await db.migrationQuery(
+ createIndexIfNotExistsSQL(
+ q(idxName),
+ q(tableName),
+ [q(colName)],
+ true,
+ db.config.type,
+ ),
+ );
+ }
+ }
+ if ((col as any).index && typeof (col as any).index === "string") {
+ const idxName = (col as any).index;
+ if (!existingIndexes.has(idxName)) {
+ await db.migrationQuery(
+ createIndexIfNotExistsSQL(
+ q(idxName),
+ q(tableName),
+ [q(colName)],
+ false,
+ db.config.type,
+ ),
+ );
+ }
+ }
}
}
- if (schema.indexes) {
- for (const idx of schema.indexes) {
- desiredIndexes.set(idx.name, {
- columns: idx.columns,
- unique: !!idx.unique,
- });
+ // Create history table if versioned
+ if (meta?.versioned) {
+ const historyTable = `${tableName}_history`;
+ if (!(await tableExists(db, historyTable))) {
+ const columns = meta.columns;
+ const colDefs: string[] = [];
+ const historyColumnNames = new Set();
+ for (const [key, col] of Object.entries(columns)) {
+ const colName = (col as any).name || key;
+ historyColumnNames.add(colName);
+ const sqlType = mapType(
+ typeof (col as any).type === "string"
+ ? (col as any).type
+ : DataTypes[(col as any).type],
+ dialect,
+ );
+ colDefs.push(`${q(colName)} ${sqlType}`);
+ }
+ const historyText = textType(dialect);
+ colDefs.push(`${q("operation")} ${historyText}`);
+ // `writeHistory` always inserts a `version` column, so the table has
+ // to carry one even when the model declares no version column of its
+ // own — otherwise every versioned write fails with "no such column".
+ // The loop above already emitted it when the model declares one.
+ if (!historyColumnNames.has("version")) {
+ colDefs.push(`${q("version")} INTEGER`);
+ }
+ colDefs.push(`${q("valid_from")} ${historyText}`);
+ colDefs.push(`${q("valid_to")} ${historyText}`);
+ colDefs.push(`${q("modified_by")} ${historyText}`);
+ colDefs.push(`${q("modified_at")} ${historyText}`);
+ await db.migrationQuery(
+ createTableIfNotExistsSQL(
+ q(historyTable),
+ colDefs.join(", "),
+ db.config.type,
+ ),
+ );
}
}
+ }
+}
- for (const [idxName, idxMeta] of desiredIndexes) {
- if (!existingIndexes.has(idxName)) {
- const createIdx = buildCreateIndex(
- schema.tableName,
- idxName,
- idxMeta.columns,
- idxMeta.unique,
- dialect,
- );
- await db.migrationQuery(createIdx);
+async function createTableFromMeta(
+ db: DBClient,
+ meta: any,
+ tableName: string,
+ dialect: "sqlite" | "mysql" | "postgres" | "mssql",
+) {
+ const colDefs: string[] = [];
+ const columns = meta.columns;
+ const q = (name: string) => quoteIdentifier(name, db.config.type);
+
+ for (const [key, col] of Object.entries(columns)) {
+ const colName = (col as any).name || key;
+ const typeName = resolveTypeName((col as any).type);
+
+ if (key === "id") {
+ // Only an integer `id` gets the database's auto-increment primary key.
+ // A declared STRING/UUID `id` — the pattern in the README, the docs site
+ // and what `generate:model` scaffolds — used to be overridden with
+ // `INTEGER PRIMARY KEY AUTOINCREMENT` regardless, so every
+ // `create({ id: generateUUID() })` failed with "datatype mismatch":
+ // SQLite will not store a UUID in a rowid column. Honour the declared
+ // type instead, and emit it as the primary key.
+ if (isIntegerType(typeName)) {
+ colDefs.push(`${q(colName)} ${getAutoIncrementPK(dialect)}`);
+ continue;
}
+ const pkParts = [q(colName), mapType(typeName, dialect)];
+ // SQLite's one historical quirk: a PRIMARY KEY that is not an INTEGER
+ // PRIMARY KEY may still hold NULL, so NOT NULL has to be explicit.
+ pkParts.push("NOT NULL", "PRIMARY KEY");
+ colDefs.push(pkParts.join(" "));
+ continue;
}
- for (const [existingIdxName, existingIdxMeta] of existingIndexes) {
- let found = false;
- for (const [desiredName, desiredMeta] of desiredIndexes) {
- if (existingIdxName === desiredName) {
- found = true;
- break;
- }
- if (
- JSON.stringify(existingIdxMeta.columns.sort()) ===
- JSON.stringify(desiredMeta.columns.sort()) &&
- existingIdxMeta.unique === desiredMeta.unique
- ) {
- found = true;
- break;
- }
- }
- if (!found) {
- const dropIdx = buildDropIndex(existingIdxName, dialect);
- await db.migrationQuery(dropIdx);
- }
+ const parts: string[] = [q(colName)];
+ parts.push(mapType(typeName, dialect));
+ if ((col as any).required) parts.push("NOT NULL");
+ if ((col as any).unique) parts.push("UNIQUE");
+ if ((col as any).defaultValue !== undefined) {
+ parts.push(`DEFAULT ${JSON.stringify((col as any).defaultValue)}`);
}
+ colDefs.push(parts.join(" "));
}
+
+ // Timestamps. Skipped when the model already declares the column in
+ // `columns` (the documented pattern), which would otherwise produce a
+ // duplicate column and make the CREATE TABLE fail.
+ if (meta.timestamps) {
+ const declared = new Set(
+ Object.entries(columns).map(([key, col]: [string, any]) => col.name || key),
+ );
+ if (meta.timestamps.createdAt && !declared.has(meta.timestamps.createdAt)) {
+ colDefs.push(`${q(meta.timestamps.createdAt)} ${textType(dialect)}`);
+ }
+ if (meta.timestamps.updatedAt && !declared.has(meta.timestamps.updatedAt)) {
+ colDefs.push(`${q(meta.timestamps.updatedAt)} ${textType(dialect)}`);
+ }
+ }
+
+ // Version column
+ if (meta.versioned) {
+ if (!columns.version) {
+ colDefs.push(`${q("version")} INTEGER DEFAULT 1`);
+ }
+ }
+
+ await db.migrationQuery(
+ createTableIfNotExistsSQL(
+ q(tableName),
+ colDefs.join(", "),
+ db.config.type,
+ ),
+ );
+}
+
+async function createTableFromSchema(
+ db: DBClient,
+ schema: any,
+ tableName: string,
+ dialect: "sqlite" | "mysql" | "postgres" | "mssql",
+) {
+ const parts: string[] = [];
+ const q = (name: string) => quoteIdentifier(name, db.config.type);
+ for (const [col, meta] of Object.entries(schema.columns)) {
+ const m = meta as any;
+ const dialectMap: Record> = {
+ sqlite: {
+ string: "TEXT",
+ number: "INTEGER",
+ boolean: "INTEGER",
+ date: "TEXT",
+ json: "TEXT",
+ },
+ mysql: {
+ string: "VARCHAR(255)",
+ number: "INT",
+ boolean: "TINYINT(1)",
+ date: "DATETIME",
+ json: "JSON",
+ },
+ postgres: {
+ string: "TEXT",
+ number: "INTEGER",
+ boolean: "BOOLEAN",
+ date: "TIMESTAMP",
+ json: "JSONB",
+ },
+ mssql: {
+ string: "NVARCHAR(255)",
+ number: "INT",
+ boolean: "BIT",
+ date: "DATETIME2",
+ json: "NVARCHAR(MAX)",
+ },
+ };
+ let sql = `${q(col)} ${dialectMap[dialect]?.[m.type] || "TEXT"}`;
+ if (m.primaryKey) sql += " PRIMARY KEY";
+ if (m.autoIncrement) {
+ sql +=
+ dialect === "mysql"
+ ? " AUTO_INCREMENT"
+ : dialect === "postgres"
+ ? " GENERATED ALWAYS AS IDENTITY"
+ : dialect === "mssql"
+ ? " IDENTITY(1,1)"
+ : " AUTOINCREMENT";
+ }
+ if (!m.nullable) sql += " NOT NULL";
+ if (m.default !== undefined) sql += ` DEFAULT ${JSON.stringify(m.default)}`;
+ parts.push(sql);
+ }
+ await db.migrationQuery(
+ `CREATE TABLE IF NOT EXISTS ${q(tableName)} (${parts.join(", ")})`,
+ );
}
(DBClient.prototype as any).autoMigrate = async function (models: any) {
@@ -354,15 +613,20 @@ export async function resetDatabase(
): Promise {
const list = Array.isArray(models) ? models : [models];
for (const model of list) {
- const schema = extractSchema(model);
- try {
- await db.migrationQuery(`DROP TABLE IF EXISTS "${schema.tableName}"`);
- } catch {}
- try {
- await db.migrationQuery(
- `DROP TABLE IF EXISTS "${schema.tableName}_history"`,
- );
- } catch {}
+ const meta = MetadataStorage.getModelMetadata(model);
+ const tableName = meta?.tableName || model.schema?.tableName;
+ if (tableName) {
+ try {
+ await db.migrationQuery(
+ `DROP TABLE IF EXISTS ${quoteIdentifier(tableName, db.config.type)}`,
+ );
+ } catch {}
+ try {
+ await db.migrationQuery(
+ `DROP TABLE IF EXISTS ${quoteIdentifier(`${tableName}_history`, db.config.type)}`,
+ );
+ } catch {}
+ }
}
await autoMigrate(db, list);
}
diff --git a/bun.lock b/bun.lock
index 9a0b5a9..656c999 100644
--- a/bun.lock
+++ b/bun.lock
@@ -4,34 +4,70 @@
"": {
"name": "stabilize",
"dependencies": {
- "@types/pg": "^8.15.5",
- "@types/uuid": "^11.0.0",
- "commander": "^12.1.0",
- "figlet": "^1.9.3",
- "glob": "^11.0.0",
- "ioredis": "^5.4.1",
+ "ioredis": "^5.8.1",
+ "mssql": "^12.7.2",
"mysql2": "^3.15.2",
"pg": "^8.16.3",
- "reflect-metadata": "^0.2.2",
- "uuid": "^13.0.0",
},
"devDependencies": {
- "@typescript-eslint/eslint-plugin": "^8.7.0",
- "@typescript-eslint/parser": "^8.7.0",
- "@vitest/coverage-v8": "^2.1.3",
- "eslint": "^9.12.0",
- "prettier": "^3.3.3",
- "typescript": "^5.6.3",
- "vitest": "^2.1.3",
+ "@types/pg": "^8.15.5",
+ "@types/uuid": "^11.0.0",
+ "@typescript-eslint/eslint-plugin": "^8.46.1",
+ "@typescript-eslint/parser": "^8.46.1",
+ "@vitest/coverage-v8": "^2.1.9",
+ "eslint": "^9.38.0",
+ "prettier": "^3.6.2",
+ "typescript": "^5.9.3",
+ "vitest": "^2.1.9",
+ },
+ "optionalDependencies": {
+ "mongodb": "^6.20.0",
},
"peerDependencies": {
"bun": ">=1.0.0",
},
+ "optionalPeers": [
+ "bun",
+ ],
},
},
"packages": {
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
+ "@azure-rest/core-client": ["@azure-rest/core-client@2.9.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.24.0", "@azure/core-tracing": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.8", "tslib": "^2.6.2" } }, "sha512-6933vNLqh06RR7rumnrq3UZIeBtRLeBHDPMrieDPIljiCaMMwt0nRw++nd3TOxiohtasNtm29rC+b/9ZVKCOqg=="],
+
+ "@azure/abort-controller": ["@azure/abort-controller@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg=="],
+
+ "@azure/core-auth": ["@azure/core-auth@1.11.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-util": "^1.13.0", "tslib": "^2.6.2" } }, "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg=="],
+
+ "@azure/core-client": ["@azure/core-client@1.11.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "tslib": "^2.6.2" } }, "sha512-2QygG2F76ZpMP2eMztiJvAiFMu71M9rDeU7vO/QKg5Css7MgM4frUOslFjhVjRhbGaCNPtz/S8M6y46/fFKVuQ=="],
+
+ "@azure/core-lro": ["@azure/core-lro@2.7.2", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-util": "^1.2.0", "@azure/logger": "^1.0.0", "tslib": "^2.6.2" } }, "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw=="],
+
+ "@azure/core-paging": ["@azure/core-paging@1.7.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-7GEAoIsaoBr6KELNRb8nypowCqvk8dnCHFCYg4XD4lOQGY2GqjQg5IhkRjyBFRO18CGSMq05PaNqSOE9GQro3g=="],
+
+ "@azure/core-process": ["@azure/core-process@1.0.0", "", {}, "sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg=="],
+
+ "@azure/core-rest-pipeline": ["@azure/core-rest-pipeline@1.25.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.4", "tslib": "^2.6.2" } }, "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA=="],
+
+ "@azure/core-tracing": ["@azure/core-tracing@1.4.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ=="],
+
+ "@azure/core-util": ["@azure/core-util@1.14.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw=="],
+
+ "@azure/identity": ["@azure/identity@4.13.2", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", "@azure/core-process": "^1.0.0", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "@azure/msal-browser": "^5.5.0", "@azure/msal-node": "^5.1.5", "open": "^10.1.0", "tslib": "^2.2.0" } }, "sha512-NXL2/pCJctLxgw8bvrwwgge743kEq8LBT+O1pmV0vyUwetzFPH9auP6jhkU/cgZCPPtWoewAe3ncaGCgPo07fA=="],
+
+ "@azure/keyvault-common": ["@azure/keyvault-common@2.1.0", "", { "dependencies": { "@azure-rest/core-client": "^2.3.3", "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.3.0", "@azure/core-rest-pipeline": "^1.8.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.10.0", "@azure/logger": "^1.1.4", "tslib": "^2.2.0" } }, "sha512-aCDidWuKY06LWQ4x7/8TIXK6iRqTaRWRL3t7T+LC+j1b07HtoIsOxP/tU90G4jCSBn5TAyUTCtA4MS/y5Hudaw=="],
+
+ "@azure/keyvault-keys": ["@azure/keyvault-keys@4.10.2", "", { "dependencies": { "@azure-rest/core-client": "^2.3.3", "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-lro": "^2.7.2", "@azure/core-paging": "^1.6.2", "@azure/core-rest-pipeline": "^1.19.0", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "@azure/keyvault-common": "^2.1.0", "@azure/logger": "^1.1.4", "tslib": "^2.8.1" } }, "sha512-VmUSLbXRAbSzDD8grXHGPaknYs0SKr3yuf6U+d4XMpX4XuVYskNqbTTwXce0zR1LyxfTZm9rWEBcvs3vdYwCmQ=="],
+
+ "@azure/logger": ["@azure/logger@1.4.0", "", { "dependencies": { "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA=="],
+
+ "@azure/msal-browser": ["@azure/msal-browser@5.21.0", "", { "dependencies": { "@azure/msal-common": "16.14.0" } }, "sha512-80OcuXDErmcEDAIH9pBtSqBsed2sPT/IWmbG3xHLoPMl5zc8TINd6SlJAbVSmN5huGa3xGAg5qR7VnpaIEK0Zw=="],
+
+ "@azure/msal-common": ["@azure/msal-common@16.14.0", "", {}, "sha512-A4rb55hI86Q9tBl/+jBj7TMz7iX2RFgQs/nExFzcAtoI/BFRVdaH5SL/MivrYD7qvweMpN8AgVvVMHV8UBYxew=="],
+
+ "@azure/msal-node": ["@azure/msal-node@5.6.0", "", { "dependencies": { "@azure/msal-common": "16.13.0", "jsonwebtoken": "^9.0.0" } }, "sha512-uFY9NxrWHw8PwZx7gAX6PDn+9vdfS05+levc/kwkx77IkjfaldnQbbcQzzDIZ5Hq5Zdr6/z92oAIoRWKp6MnOA=="],
+
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="],
@@ -116,10 +152,6 @@
"@ioredis/commands": ["@ioredis/commands@1.4.0", "", {}, "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ=="],
- "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="],
-
- "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="],
-
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@istanbuljs/schema": ["@istanbuljs/schema@0.1.3", "", {}, "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA=="],
@@ -132,34 +164,16 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+ "@js-joda/core": ["@js-joda/core@6.1.0", "", {}, "sha512-H8NTMRDJqad/leyv/D/A3kSOsf5/58Ydj4DJGDyaCWk9OU/zuZOLhndVffJgQjsgrn5GC0znHMHie7TfvPPG4w=="],
+
+ "@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.5.4", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-05UC0jQsjKAOuXQ0H9Ud9vUTJpZIg+n/FinpR30tI5I8pY2inTfPOZ5OF/cg3Ce/N9MoD1xhRCeOsJtuTbFYlw=="],
+
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
- "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WeXSaL29ylJEZMYHHW28QZ6rgAbxQ1KuNSZD9gvd3fPlo0s6s2PglvPArjjP07nmvIK9m4OffN0k4M98O7WmAg=="],
-
- "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CFKjoUWQH0Oz3UHYfKbdKLq0wGryrFsTJEYq839qAwHQSECvVZYAnxVVDYUDa0yQFonhO2qSHY41f6HK+b7xtw=="],
-
- "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-+FSr/ub5vA/EkD3fMhHJUzYioSf/sXd50OGxNDAntVxcDu4tXL/81Ka3R/gkZmjznpLFIzovU/1Ts+b7dlkrfw=="],
-
- "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WHthS/eLkCNcp9pk4W8aubRl9fIUgt2XhHyLrP0GClB1FVvmodu/zIOtG0NXNpzlzB8+gglOkGo4dPjfVf4Z+g=="],
-
- "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-HT5sr7N8NDYbQRjAnT7ISpx64y+ewZZRQozOJb0+KQObKvg4UUNXGm4Pn1xA4/WPMZDDazjO8E2vtOQw1nJlAQ=="],
-
- "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-sGEWoJQXO4GDr0x4t/yJQ/Bq1yNkOdX9tHbZZ+DBGJt3z3r7jeb4Digv8xQUk6gdTFC9vnGHuin+KW3/yD1Aww=="],
-
- "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-OmlEH3nlxQyv7HOvTH21vyNAZGv9DIPnrTznzvKiOQxkOphhCyKvPTlF13ydw4s/i18iwaUrhHy+YG9HSSxa4Q=="],
-
- "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rtzUEzCynl3Rhgn/iR9DQezSFiZMcAXAbU+xfROqsweMGKwvwIA2ckyyckO08psEP8XcUZTs3LT9CH7PnaMiEA=="],
-
- "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-hrr7mDvUjMX1tuJaXz448tMsgKIqGJBY8+rJqztKOw1U5+a/v2w5HuIIW1ce7ut0ZwEn+KIDvAujlPvpH33vpQ=="],
-
- "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-xXwtpZVVP7T+vkxcF/TUVVOGRjEfkByO4mKveKYb4xnHWV4u4NnV0oNmzyMKkvmj10to5j2h0oZxA4ZVVv4gfA=="],
-
- "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/jVZ8eYjpYHLDFNoT86cP+AjuWvpkzFY+0R0a1bdeu0sQ6ILuy1FV6hz1hUAP390E09VCo5oP76fnx29giHTtA=="],
-
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.4", "", { "os": "android", "cpu": "arm" }, "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA=="],
@@ -206,6 +220,8 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.4", "", { "os": "win32", "cpu": "x64" }, "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w=="],
+ "@tediousjs/connection-string": ["@tediousjs/connection-string@1.1.0", "", {}, "sha512-z9ZBWEG+8pIB5V1zYzlRPXx0oRJ5H7coPnMQK8EZOw03UTPI9Umn6viL36f5w+CuqkKsnCM50RVStpjZmR0Bng=="],
+
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
@@ -214,8 +230,14 @@
"@types/pg": ["@types/pg@8.15.5", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ=="],
+ "@types/readable-stream": ["@types/readable-stream@4.0.24", "", { "dependencies": { "@types/node": "*" } }, "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg=="],
+
"@types/uuid": ["@types/uuid@11.0.0", "", { "dependencies": { "uuid": "*" } }, "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA=="],
+ "@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="],
+
+ "@types/whatwg-url": ["@types/whatwg-url@11.0.5", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ=="],
+
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.46.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/type-utils": "8.46.1", "@typescript-eslint/utils": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.46.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.46.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", "@typescript-eslint/typescript-estree": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA=="],
@@ -236,6 +258,8 @@
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.46.1", "", { "dependencies": { "@typescript-eslint/types": "8.46.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA=="],
+ "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.9", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A=="],
+
"@vitest/coverage-v8": ["@vitest/coverage-v8@2.1.9", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^0.2.3", "debug": "^4.3.7", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magic-string": "^0.30.12", "magicast": "^0.3.5", "std-env": "^3.8.0", "test-exclude": "^7.0.1", "tinyrainbow": "^1.2.0" }, "peerDependencies": { "@vitest/browser": "2.1.9", "vitest": "2.1.9" }, "optionalPeers": ["@vitest/browser"] }, "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ=="],
"@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="],
@@ -252,10 +276,14 @@
"@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="],
+ "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
+
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
+ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
+
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
@@ -270,11 +298,21 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
+ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
+
+ "bl": ["bl@6.1.6", "", { "dependencies": { "@types/readable-stream": "^4.0.0", "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^4.2.0" } }, "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg=="],
+
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
- "bun": ["bun@1.3.0", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.0", "@oven/bun-darwin-x64": "1.3.0", "@oven/bun-darwin-x64-baseline": "1.3.0", "@oven/bun-linux-aarch64": "1.3.0", "@oven/bun-linux-aarch64-musl": "1.3.0", "@oven/bun-linux-x64": "1.3.0", "@oven/bun-linux-x64-baseline": "1.3.0", "@oven/bun-linux-x64-musl": "1.3.0", "@oven/bun-linux-x64-musl-baseline": "1.3.0", "@oven/bun-windows-x64": "1.3.0", "@oven/bun-windows-x64-baseline": "1.3.0" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-YI7mFs7iWc/VsGsh2aw6eAPD2cjzn1j+LKdYVk09x1CrdTWKYIHyd+dG5iQoN9//3hCDoZj8U6vKpZzEf5UARA=="],
+ "bson": ["bson@6.10.4", "", {}, "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng=="],
+
+ "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
+
+ "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
+
+ "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
@@ -292,7 +330,7 @@
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
- "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
+ "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
@@ -304,10 +342,18 @@
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
+ "default-browser": ["default-browser@5.5.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw=="],
+
+ "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="],
+
+ "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
+
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
+ "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
+
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
@@ -334,6 +380,10 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
+ "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
+
+ "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
+
"expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@@ -346,8 +396,6 @@
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
- "figlet": ["figlet@1.9.3", "", { "dependencies": { "commander": "^14.0.0" }, "bin": { "figlet": "bin/index.js" } }, "sha512-majPgOpVtrZN1iyNGbsUP6bOtZ6eaJgg5HHh0vFvm5DJhh8dc+FJpOC4GABvMZ/A7XHAJUuJujhgUY/2jPWgMA=="],
-
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -364,7 +412,7 @@
"generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="],
- "glob": ["glob@11.0.3", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.0.3", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA=="],
+ "glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
@@ -376,26 +424,40 @@
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
+ "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
+
+ "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
+
"iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="],
+ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
+
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
+ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
+
"ioredis": ["ioredis@5.8.1", "", { "dependencies": { "@ioredis/commands": "1.4.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-Qho8TgIamqEPdgiMadJwzRMW3TudIg6vpg4YONokGDudy4eqRIJtDbVX72pfLBcWxvbn3qm/40TyGUObdW4tLQ=="],
+ "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
+
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
+ "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
+
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
+ "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
+
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
@@ -406,7 +468,9 @@
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
- "jackspeak": ["jackspeak@4.1.1", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" } }, "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ=="],
+ "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
+
+ "js-md4": ["js-md4@0.3.2", "", {}, "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA=="],
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
@@ -416,6 +480,12 @@
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
+ "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="],
+
+ "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
+
+ "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
+
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
@@ -424,15 +494,29 @@
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
+ "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
+
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
+ "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
+
+ "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="],
+
+ "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="],
+
+ "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
+
+ "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="],
+
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
+ "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="],
+
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
- "lru-cache": ["lru-cache@11.2.2", "", {}, "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="],
+ "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
"lru.min": ["lru.min@1.1.2", "", {}, "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg=="],
@@ -442,6 +526,8 @@
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
+ "memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="],
+
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
@@ -450,16 +536,26 @@
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
+ "mongodb": ["mongodb@6.21.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.3.0", "bson": "^6.10.4", "mongodb-connection-string-url": "^3.0.2" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.188.0", "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", "gcp-metadata": "^5.2.0", "kerberos": "^2.0.1", "mongodb-client-encryption": ">=6.0.0 <7", "snappy": "^7.3.2", "socks": "^2.7.1" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A=="],
+
+ "mongodb-connection-string-url": ["mongodb-connection-string-url@3.0.2", "", { "dependencies": { "@types/whatwg-url": "^11.0.2", "whatwg-url": "^14.1.0 || ^13.0.0" } }, "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA=="],
+
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+ "mssql": ["mssql@12.7.2", "", { "dependencies": { "@tediousjs/connection-string": "^1.0.0", "commander": "^11.0.0", "debug": "^4.3.3", "tarn": "^3.0.2", "tedious": "^19.2.2 || ^20.0.0" }, "bin": { "mssql": "bin/mssql" } }, "sha512-zhpPw+WXBWLw7d7J6Y0aWgQy0EV8HKDGNSvmkAjP8bSM4H41X0fweAeKWI+bg9Ri+JHCbsf/QScjmACBFcEo8A=="],
+
"mysql2": ["mysql2@3.15.2", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-kFm5+jbwR5mC+lo+3Cy46eHiykWSpUtTLOH3GE+AR7GeLq8PgfJcvpMiyVWk9/O53DjQsqm6a3VOOfq7gYWFRg=="],
"named-placeholders": ["named-placeholders@1.1.3", "", { "dependencies": { "lru-cache": "^7.14.1" } }, "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+ "native-duplexpair": ["native-duplexpair@1.0.0", "", {}, "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA=="],
+
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
+ "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
+
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
@@ -474,7 +570,7 @@
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
- "path-scurry": ["path-scurry@2.0.0", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg=="],
+ "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
@@ -514,24 +610,30 @@
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
+ "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
+
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
+ "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
+
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
- "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
-
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rollup": ["rollup@4.52.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.4", "@rollup/rollup-android-arm64": "4.52.4", "@rollup/rollup-darwin-arm64": "4.52.4", "@rollup/rollup-darwin-x64": "4.52.4", "@rollup/rollup-freebsd-arm64": "4.52.4", "@rollup/rollup-freebsd-x64": "4.52.4", "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", "@rollup/rollup-linux-arm-musleabihf": "4.52.4", "@rollup/rollup-linux-arm64-gnu": "4.52.4", "@rollup/rollup-linux-arm64-musl": "4.52.4", "@rollup/rollup-linux-loong64-gnu": "4.52.4", "@rollup/rollup-linux-ppc64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-musl": "4.52.4", "@rollup/rollup-linux-s390x-gnu": "4.52.4", "@rollup/rollup-linux-x64-gnu": "4.52.4", "@rollup/rollup-linux-x64-musl": "4.52.4", "@rollup/rollup-openharmony-arm64": "4.52.4", "@rollup/rollup-win32-arm64-msvc": "4.52.4", "@rollup/rollup-win32-ia32-msvc": "4.52.4", "@rollup/rollup-win32-x64-gnu": "4.52.4", "@rollup/rollup-win32-x64-msvc": "4.52.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ=="],
+ "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
+
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
+ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
+
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@@ -548,8 +650,12 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+ "sparse-bitfield": ["sparse-bitfield@3.0.3", "", { "dependencies": { "memory-pager": "^1.0.2" } }, "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ=="],
+
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
+ "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
+
"sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
@@ -562,6 +668,8 @@
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
+ "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
+
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -570,6 +678,10 @@
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
+ "tarn": ["tarn@3.1.2", "", {}, "sha512-3RTvqKZcK/17jnJ8rMKFXbyNogywTs1z0gVPPwFsJGX46rkmUHOdIaSQ/aVO1rS7nH+soiXiWk7rvUXxndm8Dg=="],
+
+ "tedious": ["tedious@20.0.0", "", { "dependencies": { "@azure/core-auth": "^1.10.1", "@azure/identity": "^4.13.1", "@azure/keyvault-keys": "^4.10.2", "@js-joda/core": "^6.0.1", "@types/node": ">=22", "bl": "^6.1.4", "iconv-lite": "^0.7.0", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-bTR0aou0Ghucf0ytvZUJjnKHGKDV8tT57jPYtEkSpfTWFe++4uR1wxJLQ4mh5wlSvAYXzmgBxbI0vaE56qigXw=="],
+
"test-exclude": ["test-exclude@7.0.1", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^9.0.4" } }, "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
@@ -584,8 +696,12 @@
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
+ "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
+
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
@@ -602,6 +718,10 @@
"vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="],
+ "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
+
+ "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
+
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
@@ -612,10 +732,14 @@
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
+ "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
+
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
+ "@azure/msal-node/@azure/msal-common": ["@azure/msal-common@16.13.0", "", {}, "sha512-rOAy0KUcyBbdwVJ+f3uPpthXatFLLZN+/KWAsTLzk1aB23Xl9DRmmXYwSvBFOZyXj4jUQQ5FKxxRkhAFW1fOow=="],
+
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -626,11 +750,9 @@
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
- "figlet/commander": ["commander@14.0.1", "", {}, "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A=="],
+ "glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
- "glob/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="],
-
- "named-placeholders/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
+ "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -638,8 +760,6 @@
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
- "test-exclude/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
-
"test-exclude/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
@@ -650,18 +770,14 @@
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
- "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
-
- "test-exclude/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
+ "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
- "test-exclude/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
+ "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"test-exclude/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
-
- "test-exclude/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
}
}
diff --git a/client.ts b/client.ts
index 539179a..3df2743 100644
--- a/client.ts
+++ b/client.ts
@@ -7,6 +7,7 @@
import { Database, Statement } from "bun:sqlite";
import { Pool, type PoolClient } from "pg";
import mysql from "mysql2/promise";
+import sql from "mssql";
import { type DBConfig, StabilizeError, DBType } from "./types";
import { type Logger, StabilizeLogger } from "./logger";
@@ -28,6 +29,154 @@ function isMySQLConfig(config: DBConfig): boolean {
return config.type === DBType.MySQL;
}
+/**
+ * Checks if the DB configuration is for SQL Server.
+ * @param config The database configuration object.
+ * @returns True if the configuration is for SQL Server, false otherwise.
+ */
+function isMSSQLConfig(config: DBConfig): boolean {
+ return config.type === DBType.MSSQL;
+}
+
+/**
+ * Rewrites the library's `?` placeholders into a dialect's own parameter
+ * syntax.
+ *
+ * Every statement the ORM generates is written with `?`, and each driver
+ * numbers its parameters differently: PostgreSQL uses `$1`, SQL Server uses
+ * `@param0`. MySQL and SQLite take `?` as written, so their input is returned
+ * untouched.
+ *
+ * Exported, and kept free of any client state, so the rewrite can be asserted
+ * directly rather than through a live connection.
+ *
+ * @param query The SQL statement using `?` placeholders.
+ * @param dbType The target database dialect.
+ * @returns The statement with placeholders in the dialect's syntax.
+ */
+export function rewritePlaceholders(query: string, dbType: DBType): string {
+ if (dbType === DBType.Postgres) {
+ let paramIndex = 0;
+ return query.replace(/\?/g, () => `$${++paramIndex}`);
+ }
+ if (dbType === DBType.MSSQL) {
+ let paramIndex = 0;
+ return query.replace(/\?/g, () => `@param${paramIndex++}`);
+ }
+ return query;
+}
+
+/**
+ * Binds one positional parameter to an mssql request.
+ *
+ * mssql infers a parameter's type from the value it is handed, and has nothing
+ * to infer from for `null` or `undefined` — the request would be sent with a
+ * type the server rejects. Those are therefore bound explicitly as a nullable
+ * `NVARCHAR`, which every column type accepts as a NULL.
+ *
+ * A plain object or array needs the same treatment for a different reason.
+ * Only `pg` serialises an object parameter to JSON on the way out; mssql has no
+ * such fallback and fails the whole statement with "Validation failed for
+ * parameter 'paramN'. Invalid string.", and `mysql2` does something worse still
+ * (@see bindMySQLParams). Encoding it here gives SQL Server the behaviour
+ * Postgres has.
+ *
+ * `Date` and `Buffer` are left to mssql, which infers a correct type for both.
+ *
+ * Exported so the binding rules can be asserted against a stub rather than a
+ * server.
+ *
+ * @param request The mssql request to bind onto.
+ * @param index The parameter's position, zero-based.
+ * @param value The value to bind.
+ */
+export function bindMSSQLParam(
+ request: { input: (name: string, typeOrValue: any, value?: any) => any },
+ index: number,
+ value: any,
+): void {
+ const name = `param${index}`;
+ if (value === null || value === undefined) {
+ request.input(name, sql.NVarChar, null);
+ } else if (isPlainJsonValue(value)) {
+ request.input(name, sql.NVarChar, JSON.stringify(value));
+ } else {
+ request.input(name, value);
+ }
+}
+
+/**
+ * Reports whether a value should be sent to a JSON column as JSON text.
+ *
+ * Only plain objects and arrays qualify. Anything with its own prototype —
+ * `Date`, `Buffer`, a class instance — carries meaning the drivers already know
+ * how to encode, and stringifying it would silently corrupt the column.
+ *
+ * @param value The parameter value.
+ * @returns True when the value should be JSON-encoded.
+ */
+export function isPlainJsonValue(value: any): boolean {
+ if (Array.isArray(value)) return true;
+ if (typeof value !== "object" || value === null) return false;
+ const proto = Object.getPrototypeOf(value);
+ return proto === Object.prototype || proto === null;
+}
+
+/**
+ * Encodes the parameters of a MySQL-family statement for the driver.
+ *
+ * `mysql2` does not serialise an object to JSON the way `pg` does. It treats a
+ * plain object as a set of assignments — `{ nested: 1 }` binds as
+ * `` `nested` = 1 `` — which is meaningful only in an `UPDATE … SET` list and
+ * is a syntax error anywhere else. Bound inside a `VALUES` clause it rewrites
+ * the statement into one the server cannot parse: a single object parameter
+ * becomes several, and either the column count no longer matches ("Column
+ * count doesn't match value count at row 1", ER_WRONG_VALUE_COUNT_ON_ROW 1136)
+ * or the object's own keys are read as column names ("Unknown column 'nested'
+ * in 'field list'", ER_BAD_FIELD_ERROR 1054). A JSON column is therefore
+ * unwritable unless the value reaches the driver as text, which is what this
+ * does — the same answer `bindMSSQLParam` gives PostgreSQL's behaviour to SQL
+ * Server.
+ *
+ * The array is returned as a new list rather than mutated: callers reuse the
+ * parameter array they built, and on MySQL-less paths it must stay untouched.
+ *
+ * @param params The statement's positional parameters.
+ * @returns The parameters as the driver should receive them.
+ */
+export function bindMySQLParams(params: any[]): any[] {
+ return params.map((value) =>
+ isPlainJsonValue(value) ? JSON.stringify(value) : value,
+ );
+}
+
+/** Statement prefixes that cannot change any data, and so can be replayed. */
+const READ_ONLY_PREFIXES = ["SELECT", "PRAGMA", "SHOW", "EXPLAIN", "VALUES"];
+
+/**
+ * Checks whether a statement is safe to run more than once.
+ *
+ * Used to decide whether a failed statement may be retried. Anything not
+ * recognised as a read is treated as a write, so an unfamiliar statement is
+ * run once rather than risk being applied twice.
+ *
+ * @param query The SQL statement.
+ * @returns True when the statement only reads.
+ */
+function isReadOnlyStatement(query: string): boolean {
+ // Leading comments and whitespace are stripped so `/* hint */ SELECT …`
+ // is still recognised.
+ const stripped = query
+ .replace(/^\s*(?:\/\*[\s\S]*?\*\/|--[^\n]*\n|\s)+/, "")
+ .toUpperCase();
+ return READ_ONLY_PREFIXES.some(
+ (prefix) =>
+ stripped.startsWith(prefix) &&
+ // Guard against a prefix matching a longer word, e.g. `SELECTED`.
+ !/^[A-Z_]/.test(stripped.slice(prefix.length)),
+ );
+}
+
/**
* Checks if the given client is a MySQL pool.
* @param client The database client.
@@ -37,6 +186,141 @@ function isMySQLPool(client: any): client is mysql.Pool {
return typeof client.getConnection === "function";
}
+/**
+ * The shape of the mssql handles this client stores.
+ *
+ * Deliberately structural rather than `sql.ConnectionPool | sql.Transaction`.
+ * Naming those types would make the emitted `client.d.ts` import `mssql`, and
+ * every consumer of the published package — including one that only ever talks
+ * to SQLite — would then need declarations for a driver it does not use. A pool
+ * exposes `connect`, `close` and `request`; an open transaction exposes
+ * `begin`, `commit` and `rollback`, and is recognised by `begin`.
+ */
+interface MSSQLHandle {
+ connect?: () => Promise;
+ close?: () => Promise;
+ request?: () => unknown;
+ begin?: (...args: any[]) => any;
+ commit?: (...args: any[]) => any;
+ rollback?: (...args: any[]) => any;
+}
+
+/**
+ * The shape of the MongoDB handles this client stores.
+ *
+ * Structural for the same reason `MSSQLHandle` is — naming the driver's own
+ * types would emit an import of the `mongodb` package into `client.d.ts`, and
+ * every consumer of the published package would then need declarations for a
+ * driver most of them never install.
+ *
+ * Unlike mssql, though, the driver *does* ship its own declarations, so there
+ * is deliberately no ambient shim module here: one would shadow the real types
+ * for the ORM build and for any consumer that does use the driver.
+ *
+ * A `MongoClient` is recognised by `db`, a `ClientSession` by `withTransaction`.
+ */
+interface MongoHandle {
+ connect?: () => Promise;
+ close?: () => Promise;
+ db?: (name?: string) => MongoDbHandle;
+ startSession?: () => MongoSessionHandle;
+}
+
+/** A `ClientSession`, which is what an open transaction actually is. */
+interface MongoSessionHandle {
+ withTransaction?: (...args: any[]) => any;
+ endSession?: () => Promise;
+}
+
+/** A `Db` — the handle collections are read from. */
+interface MongoDbHandle {
+ collection?: (name: string) => MongoCollectionHandle;
+ command?: (
+ command: Record,
+ options?: Record,
+ ) => Promise;
+ listCollections?: (...args: any[]) => MongoCursorHandle;
+ createCollection?: (...args: any[]) => Promise;
+ admin?: () => { command: (command: Record) => Promise };
+}
+
+/** A `Collection`. Only the members the ORM actually reaches for. */
+interface MongoCollectionHandle {
+ find: (...args: any[]) => MongoCursorHandle;
+ findOne: (...args: any[]) => Promise;
+ insertOne: (...args: any[]) => Promise;
+ insertMany: (...args: any[]) => Promise;
+ updateOne: (...args: any[]) => Promise;
+ updateMany: (...args: any[]) => Promise;
+ deleteOne: (...args: any[]) => Promise;
+ deleteMany: (...args: any[]) => Promise;
+ countDocuments: (...args: any[]) => Promise;
+ distinct: (...args: any[]) => Promise;
+ aggregate: (...args: any[]) => MongoCursorHandle;
+ findOneAndUpdate: (...args: any[]) => Promise;
+ bulkWrite: (...args: any[]) => Promise;
+ createIndex: (...args: any[]) => Promise;
+ listIndexes: (...args: any[]) => MongoCursorHandle;
+ drop?: (...args: any[]) => Promise;
+ indexes?: (...args: any[]) => Promise;
+}
+
+/** A `FindCursor` or `AggregationCursor`. */
+interface MongoCursorHandle {
+ toArray: () => Promise;
+ sort?: (...args: any[]) => MongoCursorHandle;
+ skip?: (...args: any[]) => MongoCursorHandle;
+ limit?: (...args: any[]) => MongoCursorHandle;
+ project?: (...args: any[]) => MongoCursorHandle;
+ hasNext?: () => Promise;
+ next?: () => Promise;
+ close?: () => Promise;
+}
+
+/** What a mongo write reports back. Field-for-field the driver's own result. */
+interface MongoUpdateResult {
+ acknowledged?: boolean;
+ matchedCount?: number;
+ modifiedCount?: number;
+ upsertedCount?: number;
+ upsertedId?: any;
+ insertedCount?: number;
+ deletedCount?: number;
+}
+
+/**
+ * Handles whose replica-set support has already been probed.
+ *
+ * A transaction-bound client shares its parent's `MongoClient` object, so
+ * without this the probe would run again on every transaction — and the probe
+ * is a round trip on the hottest path in the library.
+ */
+const replicaSetProbed = new WeakSet();
+
+/**
+ * Loads the MongoDB driver, which is an optional dependency.
+ *
+ * The specifier is held in a variable on purpose. A literal dynamic import is
+ * resolved statically by the bundler and by `tsc`, neither of which should
+ * require the driver to be present for a build or typecheck of the SQL
+ * backends.
+ *
+ * @returns The driver module.
+ * @throws StabilizeError when the driver is not installed.
+ */
+async function loadMongoDriver(): Promise {
+ const specifier = "mongodb";
+ try {
+ return await import(specifier);
+ } catch {
+ throw new StabilizeError(
+ "The MongoDB driver is not installed. DBType.MongoDB requires it as an " +
+ "optional peer: install it with `bun add mongodb` or `npm install mongodb`.",
+ "MONGO_DRIVER_MISSING",
+ );
+ }
+}
+
/**
* Provides a unified database client for interacting with PostgreSQL, MySQL, and SQLite.
*/
@@ -46,13 +330,40 @@ export class DBClient {
| Pool
| mysql.Pool
| PoolClient
- | mysql.PoolConnection;
+ | mysql.PoolConnection
+ | MSSQLHandle
+ | MongoHandle;
private logger: Logger;
public readonly config: DBConfig;
private retryAttempts: number;
private retryDelay: number;
private maxJitter: number;
+ /**
+ * The in-flight `connect()` on the mssql pool, if one has been started.
+ * Held so that concurrent callers share a single connection attempt rather
+ * than each opening one.
+ */
+ private mssqlConnectPromise: Promise | null = null;
+
+ /**
+ * The in-flight connect on the mongo client, held for the same reason as
+ * `mssqlConnectPromise`. It resolves to the connected handle so that callers
+ * that need to reach a collection do not have to re-derive it.
+ */
+ private mongoConnectPromise: Promise | null = null;
+
+ /**
+ * The mongo session every statement on this client should run inside.
+ *
+ * Kept beside `client` rather than *as* `client`, unlike mssql. A mongo
+ * transaction is not a different connection the way `sql.Transaction` is: the
+ * commands still go to the same `MongoClient`, and the session is passed
+ * alongside them as an option. Storing it separately is what lets a
+ * transaction-bound client still resolve its parent's collections.
+ */
+ private mongoSession: MongoSessionHandle | null = null;
+
private preparedStatements: Map = new Map();
public isTransactionClient: boolean = false;
@@ -60,12 +371,22 @@ export class DBClient {
* Constructs a new DBClient instance.
* @param config The database configuration object.
* @param logger Optional logger instance. Uses StabilizeLogger if not provided.
- * @param existingClient Optional existing transaction client.
+ * @param existingClient Optional existing transaction client. For SQL Server
+ * this is the `sql.Transaction` the statements should run inside; for
+ * MongoDB it is the parent `MongoClient`, shared with the session below.
+ * @param mongoSession Optional session that scopes statements to a
+ * transaction. Only MongoDB uses it.
*/
constructor(
config: DBConfig,
logger: Logger = new StabilizeLogger(),
- existingClient: PoolClient | mysql.PoolConnection | null = null,
+ existingClient:
+ | PoolClient
+ | mysql.PoolConnection
+ | MSSQLHandle
+ | MongoHandle
+ | null = null,
+ mongoSession: MongoSessionHandle | null = null,
) {
this.config = config;
this.logger = logger;
@@ -76,6 +397,7 @@ export class DBClient {
if (existingClient) {
this.client = existingClient;
this.isTransactionClient = true;
+ this.mongoSession = mongoSession;
} else {
this.initializeClient(config);
}
@@ -95,7 +417,260 @@ export class DBClient {
} else if (config.type === DBType.Postgres) {
this.client = new Pool({ connectionString: config.connectionString! });
this.logger.logDebug(`Initialized Postgres Pool client.`);
+ } else if (isMSSQLConfig(config)) {
+ // The pool object is built here but deliberately left unconnected:
+ // `ConnectionPool.connect()` is asynchronous and this method is called
+ // from the constructor, so awaiting it would make construction async for
+ // every driver. `ensureMSSQLConnected` opens it on first use instead.
+ this.client = new sql.ConnectionPool(config.connectionString);
+ this.logger.logDebug(`Initialized MSSQL Pool client.`);
+ } else if (config.type === DBType.MongoDB) {
+ // Same constraint as mssql, one step worse: the driver is an optional
+ // dependency, so reaching it needs `await import()` — which cannot happen
+ // from a constructor either. `ensureMongoConnected` does both the import
+ // and the connect on first use and fills `client` in then. Nothing may
+ // touch `this.client` for a mongo config before awaiting it.
+ this.client = null as unknown as MongoHandle;
+ this.logger.logDebug(`Deferred MongoDB client initialization.`);
+ }
+ }
+
+ /**
+ * Opens the mongo client, once, on first use.
+ *
+ * Performs the lazy `import()` of the optional driver and then `connect()`,
+ * mirroring `ensureMSSQLConnected`. The promise is memoised so concurrent
+ * first queries share one connection attempt.
+ *
+ * @returns The connected mongo handle.
+ * @throws StabilizeError when the driver is absent or a handle is malformed.
+ */
+ private async ensureMongoConnected(): Promise {
+ if (!this.mongoConnectPromise) {
+ this.mongoConnectPromise = this.openMongoClient();
+ }
+ return this.mongoConnectPromise;
+ }
+
+ /** Builds and connects the mongo client. See `ensureMongoConnected`. */
+ private async openMongoClient(): Promise {
+ let handle = this.client as MongoHandle | null;
+
+ if (!handle || typeof handle.db !== "function") {
+ const driver = await loadMongoDriver();
+ const options: Record = {
+ ...(this.config.mongoOptions ?? {}),
+ };
+ // The URI's own database wins when it has one; the driver only consults
+ // `dbName` when the path is empty.
+ if (this.config.database && !options.dbName) {
+ options.dbName = this.config.database;
+ }
+ handle = new driver.MongoClient(
+ this.config.connectionString,
+ options,
+ ) as MongoHandle;
+ this.client = handle;
+ }
+
+ if (typeof handle.connect === "function") {
+ await handle.connect();
+ }
+ this.logger.logDebug("MongoDB client connected.");
+ await this.assertReplicaSetOrExplained(handle);
+ return handle;
+ }
+
+ /**
+ * Warns, once per client, when the server cannot serve transactions.
+ *
+ * Every write in the ORM is wrapped in a transaction, and MongoDB only
+ * supports those on a replica set or sharded cluster. A standalone `mongod`
+ * accepts the connection, answers every read, and then rejects the first
+ * `startTransaction` with a bare `IllegalOperation` — so without this the
+ * failure surfaces as "create() does not work" with nothing pointing at the
+ * cause. A warning at connect time names it.
+ *
+ * Deliberately not fatal: reads work fine standalone, and refusing to connect
+ * would break the read-only use someone may legitimately have.
+ */
+ private async assertReplicaSetOrExplained(handle: MongoHandle): Promise {
+ if (replicaSetProbed.has(handle)) return;
+ replicaSetProbed.add(handle);
+
+ const db = handle.db?.(this.config.database);
+ const admin = db?.admin?.();
+ if (!admin || typeof admin.command !== "function") return;
+
+ try {
+ const hello = await admin.command({ hello: 1 });
+ if (hello && !hello.setName && !hello.msg) {
+ this.logger.logWarn(
+ "MongoDB is running as a standalone server. Transactions require a " +
+ "replica set, so every write — including create(), which the ORM " +
+ "always wraps in one — will fail with an IllegalOperation error. " +
+ "Start the server with --replSet and run rs.initiate(), or connect " +
+ "to an existing replica set.",
+ );
+ }
+ } catch (error) {
+ // A server that will not answer `hello` is not one this check can say
+ // anything useful about; the real error will surface on first use.
+ this.logger.logDebug(
+ `Could not probe MongoDB replica-set support: ${(error as Error).message}`,
+ );
+ }
+ }
+
+ /**
+ * Turns the driver's bare "Transaction numbers are only allowed on a replica
+ * set member or mongos" into an error that says what to do about it.
+ *
+ * Code 20 (`IllegalOperation`) is the one a standalone server returns from
+ * `startTransaction`. It is worth naming precisely because the symptom is so
+ * far from the cause: reads work, the connection is healthy, and only writes
+ * fail — because the ORM wraps every write in a transaction.
+ *
+ * @param error Whatever the driver threw.
+ * @returns A StabilizeError preserving the original as `cause`.
+ */
+ private explainMongoTransactionFailure(error: unknown): StabilizeError {
+ const code = (error as { code?: number })?.code;
+ const message = (error as Error)?.message ?? String(error);
+
+ // The infrastructure signature is looked for *first*, and deliberately not
+ // after the pass-through below. The executor family wraps every driver
+ // failure in a `MONGO_ERROR` whose own `code` is a string, so by the time a
+ // code-20 rejection gets here the number is gone and the driver's wording
+ // survives only inside the wrapper's message. Checking `instanceof` first
+ // would hand that wrapper straight back and report a standalone server as a
+ // generic mongo error.
+ if (code === 20 || /replica set|mongos/i.test(message)) {
+ return new StabilizeError(
+ "MongoDB transactions require a replica set or sharded cluster, and " +
+ "this server is a standalone. Every write goes through a transaction, " +
+ "so start the server with --replSet and run rs.initiate() (or point " +
+ `the connection at an existing replica set). Driver said: ${message}`,
+ "TX_ERROR",
+ error as Error,
+ );
+ }
+
+ // Anything the ORM has already classified — a validation failure, an
+ // optimistic-lock conflict, a row that is not there, or a write the driver
+ // refused — is the answer, and the four SQL branches all let theirs through
+ // untouched. Rewriting it as a transaction failure gave the caller the
+ // wrong code to branch on and the wrong thing to go and look at: a payload
+ // that failed validation sent the reader to the server's replica-set
+ // config.
+ if (error instanceof StabilizeError) return error;
+
+ return new StabilizeError(message, "TX_ERROR", error as Error);
+ }
+
+ /**
+ * Resolves the database handle statements should be issued against.
+ * @returns The connected `Db`.
+ * @throws StabilizeError when the handle exposes no `db()`.
+ */
+ private async mongoDb(): Promise {
+ const handle = await this.ensureMongoConnected();
+ const db = handle.db?.(this.config.database);
+ if (!db) {
+ throw new StabilizeError(
+ "MongoDB client did not provide a database handle.",
+ "MONGO_ERROR",
+ );
+ }
+ return db;
+ }
+
+ /**
+ * The options every mongo command must carry.
+ *
+ * A transaction-bound client contributes its session here; a plain one
+ * contributes nothing. Threading it through every executor is what makes a
+ * repository write performed inside `transaction()` actually participate in
+ * it, rather than silently committing on its own.
+ */
+ private mongoOptions(): Record {
+ return this.mongoSession ? { session: this.mongoSession } : {};
+ }
+
+ /**
+ * Rejects a SQL statement sent to a MongoDB client.
+ *
+ * There is no fifth branch to add to `query`: a mongo command is a document,
+ * not a string, so there is nothing for the SQL path to dispatch on. Failing
+ * loudly here means a caller who reached for `rawQuery` against mongo gets a
+ * sentence explaining why, rather than a driver-level parse error.
+ *
+ * @throws StabilizeError always, when this client is a mongo client.
+ */
+ private rejectSQLForMongo(): void {
+ throw new StabilizeError(
+ "Raw SQL is not available on MongoDB. The Mongo backend speaks commands " +
+ "and documents rather than statements, so rawQuery/rawExec and the " +
+ "query-builder's SQL-only clauses (join, union, whereRaw, orderByRaw, " +
+ "selectRaw, groupByRaw, having, whereExists) have no equivalent. Use " +
+ "the repository API or the query builder's structured methods instead.",
+ "MONGO_UNSUPPORTED",
+ );
+ }
+
+ /**
+ * Opens the mssql pool, once, on first use.
+ *
+ * `initializeClient` cannot do this — see the note there — so every statement
+ * awaits it before building its request. The promise is memoised so that
+ * concurrent first queries share one connection attempt.
+ *
+ * A client holding an `sql.Transaction` has nothing to connect: the
+ * transaction already owns a pooled connection, and `connect()` does not
+ * exist on it.
+ */
+ private async ensureMSSQLConnected(): Promise {
+ const pool = this.client as MSSQLHandle;
+ if (!pool || typeof pool.connect !== "function") return;
+ if (!this.mssqlConnectPromise) {
+ this.mssqlConnectPromise = pool.connect().then(() => {
+ this.logger.logDebug("MSSQL pool connected.");
+ });
}
+ await this.mssqlConnectPromise;
+ }
+
+ /**
+ * Builds the mssql request a statement should run through.
+ *
+ * A transaction-bound client holds an `sql.Transaction`, and `begin()` is
+ * what distinguishes it: a statement sent through a `Request` built from the
+ * transaction stays inside it, whereas one built from the pool would run on
+ * an unrelated connection and commit on its own.
+ */
+ private async mssqlRequest(): Promise {
+ const handle = this.client as MSSQLHandle;
+ if (handle && typeof handle.begin === "function") {
+ return new sql.Request(handle as any);
+ }
+ await this.ensureMSSQLConnected();
+ return new sql.Request(handle as any);
+ }
+
+ /**
+ * Sends one statement through a fresh mssql request and resolves its result.
+ *
+ * Parameter values are bound in order under the names the placeholder
+ * rewrite produced, so the statement the server sees carries neither more
+ * nor fewer parameters than were supplied.
+ */
+ private async runMSSQL(
+ query: string,
+ params: any[],
+ ): Promise<{ recordset: any[]; rowsAffected: number[] }> {
+ const request = await this.mssqlRequest();
+ params.forEach((value, index) => bindMSSQLParam(request, index, value));
+ return request.query(rewritePlaceholders(query, DBType.MSSQL));
}
/**
@@ -112,9 +687,16 @@ export class DBClient {
* @throws StabilizeError if all retry attempts fail.
*/
async query(query: string, params: any[] = []): Promise {
+ if (this.config.type === DBType.MongoDB) this.rejectSQLForMongo();
const start = Date.now();
- for (let attempt = 1; attempt <= this.retryAttempts; attempt++) {
+ // Only reads are retried. Every write in the ORM — insert, update, delete,
+ // upsert — goes through this method, and a failure that happened *after*
+ // the database committed (a dropped connection on the way back, say) would
+ // be retried and applied a second time. A read is safe to repeat.
+ const attempts = isReadOnlyStatement(query) ? this.retryAttempts : 1;
+
+ for (let attempt = 1; attempt <= attempts; attempt++) {
try {
let result: any;
@@ -126,13 +708,22 @@ export class DBClient {
}
result = stmt.all(...params);
} else if (this.config.type === DBType.MySQL) {
- const [rows] = await (this.client as mysql.Pool).query(query, params);
+ const [rows] = await (this.client as mysql.Pool).query(
+ query,
+ bindMySQLParams(params),
+ );
result = rows;
} else if (this.config.type === DBType.Postgres) {
- let paramIndex = 0;
- const pgQuery = query.replace(/\?/g, () => `$${++paramIndex}`);
- const pgResult = await (this.client as Pool).query(pgQuery, params);
+ const pgResult = await (this.client as Pool).query(
+ rewritePlaceholders(query, DBType.Postgres),
+ params,
+ );
result = Array.isArray(pgResult.rows) ? pgResult.rows : [];
+ } else if (this.config.type === DBType.MSSQL) {
+ const mssqlResult = await this.runMSSQL(query, params);
+ result = Array.isArray(mssqlResult.recordset)
+ ? mssqlResult.recordset
+ : [];
} else {
throw new StabilizeError(
"Unknown database client type",
@@ -145,9 +736,9 @@ export class DBClient {
return Array.isArray(result) ? (result as T[]) : [];
} catch (error) {
this.logger.logError(error as Error);
- if (attempt === this.retryAttempts) {
+ if (attempt === attempts) {
throw new StabilizeError(
- `Query failed after ${this.retryAttempts} attempts: ${(error as Error).message}`,
+ `Query failed after ${attempts} attempt${attempts === 1 ? "" : "s"}: ${(error as Error).message}`,
"QUERY_ERROR",
);
}
@@ -178,10 +769,102 @@ export class DBClient {
if (this.isTransactionClient) return callback(this);
if (this.client instanceof Database) {
- const tx = this.client.transaction(async () => {
- return await callback(this);
- });
- return await tx();
+ // `bun:sqlite`'s own `db.transaction()` is synchronous: it issues
+ // COMMIT as soon as the callback returns, and an async callback returns
+ // a pending promise at its first `await`. The COMMIT would therefore
+ // land before the work finished, so a later throw rolled nothing back
+ // and every write in the library ran non-atomically. Drive the
+ // transaction explicitly instead, which awaits properly.
+ this.logger.logDebug("Starting SQLite transaction.");
+ // SQLite runs on a single connection, so the callback receives this same
+ // client rather than a new one. Mark it as being inside a transaction for
+ // the duration: without that, a nested `transaction()` (a repository
+ // write inside the caller's transaction) would issue a second BEGIN and
+ // fail with "cannot start a transaction within a transaction".
+ const wasTransactionClient = this.isTransactionClient;
+ this.isTransactionClient = true;
+ await this.migrationQuery("BEGIN");
+ try {
+ const result = await callback(this);
+ await this.migrationQuery("COMMIT");
+ return result;
+ } catch (error) {
+ try {
+ await this.migrationQuery("ROLLBACK");
+ } catch (rollbackError) {
+ this.logger.logError(rollbackError as Error);
+ }
+ throw error;
+ } finally {
+ this.isTransactionClient = wasTransactionClient;
+ }
+ }
+
+ if (this.config.type === DBType.MongoDB) {
+ const handle = await this.ensureMongoConnected();
+ if (typeof handle.startSession !== "function") {
+ throw new StabilizeError(
+ "MongoDB client cannot start a session, so transactions are unavailable.",
+ "TX_ERROR",
+ );
+ }
+
+ const session = handle.startSession();
+ // The transaction-bound client keeps the parent's `MongoClient` and adds
+ // the session, because mongo commands carry a session rather than being
+ // sent through a different connection.
+ const txClient = new DBClient(this.config, this.logger, handle, session);
+ this.logger.logDebug("Starting MongoDB transaction.");
+
+ try {
+ // `withTransaction` rather than an explicit start/commit pair: it
+ // replays the callback when the server reports a transient error, which
+ // is exactly what a write conflict on a per-table counter document
+ // produces when two creates allocate ids at once. Reproducing that by
+ // hand would mean re-running caller code from inside this method.
+ return await session.withTransaction!(() => callback(txClient), {
+ readConcern: { level: "snapshot" },
+ writeConcern: { w: "majority" },
+ });
+ } catch (error) {
+ throw this.explainMongoTransactionFailure(error);
+ } finally {
+ // The session is a server-side resource and leaks if it is not ended,
+ // whether the transaction committed or not.
+ try {
+ await session.endSession?.();
+ } catch (endError) {
+ this.logger.logError(endError as Error);
+ }
+ }
+ }
+
+ if (this.config.type === DBType.MSSQL) {
+ // SQL Server has no `BEGIN`/`COMMIT` text: the transaction is a
+ // server-side object opened on a borrowed pooled connection, and every
+ // statement inside it has to be sent through a `Request` built from that
+ // object. `Database` cannot appear here, so the pool handle is safe to
+ // treat as a pool — the constructor marks a transaction-bound client and
+ // the guard above returns early for it.
+ await this.ensureMSSQLConnected();
+ const transaction = new sql.Transaction(this.client as any);
+ const txClient = new DBClient(this.config, this.logger, transaction);
+ this.logger.logDebug("Starting MSSQL transaction.");
+ await transaction.begin();
+ try {
+ const result = await callback(txClient);
+ await transaction.commit();
+ return result;
+ } catch (error) {
+ try {
+ await transaction.rollback();
+ } catch (rollbackError) {
+ this.logger.logError(rollbackError as Error);
+ }
+ throw error;
+ }
+ // Nothing to release: unlike the pg and mysql pools, mssql returns the
+ // borrowed connection to the pool as part of commit/rollback.
}
if (isMySQLPool(this.client)) {
@@ -234,10 +917,29 @@ export class DBClient {
async close() {
if (this.client instanceof Database) {
this.client.close();
+ } else if (
+ this.config.type === DBType.MSSQL &&
+ this.client &&
+ "close" in this.client
+ ) {
+ // An mssql pool is torn down with `close()`, not the `end()` the pg and
+ // mysql pools expose — the generic branch below would silently skip it
+ // and leave the sockets open.
+ await (this.client as MSSQLHandle).close!();
+ } else if (
+ this.config.type === DBType.MongoDB &&
+ this.client &&
+ typeof (this.client as MongoHandle).close === "function"
+ ) {
+ // Same trap as mssql: a `MongoClient` is closed with `close()`. A client
+ // that was never used holds no handle at all, so the guard is on the
+ // function rather than the config.
+ await (this.client as MongoHandle).close!();
} else if (this.client && "end" in this.client) {
await (this.client as any).end();
}
this.client = null!;
+ this.mongoConnectPromise = null;
this.logger.logInfo("Database connection closed");
}
@@ -245,6 +947,7 @@ export class DBClient {
query: string,
params: any[] = [],
): Promise<{ affectedRows: number }> {
+ if (this.config.type === DBType.MongoDB) this.rejectSQLForMongo();
const start = Date.now();
let affectedRows = 0;
@@ -254,14 +957,20 @@ export class DBClient {
} else if (this.config.type === DBType.MySQL) {
const [mysqlResult] = await (this.client as mysql.Pool).query(
query,
- params,
+ bindMySQLParams(params),
);
affectedRows = (mysqlResult as any).affectedRows ?? 0;
} else if (this.config.type === DBType.Postgres) {
- let paramIndex = 0;
- const pgQuery = query.replace(/\?/g, () => `$${++paramIndex}`);
- const pgResult = await (this.client as Pool).query(pgQuery, params);
+ const pgResult = await (this.client as Pool).query(
+ rewritePlaceholders(query, DBType.Postgres),
+ params,
+ );
affectedRows = pgResult.rowCount ?? 0;
+ } else if (this.config.type === DBType.MSSQL) {
+ const mssqlResult = await this.runMSSQL(query, params);
+ // mssql reports one entry per statement in the batch, so the first is the
+ // count for the statement that was sent.
+ affectedRows = mssqlResult.rowsAffected?.[0] ?? 0;
}
const executionTime = Date.now() - start;
@@ -277,6 +986,7 @@ export class DBClient {
* @returns Promise that resolves once the query is complete.
*/
async migrationQuery(query: string, params: any[] = []): Promise {
+ if (this.config.type === DBType.MongoDB) this.rejectSQLForMongo();
const start = Date.now();
if (this.client instanceof Database) {
let stmt = this.preparedStatements.get(query);
@@ -286,14 +996,355 @@ export class DBClient {
}
stmt.run(...params);
} else if (this.config.type === DBType.MySQL) {
- await (this.client as mysql.Pool).query(query, params);
+ await (this.client as mysql.Pool).query(query, bindMySQLParams(params));
} else if (this.config.type === DBType.Postgres) {
- let paramIndex = 0;
- const pgQuery = query.replace(/\?/g, () => `$${++paramIndex}`);
- await (this.client as Pool).query(pgQuery, params);
+ await (this.client as Pool).query(
+ rewritePlaceholders(query, DBType.Postgres),
+ params,
+ );
+ } else if (this.config.type === DBType.MSSQL) {
+ await this.runMSSQL(query, params);
}
const executionTime = Date.now() - start;
this.logger.logQuery(query, params, executionTime);
}
+
+ // ---------------------------------------------------------------------------
+ // MongoDB executors
+ //
+ // A parallel family to query/queryExec/migrationQuery rather than a fifth
+ // branch inside them: those take a SQL string to dispatch on, and a mongo
+ // command is a document. Everything below funnels through `mongoRun` so that
+ // logging, session threading and error wrapping are written once.
+ //
+ // Reads are not retried the way `query` retries them. A transaction is
+ // already replayed wholesale by `withTransaction`, and outside one a mongo
+ // read failure is a topology problem that retrying three times will not fix.
+ // ---------------------------------------------------------------------------
+
+ /**
+ * Runs one mongo operation against a collection.
+ *
+ * @param label Short description used in logs and error messages.
+ * @param detail The filter, document or pipeline, for the log line.
+ * @param operation Receives the connected database handle.
+ * @returns Whatever the operation resolved to.
+ * @throws StabilizeError wrapping any driver failure.
+ */
+ private async mongoRun(
+ label: string,
+ detail: unknown,
+ operation: (db: MongoDbHandle) => Promise,
+ ): Promise {
+ const start = Date.now();
+ try {
+ const db = await this.mongoDb();
+ const result = await operation(db);
+ this.logger.logQuery(label, [detail], Date.now() - start);
+ return result;
+ } catch (error) {
+ if (error instanceof StabilizeError) throw error;
+ this.logger.logError(error as Error);
+ throw new StabilizeError(
+ `MongoDB ${label} failed: ${(error as Error).message}`,
+ "MONGO_ERROR",
+ error as Error,
+ );
+ }
+ }
+
+ /**
+ * Resolves a collection, or throws if the handle has none.
+ * @param name The collection name.
+ * @param db The database handle.
+ */
+ private mongoCollection(
+ name: string,
+ db: MongoDbHandle,
+ ): MongoCollectionHandle {
+ const collection = db.collection?.(name);
+ if (!collection) {
+ throw new StabilizeError(
+ `MongoDB database handle did not provide collection '${name}'.`,
+ "MONGO_ERROR",
+ );
+ }
+ return collection;
+ }
+
+ /** Merges caller options with this client's session, when it has one. */
+ private withSession(
+ options: Record = {},
+ ): Record {
+ return { ...options, ...this.mongoOptions() };
+ }
+
+ /** Reads every matching document. */
+ async mongoFind(
+ collection: string,
+ filter: Record = {},
+ options: Record = {},
+ ): Promise {
+ return this.mongoRun("find", { collection, filter }, async (db) => {
+ const cursor = this.mongoCollection(collection, db).find(
+ filter,
+ this.withSession(options),
+ );
+ return (await cursor.toArray()) ?? [];
+ });
+ }
+
+ /** Reads the first matching document, or null. */
+ async mongoFindOne(
+ collection: string,
+ filter: Record = {},
+ options: Record = {},
+ ): Promise {
+ return this.mongoRun("findOne", { collection, filter }, async (db) =>
+ this.mongoCollection(collection, db).findOne(
+ filter,
+ this.withSession(options),
+ ),
+ );
+ }
+
+ /** Inserts one document. */
+ async mongoInsertOne(
+ collection: string,
+ document: Record,
+ options: Record = {},
+ ): Promise {
+ return this.mongoRun("insertOne", { collection }, async (db) =>
+ this.mongoCollection(collection, db).insertOne(
+ document,
+ this.withSession(options),
+ ),
+ );
+ }
+
+ /**
+ * Inserts many documents.
+ *
+ * `ordered: false` is deliberately *not* the default: a batch that fails
+ * halfway should leave the caller able to tell which half landed, and an
+ * unordered insert reports that only in aggregate.
+ */
+ async mongoInsertMany(
+ collection: string,
+ documents: Record[],
+ options: Record = {},
+ ): Promise {
+ return this.mongoRun("insertMany", { collection, count: documents.length }, async (db) =>
+ this.mongoCollection(collection, db).insertMany(
+ documents,
+ this.withSession(options),
+ ),
+ );
+ }
+
+ /**
+ * Updates the first matching document.
+ *
+ * `update` also accepts a pipeline array. A pipeline is the only way to
+ * express a value computed from the document's own current contents — which
+ * is what `toggle()` needs, and there is no update operator that flips a
+ * field in place.
+ */
+ async mongoUpdateOne(
+ collection: string,
+ filter: Record,
+ update: Record | Record[],
+ options: Record = {},
+ ): Promise {
+ return this.mongoRun("updateOne", { collection, filter }, async (db) =>
+ this.mongoCollection(collection, db).updateOne(
+ filter,
+ update,
+ this.withSession(options),
+ ),
+ );
+ }
+
+ /** Updates every matching document. @see mongoUpdateOne for the pipeline form. */
+ async mongoUpdateMany(
+ collection: string,
+ filter: Record,
+ update: Record | Record[],
+ options: Record = {},
+ ): Promise {
+ return this.mongoRun("updateMany", { collection, filter }, async (db) =>
+ this.mongoCollection(collection, db).updateMany(
+ filter,
+ update,
+ this.withSession(options),
+ ),
+ );
+ }
+
+ /** Deletes the first matching document. */
+ async mongoDeleteOne(
+ collection: string,
+ filter: Record = {},
+ options: Record = {},
+ ): Promise {
+ return this.mongoRun("deleteOne", { collection, filter }, async (db) =>
+ this.mongoCollection(collection, db).deleteOne(
+ filter,
+ this.withSession(options),
+ ),
+ );
+ }
+
+ /** Deletes every matching document. */
+ async mongoDeleteMany(
+ collection: string,
+ filter: Record = {},
+ options: Record = {},
+ ): Promise {
+ return this.mongoRun("deleteMany", { collection, filter }, async (db) =>
+ this.mongoCollection(collection, db).deleteMany(
+ filter,
+ this.withSession(options),
+ ),
+ );
+ }
+
+ /** Counts matching documents without materialising them. */
+ async mongoCount(
+ collection: string,
+ filter: Record = {},
+ options: Record = {},
+ ): Promise {
+ return this.mongoRun("countDocuments", { collection, filter }, async (db) =>
+ this.mongoCollection(collection, db).countDocuments(
+ filter,
+ this.withSession(options),
+ ),
+ );
+ }
+
+ /** Lists the distinct values of a field. */
+ async mongoDistinct(
+ collection: string,
+ field: string,
+ filter: Record = {},
+ options: Record = {},
+ ): Promise {
+ return this.mongoRun("distinct", { collection, field }, async (db) =>
+ this.mongoCollection(collection, db).distinct(
+ field,
+ filter,
+ this.withSession(options),
+ ),
+ );
+ }
+
+ /** Runs an aggregation pipeline. */
+ async mongoAggregate(
+ collection: string,
+ pipeline: Record[],
+ options: Record = {},
+ ): Promise {
+ return this.mongoRun("aggregate", { collection, stages: pipeline.length }, async (db) => {
+ const cursor = this.mongoCollection(collection, db).aggregate(
+ pipeline,
+ this.withSession(options),
+ );
+ return (await cursor.toArray()) ?? [];
+ });
+ }
+
+ /**
+ * Applies an update and returns a document.
+ *
+ * The driver returns the document itself, not a `ModifyResult`, because
+ * `includeResultMetadata` has defaulted to false since driver 6 (NODE-3568).
+ * The return shape is whatever `returnDocument` asks for, so this deliberately
+ * does not normalise it — the caller that needs `$inc`'s new value and the one
+ * that needs the pre-image want different answers.
+ */
+ async mongoFindOneAndUpdate(
+ collection: string,
+ filter: Record,
+ update: Record,
+ options: Record = {},
+ ): Promise {
+ return this.mongoRun("findOneAndUpdate", { collection, filter }, async (db) =>
+ this.mongoCollection(collection, db).findOneAndUpdate(
+ filter,
+ update,
+ this.withSession(options),
+ ),
+ );
+ }
+
+ /** Runs a bulk write, for counter bumps and M2M syncs that need one trip. */
+ async mongoBulkWrite(
+ collection: string,
+ operations: Record[],
+ options: Record = {},
+ ): Promise {
+ return this.mongoRun("bulkWrite", { collection, count: operations.length }, async (db) =>
+ this.mongoCollection(collection, db).bulkWrite(
+ operations,
+ this.withSession(options),
+ ),
+ );
+ }
+
+ /** Creates an index. */
+ async mongoCreateIndex(
+ collection: string,
+ spec: Record,
+ options: Record = {},
+ ): Promise {
+ return this.mongoRun("createIndex", { collection, spec }, async (db) =>
+ this.mongoCollection(collection, db).createIndex(
+ spec,
+ this.withSession(options),
+ ),
+ );
+ }
+
+ /** Lists a collection's indexes. */
+ async mongoListIndexes(collection: string): Promise {
+ return this.mongoRun("listIndexes", { collection }, async (db) => {
+ const cursor = this.mongoCollection(collection, db).listIndexes();
+ return (await cursor.toArray()) ?? [];
+ });
+ }
+
+ /** Lists a database's collections. */
+ async mongoListCollections(): Promise {
+ return this.mongoRun("listCollections", {}, async (db) => {
+ const cursor = db.listCollections?.();
+ if (!cursor) return [];
+ return (await cursor.toArray()) ?? [];
+ });
+ }
+
+ /**
+ * Runs a database command.
+ *
+ * The catch-all for operations with no collection to hang off — `collMod` to
+ * change a validator, `ping` for the health check, `hello` for topology.
+ *
+ * The command and the options are separate arguments, and the session belongs
+ * in the second: merged into the command document it becomes a field the
+ * server tries to serialise, and a `ClientSession` is not BSON.
+ */
+ async mongoCommand(
+ command: Record,
+ ): Promise {
+ return this.mongoRun("command", command, async (db) => {
+ if (!db.command) {
+ throw new StabilizeError(
+ "MongoDB database handle did not provide command().",
+ "MONGO_ERROR",
+ );
+ }
+ return db.command(command, this.mongoOptions());
+ });
+ }
}
diff --git a/docker-compose.test.yml b/docker-compose.test.yml
new file mode 100644
index 0000000..ab8fddb
--- /dev/null
+++ b/docker-compose.test.yml
@@ -0,0 +1,162 @@
+# Real-database test fleet for stabilize-orm.
+#
+# docker compose -f docker-compose.test.yml up -d --wait
+# bun test
+# docker compose -f docker-compose.test.yml down -v
+#
+# Host ports are deliberately non-standard so this never collides with a
+# database you already run locally. Everything lives on its own network and
+# every container is named `stabilize-test-*`, so `down` cannot touch anything
+# else you have running.
+#
+# SQLite needs no container — `bun:sqlite` is built into the runtime.
+
+name: stabilize-test
+
+services:
+ postgres:
+ image: postgres:18-alpine
+ container_name: stabilize-test-postgres
+ environment:
+ POSTGRES_USER: stabilize
+ POSTGRES_PASSWORD: stabilize
+ POSTGRES_DB: stabilize_test
+ ports:
+ - "55432:5432"
+ healthcheck:
+ # -U matters: without it pg_isready checks the default user, not ours.
+ test: ["CMD-SHELL", "pg_isready -U stabilize -d stabilize_test"]
+ interval: 2s
+ timeout: 5s
+ retries: 30
+ tmpfs:
+ # Keeps the test database in RAM. These are throwaway fixtures, and it
+ # makes the suite noticeably faster than a bind mount on Windows.
+ #
+ # The mount point is `/var/lib/postgresql`, not the older
+ # `/var/lib/postgresql/data`: from 18 onward the image keeps the cluster
+ # in a versioned subdirectory so `pg_upgrade --link` can cross the mount
+ # boundary. Mounting the old path makes 18 refuse to boot, since it finds
+ # data sitting in an "unused mount/volume".
+ - /var/lib/postgresql
+ networks: [stabilize-test]
+
+ mysql:
+ image: mysql:8
+ container_name: stabilize-test-mysql
+ environment:
+ MYSQL_ROOT_PASSWORD: stabilize
+ MYSQL_DATABASE: stabilize_test
+ MYSQL_USER: stabilize
+ MYSQL_PASSWORD: stabilize
+ ports:
+ - "53306:3306"
+ healthcheck:
+ # -p is attached to the flag on purpose; a space would warn about the
+ # password being visible in the process list.
+ test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-ustabilize", "-pstabilize"]
+ interval: 2s
+ timeout: 5s
+ retries: 40
+ tmpfs:
+ - /var/lib/mysql
+ networks: [stabilize-test]
+
+ mariadb:
+ image: mariadb:11
+ container_name: stabilize-test-mariadb
+ environment:
+ MARIADB_ROOT_PASSWORD: stabilize
+ MARIADB_DATABASE: stabilize_test
+ MARIADB_USER: stabilize
+ MARIADB_PASSWORD: stabilize
+ ports:
+ - "53307:3306"
+ healthcheck:
+ test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
+ interval: 2s
+ timeout: 5s
+ retries: 40
+ tmpfs:
+ - /var/lib/mysql
+ networks: [stabilize-test]
+
+ mssql:
+ image: mcr.microsoft.com/mssql/server:2022-latest
+ container_name: stabilize-test-mssql
+ environment:
+ ACCEPT_EULA: "Y"
+ # SQL Server enforces a complexity policy: 8+ chars with upper, lower,
+ # digit and symbol. A simpler password makes the container exit on boot.
+ MSSQL_SA_PASSWORD: "Stabilize!Test123"
+ MSSQL_PID: Developer
+ ports:
+ - "51433:1433"
+ healthcheck:
+ # The image ships no sqlcmd on PATH by default in 2022+, so readiness is
+ # probed over TCP rather than by querying. /dev/tcp is a bash builtin.
+ test:
+ [
+ "CMD-SHELL",
+ "bash -c 'exec 3<>/dev/tcp/127.0.0.1/1433' || exit 1",
+ ]
+ interval: 3s
+ timeout: 5s
+ retries: 40
+ start_period: 20s
+ networks: [stabilize-test]
+
+ mongo:
+ image: mongo:7
+ container_name: stabilize-test-mongo
+ # A single-node *replica set* rather than the standalone default. MongoDB
+ # only supports transactions on a replica set or mongos, and the ORM wraps
+ # every write — including create() — in one. A standalone would accept the
+ # connection, answer every read, and then fail every write with a bare
+ # IllegalOperation.
+ command: ["--replSet", "rs0", "--bind_ip_all"]
+ ports:
+ - "57017:27017"
+ healthcheck:
+ # The set must be initiated before it will serve transactions and there is
+ # no init container here to do it, so the first probe does it and then
+ # rethrows — `rs.status()` throws while no config exists, which leaves the
+ # container unhealthy for exactly one interval. The next probe sees a
+ # healthy set. Self-contained, and no dependency cycle.
+ #
+ # The member registers itself as 127.0.0.1:27017 — the *container's* port.
+ # A driver doing topology discovery is handed that address and tries to
+ # reach it from the host, where nothing is listening. Connect with
+ # `directConnection=true` to skip discovery; see tests/integration.mongo.test.ts.
+ test:
+ [
+ "CMD-SHELL",
+ "mongosh --quiet --eval 'try { rs.status().ok } catch (e) { rs.initiate({_id:\"rs0\",members:[{_id:0,host:\"127.0.0.1:27017\"}]}); throw e }'",
+ ]
+ interval: 2s
+ timeout: 10s
+ retries: 60
+ start_period: 5s
+ mongo-standalone:
+ # Deliberately *not* a replica set, and here for one reason: to pin the
+ # failure mode a standalone produces. It accepts the connection, answers
+ # every read, and then rejects every write — because the ORM wraps each one
+ # in a transaction. Without a server like this the behaviour could only be
+ # asserted from documentation rather than observed.
+ image: mongo:7
+ container_name: stabilize-test-mongo-standalone
+ ports:
+ - "57018:27017"
+ healthcheck:
+ test: ["CMD-SHELL", "mongosh --quiet --eval 'db.adminCommand({ping:1}).ok'"]
+ interval: 2s
+ timeout: 10s
+ retries: 30
+ start_period: 3s
+ tmpfs:
+ - /data/db
+ networks: [stabilize-test]
+
+networks:
+ stabilize-test:
+ driver: bridge
\ No newline at end of file
diff --git a/hooks.ts b/hooks.ts
index f1db9c7..31b52e9 100644
--- a/hooks.ts
+++ b/hooks.ts
@@ -52,17 +52,28 @@ export function registerHooks(
* Combines hooks from MetadataStorage and class methods.
* @param entity The entity instance.
* @param type The hook type (e.g., 'beforeCreate').
+ * @param model The model class the entity belongs to. Pass it whenever the
+ * entity may be a plain row rather than a class instance.
* @returns An array of Hook objects to execute.
*/
-export function getHooks(entity: any, type: HookType): Hook[] {
+export function getHooks(
+ entity: any,
+ type: HookType,
+ model?: Function,
+): Hook[] {
const hooks: Hook[] = [];
if (!entity) return hooks;
const proto = Object.getPrototypeOf(entity);
- if (!proto) return hooks;
- const model = proto.constructor;
+ if (!proto && !model) return hooks;
+
+ // The caller's model wins over the entity's prototype. Reads return plain
+ // objects straight from the driver, so `proto.constructor` is `Object` and
+ // the metadata lookup below found nothing — which is why every `after*`
+ // hook, and both delete hooks, silently never ran.
+ const resolved = model ?? proto.constructor;
// Get hooks from MetadataStorage
- const config = MetadataStorage.getModelMetadata(model);
+ const config = MetadataStorage.getModelMetadata(resolved);
if (config?.hooks?.[type]) {
const callbacks = Array.isArray(config.hooks[type])
? config.hooks[type]
diff --git a/index.ts b/index.ts
index 0def8e9..41069c5 100644
--- a/index.ts
+++ b/index.ts
@@ -41,6 +41,12 @@ import {
type StabilizeEventHandler,
StabilizeEmitter,
generateUUID,
+ // The two MongoDB types this package re-exports. Both are reached through
+ // `types.ts`, the shared type surface: `mongo-query` and `mongo-schema` are
+ // not published entry points, so neither is somewhere a consumer could name
+ // them from.
+ type Predicate,
+ type MongoStep,
} from "./types";
import { defineModel, MetadataStorage } from "./model";
import type { Hook } from "./hooks";
@@ -50,6 +56,8 @@ export class Stabilize {
private cache: Cache | null;
private logger: Logger;
public events: StabilizeEmitter;
+ /** Repositories handed out so far, keyed by model. @see getRepository */
+ private repositories = new Map>();
constructor(
config: DBConfig,
@@ -82,8 +90,22 @@ export class Stabilize {
* ```
*/
getRepository(model: new (...args: any[]) => T): Repository {
- const cacheConfig = this.cache ? this.cache.config : undefined;
- return new Repository(this.client, model, cacheConfig, this.logger);
+ // Memoised per model. Every Repository owns an optional cache handle, so
+ // building a new one on each call opened a second Redis connection that
+ // nothing disconnected and that `getCacheStats()` never saw — it reports on
+ // the ORM's own cache, which no repository was using.
+ const existing = this.repositories.get(model);
+ if (existing) return existing as Repository;
+
+ const repository = new Repository(
+ this.client,
+ model,
+ this.cache?.config,
+ this.logger,
+ this.cache,
+ );
+ this.repositories.set(model, repository);
+ return repository;
}
/**
@@ -155,14 +177,21 @@ export class Stabilize {
}> {
const start = performance.now();
try {
- const results = await this.client.query("SELECT 1 AS ok");
+ // MongoDB has no `SELECT 1`; `ping` is its equivalent liveness command.
+ // Both are wrapped the same way so a slow or unreachable server lands in
+ // the same catch rather than escaping as a different error shape.
+ const isMongo = this.client.config.type === DBType.MongoDB;
+ const healthy = isMongo
+ ? (await this.client.mongoCommand({ ping: 1 })).ok === 1
+ : (await this.client.query("SELECT 1 AS ok")).length > 0;
+
const cacheStatus = this.cache
? (await this.cache.get("healthcheck"))
? "connected"
: "connected (miss)"
: "disabled";
return {
- status: results.length > 0 ? "healthy" : "unhealthy",
+ status: healthy ? "healthy" : "unhealthy",
database: this.client.config.type,
latencyMs: Number((performance.now() - start).toFixed(2)),
cacheStatus,
@@ -219,6 +248,24 @@ export class Stabilize {
total: raw._allConnections.length,
};
}
+ // `Stabilize.client` is the DBClient wrapper, so the driver's pool is one
+ // level down. Only the SQL Server branch below reads through it; the two
+ // checks above are left reading `raw` exactly as they always have.
+ const pool = raw.client ?? raw;
+ if (
+ this.client.config.type === DBType.MSSQL &&
+ typeof pool?.size === "number"
+ ) {
+ return {
+ active: pool.borrowed ?? 0,
+ idle: pool.available ?? 0,
+ total: pool.size ?? 0,
+ };
+ }
+ // MongoDB and SQLite land here deliberately. Neither exposes a pool whose
+ // occupancy can be read synchronously — the mongo driver's pool is internal
+ // and per-server, and SQLite has no pool at all — so the sentinel is the
+ // honest answer rather than a number invented to fill the shape.
return { active: -1, idle: -1, total: -1 };
}
}
@@ -264,4 +311,11 @@ export type {
QueryLogEntry,
StabilizeEvent,
StabilizeEventHandler,
+ // The two MongoDB types a consumer can legitimately need to name: the
+ // predicate the query builder records, and the serializable migration step.
+ // Both are reached through `types.ts`, the shared type surface, rather than
+ // through `mongo-query`/`mongo-schema` — neither of which is a published
+ // entry point.
+ Predicate,
+ MongoStep,
};
diff --git a/migrations.ts b/migrations.ts
index 1b66a40..dbcf921 100644
--- a/migrations.ts
+++ b/migrations.ts
@@ -14,6 +14,42 @@ import {
DBType,
DataTypes,
} from "./types";
+import { runMongoMigrations } from "./mongo-migrate";
+
+/**
+ * Quotes an identifier for the target dialect.
+ *
+ * `"x"` is an identifier only where the dialect's grammar says so. MySQL and
+ * MariaDB read it as a *string literal* unless the server runs with
+ * `ANSI_QUOTES` in `sql_mode` — off by default — so `CREATE TABLE "users" (…)`
+ * is a syntax error there and backticks are the spelling that always parses.
+ * Postgres and SQLite quote with `"`, and T-SQL accepts it too under
+ * `QUOTED_IDENTIFIER ON`, which is the default, so those three keep it.
+ *
+ * @param name The bare identifier.
+ * @param dbType The target database dialect.
+ * @returns The identifier wrapped for the dialect.
+ */
+export function quoteIdentifier(name: string, dbType: DBType): string {
+ return dbType === DBType.MySQL ? `\`${name}\`` : `"${name}"`;
+}
+
+/**
+ * @internal
+ * Recovers the bare name from an identifier quoted for any dialect.
+ *
+ * SQL Server's catalogue functions take an unquoted name, so the quoting
+ * {@link quoteIdentifier} added has to come back off before one is built.
+ * Both spellings are stripped rather than just the one the current dialect
+ * uses, so a caller passing an already-quoted identifier gets the right answer
+ * whichever dialect produced it.
+ *
+ * @param name The quoted identifier.
+ * @returns The bare name.
+ */
+function unquoteIdentifier(name: string): string {
+ return name.replace(/^[`"]/, "").replace(/[`"]$/, "");
+}
/**
* @internal
@@ -30,6 +66,77 @@ function formatQuery(query: string, dbType: DBType): string {
return query;
}
+/**
+ * Builds a `CREATE TABLE` that is a no-op when the table already exists.
+ *
+ * SQLite, MySQL and PostgreSQL spell this `CREATE TABLE IF NOT EXISTS`. T-SQL
+ * has no such clause and rejects the statement outright, so SQL Server gets the
+ * equivalent written as a leading existence check on the same batch instead.
+ *
+ * @param table The table identifier, already quoted for the dialect if needed.
+ * @param body The column definitions, without the surrounding parentheses.
+ * @param dbType The target database dialect.
+ * @returns The complete statement.
+ */
+export function createTableIfNotExistsSQL(
+ table: string,
+ body: string,
+ dbType: DBType,
+): string {
+ if (dbType !== DBType.MSSQL) {
+ return `CREATE TABLE IF NOT EXISTS ${table} (${body})`;
+ }
+ // `OBJECT_ID` takes the bare name, not the quoted identifier, and the `N'…'`
+ // prefix keeps it Unicode so a non-ASCII table name still resolves.
+ const bare = unquoteIdentifier(table).replace(/'/g, "''");
+ return `IF OBJECT_ID(N'${bare}', N'U') IS NULL CREATE TABLE ${table} (${body})`;
+}
+
+/**
+ * Builds a `CREATE INDEX` that is a no-op when the index already exists.
+ *
+ * Only Postgres and SQLite support the clause itself. As with
+ * {@link createTableIfNotExistsSQL}, SQL Server has no `IF NOT EXISTS` clause
+ * to hang on the statement, so the check is a separate one against
+ * `sys.indexes` on the same batch. MySQL and MariaDB have neither the clause
+ * nor an inline substitute, so for them the caller does the checking — see the
+ * `DBType.MySQL` branch below.
+ *
+ * @param index The index identifier, already quoted for the dialect if needed.
+ * @param table The table identifier, already quoted for the dialect if needed.
+ * @param columns The indexed column identifiers, already quoted.
+ * @param unique Whether the index enforces uniqueness.
+ * @param dbType The target database dialect.
+ * @returns The complete statement.
+ */
+export function createIndexIfNotExistsSQL(
+ index: string,
+ table: string,
+ columns: string[],
+ unique: boolean,
+ dbType: DBType,
+): string {
+ const kind = unique ? "UNIQUE INDEX" : "INDEX";
+ const statement = `CREATE ${kind} ${index} ON ${table} (${columns.join(", ")})`;
+ if (dbType === DBType.MySQL) {
+ // MySQL and MariaDB have no `IF NOT EXISTS` clause on `CREATE INDEX` — the
+ // server rejects it as a syntax error however the identifiers are quoted —
+ // so the statement is issued plain, and the guarantee has to come from
+ // whoever calls this. `autoMigrate` is the only caller and satisfies it
+ // already: it reads the table's indexes from `information_schema` (via
+ // `SHOW INDEX`) and skips any name it finds, which is the pre-check, done
+ // once for every index rather than once per statement. Nothing else may
+ // call this for a MySQL target without doing the same.
+ return statement;
+ }
+ if (dbType !== DBType.MSSQL) {
+ return `CREATE ${kind} IF NOT EXISTS ${index} ON ${table} (${columns.join(", ")})`;
+ }
+ const bareIndex = unquoteIdentifier(index).replace(/'/g, "''");
+ const bareTable = unquoteIdentifier(table).replace(/'/g, "''");
+ return `IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'${bareIndex}' AND object_id = OBJECT_ID(N'${bareTable}')) ${statement}`;
+}
+
/**
* Maps an abstract data type to the correct SQL type string for the specified database dialect.
* @param dt The data type to map.
@@ -108,6 +215,38 @@ function mapDataTypeToSql(dt: DataTypes | string, dbType: DBType): string {
return "TEXT";
}
}
+ if (dbType === DBType.MSSQL) {
+ switch (type) {
+ case "string":
+ return "NVARCHAR(255)";
+ case "text":
+ return "NVARCHAR(MAX)";
+ case "integer":
+ return "INT";
+ case "bigint":
+ return "BIGINT";
+ case "float":
+ return "REAL";
+ case "double":
+ return "FLOAT";
+ case "decimal":
+ return "DECIMAL(10,2)";
+ case "boolean":
+ return "BIT";
+ case "date":
+ return "DATE";
+ case "datetime":
+ return "DATETIME2";
+ case "json":
+ return "NVARCHAR(MAX)";
+ case "uuid":
+ return "UNIQUEIDENTIFIER";
+ case "blob":
+ return "VARBINARY(MAX)";
+ default:
+ return "NVARCHAR(MAX)";
+ }
+ }
if (dbType === DBType.SQLite) {
switch (type) {
case "string":
@@ -155,6 +294,8 @@ function getAutoIncrementPK(dbType: DBType): string {
return "SERIAL PRIMARY KEY";
case DBType.MySQL:
return "INT AUTO_INCREMENT PRIMARY KEY";
+ case DBType.MSSQL:
+ return "INT IDENTITY(1,1) PRIMARY KEY";
case DBType.SQLite:
default:
return "INTEGER PRIMARY KEY AUTOINCREMENT";
@@ -193,7 +334,28 @@ export async function generateMigration(
if (key === "id") {
defParts.push("id");
- defParts.push(getAutoIncrementPK(dbType));
+ // `type` is typed as the DataTypes enum but may arrive as a literal
+ // string when the model came from another copy of the ORM.
+ const idTypeRaw: any = col.type;
+ const idTypeStr =
+ typeof idTypeRaw === "string"
+ ? idTypeRaw.toLowerCase()
+ : (DataTypes as any)[idTypeRaw]?.toLowerCase();
+ if (idTypeStr === "string" || idTypeStr === "uuid") {
+ defParts.push(
+ dbType === DBType.Postgres
+ ? "UUID PRIMARY KEY"
+ : dbType === DBType.MySQL
+ ? "VARCHAR(255) PRIMARY KEY"
+ : dbType === DBType.MSSQL
+ ? idTypeStr === "uuid"
+ ? "UNIQUEIDENTIFIER PRIMARY KEY"
+ : "NVARCHAR(255) PRIMARY KEY"
+ : "TEXT PRIMARY KEY",
+ );
+ } else {
+ defParts.push(getAutoIncrementPK(dbType));
+ }
} else {
defParts.push(col.name || key);
defParts.push(mapDataTypeToSql(col.type, dbType));
@@ -217,11 +379,23 @@ export async function generateMigration(
columnDefs.push(defParts.join(" "));
}
- // Add timestamp columns if enabled
+ // Add timestamp columns if enabled. A model may declare them in `columns`
+ // as well as in `timestamps` — the documented pattern — so skip any column
+ // that is already in the table definition rather than emitting it twice
+ // (which makes the CREATE TABLE fail with "duplicate column name").
if (timestamps) {
+ const declared = new Set(
+ Object.entries(columns).map(([key, col]) => col.name || key),
+ );
for (const [field, colName] of Object.entries(timestamps)) {
+ if (!colName || declared.has(colName)) continue;
// Use the field name defined in the timestamps config
- let sqlType = dbType === DBType.Postgres ? "TIMESTAMP" : "DATETIME";
+ let sqlType =
+ dbType === DBType.Postgres
+ ? "TIMESTAMP"
+ : dbType === DBType.MSSQL
+ ? "DATETIME2"
+ : "DATETIME";
let def = `${colName} ${sqlType} NOT NULL`;
// Set default value for createdAt, and optionally for updatedAt
@@ -240,7 +414,7 @@ export async function generateMigration(
}
const up: string[] = [
- `CREATE TABLE IF NOT EXISTS ${tableName} (${columnDefs.join(", ")})`,
+ createTableIfNotExistsSQL(tableName, columnDefs.join(", "), dbType),
];
const down: string[] = [`DROP TABLE IF EXISTS ${tableName}`];
@@ -274,10 +448,17 @@ function generateHistoryMigration(
let tsType =
dbType === DBType.MySQL
? "DATETIME"
- : dbType === DBType.SQLite
- ? "TEXT"
- : "TIMESTAMP";
- let modByType = dbType === DBType.MySQL ? "VARCHAR(255)" : "TEXT";
+ : dbType === DBType.MSSQL
+ ? "DATETIME2"
+ : dbType === DBType.SQLite
+ ? "TEXT"
+ : "TIMESTAMP";
+ let modByType =
+ dbType === DBType.MySQL
+ ? "VARCHAR(255)"
+ : dbType === DBType.MSSQL
+ ? "NVARCHAR(255)"
+ : "TEXT";
let modAtType =
tsType + (dbType === DBType.Postgres ? " DEFAULT CURRENT_TIMESTAMP" : "");
@@ -296,7 +477,7 @@ function generateHistoryMigration(
`modified_at ${modAtType}`,
];
return [
- `CREATE TABLE IF NOT EXISTS ${historyTable} (${historyColumns.join(", ")})`,
+ createTableIfNotExistsSQL(historyTable, historyColumns.join(", "), dbType),
`DROP TABLE IF EXISTS ${historyTable}`,
];
}
@@ -321,6 +502,14 @@ function getMigrationsTableSQL(dbType: DBType): string {
name VARCHAR(255) UNIQUE NOT NULL,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`;
+ case DBType.MSSQL:
+ return createTableIfNotExistsSQL(
+ "stabilize_migrations",
+ `id INT IDENTITY(1,1) PRIMARY KEY,
+ name NVARCHAR(255) UNIQUE NOT NULL,
+ applied_at DATETIME2 NOT NULL DEFAULT CURRENT_TIMESTAMP`,
+ DBType.MSSQL,
+ );
case DBType.SQLite:
default:
return `CREATE TABLE IF NOT EXISTS stabilize_migrations (
@@ -337,6 +526,13 @@ function getMigrationsTableSQL(dbType: DBType): string {
* @param migrations An array of `Migration` objects to be executed.
*/
export async function runMigrations(config: DBConfig, migrations: Migration[]) {
+ // Branched before `getMigrationsTableSQL` can be asked about a dialect it has
+ // no answer for: there is no `CREATE TABLE` here, and a migration's Mongo
+ // half rides in `mongoUp`/`mongoDown` rather than in `up`/`down`.
+ if (config.type === DBType.MongoDB) {
+ return runMongoMigrations(config, migrations);
+ }
+
const client = new DBClient(config);
try {
const dbType = config.type;
@@ -366,6 +562,11 @@ export async function runMigrations(config: DBConfig, migrations: Migration[]) {
let appliedAt: string;
if (dbType === DBType.MySQL) {
appliedAt = new Date().toISOString().slice(0, 19).replace("T", " ");
+ } else if (dbType === DBType.MSSQL) {
+ // The trailing `Z` an ISO string carries is only meaningful for
+ // `datetimeoffset`; `datetime2` wants a space separator and no
+ // zone designator, which every server language parses the same way.
+ appliedAt = new Date().toISOString().slice(0, 23).replace("T", " ");
} else {
appliedAt = new Date().toISOString();
}
diff --git a/model.ts b/model.ts
index 5a36147..566b836 100644
--- a/model.ts
+++ b/model.ts
@@ -57,29 +57,94 @@ export interface ModelConfig {
timestamps?: TimestampsConfig; // Auto-managed timestamp columns
}
+/**
+ * Key under which the model registry is stored on `globalThis`.
+ *
+ * The registry is process-wide rather than module-local so that every copy of
+ * this module shares it. If the ORM is bundled more than once (duplicate
+ * dependency, mixed CJS/ESM resolution, a CLI plus an app), each copy would
+ * otherwise get its own Map and metadata written by one copy would be
+ * invisible to the other — models would look as though they were never
+ * defined.
+ */
+const MODEL_REGISTRY_KEY = Symbol.for("stabilize-orm.model-registry");
+
+function getModelRegistry(): Map {
+ const root = globalThis as any;
+ if (!root[MODEL_REGISTRY_KEY]) {
+ root[MODEL_REGISTRY_KEY] = new Map();
+ }
+ return root[MODEL_REGISTRY_KEY];
+}
+
+/**
+ * Rebuilds a model configuration from the static properties mirrored onto the
+ * class by {@link MetadataStorage.setModelMetadata}.
+ *
+ * @param model - The class constructor for the model.
+ * @returns The reconstructed configuration, or undefined if the class carries
+ * no table name and therefore was never registered.
+ */
+function getStaticMetadata(model: Function): ModelConfig | undefined {
+ const candidate = model as any;
+ if (!candidate || !candidate.tableName) return undefined;
+ return {
+ tableName: candidate.tableName,
+ versioned: candidate.versioned || false,
+ softDelete: candidate.softDelete || false,
+ columns: candidate.columns || {},
+ relations: Array.isArray(candidate.relations) ? candidate.relations : [],
+ scopes: candidate.scopes || {},
+ timestamps: candidate.timestamps || {},
+ hooks: candidate.hooks,
+ };
+}
+
/**
* Metadata storage for models.
* Stores and retrieves model configuration such as columns, relations, scopes, etc.
*/
export class MetadataStorage {
- private static models: Map = new Map();
-
/**
* Associates model metadata with a class constructor.
+ *
+ * The configuration is stored both in the shared registry and as static
+ * properties on the class itself, so it survives being read from a different
+ * copy of the ORM.
+ *
* @param model - The class constructor for the model.
* @param config - The model configuration object.
*/
static setModelMetadata(model: Function, config: ModelConfig) {
- this.models.set(model, config);
+ getModelRegistry().set(model, config);
+
+ // The mirror is best effort: the registry above is authoritative. A frozen
+ // class, or one whose static `columns` is a getter without a setter, would
+ // otherwise make registration throw.
+ const target = model as any;
+ if (typeof target !== "function") return;
+ try {
+ target.tableName = config.tableName;
+ target.versioned = config.versioned || false;
+ target.softDelete = config.softDelete || false;
+ target.columns = config.columns;
+ target.relations = config.relations || [];
+ target.scopes = config.scopes || {};
+ target.timestamps = config.timestamps || {};
+ if (config.hooks) target.hooks = config.hooks;
+ } catch {
+ // Ignore: metadata is still available through the registry.
+ }
}
/**
* Retrieves the model configuration for a given model class.
* @param model - The class constructor for the model.
- * @returns The model configuration or undefined if not found.
+ * @returns The model configuration, falling back to the class statics when
+ * the class was registered by a different copy of the ORM.
*/
static getModelMetadata(model: Function): ModelConfig | undefined {
- return this.models.get(model);
+ return getModelRegistry().get(model) ?? getStaticMetadata(model);
}
/**
@@ -173,7 +238,7 @@ export class MetadataStorage {
* @returns The model constructor or undefined if not found.
*/
static getModelByTableName(tableName: string): Function | undefined {
- for (const [model, config] of this.models) {
+ for (const [model, config] of getModelRegistry()) {
if (config.tableName === tableName) {
return model;
}
@@ -202,7 +267,8 @@ export function defineModel(config: ModelConfig) {
}
}
- // Store metadata
+ // Stored in the shared registry and mirrored onto the class as statics so
+ // that metadata stays readable across bundle boundaries.
MetadataStorage.setModelMetadata(Model, {
tableName: config.tableName,
versioned: config.versioned || false,
@@ -211,6 +277,7 @@ export function defineModel(config: ModelConfig) {
relations: config.relations || [],
scopes: config.scopes || {},
timestamps: config.timestamps || {},
+ hooks: config.hooks,
});
return Model;
diff --git a/mongo-migrate.ts b/mongo-migrate.ts
new file mode 100644
index 0000000..d8c7075
--- /dev/null
+++ b/mongo-migrate.ts
@@ -0,0 +1,215 @@
+/**
+ * @file mongo-migrate.ts
+ * @description Runs and generates MongoDB migrations.
+ * @author ElectronSz
+ *
+ * Mirrors `runMigrations` step for step, with one divergence that has a reason:
+ * **the steps of a migration are not wrapped in a transaction.** `createIndex`
+ * is not permitted inside one, and DDL is not transactional in MongoDB at all,
+ * so a transaction here would either fail on the first index or give a false
+ * impression of atomicity.
+ *
+ * The consequence is stated rather than hidden: a migration that fails halfway
+ * leaves a partially migrated collection and **no ledger entry**, so re-running
+ * applies it again from the start. That is the same failure mode MySQL's
+ * auto-committing DDL already has on the SQL side, and the alternative — a
+ * rollback that cannot exist — would be a lie.
+ */
+
+import { MetadataStorage } from "./model";
+import { DBClient } from "./client";
+import { StabilizeError, type DBConfig, type Migration } from "./types";
+import {
+ generateMongoSteps,
+ modelUsesGeneratedIds,
+ mongoCollectionName,
+ type MongoStep,
+} from "./mongo-schema";
+
+/**
+ * The collection the migration ledger lives in.
+ *
+ * No counter: a migration is identified by name, and the name is the `_id`, so
+ * uniqueness is enforced by the storage layer rather than by a read first.
+ */
+export const MONGO_MIGRATIONS_COLLECTION = "stabilize_migrations";
+
+/**
+ * Runs the steps of one migration.
+ *
+ * Sequential, and deliberately not transactional. @see the note at the top of
+ * this file.
+ *
+ * @param db The client to run through.
+ * @param steps The steps to apply, in order.
+ */
+async function runSteps(db: DBClient, steps: MongoStep[]): Promise {
+ for (const step of steps) {
+ switch (step.kind) {
+ case "createCollection": {
+ // `create` on a collection that exists is a `NamespaceExists` error, and
+ // a migration that was interrupted just after creating a collection is
+ // the ordinary case. Asked for first rather than caught, so that a
+ // genuinely unexpected failure is not swallowed along with it.
+ const existing = await db.mongoListCollections();
+ if (existing.some((entry: any) => entry?.name === step.collection)) {
+ break;
+ }
+ await db.mongoCommand({
+ create: step.collection,
+ ...(step.validator ? { validator: step.validator } : {}),
+ validationLevel: "moderate",
+ validationAction: "error",
+ });
+ break;
+ }
+
+ case "createIndex":
+ await db.mongoCreateIndex(step.collection, step.spec, step.options ?? {});
+ break;
+
+ case "dropIndex":
+ await db.mongoCommand({
+ dropIndexes: step.collection,
+ index: step.name,
+ });
+ break;
+
+ case "collMod":
+ await db.mongoCommand({
+ collMod: step.collection,
+ validator: step.validator,
+ validationLevel: "moderate",
+ });
+ break;
+
+ case "dropCollection":
+ // `drop` on a missing collection is a `NamespaceNotFound` error. The
+ // collection not being there is the state a drop is asking for, so it
+ // is not a failure.
+ await db.mongoCommand({ drop: step.collection }).catch(() => {});
+ break;
+
+ case "createCounter":
+ await db.mongoUpdateOne(
+ "stabilize_counters",
+ { _id: step.collection },
+ { $setOnInsert: { seq: 0 } },
+ { upsert: true },
+ );
+ break;
+
+ default: {
+ const unknown = step as { kind: string };
+ throw new StabilizeError(
+ `Unknown MongoDB migration step '${unknown.kind}'.`,
+ "MIGRATE_ERROR",
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Builds a migration for a model, in the shape the SQL migrations already use.
+ *
+ * `up` and `down` are empty on purpose: they are SQL, and there is no SQL here.
+ * The Mongo half rides in `mongoUp`/`mongoDown`, which `tests/migrations.test.ts`
+ * never sees and `runMigrations` never reads.
+ *
+ * @param model The model to generate for.
+ * @param name The migration's name, and its identity in the ledger.
+ */
+export function generateMongoMigration(
+ model: any,
+ name: string,
+): Migration {
+ const meta = MetadataStorage.getModelMetadata(model);
+ if (!meta?.tableName) {
+ throw new StabilizeError(
+ `Model is missing tableName. Use defineModel() or add static schema.`,
+ "MIGRATE_ERROR",
+ );
+ }
+
+ return {
+ name,
+ up: [],
+ down: [],
+ mongoUp: buildUpSteps(model),
+ // Derived, not hand-written, so the inverse of a migration is the inverse of
+ // exactly what it created — a versioned model's history collection included.
+ mongoDown: generateMongoSteps(model, "down"),
+ };
+}
+
+/**
+ * The steps that bring a model's collection into existence.
+ *
+ * Adds the counter step on top of what `generateMongoSteps` derives: the
+ * collection plan describes the collection, and the counter lives in a different
+ * one, so it is not something a per-collection plan can express.
+ *
+ * @param model The model to build for.
+ */
+function buildUpSteps(model: any): MongoStep[] {
+ const steps = generateMongoSteps(model, "up");
+ if (steps.length === 0) return steps;
+
+ if (modelUsesGeneratedIds(model)) {
+ const collection = mongoCollectionName(model);
+ if (collection) steps.push({ kind: "createCounter", collection });
+ }
+ return steps;
+}
+
+/**
+ * Applies every pending migration, recording each in the ledger.
+ *
+ * @param config The database configuration.
+ * @param migrations The migrations to apply, in order.
+ */
+export async function runMongoMigrations(
+ config: DBConfig,
+ migrations: Migration[],
+): Promise {
+ const client = new DBClient(config);
+ try {
+ for (const [index, migration] of migrations.entries()) {
+ const name =
+ migration.name || `migration_${index}_${new Date().getTime()}`;
+
+ const applied = await client.mongoFindOne(MONGO_MIGRATIONS_COLLECTION, {
+ _id: name,
+ });
+ if (applied) continue;
+
+ console.log(`Applying migration: ${name}...`);
+ const steps: MongoStep[] = migration.mongoUp ?? [];
+
+ // A migration that carries only SQL has nothing to do here. Saying so is
+ // better than reporting success for work that never happened.
+ if (steps.length === 0 && migration.up.length > 0) {
+ throw new StabilizeError(
+ `Migration '${name}' contains SQL but no MongoDB steps. ` +
+ `Generate it with generateMongoMigration() for a MongoDB target.`,
+ "MIGRATE_ERROR",
+ );
+ }
+
+ // Sequentially, and not in a transaction. @see the note at the top.
+ await runSteps(client, steps);
+
+ // Recorded only after every step succeeded, so an interrupted migration
+ // is retried rather than skipped.
+ await client.mongoInsertOne(MONGO_MIGRATIONS_COLLECTION, {
+ _id: name,
+ applied_at: new Date(),
+ });
+
+ console.log(`Migration ${name} applied successfully.`);
+ }
+ } finally {
+ await client.close();
+ }
+}
diff --git a/mongo-query.ts b/mongo-query.ts
new file mode 100644
index 0000000..21ae7d6
--- /dev/null
+++ b/mongo-query.ts
@@ -0,0 +1,720 @@
+/**
+ * @file mongo-query.ts
+ * @description Translates the query builder's structured predicates into MongoDB
+ * filters, sort specs, projections and aggregation pipelines.
+ * @author ElectronSz
+ *
+ * Everything in this file is **pure** — no server, no driver, no client — which
+ * is the point. A filter that is silently wrong returns the wrong rows with no
+ * error at all, so the translation is the one part of the MongoDB backend that
+ * has to be verifiable without a database. `tests/mongo.dialect.test.ts` asserts
+ * it directly.
+ *
+ * There is deliberately no `mongodb` import here. Nothing in this module needs
+ * a driver type: the output is plain documents that the driver serialises.
+ */
+
+import { StabilizeError } from "./types";
+
+// ─── PREDICATE MODEL ──────────────────────────────────────────────────
+//
+// A predicate is what a `QueryBuilder.where*` call records *in addition to* the
+// SQL fragment it renders. The SQL arrays are untouched, so the four working
+// backends keep emitting byte-identical statements; these sit alongside them.
+
+/** The comparison operators `whereCompare` accepts. */
+export type CompareOp = "=" | "!=" | ">" | ">=" | "<" | "<=";
+
+/**
+ * The shapes a predicate can take.
+ *
+ * Each maps to a Mongo operator that has *exactly* SQL's three-valued-logic
+ * behaviour, including the NULL handling — that last part is where a naive
+ * translation goes wrong, so it is spelled out per operator in
+ * {@link renderPredicate}.
+ */
+export type PredicateOp =
+ | "cmp"
+ | "in"
+ | "nin"
+ | "null"
+ | "notNull"
+ | "between"
+ | "notBetween"
+ | "like"
+ | "notLike"
+ | "ilike"
+ | "regex";
+
+/**
+ * One recorded condition.
+ *
+ * `column` is a column name as written at the call site — possibly qualified
+ * (`users.email`) and possibly a *property* key rather than a column name. It is
+ * not resolved until {@link translateField} runs, because resolution needs the
+ * model metadata the builder does not hold.
+ */
+export interface Predicate {
+ op: PredicateOp;
+ column: string;
+ /** The single operand for `cmp` and `like`/`ilike`/`regex`. */
+ value?: any;
+ /** The operand list for `in`/`nin`. */
+ values?: any[];
+ /** The bounds for `between`/`notBetween`. */
+ start?: any;
+ end?: any;
+ /** Which comparison `cmp` means. Defaults to `=`. */
+ compare?: CompareOp;
+}
+
+/**
+ * The recorded conditions, as a tree rather than a list.
+ *
+ * This shape is not a stylistic choice — it is what makes the translation
+ * faithful. SQL's `AND` binds tighter than `OR`, and `orWhere` in the builder
+ * does **not** append a disjunct: it folds everything accumulated so far into a
+ * single group, so
+ *
+ * where(A).where(B).orWhere(C).where(D)
+ *
+ * renders as `((A AND B) OR C) AND D`. A flat `[A, B, C(OR), D]` list cannot
+ * express that — read left-to-right with `AND` precedence it yields
+ * `(A AND B) OR (C AND D)`, which is a different set of rows. The `or` node
+ * therefore holds the *whole* left-hand side, exactly as the fold does.
+ */
+export type MongoFilterNode =
+ | { kind: "pred"; predicate: Predicate }
+ | { kind: "and"; items: MongoFilterNode[] }
+ | { kind: "or"; left: MongoFilterNode; right: MongoFilterNode };
+
+/**
+ * Where a document field name differs from the SQL column name.
+ *
+ * The ORM stores documents keyed by **column name**, with the primary key mapped
+ * to `_id` (see `mongo-repository.ts`). Callers legitimately hand the builder
+ * either spelling — `repository.ts` passes column names, model-facing code often
+ * passes property keys — so both are resolved here rather than at ~30 call sites.
+ */
+export interface MongoFieldContext {
+ /** The collection name, so a `table.column` qualifier can be stripped. */
+ table?: string;
+ /** The builder's alias, likewise stripped. */
+ alias?: string | null;
+ /** The primary-key *column* name. Defaults to `"id"`. */
+ primaryKey?: string;
+ /** The primary-key *property* key, when it differs from the column name. */
+ idProperty?: string;
+ /** Property key → column name, for callers that hold property keys. */
+ columns?: Record;
+}
+
+/** A `count`/`sum`/`avg`/`min`/`max` shortcut recorded by the builder. */
+export interface MongoAggregate {
+ fn: "count" | "sum" | "avg" | "min" | "max";
+ column: string;
+ alias: string;
+}
+
+/**
+ * The SQL-only clauses a builder was asked for.
+ *
+ * Collected rather than thrown at once, so the error a caller sees can name
+ * *every* offending method instead of only the first — a query with a `join` and
+ * a `whereRaw` should report both.
+ */
+export interface MongoBlockers {
+ methods: string[];
+ details: string[];
+}
+
+/** The filter, sort, projection and window a query resolved to. */
+export interface MongoQuerySpec {
+ filter: Record;
+ projection?: Record;
+ sort?: Record;
+ limit?: number;
+ skip?: number;
+}
+
+/** Everything {@link buildMongoSpec} needs from a builder. */
+export interface MongoSpecInput {
+ filter?: MongoFilterNode | null;
+ orderBy?: string[] | null;
+ limit?: number | null;
+ offset?: number | null;
+ select?: string[] | null;
+ blockers?: MongoBlockers | null;
+ ctx?: MongoFieldContext;
+}
+
+/**
+ * A filter that matches no documents.
+ *
+ * `$in` with an empty array is the one Mongo idiom for "nothing" that needs no
+ * field to exist and no server version to support. It stands in for the places
+ * where SQL's three-valued logic would also match nothing but Mongo has no
+ * direct spelling: `IN ()`, `NOT IN (1, NULL)`, and a comparison against NULL.
+ */
+export const MONGO_MATCHES_NOTHING: Record = Object.freeze({
+ _id: { $in: [] as any[] },
+});
+
+/**
+ * Returned by {@link sanitizeMongoValue} for values a write should **omit**
+ * rather than store as null.
+ *
+ * A symbol cannot collide with a real field value, so `if (v === OMIT)` is
+ * unambiguous where `null` would be.
+ */
+export const OMIT: unique symbol = Symbol("stabilize.mongo.omit");
+
+// ─── BLOCKERS ─────────────────────────────────────────────────────────
+
+export function createMongoBlockers(): MongoBlockers {
+ return { methods: [], details: [] };
+}
+
+/**
+ * Records a method that has no MongoDB equivalent.
+ *
+ * @param blockers The list being accumulated on the builder.
+ * @param method The method name, as the caller would have written it.
+ * @param detail The offending fragment, quoted back in the error.
+ */
+export function recordMongoBlocker(
+ blockers: MongoBlockers,
+ method: string,
+ detail?: string,
+): void {
+ if (!blockers.methods.includes(method)) blockers.methods.push(method);
+ if (detail !== undefined) {
+ blockers.details.push(`${method}: ${detail}`);
+ }
+}
+
+/**
+ * Throws the error a blocked query produces.
+ *
+ * Raised when the query is *executed*, not when the clause is added — the
+ * builder is dialect-agnostic until a client appears, which is what lets one
+ * builder be rendered for SQL and Mongo.
+ */
+export function throwMongoUnsupported(blockers: MongoBlockers): never {
+ const methods = blockers.methods;
+ const noun = methods.length === 1 ? "has" : "have";
+ const detail = blockers.details.length
+ ? `\n ${blockers.details.join("\n ")}`
+ : "";
+ throw new StabilizeError(
+ `This query cannot be translated to MongoDB: ${methods.join(", ")} ${noun} ` +
+ `no MongoDB equivalent. MongoDB is a document store — it has no joins, ` +
+ `no set operations and no SQL text. Use withRelations() for related ` +
+ `documents, or run this query against a SQL backend.${detail}`,
+ "MONGO_UNSUPPORTED",
+ );
+}
+
+// ─── FIELD TRANSLATION ────────────────────────────────────────────────
+
+/**
+ * Resolves a call-site field reference to a document field name.
+ *
+ * Three rewrites, in order, and the order matters:
+ *
+ * 1. A leading `table.` or `alias.` qualifier is stripped. It names the SQL
+ * table, which in Mongo is the collection — already implied by which
+ * collection is being queried.
+ * 2. The primary key, by *either* its property key or its column name, becomes
+ * `_id`. Checked before the column map so that a model which renames its id
+ * column (`id` property, `user_id` column) resolves whichever spelling the
+ * caller used.
+ * 3. A property key is resolved to its column name, since documents are stored
+ * under column names.
+ *
+ * A remaining dotted path is left alone: relations are separate collections and
+ * never appear as dotted paths, so a dot here means an embedded document field,
+ * which Mongo addresses by exactly that path.
+ */
+export function translateField(
+ field: string,
+ ctx: MongoFieldContext = {},
+): string {
+ let name = String(field).trim();
+
+ for (const qualifier of [ctx.alias, ctx.table]) {
+ if (qualifier && name.startsWith(`${qualifier}.`)) {
+ name = name.slice(qualifier.length + 1);
+ break;
+ }
+ }
+
+ const primaryKey = ctx.primaryKey ?? "id";
+ if (ctx.idProperty && name === ctx.idProperty) return "_id";
+ if (name === primaryKey) return "_id";
+
+ if (ctx.columns && Object.prototype.hasOwnProperty.call(ctx.columns, name)) {
+ const column = ctx.columns[name] ?? name;
+ // A renamed id column still has to reach `_id`.
+ return column === primaryKey ? "_id" : column;
+ }
+
+ return name;
+}
+
+// ─── PREDICATE RENDERING ──────────────────────────────────────────────
+
+/**
+ * Translates a SQL `LIKE` pattern into a regular expression.
+ *
+ * SQL's wildcards are not regex metacharacters and vice versa, so a pattern has
+ * to be rebuilt rather than passed through: `%` becomes `.*`, `_` becomes `.`,
+ * and every regex metacharacter in the literal part is escaped.
+ *
+ * The `s` flag is not optional. In a regex `.` does not match a newline, but
+ * SQL's `%` does, so `LIKE '%foo%'` and `/foo/` disagree about any value
+ * containing a line break.
+ *
+ * @param pattern The SQL pattern, e.g. `"a_c%"`.
+ * @param caseInsensitive Adds the `i` flag, for `ILIKE`.
+ */
+export function translateLikePattern(
+ pattern: any,
+ caseInsensitive: boolean = false,
+): { pattern: string; options: string } {
+ let body = "";
+ for (const ch of String(pattern)) {
+ if (ch === "%") body += ".*";
+ else if (ch === "_") body += ".";
+ else body += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ }
+ return { pattern: `^${body}$`, options: caseInsensitive ? "is" : "s" };
+}
+
+/**
+ * Renders one predicate to a Mongo filter document.
+ *
+ * The recurring difficulty is that SQL and Mongo disagree about absent fields.
+ * SQL compares against `NULL` and yields `UNKNOWN`, which matches nothing; Mongo
+ * has a "field is missing" state that most operators happily match. Every
+ * negation below therefore carries an explicit `$ne: null` — without it,
+ * `NOT IN` and `NOT LIKE` would return documents SQL would exclude, which is the
+ * quiet kind of wrong that survives review.
+ */
+function renderPredicate(
+ p: Predicate,
+ ctx: MongoFieldContext,
+): Record {
+ const field = translateField(p.column, ctx);
+
+ switch (p.op) {
+ case "cmp":
+ return renderCompare(field, p.compare ?? "=", p.value);
+
+ case "in": {
+ // SQL's `IN (1, NULL)` can never be satisfied by the NULL — `x = NULL` is
+ // UNKNOWN regardless of x — so the NULLs are dropped rather than matched.
+ // That makes `IN (1, NULL)` behave exactly like `IN (1)`, as SQL does.
+ const values = (p.values ?? []).filter((v) => v !== null && v !== undefined);
+ return values.length > 0
+ ? { [field]: { $in: values } }
+ : { ...MONGO_MATCHES_NOTHING };
+ }
+
+ case "nin": {
+ // One NULL anywhere in the list makes `NOT IN` unsatisfiable in SQL, since
+ // every row's comparison against it is UNKNOWN.
+ const values = p.values ?? [];
+ if (values.length === 0) return {}; // SQL renders no clause at all here.
+ if (values.some((v) => v === null || v === undefined)) {
+ return { ...MONGO_MATCHES_NOTHING };
+ }
+ // `$ne: null` is what excludes documents where the field is missing or
+ // null; `$nin` alone would match them.
+ return { [field]: { $nin: values, $ne: null } };
+ }
+
+ case "null":
+ // Matches missing *and* null, which is the intent: a document written
+ // before the field existed is not soft-deleted.
+ return { [field]: null };
+
+ case "notNull":
+ return { [field]: { $ne: null } };
+
+ case "between":
+ if (p.start === null || p.start === undefined) {
+ return { ...MONGO_MATCHES_NOTHING };
+ }
+ if (p.end === null || p.end === undefined) {
+ return { ...MONGO_MATCHES_NOTHING };
+ }
+ return { [field]: { $gte: p.start, $lte: p.end } };
+
+ case "notBetween":
+ if (p.start === null || p.start === undefined) {
+ return { ...MONGO_MATCHES_NOTHING };
+ }
+ if (p.end === null || p.end === undefined) {
+ return { ...MONGO_MATCHES_NOTHING };
+ }
+ // `$not` alone would match missing fields, which `NOT BETWEEN` does not.
+ return {
+ $and: [
+ { [field]: { $not: { $gte: p.start, $lte: p.end } } },
+ { [field]: { $ne: null } },
+ ],
+ };
+
+ case "like":
+ case "ilike": {
+ const { pattern, options } = translateLikePattern(
+ p.value,
+ p.op === "ilike",
+ );
+ return { [field]: { $regex: pattern, $options: options } };
+ }
+
+ case "notLike": {
+ // Expressed with `$nor` rather than `$not`. `$not` combined with `$regex`
+ // is documented inconsistently across server versions, whereas `$nor` over
+ // a regex is unambiguous — and `$ne: null` then supplies the NULL
+ // exclusion SQL's `NOT LIKE` performs.
+ const { pattern, options } = translateLikePattern(p.value, false);
+ return {
+ $and: [
+ { $nor: [{ [field]: { $regex: pattern, $options: options } }] },
+ { [field]: { $ne: null } },
+ ],
+ };
+ }
+
+ case "regex":
+ return { [field]: { $regex: p.value, $options: "s" } };
+
+ default: {
+ // Exhaustiveness: a new PredicateOp without a branch is a type error here
+ // rather than a predicate that silently matches everything.
+ const never: never = p.op;
+ throw new StabilizeError(
+ `Unsupported predicate op: ${String(never)}`,
+ "MONGO_UNSUPPORTED",
+ );
+ }
+ }
+}
+
+/**
+ * Renders a `cmp` predicate for a single comparison operator.
+ *
+ * `!=` is the interesting one. `{field: {$ne: v}}` matches documents where the
+ * field is **missing**, but SQL's `<>` does not — its result there is UNKNOWN.
+ * The `$ne: null` alongside it is what closes that gap.
+ */
+function renderCompare(
+ field: string,
+ op: CompareOp,
+ value: any,
+): Record {
+ switch (op) {
+ case "=":
+ // Already faithful: a missing field does not equal a value.
+ return { [field]: value };
+ case "!=":
+ if (value === null || value === undefined) {
+ // `x <> NULL` is UNKNOWN for every x, so it matches nothing.
+ return { ...MONGO_MATCHES_NOTHING };
+ }
+ // `$nin` with an explicit `null` rather than `$ne`, because `{f: {$ne: v}}`
+ // matches documents where `f` is *missing* and SQL's `<>` does not: `$in`
+ // treats a missing field as null, so excluding null excludes missing too.
+ return { [field]: { $nin: [value, null] } };
+ case ">":
+ return comparison(field, "$gt", value);
+ case ">=":
+ return comparison(field, "$gte", value);
+ case "<":
+ return comparison(field, "$lt", value);
+ case "<=":
+ return comparison(field, "$lte", value);
+ default: {
+ const never: never = op;
+ throw new StabilizeError(
+ `Unsupported comparison operator: ${String(never)}`,
+ "MONGO_UNSUPPORTED",
+ );
+ }
+ }
+}
+
+/** A range comparison, which matches nothing when the bound is NULL. */
+function comparison(
+ field: string,
+ operator: string,
+ value: any,
+): Record {
+ if (value === null || value === undefined) {
+ return { ...MONGO_MATCHES_NOTHING };
+ }
+ return { [field]: { [operator]: value } };
+}
+
+/**
+ * Renders a predicate tree to a single filter document.
+ *
+ * `$and` and `$or` rather than merging into one object, and that is not
+ * cosmetic. Merging `{age: {$gt: 18}}` with `{age: {$lt: 65}}` produces
+ * `{age: {$lt: 65}}` — the first condition is silently dropped. `$and` keeps
+ * both, which is what makes repeated conditions on one field correct.
+ */
+export function buildMongoFilter(
+ node: MongoFilterNode | null | undefined,
+ ctx: MongoFieldContext = {},
+): Record {
+ if (!node) return {};
+
+ switch (node.kind) {
+ case "pred":
+ return renderPredicate(node.predicate, ctx);
+
+ case "and": {
+ // `$and` must be a non-empty array, so the degenerate sizes are folded
+ // away rather than emitted.
+ const items = node.items.map((item) => buildMongoFilter(item, ctx));
+ if (items.length === 0) return {};
+ if (items.length === 1) return items[0]!;
+ return { $and: items };
+ }
+
+ case "or": {
+ // Both sides are always present: the builder only creates an `or` node by
+ // folding a non-empty left-hand side against the new condition.
+ return {
+ $or: [buildMongoFilter(node.left, ctx), buildMongoFilter(node.right, ctx)],
+ };
+ }
+
+ default: {
+ const never: never = node;
+ throw new StabilizeError(
+ `Unsupported filter node: ${JSON.stringify(never)}`,
+ "MONGO_UNSUPPORTED",
+ );
+ }
+ }
+}
+
+// ─── SORT / PROJECTION / SPEC ─────────────────────────────────────────
+
+/** A bare field path, optionally qualified: `users.created_at`. */
+const FIELD_PATH = /^[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*$/;
+
+/**
+ * Renders `ORDER BY` clauses to a Mongo sort document.
+ *
+ * A clause that is not ` ` came from `orderByRaw` and is an
+ * arbitrary SQL expression. It throws rather than being passed through: a sort
+ * key of `"LENGTH(name)"` would be accepted by the driver as a literal field
+ * name, sort every document as missing, and return rows in no particular order.
+ */
+export function buildMongoSort(
+ clauses: string[] | null | undefined,
+ ctx: MongoFieldContext = {},
+): Record | null {
+ if (!clauses || clauses.length === 0) return null;
+
+ const sort: Record = {};
+ for (const raw of clauses) {
+ const match = /^(.+?)\s+(ASC|DESC)$/i.exec(String(raw).trim());
+ const field = match?.[1]?.trim();
+ const direction = match?.[2]?.toUpperCase();
+ if (!field || !direction || !FIELD_PATH.test(field)) {
+ throw new StabilizeError(
+ `ORDER BY "${raw}" has no MongoDB equivalent. MongoDB sorts by field ` +
+ `path only; an expression such as orderByRaw() cannot be translated.`,
+ "MONGO_UNSUPPORTED",
+ );
+ }
+ sort[translateField(field, ctx)] = direction === "DESC" ? -1 : 1;
+ }
+ return sort;
+}
+
+/**
+ * Renders a `SELECT` list to a Mongo projection.
+ *
+ * Returns null — "take the whole document" — when the list is `*` or contains an
+ * expression, since a projection document cannot hold one. `selectRaw` is
+ * recorded as a blocker by the builder, so an expression here is already
+ * reported; returning null keeps this function total.
+ *
+ * `_id` is always included explicitly. Mongo's inclusion projections carry it by
+ * default, and it is where the model's primary key lives, so a projection that
+ * silently dropped it would leave every row without an `id`.
+ */
+export function buildMongoProjection(
+ select: string[] | null | undefined,
+ ctx: MongoFieldContext = {},
+): Record | null {
+ if (!select || select.length === 0) return null;
+ // `String(...)` rather than `select[0].trim()`: the loop below coerces for the
+ // same reason, and a `select()` handed a non-string would otherwise throw a
+ // bare TypeError out of `buildMongo` instead of being reported as an
+ // unbuildable projection.
+ if (select.length === 1 && String(select[0]).trim() === "*") return null;
+
+ const projection: Record = {};
+ for (const raw of select) {
+ const field = String(raw).trim();
+ if (!FIELD_PATH.test(field)) return null;
+ projection[translateField(field, ctx)] = 1;
+ }
+ projection._id = 1;
+ return projection;
+}
+
+/**
+ * Assembles the full spec a query resolves to, or throws if it cannot.
+ *
+ * @throws StabilizeError `MONGO_UNSUPPORTED` when the builder recorded a clause
+ * Mongo cannot express, naming every such method at once.
+ */
+export function buildMongoSpec(input: MongoSpecInput): MongoQuerySpec {
+ if (input.blockers && input.blockers.methods.length > 0) {
+ throwMongoUnsupported(input.blockers);
+ }
+
+ const ctx = input.ctx ?? {};
+ const spec: MongoQuerySpec = {
+ filter: buildMongoFilter(input.filter, ctx),
+ };
+
+ const projection = buildMongoProjection(input.select, ctx);
+ if (projection) spec.projection = projection;
+
+ const sort = buildMongoSort(input.orderBy, ctx);
+ if (sort) spec.sort = sort;
+
+ if (typeof input.limit === "number") spec.limit = input.limit;
+ if (typeof input.offset === "number" && input.offset > 0) {
+ spec.skip = input.offset;
+ }
+
+ // A skip with no ordering has no stable total order in Mongo — the server is
+ // free to return a different subset for the same query on consecutive runs,
+ // so a paging loop can revisit a document or never reach the end. `_id` is
+ // unique, so ordering by it is a total order and makes the page boundaries
+ // reproducible. The SQL path is left alone; SQL's unordered LIMIT is
+ // unspecified too, but changing it would alter existing behaviour.
+ if (spec.skip !== undefined && !spec.sort) {
+ spec.sort = { _id: 1 };
+ }
+
+ return spec;
+}
+
+// ─── AGGREGATES ───────────────────────────────────────────────────────
+
+/**
+ * Renders the aggregate shortcuts (`count`/`sum`/`avg`/`min`/`max`) to a
+ * `$group` stage.
+ *
+ * `COUNT(column)` counts non-NULL values only, so a bare `$sum: 1` would be
+ * wrong for anything but `COUNT(*)`. The `$cond` reproduces the SQL definition.
+ */
+export function buildMongoGroupStage(
+ aggregates: MongoAggregate[],
+ ctx: MongoFieldContext = {},
+): Record {
+ const group: Record = { _id: null };
+
+ for (const aggregate of aggregates) {
+ if (aggregate.fn === "count") {
+ group[aggregate.alias] =
+ aggregate.column === "*"
+ ? { $sum: 1 }
+ : {
+ $sum: {
+ $cond: [
+ { $ne: [`$${translateField(aggregate.column, ctx)}`, null] },
+ 1,
+ 0,
+ ],
+ },
+ };
+ continue;
+ }
+ group[aggregate.alias] = {
+ [`$${aggregate.fn}`]: `$${translateField(aggregate.column, ctx)}`,
+ };
+ }
+
+ return { $group: group };
+}
+
+/**
+ * Builds the aggregation pipeline for a query carrying aggregates.
+ *
+ * `limit`/`skip` are deliberately not applied. They belong to the row window,
+ * and every aggregate shortcut here groups to a single `_id: null` row, so the
+ * window has nothing to narrow — the same one row SQL would return.
+ */
+export function buildMongoAggregatePipeline(
+ filter: Record,
+ aggregates: MongoAggregate[],
+ ctx: MongoFieldContext = {},
+): Record[] {
+ const projection: Record = { _id: 0 };
+ for (const aggregate of aggregates) projection[aggregate.alias] = 1;
+
+ return [
+ { $match: filter },
+ buildMongoGroupStage(aggregates, ctx),
+ { $project: projection },
+ ];
+}
+
+// ─── VALUE COERCION ───────────────────────────────────────────────────
+
+/**
+ * Prepares a value for storage in a Mongo document.
+ *
+ * This is **not** `sanitizeSqlValue` and must not be folded into it. Every one
+ * of that function's coercions is actively wrong here:
+ *
+ * - A `Date` stringified for SQLite fails a `{bsonType: "date"}` validator and
+ * turns an indexed range query into a string comparison.
+ * - `boolean` → `1|0` fails `{bsonType: "bool"}`, and makes
+ * `whereEq("published", true)` match nothing.
+ * - `undefined` → explicit `NULL` collides in a sparse unique index, which is
+ * exactly the case a unique-but-optional column creates.
+ *
+ * So this passes `Date`, `boolean`, plain objects, arrays and `Buffer` through
+ * untouched, and reports omission with {@link OMIT} instead of writing a null.
+ *
+ * Not recursive: it governs whether a *field* is written, not what is inside a
+ * document-valued field, where the caller's structure is preserved verbatim.
+ */
+export function sanitizeMongoValue(value: any): any {
+ if (value === undefined || value === null) return OMIT;
+ if (typeof value === "function" || typeof value === "symbol") return OMIT;
+ return value;
+}
+
+/**
+ * Rewrites a stored document into the shape the rest of the ORM expects.
+ *
+ * `_id` is the Mongo primary key; the model calls it `id`. The rename happens
+ * here, before `rowTransform` and the relation loaders run, so decryption and
+ * relation key collection see the id under the name they look for.
+ */
+export function normalizeMongoDoc(
+ doc: any,
+ idColumn: string = "id",
+): T {
+ if (!doc || typeof doc !== "object") return doc;
+ if (!Object.prototype.hasOwnProperty.call(doc, "_id")) return doc;
+
+ const { _id, ...rest } = doc;
+ return { ...rest, [idColumn]: _id } as T;
+}
diff --git a/mongo-repository.ts b/mongo-repository.ts
new file mode 100644
index 0000000..b964412
--- /dev/null
+++ b/mongo-repository.ts
@@ -0,0 +1,1577 @@
+/**
+ * @file mongo-repository.ts
+ * @description The MongoDB bodies behind `Repository`'s write paths.
+ * @author ElectronSz
+ *
+ * Kept beside `repository.ts` rather than inside it because the two storage
+ * models agree on almost nothing at the statement level: `INSERT … VALUES` and
+ * `insertOne(doc)` share no structure to factor out. What they *do* share —
+ * validation, hooks, timestamps, the optimistic-lock seed, encryption, relation
+ * loading and cache invalidation — stays on the repository and reaches these
+ * bodies through {@link MongoRepositoryHost}, so none of it is reimplemented.
+ *
+ * Documents are keyed by the model's **column name** (`col.name ?? key`), with
+ * the primary key stored as `_id`. That is the decision that keeps this file
+ * small: every relation loader, cache key, history writer and transform in the
+ * repository is already written against column names, so they work unchanged.
+ */
+
+import { DBClient } from "./client";
+import { StabilizeError } from "./types";
+import {
+ type MongoAggregate,
+ OMIT,
+ buildMongoAggregatePipeline,
+ normalizeMongoDoc,
+ sanitizeMongoValue,
+} from "./mongo-query";
+
+/**
+ * The collection auto-increment ids are drawn from.
+ *
+ * One document per table, `_id` being the table name — so uniqueness is
+ * enforced by the storage layer instead of by a read-then-write the caller
+ * would have to get right.
+ */
+export const MONGO_COUNTERS_COLLECTION = "stabilize_counters";
+
+/** A column as the write bodies need to see it. */
+export interface MongoColumn {
+ name: string;
+ encrypted?: boolean;
+}
+
+/**
+ * The three names a many-to-many link is addressed by.
+ *
+ * The join collection has no model of its own, so nothing here resolves
+ * through column metadata — these names come from the relation config and are
+ * used verbatim, exactly as the SQL path uses them as table and column names.
+ */
+export interface MongoLinkRelation {
+ joinTable: string;
+ foreignKey: string;
+ inverseKey: string;
+}
+
+/**
+ * The composite `_id` a link document is keyed by.
+ *
+ * `_id` uniqueness is the one uniqueness constraint MongoDB enforces on every
+ * collection without an index being declared, so keying the link by the pair
+ * makes `attach` idempotent at the storage layer: the second insert of the same
+ * pair cannot land, whether or not the caller checked first. The SQL join table
+ * has no such constraint and needs the pre-read to avoid a duplicate row.
+ *
+ * One function, so the shape the writer builds is the shape the reader matches.
+ */
+function linkId(parent: any, child: any): { p: any; c: any } {
+ return { p: parent, c: child };
+}
+
+/**
+ * The parts of a `Repository` the MongoDB write bodies depend on.
+ *
+ * Deliberately narrow and structural: `Repository` satisfies it through an
+ * adapter object built inside the class, which keeps these bodies testable
+ * without a repository and keeps every member it touches explicit.
+ */
+export interface MongoRepositoryHost {
+ /** The collection this repository writes to. */
+ table: string;
+ /** Property key → column. Always normalised, see the `Repository` constructor. */
+ columns: Record;
+ /** The primary-key property name. `"id"` by convention. */
+ idProperty: string;
+ /** The primary-key column name, which `_id` stores. */
+ idColumn: string;
+ /**
+ * The property auto-increment ids are generated for, or null when the key is
+ * caller-supplied (a UUID or string id).
+ */
+ autoIncrementField: string | null;
+ /** The soft-delete property key, or null when the model does not soft-delete. */
+ softDeleteField: string | null;
+ /** The soft-delete *column* name. @see softDeleteField */
+ softDeleteColumn: string | null;
+ /** The optimistic-lock property key, or null when the model has no lock. */
+ optimisticLockField: string | null;
+ /** The optimistic-lock *column* name. @see optimisticLockField */
+ optimisticLockColumn: string | null;
+ /** The timestamp property keys, or null when timestamps are off. */
+ timestamps: { createdAt?: string; updatedAt?: string } | null;
+ /**
+ * The collection a versioned model's history documents are written to. Always
+ * set, because only a versioned repository reaches the bodies that use it.
+ */
+ historyTable: string;
+ logger: { logDebug(message: string): void };
+ /** Throws when the entity fails the model's validators. */
+ validate(entity: any, skipRequired?: boolean): void;
+ /** Applies the timestamp and optimistic-lock defaults a create writes. */
+ seedCreateDefaults(entity: Record): Record;
+ /** Encrypts encrypted columns and normalises values for storage. */
+ processForSave(entity: Record): Record;
+ /** Decrypts encrypted columns. */
+ processForLoad(row: any): any;
+ findOne(
+ id: number | string,
+ options: { relations?: string[] },
+ client: DBClient,
+ ): Promise;
+ loadRelations(
+ rows: any[],
+ relations: string[] | undefined,
+ client: DBClient,
+ ): Promise;
+ invalidateRowCache(id: number | string): Promise;
+ invalidateTableCache(): Promise;
+ writeThroughRow(id: number | string, row: any): Promise;
+}
+
+/**
+ * Reads the sequence number out of a `findOneAndUpdate` reply.
+ *
+ * Driver 6 returns the document itself — `includeResultMetadata` has defaulted
+ * to false since NODE-3568 — where driver 5 returned a `ModifyResult` wrapping
+ * it in `value`. Both are read rather than one being assumed, and neither being
+ * present is an error: a silent `undefined` here would allocate `NaN` ids and
+ * fail much later, somewhere else.
+ *
+ * @param reply Whatever the driver handed back.
+ * @param table The collection the counter belongs to, for the message.
+ * @throws StabilizeError `MONGO_COUNTER_ERROR` when no sequence number is present.
+ */
+function readCounterSeq(reply: any, table: string): number {
+ const seq = reply?.seq ?? reply?.value?.seq;
+ if (typeof seq !== "number" || !Number.isFinite(seq)) {
+ throw new StabilizeError(
+ `MongoDB counter for '${table}' did not return a sequence number. ` +
+ `Expected a numeric 'seq' field on the counters document.`,
+ "MONGO_COUNTER_ERROR",
+ );
+ }
+ return seq;
+}
+
+/**
+ * Reports whether an error is a duplicate-key violation.
+ *
+ * The driver's own error is usually wrapped by `mongoRun` in a `StabilizeError`,
+ * so the code is looked for on the error and on everything it was caused by.
+ *
+ * @param error The error to inspect.
+ */
+function isDuplicateKey(error: unknown): boolean {
+ let current: any = error;
+ for (let depth = 0; current && depth < 5; depth++) {
+ if (current.code === 11000) return true;
+ if (typeof current.message === "string" && current.message.includes("E11000")) {
+ return true;
+ }
+ current = current.cause;
+ }
+ return false;
+}
+
+/**
+ * Reserves a contiguous block of ids for one collection.
+ *
+ * One `$inc` allocates the whole block, so a batch of twenty costs one round
+ * trip and yields exactly twenty consecutive ids — where the SQL path has to
+ * guess which rows a multi-row `INSERT` produced.
+ *
+ * `$inc` against a field that does not exist yet initialises it to the
+ * increment, so a brand-new collection yields ids starting at 1.
+ *
+ * @param client The client to allocate through.
+ * @param table The collection the ids belong to.
+ * @param count How many ids to reserve. Zero or fewer allocates nothing.
+ * @returns The reserved ids, ascending.
+ * @throws StabilizeError `MONGO_COUNTER_ERROR` if the counter reports no sequence.
+ */
+export async function allocateMongoIds(
+ client: DBClient,
+ table: string,
+ count: number,
+): Promise {
+ if (count <= 0) return [];
+
+ const bump = () =>
+ client.mongoFindOneAndUpdate(
+ MONGO_COUNTERS_COLLECTION,
+ { _id: table },
+ { $inc: { seq: count } },
+ { upsert: true, returnDocument: "after" },
+ );
+
+ let reply: any;
+ try {
+ reply = await bump();
+ } catch (error) {
+ // Two upserts racing on the same counter `_id`: the loser gets a
+ // duplicate-key error instead of an id. One retry settles it, because by
+ // then the document exists and the same call is an ordinary `$inc`.
+ if (!isDuplicateKey(error)) throw error;
+ reply = await bump();
+ }
+
+ const last = readCounterSeq(reply, table);
+ const first = last - count + 1;
+ return Array.from({ length: count }, (_, offset) => first + offset);
+}
+
+/**
+ * Raises a counter so it is at least `maxId`.
+ *
+ * A caller-supplied id has to move the counter, or the next generated id would
+ * collide with it. SQLite gets this for free from `sqlite_sequence`; MongoDB
+ * needs it said out loud. `$max` rather than a write, so an id below the current
+ * sequence — a caller re-inserting a row it read — leaves the counter alone.
+ *
+ * @param client The client to write through.
+ * @param table The collection the counter belongs to.
+ * @param maxId The highest caller-supplied id in this write.
+ */
+export async function advanceMongoCounter(
+ client: DBClient,
+ table: string,
+ maxId: number,
+): Promise {
+ await client.mongoUpdateOne(
+ MONGO_COUNTERS_COLLECTION,
+ { _id: table },
+ { $max: { seq: maxId } },
+ { upsert: true },
+ );
+}
+
+/**
+ * Turns a prepared entity into the document that gets stored.
+ *
+ * The primary key is left out: it is written as `_id` by the caller, which is
+ * what gives the collection a unique index on it for free.
+ *
+ * `sanitizeMongoValue` returns {@link OMIT} for `null` and `undefined`, and the
+ * key is dropped rather than stored as an explicit null. Storing it would put an
+ * explicit null in every column the caller did not mention, and a sparse unique
+ * index treats two explicit nulls as a collision — so a second document omitting
+ * a unique column would be rejected.
+ *
+ * @param host The repository the entity belongs to.
+ * @param entity An entity already through `processForSave`.
+ */
+function buildMongoDocument(
+ host: MongoRepositoryHost,
+ entity: Record,
+): Record {
+ const document: Record = {};
+ for (const [key, value] of Object.entries(entity)) {
+ const column = host.columns[key];
+ if (!column) continue;
+ if (key === host.idProperty) continue;
+ const sanitized = sanitizeMongoValue(value);
+ if (sanitized === OMIT) continue;
+ document[column.name] = sanitized;
+ }
+ return document;
+}
+
+/**
+ * Splits an array into chunks of at most `size`.
+ *
+ * @param items The array to split.
+ * @param size The largest chunk to produce.
+ */
+function chunked(items: T[], size: number): T[][] {
+ const chunks: T[][] = [];
+ for (let i = 0; i < items.length; i += size) {
+ chunks.push(items.slice(i, i + size));
+ }
+ return chunks;
+}
+
+/**
+ * Turns a prepared patch into `$set`/`$unset` halves.
+ *
+ * The difference from {@link buildMongoDocument} is what a null means. On an
+ * insert it means "this column was not supplied", so the key is dropped. On an
+ * update it means "clear this column", which on a document store is `$unset` —
+ * dropping the key would leave the old value in place and silently ignore the
+ * caller. SQL spells both the same way (`SET col = NULL`), which is why the two
+ * builders cannot be one.
+ *
+ * @param host The repository the patch belongs to.
+ * @param prepared A patch already through `processForSave`.
+ */
+function buildMongoPatch(
+ host: MongoRepositoryHost,
+ prepared: Record,
+): { $set: Record; $unset: Record } {
+ const $set: Record = {};
+ const $unset: Record = {};
+ for (const [key, value] of Object.entries(prepared)) {
+ const column = host.columns[key];
+ // The primary key is `_id`, which is never patched: a change of identity is
+ // a delete and an insert, not an update.
+ if (!column || key === host.idProperty) continue;
+ const sanitized = sanitizeMongoValue(value);
+ if (sanitized === OMIT) $unset[column.name] = "";
+ else $set[column.name] = sanitized;
+ }
+ return { $set, $unset };
+}
+
+/**
+ * Assembles the update document, leaving out the halves that are empty.
+ *
+ * MongoDB rejects `$set: {}` as an empty operator, so an absent half has to be
+ * an absent key rather than an empty object.
+ */
+function updateOperation(
+ $set: Record,
+ $unset: Record,
+): Record {
+ const operation: Record = {};
+ if (Object.keys($set).length > 0) operation.$set = $set;
+ if (Object.keys($unset).length > 0) operation.$unset = $unset;
+ return operation;
+}
+
+/**
+ * Turns a set of equality conditions into a filter.
+ *
+ * The soft-delete clause is deliberately *not* added here: the operations that
+ * want it differ in the direction they want it, so each caller adds its own.
+ *
+ * @param host The repository the conditions belong to.
+ * @param conditions Property key → value, as the public methods take them.
+ * @param options `skipNull` matches `restoreBy`'s behaviour of ignoring a null
+ * condition rather than treating it as `IS NULL`.
+ * @throws StabilizeError `UNSAFE_QUERY` when the conditions name no known
+ * column: the repository has already refused a *caller* that passed nothing,
+ * but a condition set of unknown keys would otherwise become an empty filter,
+ * which is the same statement with no WHERE clause this is meant to prevent.
+ */
+function buildMongoConditions(
+ host: MongoRepositoryHost,
+ conditions: Record,
+ options: { skipNull?: boolean } = {},
+): Record {
+ const filter: Record = {};
+ for (const [key, value] of Object.entries(conditions)) {
+ const column = host.columns[key];
+ if (!column) continue;
+ if (value === null || value === undefined) {
+ if (options.skipNull) continue;
+ // `{field: null}` matches a document where the field is null *or absent*,
+ // which is what SQL's `IS NULL` means for a column that always exists.
+ filter[column.name] = null;
+ continue;
+ }
+ filter[column.name] = sanitizeMongoValue(value);
+ }
+
+ if (Object.keys(conditions).length > 0 && Object.keys(filter).length === 0) {
+ throw new StabilizeError(
+ `None of the conditions ${JSON.stringify(Object.keys(conditions))} name a column of ${host.table}; refusing to affect every document.`,
+ "UNSAFE_QUERY",
+ );
+ }
+ return filter;
+}
+
+/**
+ * Reads an affected-row count out of a driver write result.
+ *
+ * Driver 6 reports `matchedCount` for an update and `deletedCount` for a
+ * delete. Driver 5 nested both under `result.n`, so that spelling is read too —
+ * a silent zero here would make `updateBy` report having changed nothing while
+ * the write had actually landed.
+ */
+function affectedRows(
+ result: any,
+ field: "matchedCount" | "deletedCount",
+): number {
+ const direct = result?.[field];
+ if (typeof direct === "number") return direct;
+ const legacy = result?.result?.n;
+ return typeof legacy === "number" ? legacy : 0;
+}
+
+/** The soft-delete filter that keeps a write to rows that are not deleted. */
+function notDeletedFilter(host: MongoRepositoryHost): Record {
+ const column = host.softDeleteColumn;
+ if (!host.softDeleteField || !column) return {};
+ return { [column]: null };
+}
+
+/**
+ * Inserts one entity.
+ *
+ * The read-back goes through `findOne` rather than reusing the document that
+ * was just inserted, so the row a caller receives has been through exactly the
+ * same decryption and relation loading a later read would apply.
+ *
+ * @param host The repository being written through.
+ * @param entity The entity, after the create hooks have run.
+ * @param options Relation paths to eager-load onto the result.
+ * @param client The client, which supplies the session and the collection.
+ * @returns The stored entity.
+ */
+export async function mongoCreate(
+ host: MongoRepositoryHost,
+ entity: Record,
+ options: { relations?: string[] },
+ client: DBClient,
+): Promise {
+ const start = Date.now();
+ host.logger.logDebug(
+ `Creating ${host.table} with data: ${JSON.stringify(entity)}`,
+ );
+ host.validate(entity);
+
+ const prepared = host.processForSave(host.seedCreateDefaults(entity));
+ const document = buildMongoDocument(host, prepared);
+
+ const explicit = prepared[host.idProperty];
+ if (explicit !== undefined && explicit !== null) {
+ document._id = explicit;
+ // A numeric key has to move the counter past it, or the next generated id
+ // is handed out again and collides with this row.
+ if (typeof explicit === "number" && Number.isFinite(explicit)) {
+ await advanceMongoCounter(client, host.table, explicit);
+ }
+ } else if (host.autoIncrementField) {
+ document._id = (await allocateMongoIds(client, host.table, 1))[0];
+ }
+
+ await client.mongoInsertOne(host.table, document);
+ const id = document._id;
+
+ const result = await host.findOne(id, options, client);
+
+ await host.invalidateRowCache(id);
+ await host.writeThroughRow(id, result);
+
+ host.logger.logDebug(
+ `Created ${host.table} with ID ${id} in ${Date.now() - start}ms`,
+ );
+ return result;
+}
+
+/**
+ * Inserts many entities.
+ *
+ * Two divergences from the SQL path, both because documents are independent:
+ * the key sets are not unioned across the batch (that exists to build a single
+ * multi-row `VALUES` list, which has no analogue here), and the results are not
+ * re-read from the server. The SQL path re-reads because a multi-row `INSERT`
+ * does not say which keys it generated; here they are allocated up front and the
+ * documents that were sent *are* the rows, so a round trip would only be able to
+ * agree with what is already in hand.
+ *
+ * @param host The repository being written through.
+ * @param entities The entities, after the create hooks have run.
+ * @param options Batch size and relation paths to eager-load.
+ * @param client The client, which supplies the session and the collection.
+ * @returns The stored entities, in the order they were given.
+ */
+export async function mongoBulkCreate(
+ host: MongoRepositoryHost,
+ entities: Record[],
+ options: { relations?: string[]; batchSize?: number },
+ client: DBClient,
+): Promise {
+ const start = Date.now();
+ host.logger.logDebug(
+ `Bulk creating ${entities.length} ${host.table} entities`,
+ );
+ if (!entities.length) return [];
+
+ const batchSize = options.batchSize || 1000;
+ entities.forEach((entity) => host.validate(entity));
+
+ const prepared = entities.map((entity) =>
+ host.processForSave(host.seedCreateDefaults(entity)),
+ );
+
+ const results: any[] = [];
+
+ for (const batch of chunked(prepared, batchSize)) {
+ const explicitIds = batch
+ .map((row) => row[host.idProperty])
+ .filter(
+ (value): value is number =>
+ typeof value === "number" && Number.isFinite(value),
+ );
+ if (explicitIds.length > 0) {
+ await advanceMongoCounter(
+ client,
+ host.table,
+ Math.max(...explicitIds),
+ );
+ }
+
+ const generatedCount = batch.filter(
+ (row) => row[host.idProperty] === undefined || row[host.idProperty] === null,
+ ).length;
+ const generated = await allocateMongoIds(client, host.table, generatedCount);
+
+ let next = 0;
+ const documents = batch.map((row) => {
+ const document = buildMongoDocument(host, row);
+ const explicit = row[host.idProperty];
+ if (explicit !== undefined && explicit !== null) {
+ document._id = explicit;
+ } else {
+ document._id = generated[next++];
+ }
+ return document;
+ });
+
+ await client.mongoInsertMany(host.table, documents);
+ results.push(
+ ...documents.map((document) =>
+ host.processForLoad(normalizeMongoDoc(document, host.idColumn)),
+ ),
+ );
+ }
+
+ await host.invalidateTableCache();
+ await host.loadRelations(results, options.relations, client);
+
+ host.logger.logDebug(
+ `Bulk created ${results.length} ${host.table} entities in ${Date.now() - start}ms`,
+ );
+ return results;
+}
+
+/**
+ * Applies a patch to one row.
+ *
+ * The optimistic lock is advanced inside the same update and matched on in the
+ * filter, which is the whole mechanism: a filter that no longer matches is a
+ * conflict, and `matchedCount === 0` is how it is noticed. Doing the read and
+ * the compare as separate round trips would let two writers pass the check
+ * together and lose one of the updates.
+ *
+ * @param host The repository being written through.
+ * @param id The row to patch.
+ * @param entity The caller's patch.
+ * @param before The row as it was read, for the lock value the caller did not
+ * supply.
+ * @param client The client, which supplies the session and the collection.
+ * @returns The stored entity.
+ * @throws StabilizeError `CONCURRENT_MODIFICATION` when the lock filter misses.
+ */
+export async function mongoUpdate(
+ host: MongoRepositoryHost,
+ id: number | string,
+ entity: Record,
+ before: any,
+ client: DBClient,
+): Promise {
+ const start = Date.now();
+ host.logger.logDebug(`Updating ${host.table} with ID ${id}`);
+ // The payload is a partial patch, exactly like the SQL path's: required
+ // columns that are not being changed must not be demanded here.
+ host.validate(entity, true);
+
+ const prepared: Record = { ...entity };
+ const timestamps = host.timestamps;
+ if (timestamps?.updatedAt && !prepared[timestamps.updatedAt]) {
+ prepared[timestamps.updatedAt] = new Date().toISOString();
+ }
+ // Encrypt and coerce after the timestamp is in place, so it passes through
+ // the same treatment everything else does.
+ Object.assign(prepared, host.processForSave(prepared));
+
+ let lockValue: any;
+ if (host.optimisticLockField) {
+ // Rows can arrive keyed by property or by column, so both are read — a
+ // renamed lock column would otherwise be undefined and the lock would
+ // silently do nothing.
+ lockValue =
+ before?.[host.optimisticLockField] ??
+ (host.optimisticLockColumn
+ ? before?.[host.optimisticLockColumn]
+ : undefined);
+
+ // A caller that passes the version it read expects a conflict if someone
+ // else has written since, so the caller's value wins over the one just
+ // read inside this transaction, which would always match.
+ const callerVersion = entity[host.optimisticLockField];
+ const expected =
+ callerVersion !== undefined && callerVersion !== null
+ ? callerVersion
+ : lockValue;
+
+ if (expected !== undefined) {
+ lockValue = expected;
+ prepared[host.optimisticLockField] =
+ typeof expected === "number" ? expected + 1 : 1;
+ }
+ }
+
+ const { $set, $unset } = buildMongoPatch(host, prepared);
+ const operation = updateOperation($set, $unset);
+
+ const filter: Record = { _id: id };
+ if (host.optimisticLockField && lockValue !== undefined) {
+ // A null version is matched with `{col: null}`, which covers both a stored
+ // null and an absent field. SQL needs `IS NULL` there because `= NULL` is
+ // never true; here the one filter already means both.
+ filter[host.optimisticLockColumn!] = lockValue ?? null;
+ }
+ if (host.softDeleteField) {
+ filter[host.softDeleteColumn!] = null;
+ }
+
+ // An update with no operators is rejected outright by the driver, so a patch
+ // that changes nothing writes nothing. The SQL path produces `SET` with an
+ // empty list there, which is a syntax error — but only for a payload that
+ // carries just the key, and doing nothing is the honest reading of that.
+ if (Object.keys(operation).length > 0) {
+ const result = await client.mongoUpdateOne(host.table, filter, operation);
+ if (
+ host.optimisticLockField &&
+ lockValue !== undefined &&
+ affectedRows(result, "matchedCount") === 0
+ ) {
+ throw new StabilizeError(
+ `Record was modified by another transaction (optimistic lock conflict on ${host.optimisticLockField})`,
+ "CONCURRENT_MODIFICATION",
+ );
+ }
+ }
+
+ const result = await host.findOne(id, {}, client);
+
+ await host.invalidateRowCache(id);
+ await host.writeThroughRow(id, result);
+
+ host.logger.logDebug(
+ `Updated ${host.table} with ID ${id} in ${Date.now() - start}ms`,
+ );
+ return result;
+}
+
+/**
+ * Removes one row, softly when the model soft-deletes.
+ *
+ * The soft-delete stamp is a native `Date`, not an ISO string: the "is this row
+ * deleted" filter is a comparison, and a string would compare lexically against
+ * whatever else is in the column.
+ *
+ * @param host The repository being written through.
+ * @param id The row to remove.
+ * @param client The client, which supplies the session and the collection.
+ */
+export async function mongoDeleteRow(
+ host: MongoRepositoryHost,
+ id: number | string,
+ client: DBClient,
+): Promise {
+ const start = Date.now();
+ host.logger.logDebug(`Deleting ${host.table} with ID ${id}`);
+
+ if (host.softDeleteField) {
+ await client.mongoUpdateOne(
+ host.table,
+ { _id: id },
+ { $set: { [host.softDeleteColumn!]: new Date() } },
+ );
+ } else {
+ await client.mongoDeleteOne(host.table, { _id: id });
+ }
+
+ await host.invalidateRowCache(id);
+ host.logger.logDebug(
+ `Deleted ${host.table} with ID ${id} in ${Date.now() - start}ms`,
+ );
+}
+
+/**
+ * Clears a row's soft-delete stamp.
+ *
+ * `$unset` rather than a stored null, for the same reason the insert path omits
+ * null keys: an absent field is what "not deleted" looks like everywhere else
+ * in this backend, and writing an explicit null would make the state
+ * unrepresentable in a sparse index later.
+ *
+ * @param host The repository being written through.
+ * @param id The row to recover.
+ * @param client The client, which supplies the session and the collection.
+ */
+export async function mongoRecover(
+ host: MongoRepositoryHost,
+ id: number | string,
+ client: DBClient,
+): Promise {
+ await client.mongoUpdateOne(
+ host.table,
+ { _id: id },
+ { $unset: { [host.softDeleteColumn!]: "" } },
+ );
+}
+
+/**
+ * Writes the row an upsert lands on, and reads it back.
+ *
+ * The conflict keys are the filter and the payload is the update, in one
+ * `findOneAndUpdate` with `upsert: true` — so two callers racing on a key that
+ * does not exist yet cannot both insert. `before` is passed in only because the
+ * repository has already resolved it to choose the hook pair; the write does
+ * not depend on it being right.
+ *
+ * The generated `_id` is allocated before the write, which means the loser of
+ * such a race burns an id. That gap is the same one MySQL and SQLite leave when
+ * a rolled-back insert consumes an auto-increment value, and the alternative —
+ * reading the counter back after inserting — needs an `_id` that does not exist
+ * yet.
+ *
+ * @param host The repository being written through.
+ * @param keys The conflict-key property names. Empty means an unconditional
+ * insert, since there is nothing to match on and an empty filter would match
+ * an arbitrary document.
+ * @param values The payload, already through `processForSave`.
+ * @param before The row the conflict keys resolved to, or null.
+ * @param client The client, which supplies the session and the collection.
+ * @returns The stored row, normalised.
+ */
+export async function mongoUpsertRow(
+ host: MongoRepositoryHost,
+ keys: string[],
+ values: Record,
+ before: any,
+ client: DBClient,
+): Promise {
+ const existingId =
+ before?.[host.idProperty] ??
+ (host.idColumn ? before?.[host.idColumn] : undefined);
+
+ if (existingId !== undefined && existingId !== null) {
+ const { $set, $unset } = buildMongoPatch(host, values);
+ const operation = updateOperation($set, $unset);
+ if (Object.keys(operation).length > 0) {
+ await client.mongoUpdateOne(host.table, { _id: existingId }, operation);
+ }
+ return host.processForLoad(
+ normalizeMongoDoc(
+ await client.mongoFindOne(host.table, { _id: existingId }),
+ host.idColumn,
+ ),
+ );
+ }
+
+ const document = buildMongoDocument(host, values);
+ const explicit = values[host.idProperty];
+
+ if (explicit !== undefined && explicit !== null) {
+ document._id = explicit;
+ if (typeof explicit === "number" && Number.isFinite(explicit)) {
+ await advanceMongoCounter(client, host.table, explicit);
+ }
+ } else if (host.autoIncrementField) {
+ document._id = (await allocateMongoIds(client, host.table, 1))[0];
+ }
+
+ if (keys.length === 0) {
+ await client.mongoInsertOne(host.table, document);
+ return host.processForLoad(normalizeMongoDoc(document, host.idColumn));
+ }
+
+ const conflict = buildMongoConditions(
+ host,
+ Object.fromEntries(keys.map((key) => [key, values[key]])),
+ );
+
+ const { $set, $unset } = buildMongoPatch(host, values);
+ const operation = updateOperation($set, $unset);
+ Object.assign(operation, { $setOnInsert: { _id: document._id } });
+
+ try {
+ const reply = await client.mongoFindOneAndUpdate(host.table, conflict, operation, {
+ upsert: true,
+ returnDocument: "after",
+ });
+ return host.processForLoad(normalizeMongoDoc(reply, host.idColumn));
+ } catch (error) {
+ if (!isDuplicateKey(error)) throw error;
+ // The other upsert won the insert between the read and this write, so the
+ // key exists now and the same operation is an ordinary update. The id
+ // allocated above goes unused, which is the gap noted on this function.
+ const reply = await client.mongoFindOneAndUpdate(
+ host.table,
+ conflict,
+ updateOperation($set, $unset),
+ { returnDocument: "after" },
+ );
+ return host.processForLoad(normalizeMongoDoc(reply, host.idColumn));
+ }
+}
+
+/**
+ * Adds `amount` to one numeric column.
+ *
+ * `$inc` is server-side, so two concurrent callers both land — which the SQL
+ * `col = col + ?` also guarantees. One divergence is worth knowing: `$inc`
+ * against a field that is *absent* initialises it to the increment, where SQL
+ * would compute `NULL + n` and leave the column NULL. On SQL the caller gets a
+ * null back; here it gets the amount. Neither is wrong, but a caller relying on
+ * the SQL behaviour will see a different value.
+ *
+ * @param host The repository being written through.
+ * @param id The row to change.
+ * @param column The column property name.
+ * @param amount How much to add. Negative values subtract.
+ * @param client The client, which supplies the session and the collection.
+ */
+export async function mongoIncrement(
+ host: MongoRepositoryHost,
+ id: number | string,
+ column: string,
+ amount: number,
+ client: DBClient,
+): Promise {
+ const field = host.columns[column]?.name ?? column;
+ await client.mongoUpdateOne(
+ host.table,
+ { _id: id, ...notDeletedFilter(host) },
+ { $inc: { [field]: amount } },
+ );
+}
+
+/**
+ * Flips a boolean column.
+ *
+ * A pipeline update, because there is no update operator that reads a field in
+ * order to write it. The comparison is against `true`, which is the value the
+ * ORM stores for a boolean column on this backend — the SQL path's
+ * `CASE WHEN col = 1` is about the 1/0 that SQLite and MySQL are handed, and a
+ * document that has never been written reads as `false` and flips to `true`,
+ * which is what a missing column means here anyway.
+ *
+ * @param host The repository being written through.
+ * @param id The row to change.
+ * @param column The column property name.
+ * @param client The client, which supplies the session and the collection.
+ */
+export async function mongoToggle(
+ host: MongoRepositoryHost,
+ id: number | string,
+ column: string,
+ client: DBClient,
+): Promise {
+ const field = host.columns[column]?.name ?? column;
+ await client.mongoUpdateOne(
+ host.table,
+ { _id: id, ...notDeletedFilter(host) },
+ [
+ {
+ $set: {
+ [field]: { $cond: [{ $eq: [`$${field}`, true] }, false, true] },
+ },
+ },
+ ],
+ );
+}
+
+/**
+ * Applies one patch to every row a condition set matches.
+ *
+ * @param host The repository being written through.
+ * @param conditions Property key → value.
+ * @param values The patch, already through `processForSave`.
+ * @param client The client, which supplies the session and the collection.
+ * @returns How many rows matched, which is zero when the patch is empty.
+ */
+export async function mongoUpdateMany(
+ host: MongoRepositoryHost,
+ conditions: Record,
+ values: Record,
+ client: DBClient,
+): Promise {
+ const { $set, $unset } = buildMongoPatch(host, values);
+ const operation = updateOperation($set, $unset);
+
+ // A bulk update advances the version too, so the rows it touched are not left
+ // holding a version that no longer matches and rejecting the next ordinary
+ // write as a conflict. `$inc` and `$set` cannot touch the same path, so this
+ // is only added when the patch did not set the lock itself.
+ if (host.optimisticLockField && !(host.optimisticLockField in values)) {
+ const lockField = host.columns[host.optimisticLockField]!.name;
+ operation.$inc = { [lockField]: 1 };
+ }
+
+ if (Object.keys(operation).length === 0) return 0;
+
+ const filter = buildMongoConditions(host, conditions);
+ if (host.softDeleteField) filter[host.softDeleteColumn!] = null;
+
+ const result = await client.mongoUpdateMany(host.table, filter, operation);
+ return affectedRows(result, "matchedCount");
+}
+
+/**
+ * Removes every row a condition set matches, softly when the model soft-deletes.
+ *
+ * @param host The repository being written through.
+ * @param conditions Property key → value.
+ * @param client The client, which supplies the session and the collection.
+ * @returns How many rows matched.
+ */
+export async function mongoDeleteManyBy(
+ host: MongoRepositoryHost,
+ conditions: Record,
+ client: DBClient,
+): Promise {
+ const filter = buildMongoConditions(host, conditions);
+
+ if (host.softDeleteField) {
+ filter[host.softDeleteColumn!] = null;
+ const result = await client.mongoUpdateMany(host.table, filter, {
+ $set: { [host.softDeleteColumn!]: new Date() },
+ });
+ return affectedRows(result, "matchedCount");
+ }
+
+ const result = await client.mongoDeleteMany(host.table, filter);
+ return affectedRows(result, "deletedCount");
+}
+
+/**
+ * Clears the soft-delete stamp on every row a condition set matches.
+ *
+ * A null or undefined condition is skipped rather than matched, which is what
+ * the SQL path does: `restoreBy({tag: null})` restores everything whose tag is
+ * null *or* everything at all, depending on the backend, so the condition is
+ * dropped instead of being given a meaning the callers never agreed on.
+ *
+ * @param host The repository being written through.
+ * @param conditions Property key → value.
+ * @param client The client, which supplies the session and the collection.
+ * @returns How many rows matched.
+ */
+export async function mongoRestoreManyBy(
+ host: MongoRepositoryHost,
+ conditions: Record,
+ client: DBClient,
+): Promise {
+ const filter = buildMongoConditions(host, conditions, { skipNull: true });
+ filter[host.softDeleteColumn!] = { $ne: null };
+
+ const result = await client.mongoUpdateMany(host.table, filter, {
+ $unset: { [host.softDeleteColumn!]: "" },
+ });
+ return affectedRows(result, "matchedCount");
+}
+
+/**
+ * Runs the aggregates `aggregate()` was asked for, as one `$group`.
+ *
+ * The alias names match the SQL path's, because they are what the caller reads
+ * the answer out of — `count_all` for `count: "*"` and `${fn}_${column}`
+ * otherwise.
+ *
+ * @param host The repository being written through.
+ * @param options The requested aggregates.
+ * @param client The client, which supplies the session and the collection.
+ * @returns One row of results, or `{}` when nothing was asked for.
+ */
+export async function mongoAggregateRows(
+ host: MongoRepositoryHost,
+ options: {
+ count?: string | string[];
+ sum?: string[];
+ avg?: string[];
+ min?: string[];
+ max?: string[];
+ },
+ client: DBClient,
+): Promise> {
+ const aggregates: MongoAggregate[] = [];
+
+ for (const column of options.count
+ ? Array.isArray(options.count)
+ ? options.count
+ : [options.count]
+ : []) {
+ aggregates.push({
+ fn: "count",
+ column: column === "*" ? "*" : (host.columns[column]?.name ?? column),
+ alias: column === "*" ? "count_all" : `count_${column}`,
+ });
+ }
+ for (const [fn, columns] of [
+ ["sum", options.sum],
+ ["avg", options.avg],
+ ["min", options.min],
+ ["max", options.max],
+ ] as const) {
+ for (const column of columns ?? []) {
+ aggregates.push({
+ fn,
+ column: host.columns[column]?.name ?? column,
+ alias: `${fn}_${column}`,
+ });
+ }
+ }
+
+ if (aggregates.length === 0) return {};
+
+ const rows = await client.mongoAggregate(
+ host.table,
+ buildMongoAggregatePipeline(notDeletedFilter(host), aggregates, {
+ primaryKey: host.idColumn,
+ idProperty: host.idProperty,
+ }),
+ );
+ if (rows[0]) return rows[0];
+
+ // A `$group` over an empty input produces *no documents at all*, where SQL's
+ // aggregate query returns one row of zeroes and NULLs. Without this the count
+ // of nothing comes back `undefined` rather than 0, and a caller doing
+ // arithmetic on it gets `NaN` — a wrong answer with no error, on the one
+ // input (an empty table) that a fresh install always has.
+ const empty: Record = {};
+ for (const aggregate of aggregates) {
+ empty[aggregate.alias] = aggregate.fn === "count" ? 0 : null;
+ }
+ return empty;
+}
+
+/**
+ * Counts the distinct non-null values of one column.
+ *
+ * SQL's `COUNT(DISTINCT col)` skips NULLs; Mongo's `distinct` returns them as a
+ * value like any other, so an explicit `null` would make the count one too high
+ * for every column that has one.
+ *
+ * @param host The repository being written through.
+ * @param column The column property name.
+ * @param client The client, which supplies the session and the collection.
+ */
+export async function mongoCountDistinct(
+ host: MongoRepositoryHost,
+ column: string,
+ client: DBClient,
+): Promise {
+ const field = host.columns[column]?.name ?? column;
+ const values = await client.mongoDistinct(
+ host.table,
+ field,
+ notDeletedFilter(host),
+ );
+ return values.filter((value) => value !== null && value !== undefined).length;
+}
+
+/**
+ * Picks a row at random, by its id.
+ *
+ * `$sample` chooses the document server-side, and then the id goes back through
+ * the ordinary `findOne`, so the answer has been through decryption, the row
+ * transform and relation loading exactly as any other read would. Returning the
+ * sampled document directly would skip all three.
+ *
+ * @param host The repository being written through.
+ * @param client The client, which supplies the session and the collection.
+ * @returns The row, or null when the collection is empty.
+ */
+export async function mongoRandom(
+ host: MongoRepositoryHost,
+ client: DBClient,
+): Promise {
+ const sampled = await client.mongoAggregate(host.table, [
+ { $match: notDeletedFilter(host) },
+ { $sample: { size: 1 } },
+ { $project: { _id: 1 } },
+ ]);
+ if (!sampled.length) return null;
+ return host.findOne(sampled[0]._id, {}, client);
+}
+
+/**
+ * Empties a collection.
+ *
+ * `deleteMany` rather than `drop`: dropping takes the indexes and the validator
+ * with it, so a truncated collection would silently stop enforcing the schema
+ * and stop being unique where the model says it is. SQL's `DELETE FROM` leaves
+ * both in place, which is the behaviour being matched.
+ *
+ * @param host The repository being written through.
+ * @param client The client, which supplies the session and the collection.
+ */
+export async function mongoTruncate(
+ host: MongoRepositoryHost,
+ client: DBClient,
+): Promise {
+ await client.mongoDeleteMany(host.table, {});
+ await host.invalidateTableCache();
+}
+
+// ─── VERSIONING & HISTORY ─────────────────────────────────────────────
+
+/**
+ * The operations a history row records. Mirrors the union on `Repository`, and
+ * is spelled out here rather than imported from it: `repository.ts` already
+ * imports this file, and the storage layer's vocabulary is allowed to be its
+ * own.
+ */
+export type MongoVersionOperation = "insert" | "update" | "delete";
+
+/**
+ * The compound `_id` a history document is keyed by.
+ *
+ * A row's history is one document per version, so the row's own key is not
+ * unique within the collection — the same problem the link collection has, and
+ * the same answer. `_id` uniqueness is the one constraint MongoDB enforces on
+ * every collection without an index being declared, so keying a version by the
+ * pair makes the version number the thing that cannot be stored twice.
+ *
+ * One function, so the shape the writer builds is the shape a reader matches.
+ */
+function historyId(
+ host: MongoRepositoryHost,
+ idValue: any,
+ version: number,
+): Record {
+ return { [host.idColumn]: idValue, version };
+}
+
+/**
+ * Drops the compound `_id` off a history document.
+ *
+ * The identity is already on the row under its column name, which is where every
+ * reader of a history row looks for it. Handing the `_id` back as well would put
+ * a `{, version}` object on an entity whose `id` is a scalar.
+ */
+function historyRow(row: any): any {
+ if (!row || typeof row !== "object") return row ?? null;
+ const { _id, ...rest } = row;
+ return rest;
+}
+
+/**
+ * Turns an entity into the history document a version is recorded as.
+ *
+ * The same treatment {@link buildMongoDocument} gives the live row — each column
+ * under its column name, with {@link OMIT} dropping the keys an entity does not
+ * carry — plus the audit fields the version is identified by. So a history row
+ * is the row it recorded, with the window it was valid for attached, and the
+ * relation loaders and row transforms that read column names work on it
+ * unchanged.
+ *
+ * The primary key is written twice on purpose: into the compound `_id` that
+ * makes the version unique, and under its own column name, because every read
+ * path here — {@link mongoAsOf}, {@link mongoHistory}, {@link mongoRollback} —
+ * addresses a history row by column name, and a dotted path into `_id` would be
+ * the one place that did not.
+ *
+ * `valid_from` and `modified_at` are native `Date`s rather than the ISO strings
+ * the SQL path binds: the "as of" window is a range query, and a string fails a
+ * `{bsonType: "date"}` validator *and* compares lexically against a field the
+ * index was built for as a date.
+ *
+ * `valid_to` is written only when there is one, and nothing writes one yet: it
+ * is what an explicit close of a version would set, and until something does
+ * that the newest row for a key is the open one. Writing a null says the same
+ * thing at the cost of a field, and this backend's reads already treat an absent
+ * field and a null one alike (`{valid_to: null}` matches both).
+ *
+ * @param host The repository the version belongs to.
+ * @param entity The row as it now stands, or as it stood for a delete.
+ * @param operation What the write did.
+ * @param version The version number this row records.
+ * @param user Who made the change, when the caller named someone.
+ */
+function buildHistoryDocument(
+ host: MongoRepositoryHost,
+ entity: Record,
+ operation: MongoVersionOperation,
+ version: number,
+ user?: string,
+): Record {
+ const at = new Date();
+ const idValue = entity?.[host.idProperty] ?? entity?.[host.idColumn];
+ const document: Record = {
+ _id: historyId(host, idValue, version),
+ };
+
+ for (const [key, column] of Object.entries(host.columns)) {
+ // Rows arrive from both directions: a hydrated read is keyed by column name,
+ // and an entity a caller built is keyed by property. Both are read, so a
+ // renamed column is recorded rather than stored as its absence.
+ const sanitized = sanitizeMongoValue(
+ entity?.[key] ?? entity?.[column.name],
+ );
+ if (sanitized === OMIT) continue;
+ document[column.name] = sanitized;
+ }
+
+ // After the columns, so the history's own version is the one that lands when a
+ // model declares a `version` column of its own. The two hold the same number
+ // on every path through `writeHistory`, and the audit field is the one that
+ // has to be right.
+ document.operation = operation;
+ document.version = version;
+ document.valid_from = at;
+ document.modified_by = user || "system";
+ document.modified_at = at;
+
+ return document;
+}
+
+/**
+ * Appends one version of a row to its history collection.
+ *
+ * The one place that decides what a history row is, so the four paths that
+ * record one — create, update, delete and rollback — cannot disagree about it.
+ *
+ * The version the history row is recorded under is not simply the version on the
+ * entity it was handed. A delete carries the row's *current* version, which the
+ * version it was created or last updated at already occupies — on SQL that is
+ * two rows sharing a number, which nothing forbids and which leaves the audit
+ * trail readable only by its `operation`. Here the version is half the compound
+ * `_id`, so the same number twice is a duplicate-key error that fails the write
+ * it was recording. A delete is a write like any other, and the number it is
+ * recorded under is the next one.
+ *
+ * Counting past the newest *recorded* version rather than off the entity is what
+ * keeps the three paths that already passed a fresh number on it: create sends
+ * 1 against an empty history, update (and upsert) send the version the lock just
+ * advanced to, and rollback sends one past the newest. Only a write that reuses
+ * a number — the delete — moves.
+ *
+ * The read is taken inside whatever transaction the caller is in, so the number
+ * is decided against the same snapshot as the write it accompanies, and a
+ * retried transaction re-decides it rather than reusing a stale one.
+ *
+ * @param host The repository being written through.
+ * @param entity The row as it now stands, or as it stood for a delete.
+ * @param operation What the write did.
+ * @param client The client, which supplies the session and the collection.
+ * @param user Who made the change, when the caller named someone.
+ */
+export async function mongoWriteHistory(
+ host: MongoRepositoryHost,
+ entity: Record,
+ operation: MongoVersionOperation,
+ client: DBClient,
+ user?: string,
+): Promise {
+ // `|| 1`, not `?? 1`: the SQL path counts the same way, and a version of zero
+ // or an empty string is a row nobody recorded a version for.
+ const sent = Number(entity?.version) || 1;
+ const idValue = entity?.[host.idProperty] ?? entity?.[host.idColumn];
+ const newest = await client.mongoFindOne(
+ host.historyTable,
+ { [host.idColumn]: idValue },
+ { sort: { version: -1 }, projection: { version: 1 } },
+ );
+ const version = Math.max(sent, (Number(newest?.version) || 0) + 1);
+
+ await client.mongoInsertOne(
+ host.historyTable,
+ buildHistoryDocument(host, entity, operation, version, user),
+ );
+}
+
+/**
+ * Reads the version of a row that was current at an instant.
+ *
+ * `{valid_to: null}` is the open window — a version whose end was never written,
+ * or was written as an explicit null. Both are the same absence, and the pair
+ * covers what the SQL path's `valid_to IS NULL OR valid_to > ?` covers.
+ *
+ * The sort is what stops a version that was never closed from coming back as an
+ * arbitrary one: every version that started before the instant still matches the
+ * open-window half of the filter, so without it the answer would be whichever
+ * document the collection happened to hand over first.
+ *
+ * @param host The repository being read through.
+ * @param id The row whose version is wanted.
+ * @param asOfDate The instant to read the row as of.
+ * @param client The client, which supplies the session and the collection.
+ * @returns The version, or null when the row has no version covering the instant.
+ */
+export async function mongoAsOf(
+ host: MongoRepositoryHost,
+ id: number | string,
+ asOfDate: Date | string,
+ client: DBClient,
+): Promise {
+ // Coerced rather than bound as given. A caller's ISO string compared against a
+ // stored date is a comparison between BSON *types*, and MongoDB orders those by
+ // kind before value — so every date would look older than every string and the
+ // window would swallow versions that started after the instant.
+ const at = asOfDate instanceof Date ? asOfDate : new Date(asOfDate);
+
+ const row = await client.mongoFindOne(
+ host.historyTable,
+ {
+ [host.idColumn]: id,
+ valid_from: { $lte: at },
+ $or: [{ valid_to: null }, { valid_to: { $gt: at } }],
+ },
+ { sort: { version: -1 } },
+ );
+
+ return historyRow(row);
+}
+
+/**
+ * Reads every version of a row, oldest first.
+ *
+ * Sorted rather than left to the server, because this is a *history*: a caller
+ * reading it is reasoning about what happened in what order, and MongoDB gives
+ * no order to a read that does not ask for one.
+ *
+ * @param host The repository being read through.
+ * @param id The row whose history is wanted.
+ * @param client The client, which supplies the session and the collection.
+ */
+export async function mongoHistory(
+ host: MongoRepositoryHost,
+ id: number | string,
+ client: DBClient,
+): Promise {
+ const rows = await client.mongoFind(
+ host.historyTable,
+ { [host.idColumn]: id },
+ { sort: { version: 1 } },
+ );
+ return rows.map(historyRow);
+}
+
+/**
+ * Restores a row to a version it used to hold, as a new version.
+ *
+ * The whole operation is one transaction, because it is three writes that only
+ * mean anything together: the live document, the history row that records the
+ * restore, and the read that returns what the caller just wrote.
+ *
+ * The new version is the one after the *newest* recorded version, not the one
+ * after the version being restored. `version + 1` — what the SQL path writes —
+ * names a version number that is usually already recorded when a caller rolls
+ * back to anything but the latest version, and here the compound `_id` makes
+ * that a duplicate-key error rather than a second row. Counting past the newest
+ * both advances the version and leaves the versions in between in the audit
+ * trail, which is the point of having one.
+ *
+ * The optimistic lock is the one column that is advanced rather than restored.
+ * Everywhere else in this design a live row's version and the newest history
+ * row's version are the same number — create, update and upsert all leave them
+ * equal — and restoring the old one would hand a caller that had already written
+ * it a version it could match on twice.
+ *
+ * @param host The repository being written through.
+ * @param id The row to restore.
+ * @param version The version to restore it to.
+ * @param client The client, which supplies the session and the collection.
+ * @returns The reloaded row, as the ordinary read path would return it.
+ * @throws StabilizeError `ROLLBACK_ERROR` when the version or the row is gone.
+ */
+export async function mongoRollback(
+ host: MongoRepositoryHost,
+ id: number | string,
+ version: number,
+ client: DBClient,
+): Promise {
+ const start = Date.now();
+ host.logger.logDebug(`Rolling ${host.table} with ID ${id} back to version ${version}`);
+
+ return client.transaction(async (tx) => {
+ const target = await tx.mongoFindOne(host.historyTable, {
+ [host.idColumn]: id,
+ version,
+ });
+ if (!target) {
+ throw new StabilizeError("Version not found", "ROLLBACK_ERROR");
+ }
+
+ // Read inside the same transaction as the write it decides, so two rollbacks
+ // racing cannot both pick the same new version number.
+ const newest = await tx.mongoFindOne(
+ host.historyTable,
+ { [host.idColumn]: id },
+ { sort: { version: -1 }, projection: { version: 1 } },
+ );
+ const next =
+ Math.max(Number(target.version) || 1, Number(newest?.version) || 0) + 1;
+
+ // Every column the version recorded goes back onto the live document, keyed
+ // by its column name. A column the version does not carry is cleared rather
+ // than left alone: the row is being restored to a state, and a column that
+ // state does not have is part of it.
+ const restored: Record = {};
+ for (const [key, column] of Object.entries(host.columns)) {
+ // The identity is never restored: a change of key is a delete and an
+ // insert, which is why no other write path in this file touches `_id`.
+ if (key === host.idProperty) continue;
+ if (key === host.optimisticLockField) continue;
+ const sanitized = sanitizeMongoValue(
+ target[key] ?? target[column.name] ?? null,
+ );
+ restored[key] = sanitized === OMIT ? null : sanitized;
+ }
+ if (host.optimisticLockField) {
+ restored[host.optimisticLockField] = next;
+ }
+
+ const { $set, $unset } = buildMongoPatch(host, restored);
+ const operation = updateOperation($set, $unset);
+ if (Object.keys(operation).length > 0) {
+ const result = await tx.mongoUpdateOne(host.table, { _id: id }, operation);
+ if (affectedRows(result, "matchedCount") === 0) {
+ throw new StabilizeError("Not found", "ROLLBACK_ERROR");
+ }
+ }
+
+ // The version this restore *creates* is the row as it was restored, recorded
+ // like any other write. It is written after the live document, so a failure
+ // between the two rolls both back rather than leaving a history row for a
+ // state the row was never put into.
+ await mongoWriteHistory(
+ host,
+ { ...target, version: next },
+ "update",
+ tx,
+ );
+
+ const reloaded = await host.findOne(id, {}, tx);
+ await host.invalidateRowCache(id);
+ await host.writeThroughRow(id, reloaded);
+
+ host.logger.logDebug(
+ `Rolled ${host.table} with ID ${id} back to version ${version} in ${Date.now() - start}ms`,
+ );
+ return reloaded;
+ });
+}
+
+// ─── MANY-TO-MANY LINKS ───────────────────────────────────────────────
+
+/**
+ * Reads the links from `parentIds` outward, one chunk at a time.
+ *
+ * The projection is limited to the two key fields: the composite `_id` is a
+ * duplicate of them, and a link collection has nothing else on it, so reading
+ * whole documents would only move the same data twice.
+ *
+ * Sorted on `_id`, which is `{p, c}` — so a parent's children come back in
+ * ascending child order, and the same read twice gives the same answer. A SQL
+ * join table read with no `ORDER BY` has no order to match, and Mongo's is not
+ * merely unspecified but liable to differ between two reads of unchanged data.
+ *
+ * @param client The client, which also supplies the transaction's session.
+ * @param link The join collection and the two fields that name a link.
+ * @param parentIds One chunk of parent ids, already deduplicated.
+ */
+export async function mongoFindLinks(
+ client: DBClient,
+ link: MongoLinkRelation,
+ parentIds: (number | string)[],
+): Promise<{ parent: any; child: any }[]> {
+ if (parentIds.length === 0) return [];
+ const { joinTable, foreignKey, inverseKey } = link;
+ const docs = await client.mongoFind(
+ joinTable,
+ { [foreignKey]: { $in: parentIds } },
+ {
+ projection: { [foreignKey]: 1, [inverseKey]: 1 },
+ sort: { _id: 1 },
+ },
+ );
+ return docs.map((doc) => ({
+ parent: doc[foreignKey],
+ child: doc[inverseKey],
+ }));
+}
+
+/**
+ * The raw values linked to `id` through a many-to-many relation.
+ *
+ * Sorted for the same reason as {@link mongoFindLinks}: `attach` and `sync`
+ * diff this list against what the caller asked for, and a list whose order
+ * changes between calls would make those diffs look different when they are not.
+ *
+ * @param client The client, which also supplies the transaction's session.
+ * @param link The join collection and the two fields that name a link.
+ * @param id The parent whose links are wanted.
+ */
+export async function mongoFetchLinkedIds(
+ client: DBClient,
+ link: MongoLinkRelation,
+ id: number | string,
+): Promise {
+ const { joinTable, foreignKey, inverseKey } = link;
+ const docs = await client.mongoFind(
+ joinTable,
+ { [foreignKey]: id },
+ { projection: { [inverseKey]: 1 }, sort: { _id: 1 } },
+ );
+ return docs.map((doc) => doc[inverseKey]);
+}
+
+/**
+ * Creates the links from `id` to each of `childIds`.
+ *
+ * Written as one upsert per pair rather than as a batch of inserts. The caller
+ * has already read the existing links and filtered them out, so an insert would
+ * normally succeed — but two callers can pass that check at the same time, and
+ * the losing insert would fail the whole statement on the duplicate `_id`. An
+ * upsert that matches an existing link simply changes nothing.
+ *
+ * @param client The client, which also supplies the transaction's session.
+ * @param link The join collection and the two fields that name a link.
+ * @param id The parent to link from.
+ * @param childIds The children to link to, already deduplicated.
+ */
+export async function mongoAttachLinks(
+ client: DBClient,
+ link: MongoLinkRelation,
+ id: number | string,
+ childIds: (number | string)[],
+): Promise {
+ if (childIds.length === 0) return;
+ const { joinTable, foreignKey, inverseKey } = link;
+ await client.mongoBulkWrite(
+ joinTable,
+ childIds.map((childId) => ({
+ updateOne: {
+ filter: { _id: linkId(id, childId) },
+ update: {
+ $setOnInsert: { [foreignKey]: id, [inverseKey]: childId },
+ },
+ upsert: true,
+ },
+ })),
+ );
+}
+
+/**
+ * Removes the links from `id`, or just those naming one of `childIds`.
+ *
+ * @param client The client, which also supplies the transaction's session.
+ * @param link The join collection and the two fields that name a link.
+ * @param id The parent to unlink from.
+ * @param childIds The children to unlink, or `undefined` for all of them.
+ * @returns how many links were removed.
+ */
+export async function mongoDetachLinks(
+ client: DBClient,
+ link: MongoLinkRelation,
+ id: number | string,
+ childIds?: (number | string)[],
+): Promise {
+ const { joinTable, foreignKey, inverseKey } = link;
+ const filter: Record = { [foreignKey]: id };
+ if (childIds !== undefined) {
+ if (childIds.length === 0) return 0;
+ filter[inverseKey] = { $in: childIds };
+ }
+ const result = await client.mongoDeleteMany(joinTable, filter);
+ return affectedRows(result, "deletedCount");
+}
diff --git a/mongo-schema.ts b/mongo-schema.ts
new file mode 100644
index 0000000..95ed7f3
--- /dev/null
+++ b/mongo-schema.ts
@@ -0,0 +1,589 @@
+/**
+ * @file mongo-schema.ts
+ * @description Derives MongoDB collections, indexes and validators from a model.
+ * @author ElectronSz
+ *
+ * A fourth type mapper rather than a branch inside the three the SQL side
+ * already has. The three agree on a shape — a type name goes in, a SQL type name
+ * comes out — and a BSON schema is not that: it is a document, it carries the
+ * validators the model already declares, and two of its rules (`INTEGER` spans
+ * several BSON types, an encrypted column is always a string) are properties no
+ * SQL dialect has. Folding it in would have made the SQL mappers harder to read
+ * for no SQL benefit.
+ *
+ * Two rules here are load-bearing, and both exist so that `autoMigrate` cannot
+ * break data it has already written:
+ *
+ * 1. `validationLevel: "moderate"` on every validator. Under the default
+ * `"strict"`, an update to a document that lacks a newly declared required
+ * field is *rejected* — so `repo.update()` would fail on exactly the rows
+ * the migration just declared a field for.
+ * 2. `sparse: true` on every unique index. MongoDB treats two documents that
+ * both lack the field as both null, so they collide; SQL treats two NULLs
+ * as distinct. Without `sparse`, `email: { unique: true }` would reject the
+ * second document that simply omits `email`.
+ */
+
+import { DBClient } from "./client";
+import { MetadataStorage, type ColumnConfig } from "./model";
+import {
+ DataTypes,
+ RelationType,
+ StabilizeError,
+ type MongoStep,
+} from "./types";
+import { MONGO_COUNTERS_COLLECTION } from "./mongo-repository";
+
+/** Re-exported so schema callers do not have to reach into `types` for it. */
+export type { MongoStep };
+
+/**
+ * Maps a declared column type onto a BSON schema.
+ *
+ * `INTEGER` accepts three BSON types, not one: a JavaScript integer becomes an
+ * `int` inside 32-bit range and a `double` beyond it, so accepting only `"int"`
+ * would reject every id past two billion. `BIGINT` is deliberately absent for
+ * the same reason in reverse — a driver cannot tell the caller's `1` was meant
+ * to be a 64-bit value.
+ *
+ * An encrypted column is a string whatever it was declared as, because the
+ * ciphertext is what is stored. Validating it against its declared type would
+ * reject every write.
+ *
+ * @param column The column to map.
+ * @returns The `bsonType` (or `bsonType` list) for the column's schema.
+ */
+export function mapColumnToBsonSchema(
+ column: ColumnConfig,
+): Record {
+ const declared =
+ typeof column.type === "string" ? column.type : DataTypes[column.type];
+ const type = String(declared).toUpperCase();
+
+ const schema: Record = {};
+
+ if (column.encrypted) {
+ // Ciphertext, always. See above.
+ schema.bsonType = "string";
+ } else {
+ switch (type) {
+ case "INTEGER":
+ schema.bsonType = ["int", "long", "double"];
+ break;
+ case "BIGINT":
+ schema.bsonType = ["int", "long", "double"];
+ break;
+ case "FLOAT":
+ case "DOUBLE":
+ case "DECIMAL":
+ // MongoDB has no exact decimal unless the caller passes a `Decimal128`.
+ // A `DECIMAL` column is therefore a double, and a validator that
+ // demanded exactness would reject everything the ORM writes.
+ schema.bsonType = ["double", "int", "long", "decimal"];
+ break;
+ case "BOOLEAN":
+ schema.bsonType = "bool";
+ break;
+ case "DATE":
+ case "DATETIME":
+ // A native date, never a string. A string fails this and also defeats
+ // every range query that could have used an index.
+ schema.bsonType = ["date", "string"];
+ break;
+ case "JSON":
+ schema.bsonType = ["object", "array", "string"];
+ break;
+ case "BLOB":
+ schema.bsonType = ["binData", "string"];
+ break;
+ case "UUID":
+ case "STRING":
+ case "TEXT":
+ default:
+ schema.bsonType = "string";
+ break;
+ }
+ }
+
+ // The model's own validators, promoted to server-enforced ones. This is a
+ // free win: the same rules that `collectValidationErrors` checks in process
+ // are then checked by the server as well, so a writer that bypasses the ORM
+ // cannot store a value the model says is invalid.
+ if (typeof column.minLength === "number") schema.minLength = column.minLength;
+ if (typeof column.maxLength === "number") schema.maxLength = column.maxLength;
+ if (typeof column.length === "number" && typeof column.minLength !== "number") {
+ schema.maxLength = column.length;
+ }
+ if (column.pattern) {
+ // A `RegExp` does not survive a round trip through an aggregation; its
+ // source does.
+ schema.pattern = column.pattern.source;
+ }
+
+ return schema;
+}
+
+/**
+ * Builds the `$jsonSchema` validator for a model.
+ *
+ * `additionalProperties` is deliberately **not** `false`. A collection that
+ * rejects undeclared fields stops being schemaless, which is the one property
+ * that makes a document store worth using alongside the four SQL backends — and
+ * it would make `autoMigrate` a one-way door, because a field written before it
+ * was declared could never be written again.
+ *
+ * `required` includes the primary key: `_id` is the key the collection is
+ * indexed on, and a document without one has no identity to address.
+ *
+ * @param meta The model's metadata.
+ * @param idColumn The column name the primary key is stored under.
+ */
+export function buildValidatorFromColumns(
+ columns: Record,
+ idColumn: string,
+): Record {
+ const properties: Record = {};
+ const required: string[] = ["_id"];
+
+ for (const [key, column] of Object.entries(columns)) {
+ const name = column.name ?? key;
+ // The primary key lives in `_id`, so the column name is not a field.
+ if (key === "id" || name === idColumn) continue;
+ properties[name] = mapColumnToBsonSchema(column);
+ if (column.required && !column.softDelete) required.push(name);
+ }
+
+ return {
+ $jsonSchema: {
+ bsonType: "object",
+ required,
+ properties,
+ },
+ };
+}
+
+/** A collection's derived shape: its validator and the indexes it needs. */
+export interface MongoCollectionPlan {
+ collection: string;
+ validator: Record;
+ indexes: {
+ spec: Record;
+ options: Record;
+ }[];
+}
+
+/**
+ * Builds the `$jsonSchema` validator for a versioned model's history collection.
+ *
+ * The model's columns are described exactly as they are in the main collection —
+ * a history row *is* the row it recorded — with the audit columns the history
+ * writer adds appended.
+ *
+ * `valid_from` is both declared as a date and required, which is what gives the
+ * native-`Date` rule teeth: an ISO string satisfies no `bsonType: "date"` and is
+ * rejected by the server on insert, rather than quietly turning every "as of"
+ * range query into a comparison between strings.
+ *
+ * The model's own `required` columns are deliberately **not** required here.
+ * `validationLevel: "moderate"` is what keeps a row that predates a newly
+ * declared column updatable, and demanding that column of the history row too
+ * would then reject the *recording* of exactly the update the level exists to
+ * allow.
+ *
+ * @param columns The model's columns.
+ */
+export function buildHistoryValidator(
+ columns: Record,
+): Record {
+ const properties: Record = {};
+
+ for (const [key, column] of Object.entries(columns)) {
+ // The primary key is a field here, unlike in the main collection: the
+ // document's `_id` is the pair that makes one version unique, so the row's
+ // own key has nowhere else to live.
+ properties[column.name ?? key] = mapColumnToBsonSchema(column);
+ }
+
+ properties.operation = { bsonType: "string" };
+ properties.version = { bsonType: ["int", "long", "double"] };
+ properties.valid_from = { bsonType: "date" };
+ properties.valid_to = { bsonType: "date" };
+ properties.modified_by = { bsonType: "string" };
+ properties.modified_at = { bsonType: "date" };
+
+ return {
+ $jsonSchema: {
+ bsonType: "object",
+ required: ["_id", "version", "valid_from"],
+ properties,
+ },
+ };
+}
+
+/**
+ * Derives the history collection a versioned model needs.
+ *
+ * The history lives beside the model rather than in it — one collection keyed by
+ * the row and the version — and has no model of its own, so `autoMigrate` is the
+ * only thing that can create it. A model that is not versioned derives nothing.
+ *
+ * Both indexes serve a read that exists: `{row, version}` is `history()` and the
+ * newest-version lookup `rollback` makes, and `{row, valid_from}` is the window
+ * `asOf` ranges over. Without the second one the window comparison still works
+ * and stops being a range scan of one row's versions.
+ *
+ * @param model The model to derive from.
+ * @returns The history collection plan, or null when the model is not versioned.
+ */
+export function planMongoHistoryCollection(
+ model: any,
+): MongoCollectionPlan | null {
+ const meta = MetadataStorage.getModelMetadata(model);
+ if (!meta?.tableName || !meta.versioned) return null;
+
+ const columns = meta.columns as Record;
+ const idColumn = columns["id"]?.name ?? "id";
+
+ return {
+ collection: `${meta.tableName}_history`,
+ validator: buildHistoryValidator(columns),
+ indexes: [
+ { spec: { [idColumn]: 1, version: 1 }, options: {} },
+ { spec: { [idColumn]: 1, valid_from: 1 }, options: {} },
+ ],
+ };
+}
+
+/**
+ * Lists the history collections a model set needs.
+ *
+ * @param models The models being migrated.
+ */
+export function planMongoHistoryCollections(
+ models: any[],
+): MongoCollectionPlan[] {
+ const plans: MongoCollectionPlan[] = [];
+ const seen = new Set();
+
+ for (const model of models) {
+ const plan = planMongoHistoryCollection(model);
+ if (plan && !seen.has(plan.collection)) {
+ seen.add(plan.collection);
+ plans.push(plan);
+ }
+ }
+
+ return plans;
+}
+
+/**
+ * Derives everything a model needs in the database.
+ *
+ * Pure: no client, no server. That is what makes `generateMongoMigration` able
+ * to produce a migration without connecting, and what lets the plan be asserted
+ * in a unit test.
+ *
+ * @param model The model to derive from.
+ * @returns The collection plan, or null when the model has no table name.
+ */
+export function planMongoCollection(model: any): MongoCollectionPlan | null {
+ const meta = MetadataStorage.getModelMetadata(model);
+ if (!meta?.tableName) return null;
+
+ const columns = meta.columns as Record;
+ const idColumn = columns["id"]?.name ?? "id";
+ const indexes: MongoCollectionPlan["indexes"] = [];
+
+ for (const [key, column] of Object.entries(columns)) {
+ const name = column.name ?? key;
+ if (key === "id" || name === idColumn) continue;
+ if (column.unique) {
+ // Sparse, always. See the note at the top of this file.
+ indexes.push({ spec: { [name]: 1 }, options: { unique: true, sparse: true } });
+ } else if (column.index) {
+ indexes.push({ spec: { [name]: 1 }, options: { name: column.index } });
+ }
+ }
+
+ return {
+ collection: meta.tableName,
+ validator: buildValidatorFromColumns(columns, idColumn),
+ indexes,
+ };
+}
+
+/**
+ * Derives the link collection a many-to-many relation needs.
+ *
+ * The join table has no model of its own, so `autoMigrate` is the only thing
+ * that can create it.
+ *
+ * The link is keyed by a **compound `_id`** rather than a pair of fields with a
+ * unique index over them. MongoDB enforces `_id` uniqueness exactly, so
+ * `attach` becomes idempotent at the storage layer instead of by a read the
+ * caller has to perform first — and `sync` can report `{attached: 0}` on a
+ * second call without having checked anything.
+ *
+ * @param relation The relation metadata.
+ */
+export function planMongoLinkCollection(relation: any): MongoCollectionPlan | null {
+ if (relation.type !== RelationType.ManyToMany) return null;
+ if (!relation.joinTable || !relation.foreignKey || !relation.inverseKey) {
+ return null;
+ }
+ return {
+ collection: relation.joinTable,
+ validator: {
+ $jsonSchema: {
+ bsonType: "object",
+ required: ["_id"],
+ properties: {
+ _id: { bsonType: "object" },
+ [relation.foreignKey]: {},
+ [relation.inverseKey]: {},
+ },
+ },
+ },
+ indexes: [
+ { spec: { [relation.inverseKey]: 1 }, options: {} },
+ { spec: { [relation.foreignKey]: 1 }, options: {} },
+ ],
+ };
+}
+
+/**
+ * Lists the link collections every many-to-many relation in a model set needs.
+ *
+ * @param models The models being migrated, which may reference each other.
+ */
+export function planMongoLinkCollections(models: any[]): MongoCollectionPlan[] {
+ const plans: MongoCollectionPlan[] = [];
+ const seen = new Set();
+
+ for (const model of models) {
+ const relations = MetadataStorage.getModelMetadata(model)?.relations ?? [];
+ for (const relation of relations) {
+ const plan = planMongoLinkCollection(relation);
+ if (plan && !seen.has(plan.collection)) {
+ seen.add(plan.collection);
+ plans.push(plan);
+ }
+ }
+ }
+
+ return plans;
+}
+
+/**
+ * Reports whether a collection already exists.
+ *
+ * @param db The client to ask.
+ * @param collection The collection name.
+ */
+async function collectionExists(
+ db: DBClient,
+ collection: string,
+): Promise {
+ const collections = await db.mongoListCollections();
+ return collections.some((entry: any) => entry?.name === collection);
+}
+
+/**
+ * Creates a collection if it is not there yet, and installs its validator.
+ *
+ * An existing collection is not re-validated here. `collMod` is what changes a
+ * validator, and running it on every `autoMigrate` would replace a validator a
+ * DBA had tightened by hand.
+ *
+ * @param db The client to write through.
+ * @param plan The collection to ensure.
+ */
+async function ensureCollection(
+ db: DBClient,
+ plan: MongoCollectionPlan,
+): Promise {
+ if (await collectionExists(db, plan.collection)) return;
+ await db.mongoCommand({
+ create: plan.collection,
+ validator: plan.validator,
+ // Moderate, always. See the note at the top of this file: "strict" would
+ // reject updates to documents that predate a newly required field.
+ validationLevel: "moderate",
+ validationAction: "error",
+ });
+}
+
+/**
+ * Installs the indexes a collection plan asks for.
+ *
+ * `createIndex` is idempotent in MongoDB — asking for an index that already
+ * exists with the same spec and options is a no-op — so a second `autoMigrate`
+ * is safe. Asking for the *same name* with different options is an error
+ * (`IndexOptionsConflict`), which is the honest outcome: the change would need a
+ * drop first, and guessing that here would drop an index in production.
+ *
+ * @param db The client to write through.
+ * @param plan The collection whose indexes to install.
+ */
+async function ensureIndexes(
+ db: DBClient,
+ plan: MongoCollectionPlan,
+): Promise {
+ for (const index of plan.indexes) {
+ await db.mongoCreateIndex(plan.collection, index.spec, index.options);
+ }
+}
+
+/**
+ * Pre-creates a table's counter document.
+ *
+ * The steady-state allocation path upserts, so this is not required for
+ * correctness — but without it the *first* write on a table has two concurrent
+ * requests both upserting the same `_id`, and one of them gets a duplicate-key
+ * error. Creating it up front (`$setOnInsert`, so an existing counter is left
+ * alone) means the concurrent path is always a plain `$inc`.
+ *
+ * @param db The client to write through.
+ * @param collection The table whose counter to create.
+ */
+async function ensureCounter(db: DBClient, collection: string): Promise {
+ await db.mongoUpdateOne(
+ MONGO_COUNTERS_COLLECTION,
+ { _id: collection },
+ { $setOnInsert: { seq: 0 } },
+ { upsert: true },
+ );
+}
+
+/**
+ * Whether a model's key is generated rather than supplied by the caller.
+ *
+ * Mirrors `Repository#getAutoIncrementField`, and must keep mirroring it: if
+ * this created a counter for a table whose keys are strings, the counter would
+ * simply never be read.
+ *
+ * @param columns The model's columns.
+ */
+function usesGeneratedIds(columns: Record): boolean {
+ const idColumn = columns["id"];
+ if (!idColumn) return false;
+ const type = (
+ typeof idColumn.type === "string" ? idColumn.type : DataTypes[idColumn.type]
+ ).toUpperCase();
+ return type !== "STRING" && type !== "TEXT" && type !== "UUID";
+}
+
+/**
+ * Brings a set of models' collections in line with their declarations.
+ *
+ * The add-only contract the SQL side has is expressed here against the
+ * validator rather than against sampled documents, because a document either
+ * carries a field or does not — there is nothing to backfill. A document without
+ * the field reads back `undefined`, which is exactly what `ADD COLUMN` with no
+ * default produces anyway.
+ *
+ * @param db The client to migrate through.
+ * @param models The models to bring up to date.
+ */
+export async function mongoAutoMigrate(
+ db: DBClient,
+ models: any[],
+): Promise