diff --git a/.changeset/datasource-pool-turso-mongo-timeouts-reject.md b/.changeset/datasource-pool-turso-mongo-timeouts-reject.md new file mode 100644 index 0000000000..ca48451120 --- /dev/null +++ b/.changeset/datasource-pool-turso-mongo-timeouts-reject.md @@ -0,0 +1,62 @@ +--- +'@objectstack/service-datasource': patch +'@objectstack/spec': patch +--- + +datasource `pool`: the last two silent drops are now loud — `turso` whole-arm, and mongodb's two timeout keys by name (#7243) + +`datasource.pool` is declared, strict and documented, and #5714 / #5931 already +made it an authoring error on the three arms that cannot honour it +(`sqlite` / `sqlite-wasm` / `memory`). #6214's ledger pass read every remaining +arm and found two faces the rejected set could not cover, both still dropped in +silence. Measured on `origin/main` before this change: + +```text +turso + pool{min:3,max:9,idleTimeoutMillis:30000} the arm never references `spec.pool` at all +mongodb + pool{max:20,idleTimeoutMillis:30000,connectionTimeoutMillis:3000} + → driver config: url + database + maxPoolSize:20, and nothing else +``` + +The mongodb line is the harder of the two because it is **half**-effective: `max` +took effect, so the author had real evidence their pool config worked, and the +two timeouts vanished anyway. + +Maintainer ruling 2026-08-11, both halves: + +1. **`turso` joins `POOL_UNSUPPORTED_DRIVER_IDS` whole-arm**, with no fork by url + mode. `TursoDriverConfig` has no `min` / `max`; a `file:` / `:memory:` url runs + the same better-sqlite3 engine the set already rejects for, and a `libsql://` + url is a remote request transport with no persistent connections, capped by + `config.concurrency`. The arm carries its own explanation rather than + borrowing SQLite's, because an author on the remote transport told about + `:memory:` would be reading about somebody else's datasource. +2. **mongodb's two unread timeout keys are rejected by name, not wired.** + `MongoClient` does expose `maxIdleTimeMS` / `connectTimeoutMS`, so this one + could have been implemented; with no measured consumer asking for it, wiring + would be behaviour-surface expansion. Rejection keeps declared = enforced and + tells the author at authoring time. It stays a one-line change on the day real + demand appears. + +The second half is a new shape for this module: a rejection scoped to individual +**keys** rather than the whole block, because `min` / `max` on `mongodb` are +honoured and must keep working. It is a data table (`POOL_UNREAD_KEYS_BY_DRIVER`) +rather than a per-arm `if`, so the next arm that half-reads the block is one line +and inherits all three doors — the Setup wizard's create/update, the boot-time +auto-connect pre-pass, and the driver factory's last door. + +Both rejections name the datasource, name the offending key(s), say the rejection +is deliberate, and give the one edit that fixes it. Neither offers an escape-hatch +env var (#5794), and the mongodb message says what SURVIVES the edit — telling a +mongo author to "remove `pool`" would delete two keys that do take effect. + +Nothing that was honoured changes: `postgres` / `mysql` still receive all four +keys, `mongodb` still maps `min` / `max` onto `minPoolSize` / `maxPoolSize`. New +API surface is `POOL_UNREAD_KEYS_BY_DRIVER` / `unreadPoolKeys` / +`unreadPoolKeysMessage`; `unsupportedPoolIssue` and `assertDatasourcePoolSupported` +keep their signatures and now cover both gates, so an injected host factory that +already calls them inherits this with no change. + +`@objectstack/spec` carries the ledger half: `liveness/datasource.json`'s four +`pool.*` rows and their block note recorded both of these as "still dropped in +silence" — the honest record #6214 left, and false the moment this lands. They now +state the new verdicts. No schema, type or runtime behaviour changes in `spec`. diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx index f0fd0ebe99..d11aaf0520 100644 --- a/content/docs/data-modeling/drivers.mdx +++ b/content/docs/data-modeling/drivers.mdx @@ -121,16 +121,33 @@ Two things live **outside** `config`, because they are not driver-specific: - **Pool sizing** — the `pool` block on the datasource (`min`, `max`, `idleTimeoutMillis`, `connectionTimeoutMillis`), honoured by the pooled - drivers: `postgres` and `mysql` pass it to Knex, `mongo` maps `min` / `max` - onto the client's `minPoolSize` / `maxPoolSize`. Declaring it on a **sqlite** - or **sqlite-wasm** datasource is rejected — by the Setup wizard when you save, - and by the boot when a declared datasource carries one: a SQLite connection - strategy is owned by the driver (one connection per database, because a second - connection to `:memory:` opens a separate, empty one), so a pool declared - there could never take effect. It used to be dropped in silence — an `app-crm` - datasource asking for `max: 5` measurably ran on one connection - ([#5714](https://github.com/objectstack-ai/objectstack/issues/5714)). The fix - is to delete the block; it is a no-op on SQLite either way. + drivers: `postgres` and `mysql` pass all four to Knex, and `mongodb` maps + `min` / `max` — and only those two — onto the client's `minPoolSize` / + `maxPoolSize`. Everywhere else the block is an **authoring error**, rejected by + the Setup wizard when you save and by the boot when a declared datasource + carries one, rather than dropped: + + | Driver | Verdict on `pool` | + | --- | --- | + | `postgres`, `mysql` | all four keys honoured | + | `mongodb` | `min` / `max` honoured; `idleTimeoutMillis` and `connectionTimeoutMillis` **rejected by name** | + | `sqlite`, `sqlite-wasm` | whole block **rejected** | + | `memory` | whole block **rejected** | + | `turso` / `libsql` | whole block **rejected** | + + Each arm says why in its own terms. A SQLite connection strategy is owned by + the driver (one connection per database, because a second connection to + `:memory:` opens a separate, empty one); `memory` opens no connection at all; + neither libSQL transport pools — a `file:` url runs that same local SQLite + engine and a `libsql://` url is a remote request transport capped by + `config.concurrency`. Every one of these used to be dropped in silence: an + `app-crm` datasource asking for `max: 5` measurably ran on one connection + ([#5714](https://github.com/objectstack-ai/objectstack/issues/5714)), and a + mongo datasource asking for `max: 20, idleTimeoutMillis: 30000` got the first + and lost the second without a word + ([#7243](https://github.com/objectstack-ai/objectstack/issues/7243)) — the + half-honoured case, which is the hardest to notice. The fix is always to + delete the rejected keys; they were reaching nothing either way. - **TLS certificates** — the `ssl` block on the datasource (`enabled`, `rejectUnauthorized`, `ca`, `cert`, `key`). Inside `config`, `ssl` is the on/off boolean shorthand. diff --git a/packages/services/service-datasource/src/__tests__/datasource-pool-support.test.ts b/packages/services/service-datasource/src/__tests__/datasource-pool-support.test.ts index 6e4c3fc769..aa7708c933 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-pool-support.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-pool-support.test.ts @@ -24,12 +24,36 @@ // The pin that used to hold `memory` OUT of the set is flipped below, not // deleted — it is the same fact, re-judged. +// #7243 — THE REMAINDER. #6214's ledger pass read every arm and found the two +// faces the rejected set could not cover, both still silent: +// +// turso + pool{min:3,max:9,idleTimeoutMillis:30000} `spec.pool` never read by the arm +// mongodb + pool{max:20,idleTimeoutMillis:30000,connectionTimeoutMillis:3000} +// → driver config {"url":…,"database":"orders","maxPoolSize":20} +// +// The mongodb line is the half-effective one: `max` landed, the timeouts did +// not, so the author's evidence that "my pool config works" was real and half +// wrong. Maintainer ruling 2026-08-11: `turso` joins the set WHOLE-ARM (no fork +// by url mode), and mongodb's two timeouts are REJECTED, not wired — MongoClient +// has `maxIdleTimeMS` / `connectTimeoutMS`, and wiring them with no measured +// consumer would be behaviour-surface expansion. +// +// ⚠️ Every case below asserts the message's CONTENT, never merely that +// something threw. On the turso arm a bare `.toThrow()` is green before the fix +// too: `@objectstack/driver-turso` is not installed in this package, so the +// unfixed arm throws the missing-package error a few lines further down. + import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { DatasourceSchema } from '@objectstack/spec/data'; import { POOL_UNSUPPORTED_DRIVER_IDS, + POOL_UNREAD_KEYS_BY_DRIVER, driverReadsDeclaredPool, + unreadPoolKeys, unsupportedPoolIssue, unsupportedPoolMessage, + unreadPoolKeysMessage, assertDatasourcePoolSupported, } from '../datasource-pool-support.js'; import { createDefaultDatasourceDriverFactory } from '../default-datasource-driver-factory.js'; @@ -42,8 +66,8 @@ import { DatasourceAdminService, type StoredDatasource } from '../datasource-adm import type { IDatasourceDriverFactory } from '../contracts/datasource-driver-factory.js'; describe('#5714 — which driver arms read a declared `pool`', () => { - it('names the two sqlite arms and `memory` as unable to honour it (#5931)', () => { - expect([...POOL_UNSUPPORTED_DRIVER_IDS]).toEqual(['memory', 'sqlite', 'sqlite-wasm']); + it('names the two sqlite arms, `memory` and `turso` as unable to honour it (#5931 / #7243)', () => { + expect([...POOL_UNSUPPORTED_DRIVER_IDS]).toEqual(['memory', 'sqlite', 'sqlite-wasm', 'turso']); }); it('rejects every spelling of the sqlite arms, case-insensitively', () => { @@ -556,3 +580,420 @@ describe('#5714 — the Setup wizard rejects it before the record is stored', () expect(summary.active).toBe(false); }); }); + +// A rejection that RESOLVED is the failure this card is about — silently +// accepting a declaration nothing reads — so it must not surface as a confusing +// `undefined.message`. Reverse-verified: with the fix reverted, every caller of +// this helper fails with "…built it instead of rejecting", naming the cell. +// `IDatasourceDriverFactory.create` may answer synchronously, so this takes an +// `unknown` outcome rather than a `Promise` — a sync THROW still escapes to the +// call site, which fails the case with the real message either way. +async function rejectionOf(outcomeOrPromise: unknown): Promise { + const outcome = await Promise.resolve(outcomeOrPromise).then( + (value) => ({ value }), + (error: Error) => ({ error }), + ); + if ('error' in outcome) return outcome.error; + throw new Error( + `expected the declaration to be rejected, but it built it instead of rejecting: ` + + `${JSON.stringify(outcome.value)?.slice(0, 200)}`, + ); +} + +// ── #7243, half one: `turso` joins the rejected set, whole-arm ─────────────── +describe('#7243 — a declared `pool` on a turso datasource is rejected, not dropped', () => { + it('answers `false` for every spelling of the arm, case-insensitively', () => { + for (const id of ['turso', 'libsql', 'Turso', 'LibSQL', ' TURSO ']) { + expect(driverReadsDeclaredPool(id), id).toBe(false); + } + }); + + it('explains turso in its own terms — both transports, neither pooled', () => { + const msg = unsupportedPoolIssue({ driver: 'turso', pool: { min: 3, max: 9 }, name: 'edge' }) ?? ''; + expect(msg).toContain(`Datasource 'edge'`); + expect(msg).toContain(`the 'turso' driver does not read it`); + // The local mode's reason and the remote mode's reason are BOTH named: the + // ruling was whole-arm precisely because the two differ, and an author on + // `libsql://` told only about `:memory:` would think the message was about + // somebody else's datasource. + expect(msg).toMatch(/`file:` \/ `:memory:` url runs the local engine/); + expect(msg).toMatch(/better-sqlite3/); + expect(msg).toMatch(/`libsql:\/\/` url is a remote request transport/); + expect(msg).toMatch(/capped by `config\.concurrency`/); + expect(msg).toMatch(/`TursoDriverConfig` has no `min` \/ `max`/); + // The shared frame survives. + expect(msg).toMatch(/rejected instead of dropped/); + expect(msg).toMatch(/Remove `pool` from this datasource declaration/); + }); + + it('quotes the `libsql` spelling the author wrote, with the turso reason behind it', () => { + const msg = unsupportedPoolIssue({ driver: 'libsql', pool: { max: 9 }, name: 'edge' }) ?? ''; + expect(msg).toContain(`the 'libsql' driver does not read it`); + expect(msg).toMatch(/neither libSQL transport has a connection pool to size/); + }); + + it('does not paraphrase SQLite\'s reasoning, though it names the shared engine', () => { + const msg = unsupportedPoolMessage('turso', 'edge'); + expect(msg).not.toMatch(/opens a SEPARATE, empty database/); + expect(msg).not.toMatch(/split one datasource's data/); + expect(msg).not.toMatch(/connection strategy is owned by the driver/); + }); + + it('offers no escape hatch and no "use another driver" advice', () => { + const msg = unsupportedPoolIssue({ driver: 'turso', pool: { max: 9 }, name: 'edge' }) ?? ''; + expect(msg).not.toMatch(/OS_[A-Z_]*=1/); + expect(msg).not.toMatch(/switch|instead use|change the driver/i); + }); + + it('treats an absent or empty block as no declaration, as on every other arm', () => { + expect(unsupportedPoolIssue({ driver: 'turso' })).toBeUndefined(); + expect(unsupportedPoolIssue({ driver: 'turso', pool: {} })).toBeUndefined(); + expect(unsupportedPoolIssue({ driver: 'libsql', pool: undefined })).toBeUndefined(); + }); + + // The arm's `pool`-blindness is the premise of the whole-arm verdict, so it is + // pinned against the source rather than remembered. If someone ever wires + // `spec.pool` into `TursoDriver`, this fails and the verdict gets re-judged + // instead of quietly contradicting the code. + it('pins that the turso arm reads no `spec.pool` key at all', () => { + const src = readFileSync(new URL('../default-datasource-driver-factory.ts', import.meta.url), 'utf8'); + const arm = src.slice(src.indexOf(`if (kind === 'turso')`), src.indexOf(`if (kind === 'memory')`)); + expect(arm.length).toBeGreaterThan(500); + expect(arm).toContain('new TursoDriver('); + // Only comment lines may mention it — the constructor call must not. + const ctor = arm.slice(arm.indexOf('new TursoDriver(')); + expect(ctor).not.toMatch(/spec\.pool|pool\./); + }); +}); + +// ── #7243, half two: mongodb's two unread timeout keys ─────────────────────── +describe('#7243 — mongodb reads `min` / `max` and rejects the two timeouts by name', () => { + it('still reads the block, so the whole-block gate must not fire for it', () => { + for (const id of ['mongo', 'mongodb', 'MongoDB']) { + expect(driverReadsDeclaredPool(id), id).toBe(true); + } + expect(unsupportedPoolIssue({ driver: 'mongodb', pool: { min: 1, max: 20 }, name: 'orders' })) + .toBeUndefined(); + }); + + it('names exactly the two keys the arm never takes out of the block', () => { + expect([...(POOL_UNREAD_KEYS_BY_DRIVER.mongodb ?? [])]) + .toEqual(['idleTimeoutMillis', 'connectionTimeoutMillis']); + }); + + it('reports only the keys actually declared, in table order', () => { + expect(unreadPoolKeys('mongodb', { min: 1, max: 20 })).toEqual([]); + expect(unreadPoolKeys('mongodb', { max: 20, idleTimeoutMillis: 30_000 })) + .toEqual(['idleTimeoutMillis']); + expect(unreadPoolKeys('mongodb', { connectionTimeoutMillis: 3000 })) + .toEqual(['connectionTimeoutMillis']); + // Author order does not change the message: one declaration, one text. + expect(unreadPoolKeys('mongodb', { connectionTimeoutMillis: 3000, idleTimeoutMillis: 30_000 })) + .toEqual(['idleTimeoutMillis', 'connectionTimeoutMillis']); + expect(unreadPoolKeys('mongo', { idleTimeoutMillis: 30_000 })).toEqual(['idleTimeoutMillis']); + }); + + it('judges no arm but mongodb — the pooled ones read them, the rejected ones are the block gate\'s', () => { + for (const driver of ['postgres', 'mysql', 'com.vendor.snowflake']) { + expect(unreadPoolKeys(driver, { idleTimeoutMillis: 30_000, connectionTimeoutMillis: 3000 }), driver) + .toEqual([]); + } + for (const driver of ['sqlite', 'memory', 'turso']) { + expect(unreadPoolKeys(driver, { idleTimeoutMillis: 30_000 }), driver).toEqual([]); + } + }); + + it('names the datasource, both keys and the edit that fixes it', () => { + const msg = unsupportedPoolIssue({ + driver: 'mongodb', + pool: { max: 20, idleTimeoutMillis: 30_000, connectionTimeoutMillis: 3000 }, + name: 'orders', + }) ?? ''; + expect(msg).toContain(`Datasource 'orders'`); + expect(msg).toContain('`idleTimeoutMillis` and `connectionTimeoutMillis`'); + expect(msg).toContain(`the 'mongodb' driver does not read them`); + expect(msg).toMatch(/maps them onto the MongoClient's `minPoolSize` \/ `maxPoolSize`/); + expect(msg).toMatch(/This rejection is deliberate/); + expect(msg).toMatch(/Remove `idleTimeoutMillis` and `connectionTimeoutMillis` from this datasource's `pool`/); + // …and says what SURVIVES, which the whole-block message cannot: deleting + // `pool` here would throw away two keys that are honoured. + expect(msg).toMatch(/`min` \/ `max` stay meaningful here/); + expect(msg).not.toMatch(/Remove `pool` from this datasource declaration/); + }); + + it('agrees in number for a single key', () => { + const msg = unsupportedPoolIssue({ driver: 'mongodb', pool: { idleTimeoutMillis: 30_000 } }) ?? ''; + expect(msg).toContain('This datasource declares `idleTimeoutMillis` in its `pool` block'); + expect(msg).toContain(`the 'mongodb' driver does not read it`); + expect(msg).not.toContain('and `connectionTimeoutMillis`'); + }); + + it('offers no escape hatch and no "use another driver" advice', () => { + const msg = unsupportedPoolIssue({ + driver: 'mongodb', pool: { idleTimeoutMillis: 1 }, name: 'orders', + }) ?? ''; + expect(msg).not.toMatch(/OS_[A-Z_]*=1/); + expect(msg).not.toMatch(/switch|instead use|change the driver/i); + }); + + it('borrows no arm\'s reasoning for a driver with no entry in the table', () => { + const msg = unreadPoolKeysMessage('com.vendor.snowflake', ['idleTimeoutMillis']); + expect(msg).toMatch(/nothing in the arm reads it/); + expect(msg).toMatch(/the rest of the block is unaffected/); + expect(msg).not.toMatch(/MongoClient/); + // Not mongodb's "what survives" clause either — an arm with no entry has no + // measured `min` / `max` behaviour to promise. + expect(msg).not.toMatch(/`min` \/ `max` stay meaningful here/); + }); + + it('assert throws exactly when the issue is reported', () => { + expect(() => assertDatasourcePoolSupported({ driver: 'mongodb', pool: { idleTimeoutMillis: 1 } })) + .toThrow(/does not read it/); + expect(() => assertDatasourcePoolSupported({ driver: 'mongodb', pool: { min: 1, max: 5 } })) + .not.toThrow(); + }); + + // THE ANTI-DRIFT PIN. The table is a claim about the factory arm's source and + // about the spec's block, so it is re-derived from both rather than trusted: + // every declared `pool` key is either read by the arm or listed as unread, and + // never both. Wiring `maxIdleTimeMS` up later (the option the ruling deferred) + // fails here until the key leaves the table — which is the point. + it('covers the spec\'s pool block exactly: read ∪ rejected, disjoint', () => { + const declared = Object.keys((DatasourceSchema.shape.pool as any).unwrap().shape); + expect(declared).toEqual(['min', 'max', 'idleTimeoutMillis', 'connectionTimeoutMillis']); + + const src = readFileSync(new URL('../default-datasource-driver-factory.ts', import.meta.url), 'utf8'); + const arm = src.slice(src.indexOf(`if (kind === 'mongodb')`), src.indexOf(`if (kind === 'turso')`)); + const ctor = arm.slice(arm.indexOf('new MongoDBDriver(')); + const read = [...new Set([...ctor.matchAll(/pool\.([A-Za-z]+)/g)].map((m) => m[1]))].sort(); + expect(read).toEqual(['max', 'min']); + + const rejected = [...(POOL_UNREAD_KEYS_BY_DRIVER.mongodb ?? [])]; + expect(rejected.filter((k) => read.includes(k))).toEqual([]); + expect([...read, ...rejected].sort()).toEqual([...declared].sort()); + }); +}); + +// ── #7243 at all three doors ───────────────────────────────────────────────── +describe('#7243 — the factory rejects both remainders before it builds anything', () => { + const factory = () => createDefaultDatasourceDriverFactory({ dev: false }); + + // ⚠️ The assertion here is the MESSAGE, not the throw. `@objectstack/driver-turso` + // is not installed in this package, so before the fix this same call threw the + // missing-package error — a bare `.rejects.toThrow()` could not tell them apart. + it('turso + pool is rejected by the pool gate, not by the missing-package arm', async () => { + const err = await rejectionOf( + factory().create({ + name: 'edge', + driver: 'turso', + config: { url: 'file::memory:' }, + pool: { min: 3, max: 9 }, + }), + ); + expect(err.message).toContain(`Datasource 'edge' declares a \`pool\` block`); + expect(err.message).toMatch(/neither libSQL transport has a connection pool to size/); + // The gate runs BEFORE the dynamic import, so the author is told about their + // declaration rather than about an optional package they may not need. + expect(err.message).not.toMatch(/is not installed/); + expect(err.message).not.toMatch(/npm install @objectstack\/driver-turso/); + }); + + it('a remote-mode turso datasource is rejected the same way', async () => { + const err = await rejectionOf( + factory().create({ + name: 'edge', + driver: 'libsql', + config: { url: 'libsql://my-db.turso.io', authToken: 'jwt' }, + pool: { max: 9 }, + }), + ); + expect(err.message).toContain(`Datasource 'edge' declares a \`pool\` block`); + expect(err.message).not.toMatch(/is not installed/); + }); + + it('mongodb + the two timeouts is rejected instead of built with them dropped', async () => { + const err = await rejectionOf( + factory().create({ + name: 'orders', + driver: 'mongodb', + config: { host: 'db.internal', database: 'orders' }, + pool: { max: 20, idleTimeoutMillis: 30_000, connectionTimeoutMillis: 3000 }, + }), + ); + expect(err.message).toContain(`Datasource 'orders' declares \`idleTimeoutMillis\` and \`connectionTimeoutMillis\``); + expect(err.message).toMatch(/does not read them/); + }); + + // The half of the contract this change must not disturb — measured on + // `origin/main` as `{"url":…,"database":"orders","maxPoolSize":20}`. + it('mongodb + min/max still builds and still maps onto minPoolSize / maxPoolSize', async () => { + const handle: any = await factory().create({ + name: 'orders', + driver: 'mongodb', + config: { host: 'db.internal', database: 'orders' }, + pool: { min: 2, max: 20 }, + }); + const driver = handle.driver ?? handle; + expect(driver?.constructor?.name).toMatch(/MongoDBDriver$/); + expect(driver.config).toMatchObject({ minPoolSize: 2, maxPoolSize: 20 }); + expect(driver.config).not.toHaveProperty('idleTimeoutMillis'); + try { await handle.disconnect?.(); } catch { /* never connected */ } + }); + + it('postgres keeps receiving both timeouts — this gate is mongodb\'s alone', async () => { + const handle: any = await factory().create({ + driver: 'postgres', + config: { host: 'db.internal', database: 'analytics' }, + pool: { min: 3, max: 9, idleTimeoutMillis: 45_000, connectionTimeoutMillis: 3000 }, + }); + const cfg = (handle.driver ?? handle)?.config ?? {}; + expect(cfg.pool).toMatchObject({ min: 3, max: 9, idleTimeoutMillis: 45_000, acquireTimeoutMillis: 3000 }); + try { await handle.disconnect?.(); } catch { /* pool never opened */ } + }); +}); + +describe('#7243 — boot and the Setup wizard refuse both remainders too', () => { + it('boot: a turso datasource carrying a pool never reaches a connect', async () => { + const { service, factory, engine } = svc(); + const err = await rejectionOf( + service.connectDeclared({ + datasources: [{ name: 'edge', driver: 'turso', config: { url: 'libsql://x.turso.io' }, pool: { max: 9 } }], + objects: [], + }), + ); + expect(err.message).toContain(`Datasource 'edge' declares a \`pool\` block`); + expect(err.message).toMatch(/neither libSQL transport has a connection pool to size/); + expect((factory.create as any).mock.calls.length).toBe(0); + expect(engine.drivers.size).toBe(0); + }); + + it('boot: a mongodb datasource carrying the timeouts never reaches a connect', async () => { + const { service, factory, engine } = svc(); + const err = await rejectionOf( + service.connectDeclared({ + datasources: [{ + name: 'orders', + driver: 'mongodb', + config: { host: 'db.internal' }, + pool: { max: 20, idleTimeoutMillis: 30_000 }, + }], + objects: [], + }), + ); + expect(err.message).toContain(`Datasource 'orders' declares \`idleTimeoutMillis\``); + expect((factory.create as any).mock.calls.length).toBe(0); + expect(engine.drivers.size).toBe(0); + }); + + it('boot: each offender keeps its own explanation in the aggregate', async () => { + const { service } = svc(); + const err = await rejectionOf( + service.connectDeclared({ + datasources: [ + { name: 'edge', driver: 'turso', config: { url: 'libsql://x.turso.io' }, pool: { max: 9 } }, + { name: 'orders', driver: 'mongodb', config: {}, pool: { idleTimeoutMillis: 30_000 } }, + ], + objects: [], + }), + ); + expect(err.message).toMatch(/2 declared datasource\(s\)/); + expect(err.message).toMatch(/neither libSQL transport has a connection pool to size/); + expect(err.message).toMatch(/maps them onto the MongoClient's `minPoolSize` \/ `maxPoolSize`/); + }); + + it('boot: a mongodb datasource sizing only min/max connects as before', async () => { + const { service, engine } = svc(); + const results = await service.connectDeclared({ + datasources: [{ + name: 'orders', + driver: 'mongodb', + config: { host: 'db.internal' }, + pool: { min: 2, max: 20 }, + autoConnect: true, + }], + objects: [], + }); + expect(results.map((r) => r.status)).toEqual(['connected']); + expect(engine.drivers.has('orders')).toBe(true); + }); + + it('wizard: a turso draft carrying a pool never reaches the store', async () => { + const { service, records, registered } = adminHarness(); + const err = await rejectionOf( + service.createDatasource({ + name: 'edge', driver: 'turso', config: { url: 'libsql://x.turso.io' }, pool: { max: 9 }, + }), + ); + expect(err.message).toMatch(/neither libSQL transport has a connection pool to size/); + expect(records).toHaveLength(0); + expect(registered).toHaveLength(0); + }); + + it('wizard: a mongodb draft carrying a timeout never reaches the store', async () => { + const { service, records, registered } = adminHarness(); + const err = await rejectionOf( + service.createDatasource({ + name: 'orders', + driver: 'mongodb', + config: { host: 'db.internal', database: 'orders' }, + pool: { max: 20, connectionTimeoutMillis: 3000 }, + }), + ); + expect(err.message).toContain('`connectionTimeoutMillis`'); + expect(err.message).toMatch(/does not read it/); + expect(records).toHaveLength(0); + expect(registered).toHaveLength(0); + }); + + it('wizard: a mongodb draft sizing only min/max is stored as before', async () => { + const { service, records } = adminHarness(); + await service.createDatasource({ + name: 'orders', + driver: 'mongodb', + config: { host: 'db.internal', database: 'orders' }, + pool: { min: 2, max: 20 }, + }); + expect(records[0]?.pool).toEqual({ min: 2, max: 20 }); + }); + + it('wizard: patching a timeout onto a stored mongodb datasource is rejected', async () => { + const { service } = adminHarness([ + { + name: 'orders', + driver: 'mongodb', + config: { host: 'db.internal', database: 'orders' }, + pool: { max: 20 }, + origin: 'runtime', + }, + ]); + await expect( + service.updateDatasource('orders', { pool: { max: 20, idleTimeoutMillis: 30_000 } }), + ).rejects.toThrow(/`idleTimeoutMillis`/); + }); + + it('wizard: switching a pooled datasource TO turso is rejected on the merged record', async () => { + const { service } = adminHarness([ + { name: 'reporting', driver: 'postgres', config: {}, pool: { min: 3, max: 9 }, origin: 'runtime' }, + ]); + await expect( + service.updateDatasource('reporting', { driver: 'turso', config: { url: 'libsql://x.turso.io' } }), + ).rejects.toThrow(/neither libSQL transport has a connection pool to size/); + }); + + // The same carve-out the block gate has: a record written before this gate + // must stay editable, including the `active: false` that takes it out of + // service. Otherwise the remedy for a bad declaration is itself blocked. + it('wizard: a write touching neither pool nor driver is not re-judged', async () => { + const { service } = adminHarness([ + { + name: 'orders', + driver: 'mongodb', + config: { host: 'db.internal', database: 'orders' }, + pool: { max: 20, idleTimeoutMillis: 30_000 }, + origin: 'runtime', + }, + ]); + const summary = await service.updateDatasource('orders', { active: false }); + expect(summary.active).toBe(false); + }); +}); diff --git a/packages/services/service-datasource/src/datasource-pool-support.ts b/packages/services/service-datasource/src/datasource-pool-support.ts index 18bd3e2a41..b56ea3fe90 100644 --- a/packages/services/service-datasource/src/datasource-pool-support.ts +++ b/packages/services/service-datasource/src/datasource-pool-support.ts @@ -54,6 +54,40 @@ * while `memory`'s is that there is no connection to pool at all. Different * reasons, same verdict — hence one set, one message per arm. * + * ## `turso` joined the set, and `mongodb`'s two timeouts became per-KEY (#7243) + * + * #6214's ledger pass read every arm and found the two faces this set did not + * cover. Measured on `origin/main` before this change: + * + * ```text + * turso + pool{min:3,max:9,idleTimeoutMillis:30000} `spec.pool` never read by the arm at all + * mongodb + pool{max:20,idleTimeoutMillis:30000,connectionTimeoutMillis:3000} + * driver config {"url":…,"database":"orders","maxPoolSize":20} + * ``` + * + * The mongodb line is the harder failure of the two: it is HALF-effective. The + * author sizing a pool sees `max` take effect and has every reason to believe + * the timeouts did too, when they were dropped on the floor. + * + * Maintainer ruling 2026-08-11, both halves: + * + * 1. `turso` joins the set **whole-arm**, with no fork by url mode. Local mode + * (`file:` / `:memory:`) is literally the better-sqlite3 engine this set + * already rejects for; remote mode (`libsql://`) has no connection pool + * either, only a `concurrency` cap on in-flight requests. The fork buys + * complexity and serves no measured consumer. + * 2. mongodb's two unread timeout keys are **rejected loudly, not wired**. + * `MongoClient` does have `maxIdleTimeMS` / `connectTimeoutMS`, so this one + * *could* have been implemented — and that is exactly why it needed the + * ruling. Wiring it is pull-less behaviour-surface expansion with no + * measured consumer; rejection keeps declared = enforced and tells the + * author immediately. Wire them later iff real demand appears. + * + * That second half is a NEW SHAPE for this module: a rejection scoped to + * individual keys rather than to the whole block, because `min` / `max` on + * `mongodb` are honoured and must keep working. See + * {@link POOL_UNREAD_KEYS_BY_DRIVER}. + * * ## Where the boundary is, deliberately * * A driver id the platform ships no contract for (`com.vendor.snowflake`) is @@ -68,35 +102,36 @@ import { resolveDriverId } from '@objectstack/spec/data'; * Canonical driver ids that cannot honour a declared `datasource.pool`, so the * block can never reach anything. * - * Three built-ins, for two different reasons. The SQLite pair — `sqlite` + * Four built-ins, for three different reasons. The SQLite pair — `sqlite` * (better-sqlite3, via `resolveSqliteDriver`) and `sqlite-wasm` * (`SqliteWasmDriver`) — take no pool option and could not honour one, see the * module note on `:memory:`. `memory` (`InMemoryDriver`) is more absolute * still: it opens no connection at all, so there is nothing a pool could size. - * Each carries its own explanation in {@link POOL_UNSUPPORTED_REASONS} — an id - * cannot join this list without one, because that record is keyed by this type. + * `turso` (#7243) is the one whose two transport modes both land here for + * reasons of their own. Each carries its own explanation in + * {@link POOL_UNSUPPORTED_REASONS} — an id cannot join this list without one, + * because that record is keyed by this type. */ -export const POOL_UNSUPPORTED_DRIVER_IDS = ['memory', 'sqlite', 'sqlite-wasm'] as const; +export const POOL_UNSUPPORTED_DRIVER_IDS = ['memory', 'sqlite', 'sqlite-wasm', 'turso'] as const; export type PoolUnsupportedDriverId = (typeof POOL_UNSUPPORTED_DRIVER_IDS)[number]; /** - * Does this driver id read a declared `datasource.pool`? + * Does this driver id read a declared `datasource.pool` **at all**? * * `true` for the pooled built-ins (`postgres` / `mysql` / `mongodb`) **and** for * every id outside the built-in table — an unknown id is not ours to judge, so * it is left alone rather than rejected against a contract we do not ship. * - * `turso` answers `true` as well, and did so before #6345 made it a builtin - * (then via the unknown-id branch, now via "not in the rejected set") — so this - * function's verdict for it is unchanged. Whether that verdict is RIGHT is a - * separate, pre-existing question this card deliberately does not answer: - * `TursoDriverConfig` has no `min`/`max`, only `concurrency`, and in local mode - * the driver is a better-sqlite3 `SqlDriver` — the very engine - * {@link POOL_UNSUPPORTED_DRIVER_IDS} rejects a `pool` block for. A declared - * `pool` on a turso datasource is therefore dropped in silence today. Changing - * that is a new rejection on an authoring surface and needs its own ruling; see - * the #6345 PR's follow-ups. + * ⚠️ "At all" is the whole precision of this predicate, and since #7243 it is + * load-bearing: `mongodb` answers `true` while reading only `min` / `max`. Use + * {@link unreadPoolKeys} for the per-key question — a `true` here does NOT mean + * every declared key lands. + * + * `turso` answered `true` until #7243, first via the unknown-id branch and, + * after #6345 made it a builtin, via "not in the rejected set" — both wrong the + * same way: the arm never reads `spec.pool`. The 2026-08-11 ruling folds it in + * whole-arm, so this now answers `false` for every spelling of it. */ export function driverReadsDeclaredPool(driver: unknown): boolean { const id = resolveDriverId(driver); @@ -146,6 +181,18 @@ const POOL_UNSUPPORTED_REASONS: Readonly `of dropped.`, sqlite: SQLITE_POOL_REASON, 'sqlite-wasm': SQLITE_POOL_REASON, + // #7243. Deliberately NOT a paraphrase of SQLite's: an author who declared a + // pool on a libSQL datasource is as likely to be on the remote transport as + // the local one, and telling them about `:memory:` splitting a database would + // describe a mode they are not in. Both modes are named, because the honest + // answer differs by mode and the verdict does not. + turso: + `neither libSQL transport has a connection pool to size. A \`file:\` / \`:memory:\` url runs ` + + `the local engine — better-sqlite3, one connection per database, the same engine the two ` + + `sqlite arms are rejected for — and a \`libsql://\` url is a remote request transport with ` + + `no persistent connections at all, capped by \`config.concurrency\` rather than by \`min\` / ` + + `\`max\`. \`TursoDriverConfig\` has no \`min\` / \`max\` to receive them, so the block is ` + + `rejected instead of dropped.`, }; /** @@ -183,10 +230,124 @@ export function unsupportedPoolMessage(driver: string, datasourceName?: string): ); } +/** + * Keys of `datasource.pool` that a driver which DOES read the block still never + * takes out of it — the half-effective case (#7243). + * + * One entry today. The `mongodb` arm maps `min` / `max` onto the MongoClient's + * `minPoolSize` / `maxPoolSize` and reads nothing else, so a declaration of + * `pool: { max: 20, idleTimeoutMillis: 30000 }` had `max` take effect while the + * timeout vanished — measured on `origin/main` as driver config + * `{"url":…,"database":"orders","maxPoolSize":20}`. That is worse than the whole + * -block silence the set above closes, because the author's evidence that their + * pool config "works" is real, and only half of it. + * + * The whole-block set could not express this: rejecting `mongodb` there would + * throw away `min` / `max`, which ARE honoured. Hence a second, narrower gate — + * and deliberately a DATA table rather than a per-arm `if`, so the fix for the + * next arm that half-reads the block is one line here and inherits all three + * doors. + * + * ⚠️ Every key listed here must be one this driver truly does not read; the + * pin in `datasource-pool-support.test.ts` re-derives mongodb's list from the + * factory arm's own source so the table cannot drift away from the code once + * someone wires a key up. + */ +export const POOL_UNREAD_KEYS_BY_DRIVER: Readonly> = { + mongodb: ['idleTimeoutMillis', 'connectionTimeoutMillis'], +}; + +/** + * WHY the listed keys reach nothing on that arm — one clause per driver id, for + * the same reason {@link POOL_UNSUPPORTED_REASONS} is per-arm: a borrowed + * explanation sends the author looking for a knob that does not exist. + */ +const POOL_UNREAD_KEY_REASONS: Readonly> = { + mongodb: + `the mongodb arm takes \`min\` / \`max\` out of the block and maps them onto the MongoClient's ` + + `\`minPoolSize\` / \`maxPoolSize\` — and nothing else, so the rest of the block reaches no ` + + `connection. This rejection is deliberate: the platform refuses a declaration it would ` + + `otherwise drop in silence, which is what made this one hard to see (\`max\` took effect, so ` + + `the config looked honoured).`, +}; + +/** + * What SURVIVES the edit, per arm — the clause the whole-block message cannot + * have, because there the whole block goes. Telling a mongo author to "remove + * `pool`" would delete two keys that are honoured, so the message has to say + * which part of their declaration is still doing something. + */ +const POOL_UNREAD_KEY_TAILS: Readonly> = { + mongodb: + '`min` / `max` stay meaningful here, and the timeouts stay meaningful on the knex-pooled ' + + 'drivers (postgres / mysql).', +}; + +/** `` `a` ``, `` `a` and `b` ``, `` `a`, `b` and `c` `` — for a list of key names. */ +function formatKeyList(keys: readonly string[]): string { + const quoted = keys.map((k) => `\`${k}\``); + if (quoted.length <= 1) return quoted[0] ?? ''; + return `${quoted.slice(0, -1).join(', ')} and ${quoted[quoted.length - 1]}`; +} + +/** + * Which of this datasource's declared `pool` keys does its driver never read? + * + * Empty for every driver outside {@link POOL_UNREAD_KEYS_BY_DRIVER} — including + * the ones rejected whole-block above, whose verdict is not this gate's, and + * every id the platform ships no contract for. Order follows the table, not the + * author's key order, so one declaration always produces one message. + */ +export function unreadPoolKeys(driver: unknown, pool: unknown): readonly string[] { + const id = resolveDriverId(driver); + if (!id) return []; + const unread = POOL_UNREAD_KEYS_BY_DRIVER[id]; + if (!unread || !isPoolDeclared(pool)) return []; + const declared = pool as Record; + return unread.filter((key) => declared[key] !== undefined); +} + +/** + * The rejection text for `pool` keys the driver reads nothing out of. + * + * Same voice as {@link unsupportedPoolMessage}: name the datasource, name the + * offending keys, say the rejection is deliberate, give the one edit that fixes + * it, and offer no escape hatch and no "use another driver" advice. It differs + * in one respect only — it says what STAYS, because on this arm most of the + * block is honoured and telling the author to delete `pool` would be wrong. + */ +export function unreadPoolKeysMessage( + driver: string, + keys: readonly string[], + datasourceName?: string, +): string { + const subject = datasourceName ? `Datasource '${datasourceName}'` : 'This datasource'; + const id = resolveDriverId(driver); + const list = formatKeyList(keys); + const them = keys.length > 1 ? 'them' : 'it'; + const reason = + (id && POOL_UNREAD_KEY_REASONS[id]) + ?? `nothing in the arm reads ${them}, so ${them} reaches no connection.`; + const tail = + (id && POOL_UNREAD_KEY_TAILS[id]) + ?? `the rest of the block is unaffected.`; + return ( + `${subject} declares ${list} in its \`pool\` block, but the '${driver}' driver does not read ` + + `${them}: ${reason} Remove ${list} from this datasource's \`pool\`; ${tail}` + ); +} + /** * The rejection for one datasource declaration, or `undefined` when there is * nothing to reject. Never throws — callers that want the throw use * {@link assertDatasourcePoolSupported}. + * + * Two gates, in this order: the whole block first (the driver reads none of + * it), then the individual keys (the driver reads the block but not these). + * They cannot both fire — a driver in {@link POOL_UNSUPPORTED_DRIVER_IDS} has no + * entry in {@link POOL_UNREAD_KEYS_BY_DRIVER} — and the order says which message + * an author gets if one ever did: the block-level one, because deleting the + * block is the larger edit that subsumes the other. */ export function unsupportedPoolIssue(input: { driver: string; @@ -194,8 +355,12 @@ export function unsupportedPoolIssue(input: { name?: string; }): string | undefined { if (!isPoolDeclared(input.pool)) return undefined; - if (driverReadsDeclaredPool(input.driver)) return undefined; - return unsupportedPoolMessage(input.driver, input.name); + if (!driverReadsDeclaredPool(input.driver)) { + return unsupportedPoolMessage(input.driver, input.name); + } + const unread = unreadPoolKeys(input.driver, input.pool); + if (unread.length > 0) return unreadPoolKeysMessage(input.driver, unread, input.name); + return undefined; } /** diff --git a/packages/services/service-datasource/src/default-datasource-driver-factory.ts b/packages/services/service-datasource/src/default-datasource-driver-factory.ts index 62b2e1a4c7..a4451e6e7f 100644 --- a/packages/services/service-datasource/src/default-datasource-driver-factory.ts +++ b/packages/services/service-datasource/src/default-datasource-driver-factory.ts @@ -709,6 +709,18 @@ export function createDefaultDatasourceDriverFactory( // `options` (the MongoClient passthrough) and the datasource's `pool` // block reach the client since #4410 — the driver has always read // `options` / `minPoolSize` / `maxPoolSize`; only `url` was ever passed. + // + // `min` / `max` are the ONLY keys taken out of the block, and that is + // now enforced rather than merely true: `pool.idleTimeoutMillis` and + // `pool.connectionTimeoutMillis` used to be dropped here in silence — + // half a block honoured, which reads to the author as a whole one — and + // since #7243 the guard at the top of `create()` rejects them by name + // (`POOL_UNREAD_KEYS_BY_DRIVER`). Wiring them onto the MongoClient's + // `maxIdleTimeMS` / `connectTimeoutMS` was the option NOT taken + // (maintainer ruling 2026-08-11): no measured consumer asked for them. + // Adding a key here therefore means deleting it from that table in the + // same change — the pin in `datasource-pool-support.test.ts` reads this + // arm's source and fails if the two disagree. const pool = (spec.pool ?? {}) as Record; const driver = new MongoDBDriver({ url: buildMongoUrl(spec), @@ -744,6 +756,17 @@ export function createDefaultDatasourceDriverFactory( // DEPENDS on this package, so importing it would invert the dependency, // and declaring a second same-named class is precisely the identity // hazard #6268 closed (`serve.ts` decides fatality with `instanceof`). + // + // `spec.pool` is not read here and never was: `TursoDriverConfig` has + // no `min` / `max` — a `file:` url runs the local better-sqlite3 engine + // (one connection per database, the very engine the sqlite arms are + // rejected for) and a `libsql://` url is a request transport capped by + // `config.concurrency`, not a pool. It used to be dropped in silence; + // since #7243 the guard at the top of `create()` rejects it whole-arm, + // which is why this arm needs no pool handling of its own rather than + // merely having none. Whole-arm and NOT forked by url mode: maintainer + // ruling 2026-08-11 — both modes reach the same verdict, so a fork would + // buy branching and no author-visible difference. let TursoDriver: any; try { ({ TursoDriver } = await import('@objectstack/driver-turso' as any)); diff --git a/packages/services/service-datasource/src/index.ts b/packages/services/service-datasource/src/index.ts index dd9aca15d4..030948fee2 100644 --- a/packages/services/service-datasource/src/index.ts +++ b/packages/services/service-datasource/src/index.ts @@ -78,11 +78,19 @@ export type { // Which driver arms read `datasource.pool`, and the loud rejection for the ones // that do not (#5714) — exported so a host that injects its OWN driver factory // can hold the same contract instead of re-deriving (or silently dropping) it. +// `POOL_UNREAD_KEYS_BY_DRIVER` / `unreadPoolKeys` / `unreadPoolKeysMessage` are +// the per-KEY half added by #7243, for the arm that reads the block but not +// every key in it (`mongodb`). `unsupportedPoolIssue` already covers both, so an +// injected factory needs only that one; the parts are exported for the same +// reason the block-level ones are — so a host can ask the narrower question. export { POOL_UNSUPPORTED_DRIVER_IDS, + POOL_UNREAD_KEYS_BY_DRIVER, driverReadsDeclaredPool, + unreadPoolKeys, unsupportedPoolIssue, unsupportedPoolMessage, + unreadPoolKeysMessage, assertDatasourcePoolSupported, } from './datasource-pool-support.js'; export type { PoolUnsupportedDriverId } from './datasource-pool-support.js'; diff --git a/packages/spec/liveness/datasource.json b/packages/spec/liveness/datasource.json index d646fed3fa..727553117d 100644 --- a/packages/spec/liveness/datasource.json +++ b/packages/spec/liveness/datasource.json @@ -30,25 +30,25 @@ "min": { "status": "live", "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:191, packages/services/service-datasource/src/default-datasource-driver-factory.ts:482, packages/services/service-datasource/src/datasource-pool-support.ts:79", - "note": "knex pool floor on `postgres` / `mysql` (`buildSqlPool` :191, handed to `SqlDriver` at :403 and :458), and the MongoClient's `minPoolSize` on `mongodb` (:482). Live only since #4465 — the factory used to hardcode `{ min: 0, max: 5 }` over the carried value. NOT live on `memory` / `sqlite` / `sqlite-wasm`, and not silently dropped there either: declaring it is an authoring ERROR since #5714 (the SQLite pair) and #5931 (`memory`) — `POOL_UNSUPPORTED_DRIVER_IDS` (datasource-pool-support.ts:79). Still dropped in silence on `turso`. See the block note below for both." + "note": "knex pool floor on `postgres` / `mysql` (`buildSqlPool` :191, handed to `SqlDriver` at :403 and :458), and the MongoClient's `minPoolSize` on `mongodb` (:482). Live only since #4465 — the factory used to hardcode `{ min: 0, max: 5 }` over the carried value. NOT live on `memory` / `sqlite` / `sqlite-wasm`, and not silently dropped there either: declaring it is an authoring ERROR since #5714 (the SQLite pair) and #5931 (`memory`) — `POOL_UNSUPPORTED_DRIVER_IDS` (datasource-pool-support.ts:79). Also an authoring ERROR on `turso` since #7243 (maintainer ruling 2026-08-11, whole-arm — `TursoDriverConfig` has no `min` / `max` and neither libSQL transport pools). See the block note below." }, "max": { "status": "live", "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:192, packages/services/service-datasource/src/default-datasource-driver-factory.ts:483, packages/services/service-datasource/src/datasource-pool-support.ts:79", - "note": "knex pool ceiling on `postgres` / `mysql` (`buildSqlPool` :192); also mapped onto the Mongo client's `maxPoolSize` (:483, #4465). Same driver qualification as `min`: an authoring ERROR on `memory` / `sqlite` / `sqlite-wasm` (datasource-pool-support.ts:79 — #5714 / #5931), still dropped in silence on `turso`. See the block note below." + "note": "knex pool ceiling on `postgres` / `mysql` (`buildSqlPool` :192); also mapped onto the Mongo client's `maxPoolSize` (:483, #4465). Same driver qualification as `min`: an authoring ERROR on `memory` / `sqlite` / `sqlite-wasm` / `turso` (datasource-pool-support.ts:79 — #5714 / #5931 / #7243). See the block note below." }, "idleTimeoutMillis": { "status": "live", "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:193, packages/services/service-datasource/src/datasource-pool-support.ts:79", - "note": "passed through to knex verbatim on `postgres` / `mysql` (`buildSqlPool` :193) — the two SQL arms are the ONLY ones that read it. `mongodb` takes `min` / `max` out of the block and nothing else (:477-483), so this key reaches nothing on that arm; the unqualified `live` this row carried before #6214 overstated it. An authoring ERROR on `memory` / `sqlite` / `sqlite-wasm` (datasource-pool-support.ts:79 — #5714 / #5931; the rejection text names `min` / `max` and the timeouts together, :142-146), and still dropped in silence on `turso`. See the block note below." + "note": "passed through to knex verbatim on `postgres` / `mysql` (`buildSqlPool` :193) — the two SQL arms are the ONLY ones that read it. `mongodb` takes `min` / `max` out of the block and nothing else (:477-483), so this key reaches nothing on that arm; the unqualified `live` this row carried before #6214 overstated it. Since #7243 that reach-nothing is REJECTED BY NAME rather than dropped: `POOL_UNREAD_KEYS_BY_DRIVER.mongodb` lists this key, so a mongo datasource declaring it is refused at all three doors (maintainer ruling 2026-08-11 — rejected, not wired onto MongoClient's `maxIdleTimeMS`, for want of a measured consumer). An authoring ERROR on `memory` / `sqlite` / `sqlite-wasm` / `turso` too (datasource-pool-support.ts:79 — #5714 / #5931 / #7243; the whole-block rejection text names `min` / `max` and the timeouts together). See the block note below." }, "connectionTimeoutMillis": { "status": "live", "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:194, packages/services/service-datasource/src/datasource-pool-support.ts:79", - "note": "mapped onto knex's `acquireTimeoutMillis` (different name, same meaning) on `postgres` / `mysql` (`buildSqlPool` :194-196). Carries `idleTimeoutMillis`' qualification exactly: `mongodb` reads only `min` / `max` from the block (:477-483) so this key reaches nothing there, it is an authoring ERROR on `memory` / `sqlite` / `sqlite-wasm` (datasource-pool-support.ts:79 — #5714 / #5931), and it is still dropped in silence on `turso`. See the block note below." + "note": "mapped onto knex's `acquireTimeoutMillis` (different name, same meaning) on `postgres` / `mysql` (`buildSqlPool` :194-196). Carries `idleTimeoutMillis`' qualification exactly: `mongodb` reads only `min` / `max` from the block, so this key reaches nothing there and is REJECTED BY NAME since #7243 (`POOL_UNREAD_KEYS_BY_DRIVER.mongodb`), and the whole block is an authoring ERROR on `memory` / `sqlite` / `sqlite-wasm` / `turso` (datasource-pool-support.ts:79 — #5714 / #5931 / #7243). See the block note below." } }, - "note": "QUALIFIED BY DRIVER 2026-08-10 (#6214) — the spec half of the #5931 ruling. `live` on the four rows above means \"honoured by the pooled arms\", never \"honoured everywhere\", and the rows said so nowhere until this repair: an author reading them got `pool` presented as an unconditional knob on a block that three built-in drivers now REFUSE and a fourth ignores. HONOURED: `postgres` / `mysql` hand `buildSqlPool(spec)` to `SqlDriver` (packages/services/service-datasource/src/default-datasource-driver-factory.ts:188-198, applied at :403 and :458), and `mongodb` maps `min` / `max` — only those two — onto the MongoClient's `minPoolSize` / `maxPoolSize` (:477-483). LOUDLY REJECTED on `memory` / `sqlite` / `sqlite-wasm`: `POOL_UNSUPPORTED_DRIVER_IDS` (packages/services/service-datasource/src/datasource-pool-support.ts:79) with one explanation per arm (:141-149), thrown at every door a `pool` block can come in through — the Setup wizard's create/update (datasource-admin-service.ts:243, :314), the boot-time auto-connect pre-pass (datasource-connection-service.ts:508), and the factory's last door (default-datasource-driver-factory.ts:374). Two arms, two reasons, one verdict: knex's better-sqlite3 dialect pins `{min:1,max:1}` on purpose because a second connection to `:memory:` opens a SEPARATE, empty database, so sizing the pool would split one datasource's data across several stores (#5714, maintainer ruling 2026-08-06 option B); `memory` opens no connection at all — its store is a plain data structure inside this process, reached by a direct call — so `min` / `max` and the timeouts have nothing to configure (#5931, maintainer ruling 2026-08-07, which also set the default that a silently-dropped key JOINS an existing rejection set rather than queueing for its own ruling, #6140). Measured through the real factory before the rejection existed (#5931): `postgres + pool{min:3,max:9}` → live `{min:3,max:9}`; `sqlite + pool{min:3,max:9}` → live `{min:1,max:1}`; `memory + pool{min:3,max:9}` → driver config `{\"persistence\":false}`, `pool` undefined. `examples/app-crm` was the live specimen — `CrmDatasource` declared `pool: { min: 1, max: 5 }` and ran on `{min:1,max:1}`. STILL SILENT, recorded rather than hidden: `turso` is not in the rejected set, so a declared `pool` is dropped there without a word — `TursoDriverConfig` has no `min` / `max`, only `concurrency`, and in local mode the driver is the very better-sqlite3 `SqlDriver` the set rejects for (datasource-pool-support.ts:90-99). Tightening that is a new rejection on an authoring surface and needs its own ruling; the same is true of the two timeouts on `mongodb`. A driver id the platform ships no contract for (`com.vendor.snowflake`) is deliberately NOT judged — \"we validate what we can construct\" (datasource-pool-support.ts:101-105)." + "note": "QUALIFIED BY DRIVER 2026-08-10 (#6214) — the spec half of the #5931 ruling. `live` on the four rows above means \"honoured by the pooled arms\", never \"honoured everywhere\", and the rows said so nowhere until this repair: an author reading them got `pool` presented as an unconditional knob on a block that four built-in drivers now REFUSE outright and a fifth (`mongodb`) honours only half of. HONOURED: `postgres` / `mysql` hand `buildSqlPool(spec)` to `SqlDriver` (packages/services/service-datasource/src/default-datasource-driver-factory.ts:188-198, applied at :403 and :458), and `mongodb` maps `min` / `max` — only those two — onto the MongoClient's `minPoolSize` / `maxPoolSize` (:477-483). LOUDLY REJECTED on `memory` / `sqlite` / `sqlite-wasm` / `turso` (#7243): `POOL_UNSUPPORTED_DRIVER_IDS` (packages/services/service-datasource/src/datasource-pool-support.ts:79) with one explanation per arm (:141-149), thrown at every door a `pool` block can come in through — the Setup wizard's create/update (datasource-admin-service.ts:243, :314), the boot-time auto-connect pre-pass (datasource-connection-service.ts:508), and the factory's last door (default-datasource-driver-factory.ts:374). Two arms, two reasons, one verdict: knex's better-sqlite3 dialect pins `{min:1,max:1}` on purpose because a second connection to `:memory:` opens a SEPARATE, empty database, so sizing the pool would split one datasource's data across several stores (#5714, maintainer ruling 2026-08-06 option B); `memory` opens no connection at all — its store is a plain data structure inside this process, reached by a direct call — so `min` / `max` and the timeouts have nothing to configure (#5931, maintainer ruling 2026-08-07, which also set the default that a silently-dropped key JOINS an existing rejection set rather than queueing for its own ruling, #6140). Measured through the real factory before the rejection existed (#5931): `postgres + pool{min:3,max:9}` → live `{min:3,max:9}`; `sqlite + pool{min:3,max:9}` → live `{min:1,max:1}`; `memory + pool{min:3,max:9}` → driver config `{\"persistence\":false}`, `pool` undefined. `examples/app-crm` was the live specimen — `CrmDatasource` declared `pool: { min: 1, max: 5 }` and ran on `{min:1,max:1}`. NOTHING IS SILENT HERE ANY MORE, as of #7243 (maintainer ruling 2026-08-11, both halves). The two faces this note recorded as still-dropped are now loud. (a) `turso` JOINS the rejected set whole-arm, with no fork by url mode: `TursoDriverConfig` has no `min` / `max`, a `file:` / `:memory:` url runs the very better-sqlite3 engine the set already rejects for, and a `libsql://` url is a remote request transport with no persistent connections, capped by `config.concurrency`. Measured before the change: the turso arm never referenced `spec.pool` at all. (b) `mongodb`'s two timeout keys are REJECTED BY NAME rather than wired — a new per-KEY shape (`POOL_UNREAD_KEYS_BY_DRIVER`, datasource-pool-support.ts), because rejecting the whole block on that arm would throw away the `min` / `max` it does honour. Measured before the change: `mongodb + pool{max:20,idleTimeoutMillis:30000,connectionTimeoutMillis:3000}` built a driver whose config was url + database + maxPoolSize:20 and nothing else — half the block honoured, which reads to the author as all of it. MongoClient does expose `maxIdleTimeMS` / `connectTimeoutMS`, so this one COULD have been implemented; the ruling declined for want of a measured consumer and left the wiring as a one-line pulled change for the day demand appears. A driver id the platform ships no contract for (`com.vendor.snowflake`) is deliberately NOT judged — \"we validate what we can construct\" (datasource-pool-support.ts:101-105)." }, "ssl": { "children": {