diff --git a/.changeset/config.json b/.changeset/config.json
index fb3cdfc0e8..98c7ac68ff 100644
--- a/.changeset/config.json
+++ b/.changeset/config.json
@@ -34,6 +34,7 @@
"@objectstack/driver-sql",
"@objectstack/driver-mongodb",
"@objectstack/driver-sqlite-wasm",
+ "@objectstack/driver-turso",
"@objectstack/plugin-approvals",
"@objectstack/plugin-audit",
"@objectstack/plugin-auth",
diff --git a/.changeset/drivers-dir-and-turso-migration.md b/.changeset/drivers-dir-and-turso-migration.md
new file mode 100644
index 0000000000..464f203e54
--- /dev/null
+++ b/.changeset/drivers-dir-and-turso-migration.md
@@ -0,0 +1,38 @@
+---
+"@objectstack/driver-turso": minor
+"@objectstack/driver-memory": patch
+"@objectstack/driver-mongodb": patch
+"@objectstack/driver-sql": patch
+"@objectstack/driver-sqlite-wasm": patch
+---
+
+feat(drivers): `@objectstack/driver-turso` 迁回本仓并公开发布,五个 driver 统一收进 `packages/drivers/` (#4645)
+
+`TursoDriver` 一直以 `extends SqlDriver` 的方式**跨仓库继承**本仓的类,自己却住在闭源的
+`objectstack-ai/cloud`(`publishConfig: restricted`)。而本仓的 runtime 早就把 turso 当一等
+公民——`http-dispatcher.ts` 里环境 provisioning 的偏好顺序第一位就是它,`POST /cloud/environments`
+的 `driver` 参数示例是 `memory | turso`,`objectql/src/engine.ts` 还带着一段 turso 专属的瞬时
+`fetch failed` 重试。开源侧的代码路径引用着一个自己仓里既测不到也 grep 不到的 driver,闭源侧则
+在每次 pin bump 时追赶父类的重构。维护者裁定把核心迁回本仓、公开 Apache-2.0 发布。
+
+**新包 `@objectstack/driver-turso`(`packages/drivers/driver-turso`,Apache-2.0,`access: public`)**
+带着它在 cloud 的全部实现与测试落地:`TursoDriver`(local / replica / remote 三种传输模式)、
+`RemoteTransport`(纯 `@libsql/client` 走 HTTP/WebSocket,无原生依赖,可跑 serverless/edge)、
+驱动的 spec/Studio 元数据,以及 15 个测试文件 538 条断言——全部 hermetic,默认 CI 下不碰网络、
+不要凭据(remote 面走包内的 sqlite stub)。
+
+**留在 cloud(不随迁)**:按租户路由的 `multi-tenant.ts`(云产品差异化能力)及其 schema、
+`vector-poc.test.ts`。因此本包的 barrel **不再导出** `createMultiTenantRouter` /
+`MultiTenantConfig` / `MultiTenantRouter`,也不导出多租户 schema——它们从来不是这个 driver 的
+一部分,只是曾经同包而已。
+
+**目录重组**:五个 `IDataDriver` 实现(`driver-memory` / `driver-mongodb` / `driver-sql` /
+`driver-sqlite-wasm` + 迁入的 `driver-turso`)现在都住在 `packages/drivers/`,
+`knowledge-*` 与 `embedder-*` 留在 `packages/plugins/`。四个存量包**内容零改动**,只有
+`repository.directory` 随目录更新——包名、入口、导出面、行为全部不变,消费者无需改动任何 import。
+
+这也把 turso 交给了本仓的仓库级守卫:`check:driver-conformance` 从磁盘发现 driver 包,
+迁入即入矩阵(5 drivers × 5 case-sets)。它的 temporal 两格是真绿(local 与 remote 双面套件),
+filter 组合语义与两个分页 case-set 记为 measured DEBT——remote 传输自带一套 `buildWhereSQL` 与
+`LIMIT`/`OFFSET` 拼装,是独立实现,"继承所以没问题"正是这些共享套件存在来证伪的假设。
+补齐工作跟踪在 #5590。
diff --git a/.claude/skills/pm-dispatch/SKILL.md b/.claude/skills/pm-dispatch/SKILL.md
index e802958c40..24340b66a3 100644
--- a/.claude/skills/pm-dispatch/SKILL.md
+++ b/.claude/skills/pm-dispatch/SKILL.md
@@ -392,7 +392,7 @@ file the fix touches, you have not triaged it yet, and it is not labelable.
| 标签 | 包家族 |
|:--|:--|
| `domain:engine-core` | `packages/objectql`、`packages/metadata*`、`packages/platform-objects`、`packages/core`、`packages/formula`(CEL / `matches-filter` / RLS 谓词求值)、`plugin-pinyin-search`(`__search` 伴生列由 SchemaRegistry 声明、engine 把它 OR 进 `$search`,落点在编译/查询核心而非任何 driver;全局写钩子同 #4775 锚定) |
-| `domain:drivers` | `packages/plugins/driver-*`(`driver-memory` / `driver-mongodb` / `driver-sql` / `driver-sqlite-wasm`) |
+| `domain:drivers` | `packages/drivers/driver-*`(`driver-memory` / `driver-mongodb` / `driver-sql` / `driver-sqlite-wasm`) |
| `domain:services` | `packages/services/*`、`packages/connectors/*`、`packages/triggers/*`(flow 触发器)、`packages/plugins/plugin-approvals`、`plugin-webhooks`、`plugin-email`、`plugin-reports`、`embedder-openai`、`knowledge-memory`、`knowledge-ragflow` |
| `domain:identity` | `packages/plugins/plugin-auth`、`plugin-security`、`plugin-sharing`、`plugin-audit` |
| `domain:devx` | `packages/lint`、`packages/sdui-parser`(仅 lint 消费)、`packages/vscode-objectstack`、`skills/**`、`content/docs/**`、`apps/docs`、`scripts/`(门禁类) |
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 592878ef25..7fce915e8f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -959,7 +959,7 @@ jobs:
- name: Verify capability packages ship a runtime entry (no dts-only / half-built)
run: |
fail=0
- for d in packages/triggers/* packages/services/* packages/plugins/*; do
+ for d in packages/triggers/* packages/services/* packages/drivers/* packages/plugins/*; do
[ -f "$d/package.json" ] || continue
has_build=$(node -p "Boolean((require('./$d/package.json').scripts||{}).build)" 2>/dev/null || echo false)
[ "$has_build" = "true" ] || continue
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 5589d0920e..07772f3bfe 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -618,8 +618,9 @@ jobs:
# The per-package `typecheck` scripts the coverage gate above requires
# (#4311). tsc resolves workspace imports through each dependency's built
# dist/*.d.ts, so the task carries `dependsOn: ^build` in turbo.json —
- # which also builds the handful of nested packages (packages/plugins/*,
- # packages/services/*, …) the build step's direct-children glob misses
+ # which also builds the handful of nested packages (packages/drivers/*,
+ # packages/plugins/*, packages/services/*, …) the build step's
+ # direct-children glob misses
# when no example depends on them. Three filters because the bare
# `./packages/*` glob only matches direct children (see the build step's
# comment): the nested group dirs and apps/ (docs) need their own globs.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index abe10cebd1..2776514839 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -242,12 +242,13 @@ ObjectStack is organized as a **monorepo** with distinct package layers:
**Dependencies**: `@objectstack/client`, `@objectstack/core`, `@objectstack/spec`
**Peer Dependencies**: `react`
-### Plugin Packages
+### Driver & Plugin Packages
-Located in `packages/plugins/*`:
+Drivers (`IDataDriver` implementations) live in `packages/drivers/*`; every other
+official plugin lives in `packages/plugins/*`.
#### `@objectstack/driver-memory`
-**Location**: `packages/plugins/driver-memory/`
+**Location**: `packages/drivers/driver-memory/`
**Role**: In-Memory Driver (Reference Implementation)
- Complete ObjectQL driver implementation
diff --git a/README.md b/README.md
index 4799f668aa..aabc06ae39 100644
--- a/README.md
+++ b/README.md
@@ -284,11 +284,12 @@ For the browser, the typed client SDK and React hooks (`useQuery` / `useMutation
| Package | Description |
| :--- | :--- |
-| [`@objectstack/driver-memory`](packages/plugins/driver-memory) | In-memory driver (development and testing) |
-| [`@objectstack/driver-sql`](packages/plugins/driver-sql) | SQL driver — PostgreSQL, MySQL, SQLite (production) |
-| [`@objectstack/driver-mongodb`](packages/plugins/driver-mongodb) | MongoDB driver (native document database) |
+| [`@objectstack/driver-memory`](packages/drivers/driver-memory) | In-memory driver (development and testing) |
+| [`@objectstack/driver-sql`](packages/drivers/driver-sql) | SQL driver — PostgreSQL, MySQL, SQLite (production) |
+| [`@objectstack/driver-mongodb`](packages/drivers/driver-mongodb) | MongoDB driver (native document database) |
+| [`@objectstack/driver-turso`](packages/drivers/driver-turso) | Turso / libSQL driver — edge-first SQLite, embedded replicas, remote mode |
-> Turso / libSQL driver (`@objectstack/driver-turso`) and the libSQL-backed vector knowledge plugin (`@objectstack/knowledge-turso`) live in the [ObjectStack Cloud](https://github.com/objectstack-ai/cloud) monorepo as of this release.
+> The libSQL-backed vector knowledge plugin (`@objectstack/knowledge-turso`) and Turso database-per-tenant routing live in the [ObjectStack Cloud](https://github.com/objectstack-ai/cloud) monorepo.
### Client
diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx
index ff1f22191f..fd1f26ea85 100644
--- a/content/docs/data-modeling/drivers.mdx
+++ b/content/docs/data-modeling/drivers.mdx
@@ -52,10 +52,13 @@ Drivers can be selected in two ways:
| _(unset, dev mode)_ | SQLite (native, falling back to WASM, then in-memory) | `@objectstack/driver-sql` / `-sqlite-wasm` / `-memory` |
-Turso / libSQL (`libsql://`, `*.turso.io`) is **not** supported by the
-open-source framework. The CLI recognizes these URLs (mapping them to a
-`turso` driver kind), but no bundled driver implements it — so they never
-actually connect to Turso.
+Turso / libSQL (`libsql://`, `*.turso.io`) is **not** inferred from a URL.
+`@objectstack/driver-turso` does live in this repo (`packages/drivers/driver-turso`)
+and you can register it yourself — a datasource with `driver: 'turso'` and
+`config: { url, authToken }` — but the CLI's URL → driver resolver does not
+construct it. A `libsql://` URL therefore fails loudly at boot rather than
+silently degrading to SQLite. Whether the inference table should construct it
+is tracked in issue #5602.
## Supported Drivers
diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx
index d7f846fc4d..69890a93a3 100644
--- a/content/docs/deployment/environment-variables.mdx
+++ b/content/docs/deployment/environment-variables.mdx
@@ -47,7 +47,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| Variable | Type | Default | Description |
|:---|:---|:---|:---|
-| `OS_DATABASE_URL` | url | — | Database connection string (e.g. `file:./data.sqlite`, `postgres://…`, `mongodb://…`, `memory://`). `libsql://` (Turso) is not supported. |
+| `OS_DATABASE_URL` | url | — | Database connection string (e.g. `file:./data.sqlite`, `postgres://…`, `mongodb://…`, `memory://`). `libsql://` (Turso) is not inferred — register `@objectstack/driver-turso` explicitly. |
| `OS_DATABASE_DRIVER` | enum | inferred | Force a specific driver when the URL is ambiguous. `memory` \| `sqlite` \| `sqlite-wasm` \| `postgres` \| `mongodb`. |
| `OS_DATABASE_SQLITE_JOURNAL_MODE` | enum | `wal` | Journal mode for **file-backed** SQLite. `wal` (default) lets a dev server and CLI commands share one file without blocking each other, and is what makes the `os migrate` occupancy check reliable. Set to `delete` for SQLite's rollback journal — required when the database lives on a **network filesystem** (NFS/SMB), where WAL cannot work. The setting is applied, not merely skipped: `delete` converts a database that already adopted WAL back. Ignored for `:memory:`, for the WASM SQLite driver, and for non-SQLite drivers. A per-datasource `sqliteJournalMode` in driver config outranks it. See [Journal mode](/docs/data-modeling/drivers#journal-mode-wal-and-cross-process-access). |
| `OS_ALLOW_DRIVER_CONNECT_FAILURE` | boolean | `false` | Escape hatch for the driver-connect boot guard. By default a data driver that fails to connect at startup **refuses the boot** — a server that cannot reach its database must not report itself started and then fail every request. The same guard covers a **declared datasource** that objects bind to via `datasource: '…'`, or an `external` one with `validation.onMismatch: 'fail'`: those objects have no fallback datasource, so an unconnected one means they are all dead. Set to `1` to boot anyway, in an explicitly degraded state logged loudly at startup. There is **no reconnection**: whatever failed stays dead for the process lifetime and every query and schema sync routed to it fails. |
diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx
index 46109d8c9f..62c55c5eec 100644
--- a/content/docs/deployment/self-hosting.mdx
+++ b/content/docs/deployment/self-hosting.mdx
@@ -32,7 +32,7 @@ workable default:
| Variable | Why it must be set |
|:---|:---|
-| `OS_DATABASE_URL` | Without it, data lands in a SQLite file under the ObjectStack home directory (`~/.objectstack`, or `/.objectstack` next to a project config) — fine for one box, wrong for containers. Use `postgres://…`, `mongodb://…`, or a mounted `file:…` path (`libsql://` / Turso is **not** supported by the open framework — that driver ships in ObjectStack Cloud). `mongodb://…` is **single-tenant only**: the MongoDB driver has no row-level tenant isolation and refuses to boot unless the tenancy posture is `single` — see [Drivers → Multi-tenancy](/docs/data-modeling/drivers#multi-tenancy-not-supported). |
+| `OS_DATABASE_URL` | Without it, data lands in a SQLite file under the ObjectStack home directory (`~/.objectstack`, or `/.objectstack` next to a project config) — fine for one box, wrong for containers. Use `postgres://…`, `mongodb://…`, or a mounted `file:…` path (`libsql://` / Turso is **not** inferred from the URL — `@objectstack/driver-turso` is in-repo but must be registered explicitly in your stack config; see [Drivers](/docs/data-modeling/drivers)). `mongodb://…` is **single-tenant only**: the MongoDB driver has no row-level tenant isolation and refuses to boot unless the tenancy posture is `single` — see [Drivers → Multi-tenancy](/docs/data-modeling/drivers#multi-tenancy-not-supported). |
| `OS_AUTH_SECRET` | Session secret for the auth plugin (`AUTH_SECRET` is the legacy alias). Without it, `/api/v1/auth/*` is **silently skipped** — the server runs unauthenticated. |
| `OS_SECRET_KEY` | 32-byte master key encrypting every stored secret (`openssl rand -hex 32`). On a container's ephemeral filesystem the auto-minted key is **lost on restart**, making previously-encrypted secrets undecryptable. |
| `OS_PORT` | `os start` **fails loudly** if the port is busy (it never auto-shifts like `os dev`). Pin it and keep your reverse-proxy upstream in sync. |
diff --git a/content/docs/getting-started/glossary.mdx b/content/docs/getting-started/glossary.mdx
index 6077d8dfac..a930237548 100644
--- a/content/docs/getting-started/glossary.mdx
+++ b/content/docs/getting-started/glossary.mdx
@@ -106,8 +106,8 @@ The fundamental unit of data modeling in ObjectStack. Roughly equivalent to a "T
### Driver
An adapter plugin in the Data Protocol runtime stack that allows the Data Layer to communicate with a specific underlying storage engine.
-* *Example (this repo):* `@objectstack/driver-sql` (Postgres / MySQL / SQLite via Knex), `@objectstack/driver-mongodb`, `@objectstack/driver-memory` (in-memory, for testing), `@objectstack/driver-sqlite-wasm`.
-* *Cloud distribution:* `@objectstack/driver-turso` (edge-first SQLite) ships separately, not in this repository.
+* *Example (this repo):* `@objectstack/driver-sql` (Postgres / MySQL / SQLite via Knex), `@objectstack/driver-mongodb`, `@objectstack/driver-memory` (in-memory, for testing), `@objectstack/driver-sqlite-wasm`, `@objectstack/driver-turso` (edge-first SQLite / libSQL).
+* All of them live under `packages/drivers/`. Turso *database-per-tenant routing* — a layer above the driver, not part of it — is a cloud product capability and ships separately.
### AST (Abstract Syntax Tree)
The intermediate representation of a query or schema. The Data Protocol parses a JSON request into an AST before the Driver translates it into SQL/NoSQL queries. This allows for security validation and optimization before execution.
diff --git a/content/docs/plugins/packages.mdx b/content/docs/plugins/packages.mdx
index cfe69561a2..95ca6728b6 100644
--- a/content/docs/plugins/packages.mdx
+++ b/content/docs/plugins/packages.mdx
@@ -5,7 +5,7 @@ description: Complete guide to all ObjectStack packages, services, drivers, plug
# Package Overview
-ObjectStack is organized into **72 package manifests** across multiple categories. This guide provides an overview of the framework packages, services, drivers, plugins, and adapters in the [framework repository](https://github.com/objectstack-ai/objectstack/tree/main/packages).
+ObjectStack is organized into **73 package manifests** across multiple categories. This guide provides an overview of the framework packages, services, drivers, plugins, and adapters in the [framework repository](https://github.com/objectstack-ai/objectstack/tree/main/packages).
### Package categories at a glance
@@ -14,7 +14,7 @@ ObjectStack is organized into **72 package manifests** across multiple categorie
| **Core runtime** | 9 | `spec`, `core`, `runtime`, `types`, `metadata`, `objectql`, `rest`, `formula`, `platform-objects` |
| **Client / DX** | 5 | `client`, `client-react`, `cli`, `create-objectstack`, `vscode-objectstack` |
| **Framework adapters** | 1 | `hono` (other frameworks: build a thin adapter on `HttpDispatcher` — see below) |
-| **Drivers** | 4 | `driver-memory`, `driver-sql`, `driver-sqlite-wasm`, `driver-mongodb` |
+| **Drivers** | 5 | `driver-memory`, `driver-sql`, `driver-sqlite-wasm`, `driver-mongodb`, `driver-turso` |
| **Plugins** | 18 | `plugin-auth`, `plugin-security`, `plugin-audit`, `plugin-approvals`, `plugin-sharing`, `plugin-email`, `plugin-webhooks`, `plugin-reports`, `plugin-hono-server`, `plugin-dev`, `plugin-pinyin-search`, `mcp`, trigger plugins (`trigger-api`, `trigger-record-change`, `trigger-schedule`), and knowledge/embedder plugins (`knowledge-memory`, `knowledge-ragflow`, `embedder-openai`) |
| **Platform services** | 16 | `service-analytics`, `service-automation`, `service-cache`, `service-cluster`, `service-cluster-redis`, `service-datasource`, `service-i18n`, `service-job`, `service-knowledge`, `service-messaging`, `service-package`, `service-queue`, `service-realtime`, `service-settings`, `service-sms`, `service-storage` |
@@ -145,7 +145,7 @@ import { useQuery, useMutation } from '@objectstack/client-react';
- **Purpose**: Fast in-memory data storage with full ObjectQL support
- **When to use**: Development, testing, demos (data is lost on restart)
-- **README**: [View README](https://github.com/objectstack-ai/objectstack/blob/main/packages/plugins/driver-memory/README.md)
+- **README**: [View README](https://github.com/objectstack-ai/objectstack/blob/main/packages/drivers/driver-memory/README.md)
```typescript
import { InMemoryDriver } from '@objectstack/driver-memory';
@@ -158,7 +158,7 @@ import { InMemoryDriver } from '@objectstack/driver-memory';
- **Purpose**: Production-ready SQL database support with migrations
- **Supports**: PostgreSQL, MySQL, SQLite, and all Knex-compatible databases
- **When to use**: Traditional relational database deployments
-- **README**: [View README](https://github.com/objectstack-ai/objectstack/blob/main/packages/plugins/driver-sql/README.md)
+- **README**: [View README](https://github.com/objectstack-ai/objectstack/blob/main/packages/drivers/driver-sql/README.md)
```typescript
import { SqlDriver } from '@objectstack/driver-sql';
@@ -175,7 +175,7 @@ const driver = new SqlDriver({
- **Purpose**: SQLite running entirely in WebAssembly (no native bindings), with optional `fs`-backed persistence
- **Modes**: In-memory (`:memory:`) or a file path persisted via the `persist` option
- **When to use**: Environments without native SQLite, edge/browser runtimes, lightweight local-first storage
-- **README**: [View README](https://github.com/objectstack-ai/objectstack/blob/main/packages/plugins/driver-sqlite-wasm/README.md)
+- **README**: [View README](https://github.com/objectstack-ai/objectstack/blob/main/packages/drivers/driver-sqlite-wasm/README.md)
```typescript
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
@@ -189,7 +189,21 @@ const driver = new SqliteWasmDriver({ filename: ':memory:' });
- **Purpose**: Native MongoDB driver for ObjectQL with document-flavored objects
- **When to use**: Existing MongoDB infrastructure, document-shaped data — **single-tenant deployments only**
- **Not supported**: row-level tenant isolation. The driver refuses to boot when the tenancy posture is not `single` — see [Drivers → Multi-tenancy](/docs/data-modeling/drivers#multi-tenancy-not-supported)
-- **README**: [View README](https://github.com/objectstack-ai/objectstack/blob/main/packages/plugins/driver-mongodb/README.md)
+- **README**: [View README](https://github.com/objectstack-ai/objectstack/blob/main/packages/drivers/driver-mongodb/README.md)
+
+### @objectstack/driver-turso
+
+**Turso / libSQL Driver** — Edge-first SQLite with embedded replicas and a remote transport.
+
+- **Purpose**: Turso/libSQL storage; extends `SqlDriver`, so all CRUD, schema, filtering and aggregation logic is inherited rather than duplicated
+- **Modes**: `local` (file / `:memory:` via better-sqlite3), `replica` (local file synced from a remote database), `remote` (pure `@libsql/client` over HTTP/WebSocket — no native bindings, so it runs on serverless/edge)
+- **When to use**: Globally distributed reads, edge deployments, or a serverless runtime where native SQLite is unavailable
+- **README**: [View README](https://github.com/objectstack-ai/objectstack/blob/main/packages/drivers/driver-turso/README.md)
+
+```typescript
+import { TursoDriver } from '@objectstack/driver-turso';
+const driver = new TursoDriver({ url: 'libsql://my-db.turso.io', authToken: process.env.TURSO_AUTH_TOKEN });
+```
---
diff --git a/content/docs/protocol/objectql/index.mdx b/content/docs/protocol/objectql/index.mdx
index cbe8a5a4da..4e13af181f 100644
--- a/content/docs/protocol/objectql/index.mdx
+++ b/content/docs/protocol/objectql/index.mdx
@@ -380,7 +380,7 @@ See [Security Protocol](/docs/protocol/objectql/security) for details.
- **Zod Schemas:** `packages/spec/src/data/*.zod.ts`
- **TypeScript Types:** `packages/spec/src/data/*.ts`
-- **Driver Implementations:** `packages/plugins/driver-*`
+- **Driver Implementations:** `packages/drivers/driver-*`
## Next Steps
diff --git a/content/docs/protocol/objectql/query-syntax.mdx b/content/docs/protocol/objectql/query-syntax.mdx
index a113fe4385..a55aae4d8c 100644
--- a/content/docs/protocol/objectql/query-syntax.mdx
+++ b/content/docs/protocol/objectql/query-syntax.mdx
@@ -82,7 +82,7 @@ interface QueryAST {
`QuerySchema` validates the whole structure above, but `IDataEngine.find()` plus the
shipped drivers run a subset. `SqlDriver.find()` builds only `where` / `orderBy` /
-`limit` / `offset` / `fields` (`packages/plugins/driver-sql/src/sql-driver.ts`), and
+`limit` / `offset` / `fields` (`packages/drivers/driver-sql/src/sql-driver.ts`), and
`expand` is resolved afterwards by the engine as a batched `$in` read
(`packages/objectql/src/engine.ts`). These members validate but are **not executed**
on the `find()` path:
diff --git a/docs/adr/0015-external-datasource-federation.md b/docs/adr/0015-external-datasource-federation.md
index f9f0e879d1..c41c5d8c17 100644
--- a/docs/adr/0015-external-datasource-federation.md
+++ b/docs/adr/0015-external-datasource-federation.md
@@ -386,7 +386,7 @@ if (datasource.schemaMode !== 'managed') {
}
```
-**Concretely** — `packages/plugins/driver-sql/src/sql-driver.ts:1064`
+**Concretely** — `packages/drivers/driver-sql/src/sql-driver.ts:1064`
and `:1084` (current `createTable` / `alterTable` call sites) gain a
guard at the top. The `applyMigrations` implementation (forthcoming
in `service-migration` per ADR-0008) also calls this guard.
@@ -862,7 +862,7 @@ The ADR is considered "delivered" when:
- `packages/spec/src/data/external-lookup.zod.ts`
- `packages/spec/src/automation/sync.zod.ts`
- `packages/spec/src/contracts/schema-diff-service.ts`
-- `packages/plugins/driver-sql/src/sql-driver.ts` (introspectSchema, createTable, alterTable)
+- `packages/drivers/driver-sql/src/sql-driver.ts` (introspectSchema, createTable, alterTable)
- `packages/services/service-ai/src/tools/query-data.tool.ts`
- `packages/services/service-ai/src/schema-retriever.ts`
@@ -953,7 +953,7 @@ external-binding mechanism.
### Tests
-`packages/plugins/driver-sql/src/sql-driver-external-remote-name.test.ts` (read /
+`packages/drivers/driver-sql/src/sql-driver-external-remote-name.test.ts` (read /
filter / coercion against a differently-named remote table, no DDL leakage) and an
added case in `sql-driver-ddl-gate.test.ts` (`registerExternalObject` is DDL-free).
File-based better-sqlite3 tests require Node ≥ 25 (ABI 141) in this repo.
diff --git a/docs/adr/0025-plugin-package-distribution.md b/docs/adr/0025-plugin-package-distribution.md
index f154e047aa..eafb7320ca 100644
--- a/docs/adr/0025-plugin-package-distribution.md
+++ b/docs/adr/0025-plugin-package-distribution.md
@@ -502,4 +502,4 @@ the developers and operators who compose Apps and provision runtimes.
- `packages/runtime/src/cloud/marketplace-proxy-plugin.ts` — marketplace browse proxy
- `packages/runtime/src/package-state-store.ts` — per-environment disable-state persistence
- `packages/cli/src/commands/package/publish.ts` — existing package publish pipeline
-- `packages/plugins/driver-memory/objectstack.config.ts` — example plugin manifest (`ObjectStackManifest`)
+- `packages/drivers/driver-memory/objectstack.config.ts` — example plugin manifest (`ObjectStackManifest`)
diff --git a/docs/adr/0062-external-datasource-runtime.md b/docs/adr/0062-external-datasource-runtime.md
index 62b6902bb4..1a55705fe9 100644
--- a/docs/adr/0062-external-datasource-runtime.md
+++ b/docs/adr/0062-external-datasource-runtime.md
@@ -185,4 +185,4 @@ Each phase is its own PR with its own changeset; Phase 1 lands behind the full d
- `packages/runtime/src/standalone-stack.ts`, `packages/runtime/src/app-plugin.ts` (connection + registration sites).
- `packages/objectql/src/engine.ts` (`getDriver`/`registerDriver` routing).
- `packages/services/service-datasource/src/*` (driver factory, admin, external service, `SecretBinder`).
-- `packages/plugins/driver-sql/src/sql-driver.ts` (`registerExternalObject`, physical table/column resolution).
+- `packages/drivers/driver-sql/src/sql-driver.ts` (`registerExternalObject`, physical table/column resolution).
diff --git a/docs/adr/0104-field-runtime-value-shape-contract.md b/docs/adr/0104-field-runtime-value-shape-contract.md
index cf4333ef10..03c0c2339c 100644
--- a/docs/adr/0104-field-runtime-value-shape-contract.md
+++ b/docs/adr/0104-field-runtime-value-shape-contract.md
@@ -35,7 +35,7 @@ hand-duplicated re-derivations** in every consumer:
(lines 37–57, including a copy of `MULTI_CAPABLE_TYPES`) and is the only
place the intended storage shape per type is even written down (header
comment, lines 6–31).
-- `packages/plugins/driver-sql/src/sql-driver.ts` — `JSON_COLUMN_TYPES`
+- `packages/drivers/driver-sql/src/sql-driver.ts` — `JSON_COLUMN_TYPES`
(line 59) and `NUMERIC_SCALAR_TYPES` (line 81) drive DDL and (de)serialization;
the file itself warns these must be kept in sync by hand because drift
already caused a binder crash.
diff --git a/docs/adr/0119-plugin-reachable-transactions-and-honest-atomic-batch.md b/docs/adr/0119-plugin-reachable-transactions-and-honest-atomic-batch.md
index cba07cabd0..6ecd0d5778 100644
--- a/docs/adr/0119-plugin-reachable-transactions-and-honest-atomic-batch.md
+++ b/docs/adr/0119-plugin-reachable-transactions-and-honest-atomic-batch.md
@@ -28,7 +28,10 @@ So this ADR does not pick one of the three. It separates what the four consumers
`ObjectQL.transaction()` (`packages/objectql/src/engine.ts:4934-4973`) opens a driver transaction and runs the callback inside an `AsyncLocalStorage` store; `buildDriverOptions` (`:1257-1275`) lifts that ambient handle onto **every** driver call. The consequence is precisely the hard part the issue budgets for: a hook body, a validation predicate, an FK-resolution read, or any nested `engine.*` call issued during a transactional write automatically binds to that transaction's connection. ADR-0034 exists because *not* doing this deadlocked SQLite's single-connection pool — the failure was found, fixed, and pinned.
-The issue's second premise is also stale in this repo: it cites `driver-turso` primitives at `turso-driver.ts:764-776` and `remote-transport.ts:430-443` as evidence the capability exists but is unsurfaced. There is no Turso driver here — only `docs/design/driver-turso.md` (Status: Proposal). The in-repo drivers need no surfacing work either: `beginTransaction` / `commit` / `rollback` are **required** members of `IDataDriver` (`packages/spec/src/contracts/data-driver.ts:221-235`), implemented by driver-sql (`sql-driver.ts:2214+`), driver-memory (`:595+`), and driver-mongodb (`:545+`).
+The issue's second premise is also stale in this repo: it cites `driver-turso` primitives at `turso-driver.ts:764-776` and `remote-transport.ts:430-443` as evidence the capability exists but is unsurfaced. There is no Turso driver here — only `docs/design/driver-turso.md` (Status: Proposal). The in-repo drivers need no surfacing work either:
+
+> **Editorial note (2026-08-05, #4645):** the sentence above was true when this ADR was written. `@objectstack/driver-turso` has since been migrated back into this repo at `packages/drivers/driver-turso`. The *decision* recorded here is unaffected — Turso extends `SqlDriver` and inherits the same `beginTransaction` / `commit` / `rollback` members the argument turns on, so it needs no surfacing work either.
+ `beginTransaction` / `commit` / `rollback` are **required** members of `IDataDriver` (`packages/spec/src/contracts/data-driver.ts:221-235`), implemented by driver-sql (`sql-driver.ts:2214+`), driver-memory (`:595+`), and driver-mongodb (`:545+`).
### The gap that is real: declared reach
diff --git a/docs/adr/0120-unique-scope-vocabulary-and-null-safe-tenant-uniqueness.md b/docs/adr/0120-unique-scope-vocabulary-and-null-safe-tenant-uniqueness.md
index 317a6940e0..cb14ede529 100644
--- a/docs/adr/0120-unique-scope-vocabulary-and-null-safe-tenant-uniqueness.md
+++ b/docs/adr/0120-unique-scope-vocabulary-and-null-safe-tenant-uniqueness.md
@@ -2,7 +2,7 @@
**Status**: Accepted (2026-08-04, maintainer decision on #4986 / #5030; proposed same day) — implementation not started; the 17.x wave (D7) is the first work package, the protocol-18 items (D2 conversion, bare-`true` rejection) are deliberately deferred to the 18 train
**Deciders**: ObjectStack Protocol Architects (maintainer decision requested on #4986)
-**Amends**: the #3696 decision *"a declared index is materialized verbatim — no tenant column is injected"* as recorded in `packages/spec/src/data/object.zod.ts` (`IndexSchema`), `content/docs/data-modeling/indexing.mdx` §*Two ways to say "unique"*, `content/docs/references/data/object.mdx`, the `syncDeclaredIndexes` doc block in `packages/plugins/driver-sql/src/sql-driver.ts`, and lint R10 `unique/double-declaration` (`packages/lint/src/data-model-rules.ts`). Per Prime Directive #13 this reversal is itself a decision and is recorded here — **the verbatim contract is not abolished; it becomes the `'global'` arm of an explicit vocabulary, and every stored declaration keeps its exact current physical shape.**
+**Amends**: the #3696 decision *"a declared index is materialized verbatim — no tenant column is injected"* as recorded in `packages/spec/src/data/object.zod.ts` (`IndexSchema`), `content/docs/data-modeling/indexing.mdx` §*Two ways to say "unique"*, `content/docs/references/data/object.mdx`, the `syncDeclaredIndexes` doc block in `packages/drivers/driver-sql/src/sql-driver.ts`, and lint R10 `unique/double-declaration` (`packages/lint/src/data-model-rules.ts`). Per Prime Directive #13 this reversal is itself a decision and is recorded here — **the verbatim contract is not abolished; it becomes the `'global'` arm of an explicit vocabulary, and every stored declaration keeps its exact current physical shape.**
**Builds on**: [ADR-0087](./0087-metadata-protocol-upgrade-contract.md) (D2 conversion layer — this ADR adds one entry), [ADR-0078](./0078-no-silently-inert-metadata.md) (no declarable-but-inert keys — `unique: 'global'` on a declared index is today documented as "changes nothing"; this ADR makes it load-bearing), [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove — #5030 is a declared-but-unenforced unique constraint), [ADR-0048](./0048-cross-package-metadata-collision.md) (the `COALESCE(col, '')` canonical index key part this ADR reuses), [ADR-0105](./0105-group-tenancy-posture-and-first-class-org-scope.md) (tenancy posture), [ADR-0113](./0113-required-write-contract-vs-column-constraint.md) (precedent: two look-alike spellings split into explicit, separately-owned contracts)
**Consumers**: `@objectstack/spec` (`IndexSchema`, `UniqueScopeSchema`, conversion registry), `@objectstack/lint` (R10 + one new rule), `driver-sql` (`uniqueIndexesFromFields`, `normalizeDeclaredIndex`, schema-drift both sides, migration planner), `@objectstack/cli` (`os migrate plan` duplicate pre-flight), docs, and every AI metadata author reading the generated JSON Schema
**Surfaced by**: [#4986](https://github.com/objectstack-ai/objectstack/issues/4986) (same intent, two spellings, two semantics — dev stop-work report and PM retraction in the issue thread), [#5030](https://github.com/objectstack-ai/objectstack/issues/5030) (tenant-composite UNIQUE is void on NULL tenant rows — measured, not inferred), [#4698](https://github.com/objectstack-ai/objectstack/issues/4698) (origin instance 2), [#3696](https://github.com/objectstack-ai/objectstack/issues/3696) (the decision being amended), [#4884](https://github.com/objectstack-ai/objectstack/issues/4884) (COALESCE key-part parsing infrastructure this ADR reuses)
diff --git a/docs/plans/external-datasource-federation-impl.md b/docs/plans/external-datasource-federation-impl.md
index 166a2725b5..8e6dbf53ee 100644
--- a/docs/plans/external-datasource-federation-impl.md
+++ b/docs/plans/external-datasource-federation-impl.md
@@ -28,7 +28,7 @@ already exists and is reused:
| Already present (reused) | Location |
|:--|:--|
-| Driver `introspectSchema()` (dialect-aware) | `packages/plugins/driver-sql/src/sql-driver.ts` |
+| Driver `introspectSchema()` (dialect-aware) | `packages/drivers/driver-sql/src/sql-driver.ts` |
| Per-object datasource routing | `packages/objectql/src/engine.ts`, `Object.datasource` |
| `kernel:ready` hook pattern for plugins | `packages/runtime/src/*-plugin.ts` |
| Metadata type registry | `packages/spec/src/kernel/metadata-plugin.zod.ts` (`DEFAULT_METADATA_TYPE_REGISTRY`) |
@@ -86,7 +86,7 @@ behaviour).
`ExternalSchemaModeViolationError`, each with a stable `code`.
- `SchemaDiffEntry` type + pure `renderDiffMessage()` (P2/P3 consume it).
-4. **DDL gate — `packages/plugins/driver-sql/src/sql-driver.ts`**
+4. **DDL gate — `packages/drivers/driver-sql/src/sql-driver.ts`**
- `SqlDriverConfig` gains an optional `schemaMode` (stripped before Knex).
- `assertSchemaMutable()` choke-point throws
`ExternalSchemaModeViolationError` when `schemaMode !== 'managed'`;
diff --git a/examples/app-crm/README.md b/examples/app-crm/README.md
index c43a2432d4..a7f8755ba8 100644
--- a/examples/app-crm/README.md
+++ b/examples/app-crm/README.md
@@ -43,7 +43,7 @@ Open after `pnpm dev` boots.
- Not a place to add new feature demos. Add them to
[hotcrm](https://github.com/objectstack-ai/hotcrm) instead.
- Not a driver-acceptance harness. Driver E2E lives next to each driver
- package (`packages/plugins/driver-*/src/*.test.ts`) and in
+ package (`packages/drivers/driver-*/src/*.test.ts`) and in
[hotcrm](https://github.com/objectstack-ai/hotcrm).
## License
diff --git a/examples/app-showcase/test/external-datasource.test.ts b/examples/app-showcase/test/external-datasource.test.ts
index a8f7cdf9fd..0220200511 100644
--- a/examples/app-showcase/test/external-datasource.test.ts
+++ b/examples/app-showcase/test/external-datasource.test.ts
@@ -5,7 +5,7 @@
* Asserts the datasource + federated objects are declared correctly — the
* remote-table remap (`object.name !== external.remoteName`) is the whole point.
* The live read path is covered by the driver-level integration test
- * (packages/plugins/driver-sql/src/sql-driver-external-remote-name.test.ts).
+ * (packages/drivers/driver-sql/src/sql-driver-external-remote-name.test.ts).
*/
import { describe, it, expect } from 'vitest';
diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts
index a1a513af90..f2742e2e7e 100644
--- a/packages/cli/src/commands/serve.ts
+++ b/packages/cli/src/commands/serve.ts
@@ -1091,8 +1091,8 @@ export default class Serve extends Command {
}
} catch (e: any) {
// "declared ≠ enforced" guard (#3276-class): a driver that is
- // RECOGNIZED but the open-core CLI cannot construct — currently
- // `turso`/libSQL, a cloud/EE driver — must fail LOUDLY, never silently
+ // RECOGNIZED but the CLI's resolver does not construct — currently
+ // `turso`/libSQL, in-repo since #4645 but unwired — must fail LOUDLY, never silently
// fall through to the SQLite default and ignore the selected engine.
// Re-throw so run()'s fatal handler restores output, prints the
// actionable message, and exits 1 (in dev AND prod). All OTHER driver
diff --git a/packages/cli/src/utils/storage-driver.test.ts b/packages/cli/src/utils/storage-driver.test.ts
index b1fd51ec81..546d9112e2 100644
--- a/packages/cli/src/utils/storage-driver.test.ts
+++ b/packages/cli/src/utils/storage-driver.test.ts
@@ -140,11 +140,17 @@ describe('resolveStorageDefinition (#3826 — a definition, not a driver)', () =
});
describe('resolveStorageDefinition: turso / libSQL is recognized but fails loud', () => {
- // turso is a cloud/EE driver (@objectstack/driver-turso) the open-core CLI
- // cannot build. Selecting it must THROW a typed error — NOT fall through to
- // the SQLite default. Remove the `turso` branch and these go red: in dev it
- // resolves to the sqlite dev-default, in prod it returns null — both silently
- // ignoring the requested turso engine (the reported bug).
+ // `turso` (@objectstack/driver-turso) is recognized as a kind but is not one
+ // this resolver constructs from a URL. Selecting it must THROW a typed error —
+ // NOT fall through to the SQLite default. Remove the `turso` branch and these
+ // go red: in dev it resolves to the sqlite dev-default, in prod it returns
+ // null — both silently ignoring the requested turso engine (the reported bug).
+ //
+ // Since #4645 the package lives in THIS repo (`packages/drivers/driver-turso`),
+ // so the refusal is no longer "it ships elsewhere" — it is "the CLI does not
+ // build it from a URL; register it explicitly". The behaviour is unchanged;
+ // only the reason given to the operator is. Whether URL inference should
+ // construct it is #5602.
it('throws UnsupportedDriverError for `turso` in DEV and PROD', () => {
expect(() => resolveStorageDefinition('turso', { isDev: true })).toThrow(UnsupportedDriverError);
expect(() => resolveStorageDefinition('turso', { isDev: false })).toThrow(UnsupportedDriverError);
@@ -154,15 +160,20 @@ describe('resolveStorageDefinition: turso / libSQL is recognized but fails loud'
expect(() => resolveStorageDefinition('libsql', { isDev: true })).toThrow(UnsupportedDriverError);
});
- // The message must be actionable: name the cloud/EE package and the open-core
- // alternatives, so an operator knows exactly how to proceed.
- it('carries an actionable message (cloud/EE package + open-core alternatives)', () => {
+ // The message must be actionable: name the package, name the way OUT (register
+ // it explicitly), and name the alternatives this resolver does build — so an
+ // operator knows exactly how to proceed. It must NOT claim the package ships
+ // somewhere else; that stopped being true at #4645 and an error message that
+ // sends an operator to the wrong repo is worse than a terse one.
+ it('carries an actionable message (package + explicit-registration route + alternatives)', () => {
expect(() => resolveStorageDefinition('turso', { isDev: false })).toThrow(/@objectstack\/driver-turso/);
let err: unknown;
try { resolveStorageDefinition('turso', { isDev: false }); } catch (e) { err = e; }
expect(err).toBeInstanceOf(UnsupportedDriverError);
expect((err as UnsupportedDriverError).driverType).toBe('turso');
- expect((err as Error).message).toMatch(/cloud|enterprise/i);
+ expect((err as Error).message).toMatch(/register it explicitly/i);
+ expect((err as Error).message).toMatch(/sqlite \| postgres \| mysql \| mongodb \| memory/);
+ expect((err as Error).message).not.toMatch(/cloud|enterprise/i);
});
// A `libsql://` / Turso URL routes to the same loud failure — it is NOT left
diff --git a/packages/cli/src/utils/storage-driver.ts b/packages/cli/src/utils/storage-driver.ts
index aecd27720a..71969f8223 100644
--- a/packages/cli/src/utils/storage-driver.ts
+++ b/packages/cli/src/utils/storage-driver.ts
@@ -37,11 +37,12 @@
export type SqliteFamilyEngine = 'better-sqlite3' | 'sqlite-wasm' | 'memory';
/**
- * Thrown by {@link resolveStorageDefinition} when a driver kind is *recognized* but the
- * open-core CLI cannot construct it — currently `turso`/libSQL, which ships in the
- * ObjectStack cloud / enterprise distribution (`@objectstack/driver-turso`, an
- * extension of SqlDriver over `@libsql/client`), composed by the cloud runtime's
- * own kernel factory, not by open-core's auto driver-registration.
+ * Thrown by {@link resolveStorageDefinition} when a driver kind is *recognized* but this
+ * resolver does not construct it — currently `turso`/libSQL (`@objectstack/driver-turso`,
+ * an extension of SqlDriver over `@libsql/client`). Since #4645 that package lives in
+ * this repo (`packages/drivers/driver-turso`), but the CLI still does not build it from
+ * a URL: it is registered explicitly in a stack config, or composed by a host's own
+ * kernel factory. Whether URL inference should construct it is #5602.
*
* The whole point of surfacing this as a *typed* error is so `serve.ts` can fail
* LOUDLY (fatal) instead of letting the selection fall through to the SQLite
@@ -70,8 +71,8 @@ export function inferDriverTypeFromUrl(url: string | undefined): string {
if (/^postgres(ql)?:\/\//i.test(u)) return 'postgres';
if (/^mysql2?:\/\//i.test(u)) return 'mysql';
// libSQL / Turso URLs are DELIBERATELY still classified as `turso` (not left
- // unrecognized). Open-core can't construct that driver, but classifying it
- // lets resolveStorageDefinition fail LOUDLY with a clear cloud/EE message — if we
+ // unrecognized). This resolver does not construct that driver, but classifying it
+ // lets resolveStorageDefinition fail LOUDLY with an actionable message — if we
// returned '' here instead, a `libsql://` URL would fall through to the SQLite
// default and silently ignore the remote connection (the very bug we're fixing).
if (/^libsql:\/\//i.test(u)) return 'turso';
@@ -145,9 +146,12 @@ export interface StorageDefinitionResolution {
* unknown/absent driver registers no datasource, so the missing driver surfaces
* loudly downstream — the pre-#3826 behavior).
*
- * Throws {@link UnsupportedDriverError} for `turso`/libSQL — a cloud/EE driver
- * open-core cannot build. serve.ts surfaces that as a fatal, actionable boot
- * error so the selection never silently degrades to SQLite.
+ * Throws {@link UnsupportedDriverError} for `turso`/libSQL — recognized as a kind
+ * but not one this resolver constructs. (#4645 moved `@objectstack/driver-turso`
+ * into this repo at `packages/drivers/driver-turso`; wiring it into URL inference
+ * is a separate decision — #5602 — so the loud refusal stands unchanged.)
+ * serve.ts surfaces that as a fatal, actionable boot error so the selection never
+ * silently degrades to SQLite.
*/
export function resolveStorageDefinition(
driverType: string,
@@ -232,12 +236,12 @@ export function resolveStorageDefinition(
if (driverType === 'turso' || driverType === 'libsql') {
throw new UnsupportedDriverError(
'turso',
- 'The `turso`/libSQL driver ships with the ObjectStack cloud / enterprise '
- + 'distribution (@objectstack/driver-turso), not the open-core CLI. To use '
- + "it, register it explicitly in your stack config (a datasource with driver: "
- + "'turso' and config { url, authToken }, with @objectstack/driver-turso "
- + 'installed), or run under the cloud distribution. Otherwise select an '
- + 'open-core driver via OS_DATABASE_DRIVER / OS_DATABASE_URL: '
+ 'The `turso`/libSQL driver (@objectstack/driver-turso) is not one the CLI '
+ + 'constructs from a URL — it is not bundled into the CLI\'s driver '
+ + 'resolver. To use it, register it explicitly in your stack config (a '
+ + "datasource with driver: 'turso' and config { url, authToken }, with "
+ + '@objectstack/driver-turso installed). Otherwise select a CLI-resolvable '
+ + 'driver via OS_DATABASE_DRIVER / OS_DATABASE_URL: '
+ 'sqlite | postgres | mysql | mongodb | memory.',
);
}
diff --git a/packages/plugins/driver-memory/CHANGELOG.md b/packages/drivers/driver-memory/CHANGELOG.md
similarity index 100%
rename from packages/plugins/driver-memory/CHANGELOG.md
rename to packages/drivers/driver-memory/CHANGELOG.md
diff --git a/packages/plugins/driver-memory/LICENSE b/packages/drivers/driver-memory/LICENSE
similarity index 100%
rename from packages/plugins/driver-memory/LICENSE
rename to packages/drivers/driver-memory/LICENSE
diff --git a/packages/plugins/driver-memory/README.md b/packages/drivers/driver-memory/README.md
similarity index 100%
rename from packages/plugins/driver-memory/README.md
rename to packages/drivers/driver-memory/README.md
diff --git a/packages/plugins/driver-memory/objectstack.config.ts b/packages/drivers/driver-memory/objectstack.config.ts
similarity index 100%
rename from packages/plugins/driver-memory/objectstack.config.ts
rename to packages/drivers/driver-memory/objectstack.config.ts
diff --git a/packages/plugins/driver-memory/package.json b/packages/drivers/driver-memory/package.json
similarity index 96%
rename from packages/plugins/driver-memory/package.json
rename to packages/drivers/driver-memory/package.json
index 288bd3b0df..d4d425aab1 100644
--- a/packages/plugins/driver-memory/package.json
+++ b/packages/drivers/driver-memory/package.json
@@ -39,7 +39,7 @@
"repository": {
"type": "git",
"url": "https://github.com/objectstack-ai/objectstack.git",
- "directory": "packages/plugins/driver-memory"
+ "directory": "packages/drivers/driver-memory"
},
"homepage": "https://objectstack.ai/docs",
"bugs": "https://github.com/objectstack-ai/objectstack/issues",
diff --git a/packages/plugins/driver-memory/src/filter-refusal.ts b/packages/drivers/driver-memory/src/filter-refusal.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/filter-refusal.ts
rename to packages/drivers/driver-memory/src/filter-refusal.ts
diff --git a/packages/plugins/driver-memory/src/in-memory-strategy.ts b/packages/drivers/driver-memory/src/in-memory-strategy.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/in-memory-strategy.ts
rename to packages/drivers/driver-memory/src/in-memory-strategy.ts
diff --git a/packages/plugins/driver-memory/src/index.ts b/packages/drivers/driver-memory/src/index.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/index.ts
rename to packages/drivers/driver-memory/src/index.ts
diff --git a/packages/plugins/driver-memory/src/memory-analytics-filter-refusal.test.ts b/packages/drivers/driver-memory/src/memory-analytics-filter-refusal.test.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/memory-analytics-filter-refusal.test.ts
rename to packages/drivers/driver-memory/src/memory-analytics-filter-refusal.test.ts
diff --git a/packages/plugins/driver-memory/src/memory-analytics.test.ts b/packages/drivers/driver-memory/src/memory-analytics.test.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/memory-analytics.test.ts
rename to packages/drivers/driver-memory/src/memory-analytics.test.ts
diff --git a/packages/plugins/driver-memory/src/memory-analytics.ts b/packages/drivers/driver-memory/src/memory-analytics.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/memory-analytics.ts
rename to packages/drivers/driver-memory/src/memory-analytics.ts
diff --git a/packages/plugins/driver-memory/src/memory-datetime-storage.test.ts b/packages/drivers/driver-memory/src/memory-datetime-storage.test.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/memory-datetime-storage.test.ts
rename to packages/drivers/driver-memory/src/memory-datetime-storage.test.ts
diff --git a/packages/plugins/driver-memory/src/memory-driver-calendar-day-upper-bound.test.ts b/packages/drivers/driver-memory/src/memory-driver-calendar-day-upper-bound.test.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/memory-driver-calendar-day-upper-bound.test.ts
rename to packages/drivers/driver-memory/src/memory-driver-calendar-day-upper-bound.test.ts
diff --git a/packages/plugins/driver-memory/src/memory-driver-document-not.test.ts b/packages/drivers/driver-memory/src/memory-driver-document-not.test.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/memory-driver-document-not.test.ts
rename to packages/drivers/driver-memory/src/memory-driver-document-not.test.ts
diff --git a/packages/plugins/driver-memory/src/memory-driver-filter-logic-conformance.test.ts b/packages/drivers/driver-memory/src/memory-driver-filter-logic-conformance.test.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/memory-driver-filter-logic-conformance.test.ts
rename to packages/drivers/driver-memory/src/memory-driver-filter-logic-conformance.test.ts
diff --git a/packages/plugins/driver-memory/src/memory-driver.test.ts b/packages/drivers/driver-memory/src/memory-driver.test.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/memory-driver.test.ts
rename to packages/drivers/driver-memory/src/memory-driver.test.ts
diff --git a/packages/plugins/driver-memory/src/memory-driver.ts b/packages/drivers/driver-memory/src/memory-driver.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/memory-driver.ts
rename to packages/drivers/driver-memory/src/memory-driver.ts
diff --git a/packages/plugins/driver-memory/src/memory-empty-field-constraint.test.ts b/packages/drivers/driver-memory/src/memory-empty-field-constraint.test.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/memory-empty-field-constraint.test.ts
rename to packages/drivers/driver-memory/src/memory-empty-field-constraint.test.ts
diff --git a/packages/plugins/driver-memory/src/memory-filter-ast-vocabulary.test.ts b/packages/drivers/driver-memory/src/memory-filter-ast-vocabulary.test.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/memory-filter-ast-vocabulary.test.ts
rename to packages/drivers/driver-memory/src/memory-filter-ast-vocabulary.test.ts
diff --git a/packages/plugins/driver-memory/src/memory-filter-refusal-envelope.test.ts b/packages/drivers/driver-memory/src/memory-filter-refusal-envelope.test.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/memory-filter-refusal-envelope.test.ts
rename to packages/drivers/driver-memory/src/memory-filter-refusal-envelope.test.ts
diff --git a/packages/plugins/driver-memory/src/memory-filter-vocabulary-refusal.test.ts b/packages/drivers/driver-memory/src/memory-filter-vocabulary-refusal.test.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/memory-filter-vocabulary-refusal.test.ts
rename to packages/drivers/driver-memory/src/memory-filter-vocabulary-refusal.test.ts
diff --git a/packages/plugins/driver-memory/src/memory-matcher-not-null-safe.test.ts b/packages/drivers/driver-memory/src/memory-matcher-not-null-safe.test.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/memory-matcher-not-null-safe.test.ts
rename to packages/drivers/driver-memory/src/memory-matcher-not-null-safe.test.ts
diff --git a/packages/plugins/driver-memory/src/memory-matcher-or-semantics.test.ts b/packages/drivers/driver-memory/src/memory-matcher-or-semantics.test.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/memory-matcher-or-semantics.test.ts
rename to packages/drivers/driver-memory/src/memory-matcher-or-semantics.test.ts
diff --git a/packages/plugins/driver-memory/src/memory-matcher.ts b/packages/drivers/driver-memory/src/memory-matcher.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/memory-matcher.ts
rename to packages/drivers/driver-memory/src/memory-matcher.ts
diff --git a/packages/plugins/driver-memory/src/memory-null-comparand-refusal.test.ts b/packages/drivers/driver-memory/src/memory-null-comparand-refusal.test.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/memory-null-comparand-refusal.test.ts
rename to packages/drivers/driver-memory/src/memory-null-comparand-refusal.test.ts
diff --git a/packages/plugins/driver-memory/src/memory-pagination-conformance.test.ts b/packages/drivers/driver-memory/src/memory-pagination-conformance.test.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/memory-pagination-conformance.test.ts
rename to packages/drivers/driver-memory/src/memory-pagination-conformance.test.ts
diff --git a/packages/plugins/driver-memory/src/memory-temporal-conformance.test.ts b/packages/drivers/driver-memory/src/memory-temporal-conformance.test.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/memory-temporal-conformance.test.ts
rename to packages/drivers/driver-memory/src/memory-temporal-conformance.test.ts
diff --git a/packages/plugins/driver-memory/src/memory-temporal.ts b/packages/drivers/driver-memory/src/memory-temporal.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/memory-temporal.ts
rename to packages/drivers/driver-memory/src/memory-temporal.ts
diff --git a/packages/plugins/driver-memory/src/persistence/file-adapter.ts b/packages/drivers/driver-memory/src/persistence/file-adapter.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/persistence/file-adapter.ts
rename to packages/drivers/driver-memory/src/persistence/file-adapter.ts
diff --git a/packages/plugins/driver-memory/src/persistence/index.ts b/packages/drivers/driver-memory/src/persistence/index.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/persistence/index.ts
rename to packages/drivers/driver-memory/src/persistence/index.ts
diff --git a/packages/plugins/driver-memory/src/persistence/local-storage-adapter.ts b/packages/drivers/driver-memory/src/persistence/local-storage-adapter.ts
similarity index 100%
rename from packages/plugins/driver-memory/src/persistence/local-storage-adapter.ts
rename to packages/drivers/driver-memory/src/persistence/local-storage-adapter.ts
diff --git a/packages/plugins/driver-memory/src/persistence/persistence.test.ts b/packages/drivers/driver-memory/src/persistence/persistence.test.ts
similarity index 99%
rename from packages/plugins/driver-memory/src/persistence/persistence.test.ts
rename to packages/drivers/driver-memory/src/persistence/persistence.test.ts
index 280b22bc52..39ccc36123 100644
--- a/packages/plugins/driver-memory/src/persistence/persistence.test.ts
+++ b/packages/drivers/driver-memory/src/persistence/persistence.test.ts
@@ -12,7 +12,7 @@ const TEST_FILE_PATH = path.join(TEST_DATA_DIR, 'test-db.json');
* The shorthand forms (`persistence: 'file'` / `'auto'`) deliberately take the
* adapter's DEFAULT path, which is `.objectstack/data/memory-driver.json`
* *relative to the CWD* — so without this the suite wrote that file into
- * `packages/plugins/driver-memory/` and the next run loaded it back. A unit
+ * `packages/drivers/driver-memory/` and the next run loaded it back. A unit
* test must not have write side effects on the package directory (#4065).
*/
async function inScratchCwd(fn: () => Promise): Promise {
diff --git a/packages/plugins/driver-memory/tsconfig.json b/packages/drivers/driver-memory/tsconfig.json
similarity index 100%
rename from packages/plugins/driver-memory/tsconfig.json
rename to packages/drivers/driver-memory/tsconfig.json
diff --git a/packages/plugins/driver-memory/vitest.config.ts b/packages/drivers/driver-memory/vitest.config.ts
similarity index 100%
rename from packages/plugins/driver-memory/vitest.config.ts
rename to packages/drivers/driver-memory/vitest.config.ts
diff --git a/packages/plugins/driver-mongodb/CHANGELOG.md b/packages/drivers/driver-mongodb/CHANGELOG.md
similarity index 100%
rename from packages/plugins/driver-mongodb/CHANGELOG.md
rename to packages/drivers/driver-mongodb/CHANGELOG.md
diff --git a/packages/plugins/driver-mongodb/LICENSE b/packages/drivers/driver-mongodb/LICENSE
similarity index 100%
rename from packages/plugins/driver-mongodb/LICENSE
rename to packages/drivers/driver-mongodb/LICENSE
diff --git a/packages/plugins/driver-mongodb/README.md b/packages/drivers/driver-mongodb/README.md
similarity index 100%
rename from packages/plugins/driver-mongodb/README.md
rename to packages/drivers/driver-mongodb/README.md
diff --git a/packages/plugins/driver-mongodb/package.json b/packages/drivers/driver-mongodb/package.json
similarity index 96%
rename from packages/plugins/driver-mongodb/package.json
rename to packages/drivers/driver-mongodb/package.json
index 8bb43d298c..be4004fe94 100644
--- a/packages/plugins/driver-mongodb/package.json
+++ b/packages/drivers/driver-mongodb/package.json
@@ -42,7 +42,7 @@
"repository": {
"type": "git",
"url": "https://github.com/objectstack-ai/objectstack.git",
- "directory": "packages/plugins/driver-mongodb"
+ "directory": "packages/drivers/driver-mongodb"
},
"homepage": "https://objectstack.ai/docs",
"bugs": "https://github.com/objectstack-ai/objectstack/issues",
diff --git a/packages/plugins/driver-mongodb/src/index.ts b/packages/drivers/driver-mongodb/src/index.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/index.ts
rename to packages/drivers/driver-mongodb/src/index.ts
diff --git a/packages/plugins/driver-mongodb/src/mongodb-aggregation.test.ts b/packages/drivers/driver-mongodb/src/mongodb-aggregation.test.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/mongodb-aggregation.test.ts
rename to packages/drivers/driver-mongodb/src/mongodb-aggregation.test.ts
diff --git a/packages/plugins/driver-mongodb/src/mongodb-aggregation.ts b/packages/drivers/driver-mongodb/src/mongodb-aggregation.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/mongodb-aggregation.ts
rename to packages/drivers/driver-mongodb/src/mongodb-aggregation.ts
diff --git a/packages/plugins/driver-mongodb/src/mongodb-datetime-storage.test.ts b/packages/drivers/driver-mongodb/src/mongodb-datetime-storage.test.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/mongodb-datetime-storage.test.ts
rename to packages/drivers/driver-mongodb/src/mongodb-datetime-storage.test.ts
diff --git a/packages/plugins/driver-mongodb/src/mongodb-driver.test.ts b/packages/drivers/driver-mongodb/src/mongodb-driver.test.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/mongodb-driver.test.ts
rename to packages/drivers/driver-mongodb/src/mongodb-driver.test.ts
diff --git a/packages/plugins/driver-mongodb/src/mongodb-driver.ts b/packages/drivers/driver-mongodb/src/mongodb-driver.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/mongodb-driver.ts
rename to packages/drivers/driver-mongodb/src/mongodb-driver.ts
diff --git a/packages/plugins/driver-mongodb/src/mongodb-filter-boolean-identity.test.ts b/packages/drivers/driver-mongodb/src/mongodb-filter-boolean-identity.test.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/mongodb-filter-boolean-identity.test.ts
rename to packages/drivers/driver-mongodb/src/mongodb-filter-boolean-identity.test.ts
diff --git a/packages/plugins/driver-mongodb/src/mongodb-filter-logic-conformance.test.ts b/packages/drivers/driver-mongodb/src/mongodb-filter-logic-conformance.test.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/mongodb-filter-logic-conformance.test.ts
rename to packages/drivers/driver-mongodb/src/mongodb-filter-logic-conformance.test.ts
diff --git a/packages/plugins/driver-mongodb/src/mongodb-filter-logic-translation.test.ts b/packages/drivers/driver-mongodb/src/mongodb-filter-logic-translation.test.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/mongodb-filter-logic-translation.test.ts
rename to packages/drivers/driver-mongodb/src/mongodb-filter-logic-translation.test.ts
diff --git a/packages/plugins/driver-mongodb/src/mongodb-filter.test.ts b/packages/drivers/driver-mongodb/src/mongodb-filter.test.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/mongodb-filter.test.ts
rename to packages/drivers/driver-mongodb/src/mongodb-filter.test.ts
diff --git a/packages/plugins/driver-mongodb/src/mongodb-filter.ts b/packages/drivers/driver-mongodb/src/mongodb-filter.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/mongodb-filter.ts
rename to packages/drivers/driver-mongodb/src/mongodb-filter.ts
diff --git a/packages/plugins/driver-mongodb/src/mongodb-findone-cases.ts b/packages/drivers/driver-mongodb/src/mongodb-findone-cases.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/mongodb-findone-cases.ts
rename to packages/drivers/driver-mongodb/src/mongodb-findone-cases.ts
diff --git a/packages/plugins/driver-mongodb/src/mongodb-findone-options.test.ts b/packages/drivers/driver-mongodb/src/mongodb-findone-options.test.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/mongodb-findone-options.test.ts
rename to packages/drivers/driver-mongodb/src/mongodb-findone-options.test.ts
diff --git a/packages/plugins/driver-mongodb/src/mongodb-findone-query.test.ts b/packages/drivers/driver-mongodb/src/mongodb-findone-query.test.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/mongodb-findone-query.test.ts
rename to packages/drivers/driver-mongodb/src/mongodb-findone-query.test.ts
diff --git a/packages/plugins/driver-mongodb/src/mongodb-memory-server-gate.test.ts b/packages/drivers/driver-mongodb/src/mongodb-memory-server-gate.test.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/mongodb-memory-server-gate.test.ts
rename to packages/drivers/driver-mongodb/src/mongodb-memory-server-gate.test.ts
diff --git a/packages/plugins/driver-mongodb/src/mongodb-null-comparand-refusal.test.ts b/packages/drivers/driver-mongodb/src/mongodb-null-comparand-refusal.test.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/mongodb-null-comparand-refusal.test.ts
rename to packages/drivers/driver-mongodb/src/mongodb-null-comparand-refusal.test.ts
diff --git a/packages/plugins/driver-mongodb/src/mongodb-pagination-conformance.test.ts b/packages/drivers/driver-mongodb/src/mongodb-pagination-conformance.test.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/mongodb-pagination-conformance.test.ts
rename to packages/drivers/driver-mongodb/src/mongodb-pagination-conformance.test.ts
diff --git a/packages/plugins/driver-mongodb/src/mongodb-schema.ts b/packages/drivers/driver-mongodb/src/mongodb-schema.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/mongodb-schema.ts
rename to packages/drivers/driver-mongodb/src/mongodb-schema.ts
diff --git a/packages/plugins/driver-mongodb/src/mongodb-temporal-conformance.test.ts b/packages/drivers/driver-mongodb/src/mongodb-temporal-conformance.test.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/mongodb-temporal-conformance.test.ts
rename to packages/drivers/driver-mongodb/src/mongodb-temporal-conformance.test.ts
diff --git a/packages/plugins/driver-mongodb/src/mongodb-temporal.ts b/packages/drivers/driver-mongodb/src/mongodb-temporal.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/mongodb-temporal.ts
rename to packages/drivers/driver-mongodb/src/mongodb-temporal.ts
diff --git a/packages/plugins/driver-mongodb/src/mongodb-tenancy-guard.test.ts b/packages/drivers/driver-mongodb/src/mongodb-tenancy-guard.test.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/mongodb-tenancy-guard.test.ts
rename to packages/drivers/driver-mongodb/src/mongodb-tenancy-guard.test.ts
diff --git a/packages/plugins/driver-mongodb/src/mongodb-tenancy-guard.ts b/packages/drivers/driver-mongodb/src/mongodb-tenancy-guard.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/mongodb-tenancy-guard.ts
rename to packages/drivers/driver-mongodb/src/mongodb-tenancy-guard.ts
diff --git a/packages/plugins/driver-mongodb/src/mongodb-time-storage.test.ts b/packages/drivers/driver-mongodb/src/mongodb-time-storage.test.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/mongodb-time-storage.test.ts
rename to packages/drivers/driver-mongodb/src/mongodb-time-storage.test.ts
diff --git a/packages/plugins/driver-mongodb/src/test-mongod.ts b/packages/drivers/driver-mongodb/src/test-mongod.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/src/test-mongod.ts
rename to packages/drivers/driver-mongodb/src/test-mongod.ts
diff --git a/packages/plugins/driver-mongodb/tsconfig.json b/packages/drivers/driver-mongodb/tsconfig.json
similarity index 100%
rename from packages/plugins/driver-mongodb/tsconfig.json
rename to packages/drivers/driver-mongodb/tsconfig.json
diff --git a/packages/plugins/driver-mongodb/vitest.config.ts b/packages/drivers/driver-mongodb/vitest.config.ts
similarity index 100%
rename from packages/plugins/driver-mongodb/vitest.config.ts
rename to packages/drivers/driver-mongodb/vitest.config.ts
diff --git a/packages/plugins/driver-sql/CHANGELOG.md b/packages/drivers/driver-sql/CHANGELOG.md
similarity index 100%
rename from packages/plugins/driver-sql/CHANGELOG.md
rename to packages/drivers/driver-sql/CHANGELOG.md
diff --git a/packages/plugins/driver-sql/LICENSE b/packages/drivers/driver-sql/LICENSE
similarity index 100%
rename from packages/plugins/driver-sql/LICENSE
rename to packages/drivers/driver-sql/LICENSE
diff --git a/packages/plugins/driver-sql/README.md b/packages/drivers/driver-sql/README.md
similarity index 98%
rename from packages/plugins/driver-sql/README.md
rename to packages/drivers/driver-sql/README.md
index 776f166eec..9eb84814e3 100644
--- a/packages/plugins/driver-sql/README.md
+++ b/packages/drivers/driver-sql/README.md
@@ -529,5 +529,5 @@ Apache-2.0. See [LICENSING.md](../../../LICENSING.md).
- [Knex.js Documentation](https://knexjs.org/)
- [PostgreSQL Documentation](https://www.postgresql.org/docs/)
- [MySQL Documentation](https://dev.mysql.com/doc/)
-- [@objectstack/driver-turso](https://github.com/objectstack-ai/cloud) - Edge-first SQLite alternative (cloud distribution)
+- [@objectstack/driver-turso](../driver-turso/) - Edge-first SQLite alternative (extends this driver)
- [@objectstack/driver-memory](../driver-memory/) - In-memory driver for testing
diff --git a/packages/plugins/driver-sql/package.json b/packages/drivers/driver-sql/package.json
similarity index 97%
rename from packages/plugins/driver-sql/package.json
rename to packages/drivers/driver-sql/package.json
index cef800e02a..8adc0514c8 100644
--- a/packages/plugins/driver-sql/package.json
+++ b/packages/drivers/driver-sql/package.json
@@ -63,7 +63,7 @@
"repository": {
"type": "git",
"url": "https://github.com/objectstack-ai/objectstack.git",
- "directory": "packages/plugins/driver-sql"
+ "directory": "packages/drivers/driver-sql"
},
"homepage": "https://objectstack.ai/docs",
"bugs": "https://github.com/objectstack-ai/objectstack/issues",
diff --git a/packages/plugins/driver-sql/src/adr0120-three-posture-conformance.test.ts b/packages/drivers/driver-sql/src/adr0120-three-posture-conformance.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/adr0120-three-posture-conformance.test.ts
rename to packages/drivers/driver-sql/src/adr0120-three-posture-conformance.test.ts
diff --git a/packages/plugins/driver-sql/src/index.ts b/packages/drivers/driver-sql/src/index.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/index.ts
rename to packages/drivers/driver-sql/src/index.ts
diff --git a/packages/plugins/driver-sql/src/legacy-datetime-storage.testkit.ts b/packages/drivers/driver-sql/src/legacy-datetime-storage.testkit.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/legacy-datetime-storage.testkit.ts
rename to packages/drivers/driver-sql/src/legacy-datetime-storage.testkit.ts
diff --git a/packages/plugins/driver-sql/src/live-dialect-matrix.testkit.ts b/packages/drivers/driver-sql/src/live-dialect-matrix.testkit.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/live-dialect-matrix.testkit.ts
rename to packages/drivers/driver-sql/src/live-dialect-matrix.testkit.ts
diff --git a/packages/plugins/driver-sql/src/schema-drift.nullability.test.ts b/packages/drivers/driver-sql/src/schema-drift.nullability.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/schema-drift.nullability.test.ts
rename to packages/drivers/driver-sql/src/schema-drift.nullability.test.ts
diff --git a/packages/plugins/driver-sql/src/schema-drift.ts b/packages/drivers/driver-sql/src/schema-drift.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/schema-drift.ts
rename to packages/drivers/driver-sql/src/schema-drift.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-advanced.test.ts b/packages/drivers/driver-sql/src/sql-driver-advanced.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-advanced.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-advanced.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-aggregate-datetime-window.test.ts b/packages/drivers/driver-sql/src/sql-driver-aggregate-datetime-window.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-aggregate-datetime-window.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-aggregate-datetime-window.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-aggregate-temporal-output.test.ts b/packages/drivers/driver-sql/src/sql-driver-aggregate-temporal-output.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-aggregate-temporal-output.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-aggregate-temporal-output.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-analytics-datetime.test.ts b/packages/drivers/driver-sql/src/sql-driver-analytics-datetime.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-analytics-datetime.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-analytics-datetime.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-array-fields.test.ts b/packages/drivers/driver-sql/src/sql-driver-array-fields.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-array-fields.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-array-fields.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-autonumber-persistence.test.ts b/packages/drivers/driver-sql/src/sql-driver-autonumber-persistence.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-autonumber-persistence.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-autonumber-persistence.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-autonumber-tokens.test.ts b/packages/drivers/driver-sql/src/sql-driver-autonumber-tokens.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-autonumber-tokens.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-autonumber-tokens.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-autonumber-tx.test.ts b/packages/drivers/driver-sql/src/sql-driver-autonumber-tx.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-autonumber-tx.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-autonumber-tx.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-autonumber.test.ts b/packages/drivers/driver-sql/src/sql-driver-autonumber.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-autonumber.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-autonumber.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-boolean-identity.test.ts b/packages/drivers/driver-sql/src/sql-driver-boolean-identity.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-boolean-identity.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-boolean-identity.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-bulk-json.test.ts b/packages/drivers/driver-sql/src/sql-driver-bulk-json.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-bulk-json.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-bulk-json.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-calendar-day-upper-bound.test.ts b/packages/drivers/driver-sql/src/sql-driver-calendar-day-upper-bound.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-calendar-day-upper-bound.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-calendar-day-upper-bound.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-connect-bound.test.ts b/packages/drivers/driver-sql/src/sql-driver-connect-bound.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-connect-bound.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-connect-bound.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-cross-field-reference.test.ts b/packages/drivers/driver-sql/src/sql-driver-cross-field-reference.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-cross-field-reference.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-cross-field-reference.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-date-bucket-storage.test.ts b/packages/drivers/driver-sql/src/sql-driver-date-bucket-storage.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-date-bucket-storage.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-date-bucket-storage.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-date-bucket.test.ts b/packages/drivers/driver-sql/src/sql-driver-date-bucket.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-date-bucket.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-date-bucket.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-date-now-default-live.test.ts b/packages/drivers/driver-sql/src/sql-driver-date-now-default-live.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-date-now-default-live.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-date-now-default-live.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-date-only.test.ts b/packages/drivers/driver-sql/src/sql-driver-date-only.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-date-only.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-date-only.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-datetime-canonical-storage.test.ts b/packages/drivers/driver-sql/src/sql-driver-datetime-canonical-storage.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-datetime-canonical-storage.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-datetime-canonical-storage.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-datetime-filter-text-storage.test.ts b/packages/drivers/driver-sql/src/sql-driver-datetime-filter-text-storage.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-datetime-filter-text-storage.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-datetime-filter-text-storage.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-datetime-filter.test.ts b/packages/drivers/driver-sql/src/sql-driver-datetime-filter.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-datetime-filter.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-datetime-filter.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-datetime-mysql-storage.test.ts b/packages/drivers/driver-sql/src/sql-driver-datetime-mysql-storage.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-datetime-mysql-storage.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-datetime-mysql-storage.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-datetime-postgres-timezone.test.ts b/packages/drivers/driver-sql/src/sql-driver-datetime-postgres-timezone.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-datetime-postgres-timezone.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-datetime-postgres-timezone.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-ddl-gate.test.ts b/packages/drivers/driver-sql/src/sql-driver-ddl-gate.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-ddl-gate.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-ddl-gate.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-deferred-datetime-convergence.test.ts b/packages/drivers/driver-sql/src/sql-driver-deferred-datetime-convergence.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-deferred-datetime-convergence.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-deferred-datetime-convergence.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-deferred-ddl.test.ts b/packages/drivers/driver-sql/src/sql-driver-deferred-ddl.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-deferred-ddl.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-deferred-ddl.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-empty-field-constraint.test.ts b/packages/drivers/driver-sql/src/sql-driver-empty-field-constraint.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-empty-field-constraint.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-empty-field-constraint.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-external-columnmap.test.ts b/packages/drivers/driver-sql/src/sql-driver-external-columnmap.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-external-columnmap.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-external-columnmap.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-external-remote-name.test.ts b/packages/drivers/driver-sql/src/sql-driver-external-remote-name.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-external-remote-name.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-external-remote-name.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-filter-no-silent-drop.test.ts b/packages/drivers/driver-sql/src/sql-driver-filter-no-silent-drop.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-filter-no-silent-drop.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-filter-no-silent-drop.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-filter-refusal-envelope.test.ts b/packages/drivers/driver-sql/src/sql-driver-filter-refusal-envelope.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-filter-refusal-envelope.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-filter-refusal-envelope.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-index-drift.test.ts b/packages/drivers/driver-sql/src/sql-driver-index-drift.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-index-drift.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-index-drift.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-introspection.test.ts b/packages/drivers/driver-sql/src/sql-driver-introspection.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-introspection.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-introspection.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-like-escape.test.ts b/packages/drivers/driver-sql/src/sql-driver-like-escape.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-like-escape.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-like-escape.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-multiwrite-tx.test.ts b/packages/drivers/driver-sql/src/sql-driver-multiwrite-tx.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-multiwrite-tx.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-multiwrite-tx.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-nested-and-filter.test.ts b/packages/drivers/driver-sql/src/sql-driver-nested-and-filter.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-nested-and-filter.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-nested-and-filter.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-not-null-safe.test.ts b/packages/drivers/driver-sql/src/sql-driver-not-null-safe.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-not-null-safe.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-not-null-safe.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-null-operators.test.ts b/packages/drivers/driver-sql/src/sql-driver-null-operators.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-null-operators.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-null-operators.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-numeric-fidelity.test.ts b/packages/drivers/driver-sql/src/sql-driver-numeric-fidelity.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-numeric-fidelity.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-numeric-fidelity.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-or-filter.test.ts b/packages/drivers/driver-sql/src/sql-driver-or-filter.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-or-filter.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-or-filter.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-out-of-contract-filter-input.test.ts b/packages/drivers/driver-sql/src/sql-driver-out-of-contract-filter-input.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-out-of-contract-filter-input.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-out-of-contract-filter-input.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-overlay-index-drift.test.ts b/packages/drivers/driver-sql/src/sql-driver-overlay-index-drift.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-overlay-index-drift.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-overlay-index-drift.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-pagination-conformance.test.ts b/packages/drivers/driver-sql/src/sql-driver-pagination-conformance.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-pagination-conformance.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-pagination-conformance.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-queryast.test.ts b/packages/drivers/driver-sql/src/sql-driver-queryast.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-queryast.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-queryast.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-retention-prune.test.ts b/packages/drivers/driver-sql/src/sql-driver-retention-prune.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-retention-prune.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-retention-prune.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-rotation.test.ts b/packages/drivers/driver-sql/src/sql-driver-rotation.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-rotation.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-rotation.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-runtime-token-default.test.ts b/packages/drivers/driver-sql/src/sql-driver-runtime-token-default.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-runtime-token-default.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-runtime-token-default.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-schema-drift.test.ts b/packages/drivers/driver-sql/src/sql-driver-schema-drift.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-schema-drift.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-schema-drift.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-schema-sync-stats.test.ts b/packages/drivers/driver-sql/src/sql-driver-schema-sync-stats.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-schema-sync-stats.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-schema-sync-stats.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-schema.test.ts b/packages/drivers/driver-sql/src/sql-driver-schema.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-schema.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-schema.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-server-timing.test.ts b/packages/drivers/driver-sql/src/sql-driver-server-timing.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-server-timing.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-server-timing.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-sqlite-journal-mode.test.ts b/packages/drivers/driver-sql/src/sql-driver-sqlite-journal-mode.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-sqlite-journal-mode.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-sqlite-journal-mode.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-sqlite-tx-guard.test.ts b/packages/drivers/driver-sql/src/sql-driver-sqlite-tx-guard.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-sqlite-tx-guard.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-sqlite-tx-guard.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-temporal-conformance.test.ts b/packages/drivers/driver-sql/src/sql-driver-temporal-conformance.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-temporal-conformance.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-temporal-conformance.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-temporal-dialect.test.ts b/packages/drivers/driver-sql/src/sql-driver-temporal-dialect.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-temporal-dialect.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-temporal-dialect.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-tenant-audit-posture.test.ts b/packages/drivers/driver-sql/src/sql-driver-tenant-audit-posture.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-tenant-audit-posture.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-tenant-audit-posture.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts b/packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-time-canonical-storage.test.ts b/packages/drivers/driver-sql/src/sql-driver-time-canonical-storage.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-time-canonical-storage.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-time-canonical-storage.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-time-live-dialects.test.ts b/packages/drivers/driver-sql/src/sql-driver-time-live-dialects.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-time-live-dialects.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-time-live-dialects.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-time-of-day.test.ts b/packages/drivers/driver-sql/src/sql-driver-time-of-day.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-time-of-day.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-time-of-day.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-timestamp-format.test.ts b/packages/drivers/driver-sql/src/sql-driver-timestamp-format.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-timestamp-format.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-timestamp-format.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-unique-tenancy.test.ts b/packages/drivers/driver-sql/src/sql-driver-unique-tenancy.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-unique-tenancy.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-unique-tenancy.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-unknown-column-recovery.test.ts b/packages/drivers/driver-sql/src/sql-driver-unknown-column-recovery.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-unknown-column-recovery.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-unknown-column-recovery.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver-user-datetime-default-format.test.ts b/packages/drivers/driver-sql/src/sql-driver-user-datetime-default-format.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver-user-datetime-default-format.test.ts
rename to packages/drivers/driver-sql/src/sql-driver-user-datetime-default-format.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver.test.ts b/packages/drivers/driver-sql/src/sql-driver.test.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver.test.ts
rename to packages/drivers/driver-sql/src/sql-driver.test.ts
diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts
similarity index 100%
rename from packages/plugins/driver-sql/src/sql-driver.ts
rename to packages/drivers/driver-sql/src/sql-driver.ts
diff --git a/packages/plugins/driver-sql/tsconfig.json b/packages/drivers/driver-sql/tsconfig.json
similarity index 100%
rename from packages/plugins/driver-sql/tsconfig.json
rename to packages/drivers/driver-sql/tsconfig.json
diff --git a/packages/plugins/driver-sql/vitest.config.ts b/packages/drivers/driver-sql/vitest.config.ts
similarity index 100%
rename from packages/plugins/driver-sql/vitest.config.ts
rename to packages/drivers/driver-sql/vitest.config.ts
diff --git a/packages/plugins/driver-sqlite-wasm/CHANGELOG.md b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/CHANGELOG.md
rename to packages/drivers/driver-sqlite-wasm/CHANGELOG.md
diff --git a/packages/plugins/driver-sqlite-wasm/LICENSE b/packages/drivers/driver-sqlite-wasm/LICENSE
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/LICENSE
rename to packages/drivers/driver-sqlite-wasm/LICENSE
diff --git a/packages/plugins/driver-sqlite-wasm/README.md b/packages/drivers/driver-sqlite-wasm/README.md
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/README.md
rename to packages/drivers/driver-sqlite-wasm/README.md
diff --git a/packages/plugins/driver-sqlite-wasm/STACKBLITZ.md b/packages/drivers/driver-sqlite-wasm/STACKBLITZ.md
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/STACKBLITZ.md
rename to packages/drivers/driver-sqlite-wasm/STACKBLITZ.md
diff --git a/packages/plugins/driver-sqlite-wasm/package.json b/packages/drivers/driver-sqlite-wasm/package.json
similarity index 96%
rename from packages/plugins/driver-sqlite-wasm/package.json
rename to packages/drivers/driver-sqlite-wasm/package.json
index bdf70e8f19..8bb868f395 100644
--- a/packages/plugins/driver-sqlite-wasm/package.json
+++ b/packages/drivers/driver-sqlite-wasm/package.json
@@ -46,7 +46,7 @@
"repository": {
"type": "git",
"url": "https://github.com/objectstack-ai/objectstack.git",
- "directory": "packages/plugins/driver-sqlite-wasm"
+ "directory": "packages/drivers/driver-sqlite-wasm"
},
"homepage": "https://objectstack.ai/docs",
"bugs": "https://github.com/objectstack-ai/objectstack/issues",
diff --git a/packages/plugins/driver-sqlite-wasm/scripts/.restart-proof-id b/packages/drivers/driver-sqlite-wasm/scripts/.restart-proof-id
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/scripts/.restart-proof-id
rename to packages/drivers/driver-sqlite-wasm/scripts/.restart-proof-id
diff --git a/packages/plugins/driver-sqlite-wasm/scripts/restart-proof.mjs b/packages/drivers/driver-sqlite-wasm/scripts/restart-proof.mjs
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/scripts/restart-proof.mjs
rename to packages/drivers/driver-sqlite-wasm/scripts/restart-proof.mjs
diff --git a/packages/plugins/driver-sqlite-wasm/src/index.ts b/packages/drivers/driver-sqlite-wasm/src/index.ts
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/src/index.ts
rename to packages/drivers/driver-sqlite-wasm/src/index.ts
diff --git a/packages/plugins/driver-sqlite-wasm/src/knex-wasm-dialect.ts b/packages/drivers/driver-sqlite-wasm/src/knex-wasm-dialect.ts
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/src/knex-wasm-dialect.ts
rename to packages/drivers/driver-sqlite-wasm/src/knex-wasm-dialect.ts
diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-advanced.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-advanced.test.ts
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-advanced.test.ts
rename to packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-advanced.test.ts
diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-autonumber.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-autonumber.test.ts
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-autonumber.test.ts
rename to packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-autonumber.test.ts
diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-calendar-day-upper-bound.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-calendar-day-upper-bound.test.ts
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-calendar-day-upper-bound.test.ts
rename to packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-calendar-day-upper-bound.test.ts
diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-date-bucket.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-date-bucket.test.ts
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-date-bucket.test.ts
rename to packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-date-bucket.test.ts
diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-durability.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-durability.test.ts
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-durability.test.ts
rename to packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-durability.test.ts
diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-introspection.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-introspection.test.ts
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-introspection.test.ts
rename to packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-introspection.test.ts
diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-journal-mode.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-journal-mode.test.ts
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-journal-mode.test.ts
rename to packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-journal-mode.test.ts
diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-queryast.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-queryast.test.ts
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-queryast.test.ts
rename to packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-queryast.test.ts
diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-returning-persist.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-returning-persist.test.ts
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-returning-persist.test.ts
rename to packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-returning-persist.test.ts
diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-schema.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-schema.test.ts
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-schema.test.ts
rename to packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-schema.test.ts
diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts
rename to packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts
diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-transaction-persist.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-transaction-persist.test.ts
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-transaction-persist.test.ts
rename to packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-transaction-persist.test.ts
diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver.test.ts
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver.test.ts
rename to packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver.test.ts
diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver.ts
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver.ts
rename to packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver.ts
diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-empty-field-constraint.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-empty-field-constraint.test.ts
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-empty-field-constraint.test.ts
rename to packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-empty-field-constraint.test.ts
diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-filter-logic-conformance.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-filter-logic-conformance.test.ts
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-filter-logic-conformance.test.ts
rename to packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-filter-logic-conformance.test.ts
diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-out-of-contract-filter-input.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-out-of-contract-filter-input.test.ts
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-out-of-contract-filter-input.test.ts
rename to packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-out-of-contract-filter-input.test.ts
diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-pagination-conformance.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-pagination-conformance.test.ts
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-pagination-conformance.test.ts
rename to packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-pagination-conformance.test.ts
diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-temporal-conformance.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-temporal-conformance.test.ts
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-temporal-conformance.test.ts
rename to packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-temporal-conformance.test.ts
diff --git a/packages/plugins/driver-sqlite-wasm/src/wasm-connection.ts b/packages/drivers/driver-sqlite-wasm/src/wasm-connection.ts
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/src/wasm-connection.ts
rename to packages/drivers/driver-sqlite-wasm/src/wasm-connection.ts
diff --git a/packages/plugins/driver-sqlite-wasm/tsconfig.json b/packages/drivers/driver-sqlite-wasm/tsconfig.json
similarity index 100%
rename from packages/plugins/driver-sqlite-wasm/tsconfig.json
rename to packages/drivers/driver-sqlite-wasm/tsconfig.json
diff --git a/packages/drivers/driver-turso/CHANGELOG.md b/packages/drivers/driver-turso/CHANGELOG.md
new file mode 100644
index 0000000000..85fce94889
--- /dev/null
+++ b/packages/drivers/driver-turso/CHANGELOG.md
@@ -0,0 +1,262 @@
+# @objectstack/driver-turso
+
+## 6.9.0
+
+### Patch Changes
+
+- @objectstack/spec@6.9.0
+- @objectstack/core@6.9.0
+- @objectstack/driver-sql@6.9.0
+
+## 6.8.1
+
+### Patch Changes
+
+- @objectstack/spec@6.8.1
+- @objectstack/core@6.8.1
+- @objectstack/driver-sql@6.8.1
+
+## 6.8.0
+
+### Patch Changes
+
+- Updated dependencies [6e88f77]
+- Updated dependencies [c8b9f57]
+ - @objectstack/spec@6.8.0
+ - @objectstack/core@6.8.0
+ - @objectstack/driver-sql@6.8.0
+
+## 6.7.1
+
+### Patch Changes
+
+- @objectstack/spec@6.7.1
+- @objectstack/core@6.7.1
+- @objectstack/driver-sql@6.7.1
+
+## 6.7.0
+
+### Patch Changes
+
+- Updated dependencies [4944f3a]
+- Updated dependencies [430067b]
+- Updated dependencies [4f9e9d4]
+ - @objectstack/driver-sql@6.7.0
+ - @objectstack/spec@6.7.0
+ - @objectstack/core@6.7.0
+
+## 6.6.0
+
+### Patch Changes
+
+- Updated dependencies [a49cfc2]
+ - @objectstack/spec@6.6.0
+ - @objectstack/core@6.6.0
+ - @objectstack/driver-sql@6.6.0
+
+## 6.5.1
+
+### Patch Changes
+
+- @objectstack/spec@6.5.1
+- @objectstack/core@6.5.1
+- @objectstack/driver-sql@6.5.1
+
+## 6.5.0
+
+### Patch Changes
+
+- @objectstack/spec@6.5.0
+- @objectstack/core@6.5.0
+- @objectstack/driver-sql@6.5.0
+
+## 6.4.0
+
+### Patch Changes
+
+- Updated dependencies [f8651cc]
+- Updated dependencies [f8651cc]
+- Updated dependencies [0bf6f9a]
+ - @objectstack/spec@6.4.0
+ - @objectstack/core@6.4.0
+ - @objectstack/driver-sql@6.4.0
+
+## 6.3.0
+
+### Patch Changes
+
+- @objectstack/spec@6.3.0
+- @objectstack/core@6.3.0
+- @objectstack/driver-sql@6.3.0
+
+## 6.2.0
+
+### Patch Changes
+
+- Updated dependencies [b4c74a9]
+ - @objectstack/spec@6.2.0
+ - @objectstack/core@6.2.0
+ - @objectstack/driver-sql@6.2.0
+
+## 6.1.1
+
+### Patch Changes
+
+- @objectstack/spec@6.1.1
+- @objectstack/core@6.1.1
+- @objectstack/driver-sql@6.1.1
+
+## 6.1.0
+
+### Patch Changes
+
+- Updated dependencies [93c0589]
+ - @objectstack/spec@6.1.0
+ - @objectstack/core@6.1.0
+ - @objectstack/driver-sql@6.1.0
+
+## 6.0.0
+
+### Patch Changes
+
+- Updated dependencies [629a716]
+- Updated dependencies [dbc4f7d]
+- Updated dependencies [944f187]
+ - @objectstack/spec@6.0.0
+ - @objectstack/core@6.0.0
+ - @objectstack/driver-sql@6.0.0
+
+## 5.2.0
+
+### Patch Changes
+
+- Updated dependencies [bab2b20]
+- Updated dependencies [fa011d8]
+- Updated dependencies [b806f58]
+ - @objectstack/spec@5.2.0
+ - @objectstack/core@5.2.0
+ - @objectstack/driver-sql@5.2.0
+
+## 5.1.0
+
+### Patch Changes
+
+- Updated dependencies [75f4ee6]
+- Updated dependencies [823d559]
+ - @objectstack/spec@5.1.0
+ - @objectstack/core@5.1.0
+ - @objectstack/driver-sql@5.1.0
+
+## 5.0.0
+
+### Patch Changes
+
+- Updated dependencies [2f9073a]
+ - @objectstack/spec@5.0.0
+ - @objectstack/core@5.0.0
+ - @objectstack/driver-sql@5.0.0
+
+## 4.2.0
+
+### Patch Changes
+
+- Updated dependencies [2869891]
+ - @objectstack/spec@4.2.0
+ - @objectstack/core@4.2.0
+ - @objectstack/driver-sql@4.2.0
+
+## 4.1.1
+
+### Patch Changes
+
+- @objectstack/spec@4.1.1
+- @objectstack/core@4.1.1
+- @objectstack/driver-sql@4.1.1
+
+## 4.1.0
+
+### Patch Changes
+
+- Updated dependencies [2108c30]
+- Updated dependencies [23db640]
+- Updated dependencies [5683206]
+- Updated dependencies [0cc0374]
+- Updated dependencies [5b878d9]
+- Updated dependencies [f0b3972]
+- Updated dependencies [0e63f2f]
+ - @objectstack/spec@4.1.0
+ - @objectstack/driver-sql@4.1.0
+ - @objectstack/core@4.1.0
+
+## 4.0.5
+
+### Patch Changes
+
+- 15e0df6: chore: unify all package versions to a single patch release
+- Updated dependencies [15e0df6]
+ - @objectstack/spec@4.0.5
+ - @objectstack/core@4.0.5
+ - @objectstack/driver-sql@4.0.5
+
+## 4.0.4
+
+### Patch Changes
+
+- Updated dependencies [326b66b]
+ - @objectstack/spec@4.0.4
+ - @objectstack/core@4.0.4
+ - @objectstack/driver-sql@4.0.4
+
+## 4.0.3
+
+### Patch Changes
+
+- @objectstack/spec@4.0.3
+- @objectstack/core@4.0.3
+- @objectstack/driver-sql@4.0.3
+
+## 4.0.3
+
+### Patch Changes
+
+- fix: implement lazy connect in RemoteTransport to self-heal from serverless cold-start failures, transient network errors, or missed `connect()` calls. The transport now accepts a connect factory and auto-initializes the @libsql/client on first operation when the client is not yet available. Concurrent reconnection attempts are de-duplicated.
+
+## 4.0.2
+
+### Patch Changes
+
+- Updated dependencies [5f659e9]
+ - @objectstack/driver-sql@4.0.2
+ - @objectstack/spec@4.0.2
+ - @objectstack/core@4.0.2
+
+## 3.3.2
+
+### Patch Changes
+
+- Updated dependencies [f08ffc3]
+- Updated dependencies [e0b0a78]
+ - @objectstack/spec@4.0.0
+ - @objectstack/core@4.0.0
+ - @objectstack/driver-sql@3.3.2
+
+## 3.3.1
+
+### Patch Changes
+
+- @objectstack/spec@3.3.1
+- @objectstack/core@3.3.1
+- @objectstack/driver-sql@3.3.1
+
+## 3.3.0
+
+### Minor Changes
+
+- 814a6c4: sql driver
+
+### Patch Changes
+
+- Updated dependencies [814a6c4]
+ - @objectstack/driver-sql@3.3.0
+ - @objectstack/spec@3.3.0
+ - @objectstack/core@3.3.0
diff --git a/packages/drivers/driver-turso/LICENSE b/packages/drivers/driver-turso/LICENSE
new file mode 100644
index 0000000000..16bc23f404
--- /dev/null
+++ b/packages/drivers/driver-turso/LICENSE
@@ -0,0 +1,202 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute
+ must include a readable copy of the attribution notices
+ contained within such NOTICE file, excluding those notices
+ that do not pertain to any part of the Derivative Works,
+ in at least one of the following places: within a NOTICE
+ text file distributed as part of the Derivative Works; within
+ the Source form or documentation, if provided along with
+ the Derivative Works; or, within a display generated by the
+ Derivative Works, if and wherever such third-party notices
+ normally appear. The contents of the NOTICE file are for
+ informational purposes only and do not modify the License.
+ You may add Your own attribution notices within Derivative
+ Works that You distribute, alongside or as an addendum to
+ the NOTICE text from the Work, provided that such additional
+ attribution notices cannot be construed as modifying the
+ License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2026 ObjectStack
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/packages/drivers/driver-turso/README.md b/packages/drivers/driver-turso/README.md
new file mode 100644
index 0000000000..db27a427ae
--- /dev/null
+++ b/packages/drivers/driver-turso/README.md
@@ -0,0 +1,265 @@
+# @objectstack/driver-turso
+
+Turso/libSQL driver for ObjectStack — edge-first SQLite with embedded replicas and cloud-only remote mode.
+
+## Architecture
+
+`TursoDriver` implements a **dual-transport architecture**:
+
+- **Local/Replica modes:** Extends `SqlDriver` from `@objectstack/driver-sql`. All CRUD, schema, filtering, aggregation, window functions, introspection, and transactions are **inherited** via Knex + better-sqlite3.
+- **Remote mode:** Delegates all operations to `RemoteTransport` which uses `@libsql/client` SDK directly (HTTP/WebSocket). No local SQLite or Knex dependency needed.
+
+```
+TursoDriver extends SqlDriver (dual transport)
+├── Transport: local/replica (via Knex + better-sqlite3)
+│ ├── Inherited: find, findOne, create, update, delete, count, upsert
+│ ├── Inherited: bulkCreate, bulkUpdate, bulkDelete, updateMany, deleteMany
+│ ├── Inherited: syncSchema, dropTable, introspectSchema
+│ ├── Inherited: aggregate, distinct, findWithWindowFunctions
+│ ├── Inherited: beginTransaction, commit, rollback
+│ └── Inherited: applyFilters (MongoDB-style + array-style)
+├── Transport: remote (via @libsql/client)
+│ ├── RemoteTransport: find, findOne, create, update, delete, count, upsert
+│ ├── RemoteTransport: bulkCreate, bulkUpdate, bulkDelete, updateMany, deleteMany
+│ ├── RemoteTransport: syncSchema, dropTable
+│ ├── RemoteTransport: beginTransaction, commit, rollback
+│ └── RemoteTransport: execute (raw SQL)
+├── Override: name, version, supports (Turso-specific capabilities)
+├── Override: connect / disconnect (transport-aware lifecycle)
+├── Added: transportMode ('local' | 'replica' | 'remote')
+├── Added: sync() — Embedded replica sync via @libsql/client
+└── Added: TursoDriverConfig (url, authToken, syncUrl, mode, client)
+```
+
+## Installation
+
+```bash
+pnpm add @objectstack/driver-turso
+```
+
+### Dependencies by Mode
+
+The `driver-turso` package has different dependency requirements based on the connection mode:
+
+| Mode | Required Dependencies | Notes |
+|:---|:---|:---|
+| **Remote** | `@libsql/client` only | ✅ Vercel/Edge compatible — no native dependencies |
+| **Local** | `@libsql/client` + `better-sqlite3` | Requires `better-sqlite3` for local SQLite access |
+| **Replica** | `@libsql/client` + `better-sqlite3` | Requires `better-sqlite3` for local SQLite + sync |
+
+**For Vercel/Edge deployments (remote mode only):**
+```bash
+pnpm add @objectstack/driver-turso
+# better-sqlite3 is NOT required
+```
+
+**For local/replica modes:**
+```bash
+pnpm add @objectstack/driver-turso better-sqlite3
+```
+
+The `better-sqlite3` package is an **optional peer dependency**. If you're only using remote mode (e.g., on Vercel), you don't need to install it. npm/pnpm will show a warning that can be safely ignored.
+
+## Connection Modes
+
+### Local File (Embedded SQLite)
+
+```typescript
+import { TursoDriver } from '@objectstack/driver-turso';
+
+const driver = new TursoDriver({
+ url: 'file:./data/app.db',
+});
+await driver.connect();
+```
+
+### In-Memory (Testing)
+
+```typescript
+const driver = new TursoDriver({
+ url: ':memory:',
+});
+await driver.connect();
+```
+
+### Embedded Replica (Hybrid)
+
+Local SQLite file + automatic sync from Turso cloud:
+
+```typescript
+const driver = new TursoDriver({
+ url: 'file:./data/replica.db',
+ syncUrl: 'libsql://my-db-orgname.turso.io',
+ authToken: process.env.TURSO_AUTH_TOKEN,
+ sync: {
+ intervalSeconds: 60, // sync every 60 seconds
+ onConnect: true, // sync on initial connect
+ },
+});
+await driver.connect();
+
+// Manual sync
+await driver.sync();
+```
+
+### Remote (Cloud-Only)
+
+Pure remote queries via `@libsql/client` — no local SQLite needed.
+Ideal for Vercel, Cloudflare Workers, and other serverless/edge runtimes:
+
+```typescript
+const driver = new TursoDriver({
+ url: 'libsql://my-db-orgname.turso.io',
+ authToken: process.env.TURSO_AUTH_TOKEN,
+});
+await driver.connect();
+
+// All CRUD operations work the same as local mode
+const users = await driver.find('users', { where: { active: true } });
+```
+
+### Auto-Detection
+
+Transport mode is automatically detected from the URL:
+
+| URL Pattern | Mode | Engine |
+|:---|:---|:---|
+| `file:./data/app.db` | `local` | Knex + better-sqlite3 |
+| `:memory:` | `local` | Knex + better-sqlite3 |
+| `file:...` + `syncUrl` | `replica` | Knex + @libsql/client sync |
+| `libsql://...` | `remote` | @libsql/client only |
+| `https://...` | `remote` | @libsql/client only |
+
+You can also force a specific mode:
+
+```typescript
+const driver = new TursoDriver({
+ url: 'libsql://my-db.turso.io',
+ authToken: process.env.TURSO_AUTH_TOKEN,
+ mode: 'remote', // Force remote mode
+});
+```
+
+### Custom Client
+
+Pass a pre-configured `@libsql/client` instance for advanced use cases
+(custom caching, connection pooling, testing):
+
+```typescript
+import { createClient } from '@libsql/client';
+
+const client = createClient({
+ url: 'libsql://my-db.turso.io',
+ authToken: process.env.TURSO_AUTH_TOKEN,
+});
+
+const driver = new TursoDriver({
+ url: 'libsql://my-db.turso.io',
+ client, // Inject pre-configured client
+});
+await driver.connect();
+```
+
+## Multi-Tenant Routing
+
+**Not shipped by this package.** Database-per-tenant routing on top of
+`TursoDriver` is a cloud product capability and lives in the closed
+`objectstack-ai/cloud` repository (objectstack#4645 decision 2). This package
+ships the driver; a router that maps a tenant to a `TursoDriver` instance is
+layered above it and is not part of the open Apache-2.0 surface.
+
+## Configuration
+
+```typescript
+interface TursoDriverConfig {
+ /**
+ * Database URL.
+ * - file:./data/local.db → local mode
+ * - :memory: → local mode (ephemeral)
+ * - libsql://my-db.turso.io → remote mode
+ * - https://my-db.turso.io → remote mode
+ */
+ url: string;
+
+ /** JWT auth token for the remote Turso database */
+ authToken?: string;
+
+ /**
+ * AES-256 encryption key for local database file.
+ * Only effective in local/replica modes.
+ */
+ encryptionKey?: string;
+
+ /**
+ * Maximum concurrent requests to the remote database.
+ * Effective in replica and remote modes.
+ * Default: 20
+ */
+ concurrency?: number;
+
+ /** Remote sync URL for embedded replica mode (libsql:// or https://) */
+ syncUrl?: string;
+
+ /** Sync configuration (requires syncUrl) */
+ sync?: {
+ intervalSeconds?: number; // Default: 60
+ onConnect?: boolean; // Default: true
+ };
+
+ /**
+ * Operation timeout in milliseconds for remote operations.
+ * Effective in replica and remote modes.
+ */
+ timeout?: number;
+
+ /**
+ * Force a specific transport mode.
+ * If not set, mode is auto-detected from the URL.
+ */
+ mode?: 'local' | 'replica' | 'remote';
+
+ /**
+ * Pre-configured @libsql/client instance.
+ * Useful for custom caching, connection pooling, or testing.
+ */
+ client?: Client;
+}
+```
+
+## Capabilities
+
+TursoDriver declares enhanced capabilities beyond the base SqlDriver:
+
+| Capability | SqlDriver | TursoDriver (local) | TursoDriver (remote) |
+|:---|:---:|:---:|:---:|
+| FTS5 Full-Text Search | ❌ | ✅ | ✅ |
+| JSON1 Query | ❌ | ✅ | ✅ |
+| Common Table Expressions | ❌ | ✅ | ✅ |
+| Savepoints | ❌ | ✅ | ✅ |
+| Indexes | ❌ | ✅ | ✅ |
+| Connection Pooling | ✅ | ❌ (concurrency limits) | ❌ |
+| Embedded Replica Sync | — | ✅ | — |
+| Serverless/Edge | — | — | ✅ |
+
+## Plugin Registration
+
+```typescript
+import tursoPlugin from '@objectstack/driver-turso';
+
+// Via plugin system
+await kernel.enablePlugin(tursoPlugin, {
+ url: 'file:./data/app.db',
+});
+```
+
+## Testing
+
+```bash
+pnpm test # Run all tests
+```
+
+Tests run against in-memory SQLite (`:memory:`) — no external services required.
+
+## License
+
+Apache-2.0. See [LICENSING.md](../../../LICENSING.md).
diff --git a/packages/drivers/driver-turso/package.json b/packages/drivers/driver-turso/package.json
new file mode 100644
index 0000000000..d98e8956cd
--- /dev/null
+++ b/packages/drivers/driver-turso/package.json
@@ -0,0 +1,73 @@
+{
+ "name": "@objectstack/driver-turso",
+ "version": "17.0.0-rc.2",
+ "license": "Apache-2.0",
+ "description": "Turso/libSQL Driver for ObjectStack — Edge-first SQLite with embedded replicas",
+ "keywords": [
+ "objectstack",
+ "driver",
+ "turso",
+ "libsql",
+ "sqlite",
+ "edge",
+ "serverless",
+ "embedded-replica"
+ ],
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.mjs",
+ "require": "./dist/index.js"
+ }
+ },
+ "scripts": {
+ "build": "tsup --config ../../../tsup.config.ts",
+ "dev": "tsc -w",
+ "test": "vitest run",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@libsql/client": "^0.17.3",
+ "@objectstack/core": "workspace:*",
+ "@objectstack/driver-sql": "workspace:*",
+ "@objectstack/spec": "workspace:*",
+ "nanoid": "^6.0.0",
+ "zod": "^4.4.3"
+ },
+ "peerDependencies": {
+ "better-sqlite3": "^13.0.2"
+ },
+ "peerDependenciesMeta": {
+ "better-sqlite3": {
+ "optional": true
+ }
+ },
+ "devDependencies": {
+ "@objectstack/verify": "workspace:*",
+ "@types/node": "^26.1.2",
+ "better-sqlite3": "^13.0.2",
+ "typescript": "^6.0.3",
+ "vitest": "^4.1.10"
+ },
+ "author": "ObjectStack",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/objectstack-ai/objectstack.git",
+ "directory": "packages/drivers/driver-turso"
+ },
+ "homepage": "https://objectstack.ai/docs",
+ "bugs": "https://github.com/objectstack-ai/objectstack/issues",
+ "publishConfig": {
+ "access": "public"
+ },
+ "files": [
+ "dist",
+ "README.md",
+ "CHANGELOG.md"
+ ],
+ "engines": {
+ "node": ">=22.0.0"
+ }
+}
diff --git a/packages/drivers/driver-turso/src/date-bucket-parity.test.ts b/packages/drivers/driver-turso/src/date-bucket-parity.test.ts
new file mode 100644
index 0000000000..6ae25fe8c5
--- /dev/null
+++ b/packages/drivers/driver-turso/src/date-bucket-parity.test.ts
@@ -0,0 +1,135 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Date-bucket parity for TursoDriver (framework#3773, gate from framework#3813).
+ *
+ * A driver that advertises `supports.queryDateGranularity[g]` tells
+ * `engine.aggregate` it may push that granularity down as SQL rather than
+ * fetching rows and bucketing them in JS. The two then have to agree, because
+ * the engine picks between them per query. `checkDateBucketParity` compares the
+ * driver's pushed-down result against the REAL `applyInMemoryAggregation` over
+ * the driver's own `find()` rows.
+ *
+ * Why this matters HERE and not only in the framework: TursoDriver extends
+ * SqlDriver, so in local/replica mode it inherits `buildDateBucketExpr` and the
+ * SQLite datetime storage convention along with it. That convention CHANGED in
+ * framework#3912 ("give `Field.datetime` one UTC storage form per dialect"):
+ * SQLite now stores a `Field.datetime` as canonical ISO TEXT
+ * (`YYYY-MM-DDTHH:MM:SS.sssZ`), not the old INTEGER epoch ms. That retires the
+ * framework#3773 hazard at its root for SQLite — `strftime` parses ISO TEXT
+ * natively, so a `Field.datetime` can no longer be misread as a Julian day and
+ * bucketed to NULL — and the precondition below now pins the NEW canonical form
+ * so this suite still can't pass vacuously.
+ *
+ * What each mode is worth is stated explicitly below, because the honest answer
+ * differs per mode and a vacuous pass must not read as coverage.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { checkDateBucketParity } from '@objectstack/verify';
+import { TursoDriver } from './turso-driver.js';
+
+describe('TursoDriver date-bucket parity (framework#3773)', () => {
+ describe('local mode — the real check', () => {
+ it('buckets identically pushed-down and in-memory, on both storage forms', async () => {
+ // Local/replica keeps SqlDriver's native bucketing, so every granularity
+ // it advertises is genuinely exercised here: `Field.datetime` (canonical
+ // ISO TEXT since framework#3912) and `Field.date` (ISO TEXT) under one
+ // probe object, compared against the in-memory reference at every
+ // advertised granularity.
+ const driver = new TursoDriver({ url: ':memory:' });
+ expect(driver.transportMode).toBe('local');
+
+ const problems = await checkDateBucketParity(driver as never, {
+ createOptions: { bypassTenantAudit: true },
+ });
+ expect(problems).toEqual([]);
+ });
+
+ it('stores a Field.datetime in the canonical UTC form for SQLite (ISO TEXT, framework#3912) — the precondition', async () => {
+ // Without this the suite above could be green for the wrong reason: it
+ // pins the actual on-disk storage form so a driver/framework change that
+ // silently altered it would fail HERE rather than letting the parity
+ // check pass over a shape it no longer exercises. framework#3912 gave
+ // `Field.datetime` one canonical UTC storage form per dialect — ISO TEXT
+ // (`YYYY-MM-DDTHH:MM:SS.sssZ`) on SQLite, replacing the old ambiguous
+ // INTEGER-epoch-vs-TEXT mix. `strftime` parses that text natively, which
+ // is exactly why the framework#3773 all-NULL-buckets hazard no longer
+ // applies to datetime on SQLite.
+ const driver = new TursoDriver({ url: ':memory:' });
+ try {
+ await driver.syncSchema('bucket_storage_probe', {
+ name: 'bucket_storage_probe',
+ fields: { at: { type: 'datetime' } },
+ });
+ await driver.create(
+ 'bucket_storage_probe',
+ { id: 'p1', at: new Date('2026-01-10T09:00:00Z') },
+ { bypassTenantAudit: true } as never,
+ );
+ const res: any = await driver.execute(
+ `SELECT typeof("at") AS t FROM "bucket_storage_probe" WHERE id = 'p1'`,
+ );
+ const row = Array.isArray(res) ? res[0] : (res?.rows?.[0] ?? res);
+ // framework#3912: canonical UTC storage for SQLite datetime is ISO TEXT.
+ expect(row.t).toBe('text');
+ } finally {
+ await driver.disconnect?.();
+ }
+ });
+
+ it('advertises something for the check to have bitten on', async () => {
+ // Guards the test above from going quiet: if local ever stopped
+ // advertising granularities, `checkDateBucketParity` would skip every one
+ // of them and pass vacuously — the same silence this whole family of bugs
+ // hides in.
+ const driver = new TursoDriver({ url: ':memory:' });
+ const caps = driver.supports.queryDateGranularity ?? {};
+ expect(Object.entries(caps).filter(([, v]) => v === true).length).toBeGreaterThan(0);
+ await driver.disconnect?.();
+ });
+ });
+
+ describe('remote mode — what this does and does not prove', () => {
+ it('advertises NO granularity, so the engine never pushes bucketing down here', () => {
+ // Remote delegates aggregate() to RemoteTransport, which takes only string
+ // group-by identifiers and has no bucketing — so it correctly advertises
+ // nothing, and `engine.aggregate` always falls back to find() + in-memory
+ // bucketing, which the contract guarantees is correct.
+ //
+ // `checkDateBucketParity` is deliberately NOT run here. It skips every
+ // granularity a driver does not advertise, so against remote it would
+ // return `[]` without comparing anything — a pass that looks like
+ // coverage and is not. (Setting its probe up over the remote transport
+ // would need a live Turso database, which no unit test has.) What
+ // actually guards remote is this capability assertion plus the tripwire
+ // below.
+ const driver = new TursoDriver({ url: 'libsql://test-db.turso.io', authToken: 'test-token' });
+ expect(driver.transportMode).toBe('remote');
+ expect(driver.supports.queryDateGranularity).toEqual({});
+ });
+
+ it('the checker WOULD catch a driver that advertises a granularity it cannot run', async () => {
+ // The tripwire the vacuous pass above is worth having. Rather than mock a
+ // remote transport into life, this drives a real local driver and makes it
+ // advertise `week` — which SqlDriver deliberately does NOT implement on
+ // SQLite (`%V` needs 3.46+), so `aggregate()` throws exactly as
+ // RemoteTransport would on a structured groupBy.
+ //
+ // So: if someone deletes remote's `queryDateGranularity: {}` override
+ // believing SqlDriver handles it, this is the shape of failure they get —
+ // named, not silent.
+ const driver = new TursoDriver({ url: ':memory:' });
+ Object.defineProperty(driver, 'supports', {
+ get: () => ({ queryDateGranularity: { week: true } }),
+ configurable: true,
+ });
+
+ const problems = await checkDateBucketParity(driver as never, {
+ createOptions: { bypassTenantAudit: true },
+ });
+ expect(problems.join('\n')).toMatch(/advertises this granularity but aggregate\(\) threw/);
+ expect(problems.join('\n')).toMatch(/week/);
+ });
+ });
+});
diff --git a/packages/drivers/driver-turso/src/index.ts b/packages/drivers/driver-turso/src/index.ts
new file mode 100644
index 0000000000..878b5cf84d
--- /dev/null
+++ b/packages/drivers/driver-turso/src/index.ts
@@ -0,0 +1,92 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * @objectstack/driver-turso
+ *
+ * Turso/libSQL driver for ObjectStack — edge-first, globally distributed
+ * SQLite with embedded replicas.
+ *
+ * Extends `@objectstack/driver-sql` (SqlDriver) and inherits all CRUD,
+ * schema management, filtering, aggregation, and introspection logic.
+ * Only Turso-specific features (connection modes, sync) are implemented
+ * here — zero duplicated query/schema code.
+ *
+ * Database-per-tenant routing is NOT part of this package: it is a cloud
+ * product capability and stays in the closed `objectstack-ai/cloud` repo
+ * (#4645 decision 2), layered on top of this driver rather than inside it.
+ *
+ * Supports four connection modes:
+ * 1. Local (Embedded): `url: 'file:./data/local.db'`
+ * 2. In-Memory (Testing): `url: ':memory:'`
+ * 3. Embedded Replica (Hybrid): `url` + `syncUrl`
+ * 4. Remote (Cloud): `url: 'libsql://my-db.turso.io'`
+ *
+ * @example
+ * ```typescript
+ * import { TursoDriver } from '@objectstack/driver-turso';
+ *
+ * const driver = new TursoDriver({
+ * url: 'file:./data/app.db',
+ * });
+ * await driver.connect();
+ * ```
+ */
+
+import { TursoDriver } from './turso-driver.js';
+
+export { TursoDriver, type TursoDriverConfig, type TursoTransportMode } from './turso-driver.js';
+export { RemoteTransport, type FilterColumnSqlResolver } from './remote-transport.js';
+
+// Spec / Studio metadata for the Turso driver — published from this package
+// so a host exposes Turso configuration UI without the driver-specific shape
+// landing in the shared `@objectstack/spec` surface.
+export * from './spec/turso.zod.js';
+
+/**
+ * Factory function to create a TursoDriver instance.
+ *
+ * @param config - Turso driver configuration
+ * @returns A new TursoDriver instance (not yet connected)
+ *
+ * @example
+ * ```typescript
+ * import { createTursoDriver } from '@objectstack/driver-turso';
+ *
+ * // Local file
+ * const driver = createTursoDriver({ url: 'file:./data/app.db' });
+ *
+ * // In-memory (testing)
+ * const driver = createTursoDriver({ url: ':memory:' });
+ *
+ * // Embedded replica
+ * const driver = createTursoDriver({
+ * url: 'file:./data/replica.db',
+ * syncUrl: 'libsql://my-db-orgname.turso.io',
+ * authToken: process.env.TURSO_AUTH_TOKEN,
+ * sync: { intervalSeconds: 60, onConnect: true },
+ * });
+ *
+ * await driver.connect();
+ * ```
+ */
+export function createTursoDriver(config: import('./turso-driver.js').TursoDriverConfig): TursoDriver {
+ return new TursoDriver(config);
+}
+
+export default {
+ id: 'com.objectstack.driver.turso',
+ version: '1.0.0',
+
+ onEnable: async (context: any) => {
+ const { logger, config, drivers } = context;
+ logger.info('[Turso Driver] Initializing...');
+
+ if (drivers) {
+ const driver = new TursoDriver(config);
+ drivers.register(driver);
+ logger.info(`[Turso Driver] Registered driver: ${driver.name}`);
+ } else {
+ logger.warn('[Turso Driver] No driver registry found in context.');
+ }
+ },
+};
diff --git a/packages/drivers/driver-turso/src/libsql-sqlite-stub.testkit.ts b/packages/drivers/driver-turso/src/libsql-sqlite-stub.testkit.ts
new file mode 100644
index 0000000000..0a2d080b9a
--- /dev/null
+++ b/packages/drivers/driver-turso/src/libsql-sqlite-stub.testkit.ts
@@ -0,0 +1,87 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * A real SQLite database wearing the `@libsql/client` interface — so the
+ * REMOTE transport can be exercised end-to-end, on rows, without a network.
+ *
+ * Every other remote-mode suite in this package mocks `execute` and asserts on
+ * the SQL string it was handed. That is the wrong instrument for the failures
+ * #937 was about: a dropped or mis-widened predicate leaves the SQL perfectly
+ * valid — just wider or narrower — so a string assertion sails past it. Exactly
+ * the blind spot framework#4081 hit one layer up, where `NativeSQLStrategy`
+ * looked covered until something executed its output and `$between` turned out
+ * to return the entire table.
+ *
+ * libsql IS SQLite, so backing the interface with `better-sqlite3` gives the
+ * transport the same value semantics a real Turso endpoint would (TEXT/INTEGER
+ * affinity, lexicographic TEXT ordering) — the semantics every row-result
+ * assertion here depends on. What it deliberately does NOT model is the
+ * network: HTTP batching, retries and cold-start reconnection are the concern
+ * of the transport suites that already mock `execute`.
+ */
+
+import { createRequire } from 'node:module';
+
+// better-sqlite3 is a knex PEER here and is never imported directly outside
+// this testkit, so the package carries no `@types` for it. Rather than take a
+// dependency to type one test double, declare the sliver of its surface the
+// stub actually uses — which also documents that surface.
+interface SqliteStatement {
+ readonly reader: boolean;
+ all(...args: unknown[]): Record[];
+ run(...args: unknown[]): { changes: number };
+}
+export interface SqliteDatabase {
+ prepare(sql: string): SqliteStatement;
+ close(): void;
+}
+
+const Database = createRequire(import.meta.url)('better-sqlite3') as new (
+ filename: string,
+) => SqliteDatabase;
+
+export interface LibsqlSqliteStub {
+ execute(stmt: unknown): Promise<{ rows: unknown[]; columns: string[]; rowsAffected: number }>;
+ batch(stmts: unknown[]): Promise;
+ close(): void;
+ /** The underlying database, for asserting on what actually landed on disk. */
+ raw: SqliteDatabase;
+}
+
+/**
+ * Build the stub. `:memory:` unless a file is given.
+ *
+ * `undefined` args are normalised to `null` because better-sqlite3 rejects
+ * them, while libsql accepts and binds them as NULL.
+ */
+export function makeLibsqlSqliteStub(filename = ':memory:'): LibsqlSqliteStub {
+ const db = new Database(filename);
+
+ const run = (stmt: unknown) => {
+ const sql = typeof stmt === 'string' ? stmt : (stmt as { sql: string }).sql;
+ const args = typeof stmt === 'string' ? [] : normalize((stmt as { args?: unknown[] }).args);
+ const prepared = db.prepare(sql);
+ if (prepared.reader) {
+ const rows = prepared.all(...args);
+ return { rows, columns: rows.length ? Object.keys(rows[0]) : [], rowsAffected: 0 };
+ }
+ const info = prepared.run(...args);
+ return { rows: [], columns: [], rowsAffected: info.changes };
+ };
+
+ return {
+ async execute(stmt) {
+ return run(stmt);
+ },
+ async batch(stmts) {
+ return stmts.map(run);
+ },
+ close() {
+ db.close();
+ },
+ raw: db,
+ };
+}
+
+const normalize = (args: unknown[] | undefined) =>
+ (args ?? []).map((a) => (a === undefined ? null : a));
diff --git a/packages/drivers/driver-turso/src/remote-read-coercion.test.ts b/packages/drivers/driver-turso/src/remote-read-coercion.test.ts
new file mode 100644
index 0000000000..0e4d2694f3
--- /dev/null
+++ b/packages/drivers/driver-turso/src/remote-read-coercion.test.ts
@@ -0,0 +1,135 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { describe, it, expect, vi } from 'vitest';
+import { TursoDriver } from './turso-driver.js';
+
+/**
+ * Regression: in REMOTE mode (cloud/Turso via @libsql/client) reads returned
+ * raw SQLite column values — a `boolean` came back as the integer `1`, a `json`
+ * field as its stored text, a numeric-on-TEXT column as a string — because the
+ * remote path (`RemoteTransport` → `mapRows`) never ran `SqlDriver.formatOutput`
+ * AND `TursoDriver.initObjects`/`syncSchema` never populated the boolean/json/…
+ * coercion registries in remote mode. Local/replica mode goes through Knex +
+ * formatOutput, so it coerces correctly — the two transports disagreed.
+ *
+ * The concrete failure: a HotCRM `case_escalation` flow guarded on a boolean
+ * with CEL `field != true`. On Turso the field read back as `1`, so `1 != true`
+ * is always true → the guard never fired → the flow self-triggered infinitely
+ * and wedged first-boot seed. A local repro (memory/sqlite) was green because
+ * both coerce the boolean back to `true`.
+ *
+ * The fix makes remote reads run the same `formatOutput()` coercion, so remote
+ * == local == memory. These assertions FAIL on the pre-fix driver (they'd see
+ * `active === 1`, `meta` a string) and pass after it.
+ */
+
+const RAW_ROW = {
+ id: '1',
+ name: 'Widget',
+ active: 1, // SQLite stores boolean as integer 0/1
+ meta: '{"k":1}', // json stored as TEXT
+ count: '5', // numeric scalar that came back as a string
+};
+
+/**
+ * Minimal @libsql/client stand-in. Routes the handful of SQL shapes the remote
+ * schema-sync + read paths emit; every SELECT against the table yields one raw
+ * row so we can assert the driver coerces it.
+ */
+function makeMockClient() {
+ const execute = vi.fn(async (stmt: any) => {
+ const sql: string = typeof stmt === 'string' ? stmt : stmt.sql;
+ if (/sqlite_master/i.test(sql)) return { rows: [], columns: ['name'] }; // table "does not exist" → CREATE path
+ if (/^\s*SELECT/i.test(sql)) return { rows: [{ ...RAW_ROW }], columns: Object.keys(RAW_ROW) };
+ return { rows: [], columns: [] }; // CREATE TABLE / ALTER / etc.
+ });
+ return { execute, batch: vi.fn(async () => []), close: vi.fn() };
+}
+
+async function makeRemoteDriver() {
+ const client = makeMockClient();
+ const driver = new TursoDriver({ url: 'libsql://test-db.turso.io', client: client as any });
+ await driver.connect();
+ expect(driver.isRemote).toBe(true);
+ // Registers the field-type metadata (boolean/json/numeric) for coercion.
+ await driver.syncSchema('widgets', {
+ name: 'widgets',
+ fields: {
+ name: { type: 'string' },
+ active: { type: 'boolean' },
+ meta: { type: 'json' },
+ count: { type: 'integer' },
+ },
+ });
+ return driver;
+}
+
+describe('TursoDriver remote read coercion', () => {
+ it('find() coerces boolean 0/1 → real boolean, json text → object, numeric string → number', async () => {
+ const driver = await makeRemoteDriver();
+ const rows = await driver.find('widgets', {});
+ expect(rows).toHaveLength(1);
+ const row = rows[0];
+
+ expect(row.active).toBe(true); // NOT the integer 1
+ expect(typeof row.active).toBe('boolean');
+ expect(row.meta).toEqual({ k: 1 }); // parsed, not the string '{"k":1}'
+ expect(row.count).toBe(5); // number, not '5'
+ });
+
+ it('closes the CEL `field != true` hole that caused the case_escalation incident', async () => {
+ const driver = await makeRemoteDriver();
+ const [row] = await driver.find('widgets', {});
+ // The guard the flow used. Pre-fix this was `1 != true` === true (guard never
+ // fired → infinite self-trigger). Post-fix it is `true != true` === false.
+ expect(row.active !== true).toBe(false);
+ });
+
+ it('findOne() applies the same coercion', async () => {
+ const driver = await makeRemoteDriver();
+ const row = await driver.findOne('widgets', { where: { id: '1' } });
+ expect(row).not.toBeNull();
+ expect(row.active).toBe(true);
+ expect(row.meta).toEqual({ k: 1 });
+ });
+});
+
+/**
+ * Transport parity: the local (better-sqlite3) and remote (@libsql/client)
+ * transports of the SAME driver must return values of the SAME declared type.
+ * Cloud runs remote; dev-stack / file mode runs local — so a divergence here is
+ * exactly a "green locally, red in prod" trap. This is the contract the fix
+ * establishes; it guards against the two paths drifting again.
+ */
+describe('TursoDriver transport parity (local vs remote)', () => {
+ const SCHEMA = {
+ name: 'widgets',
+ fields: {
+ name: { type: 'string' },
+ active: { type: 'boolean' },
+ meta: { type: 'json' },
+ count: { type: 'integer' },
+ },
+ };
+
+ it('local (better-sqlite3 :memory:) and remote agree on boolean/json/number types', async () => {
+ const local = new TursoDriver({ url: ':memory:' });
+ await local.connect();
+ expect(local.isRemote).toBe(false);
+ await local.syncSchema('widgets', SCHEMA);
+ await local.create('widgets', { id: '1', name: 'Widget', active: true, meta: { k: 1 }, count: 5 });
+ const [lrow] = await local.find('widgets', {});
+
+ const remote = await makeRemoteDriver();
+ const [rrow] = await remote.find('widgets', {});
+
+ for (const field of ['active', 'meta', 'count'] as const) {
+ expect(typeof rrow[field]).toBe(typeof lrow[field]);
+ }
+ expect(rrow.active).toBe(lrow.active); // both true, not 1
+ expect(rrow.meta).toEqual(lrow.meta); // both { k: 1 }
+ expect(rrow.count).toBe(lrow.count); // both 5
+
+ await local.disconnect();
+ });
+});
diff --git a/packages/drivers/driver-turso/src/remote-transport-bare-date-routing.test.ts b/packages/drivers/driver-turso/src/remote-transport-bare-date-routing.test.ts
new file mode 100644
index 0000000000..1396fa9392
--- /dev/null
+++ b/packages/drivers/driver-turso/src/remote-transport-bare-date-routing.test.ts
@@ -0,0 +1,364 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
+import { RemoteTransport } from './remote-transport.js';
+import { TursoDriver } from './turso-driver.js';
+import { makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';
+
+/**
+ * Regression: the MIRROR of #1058 — a predicate that vanishes (#1066).
+ *
+ * `buildWhereSQL` decided "this field's comparand is an operator map" with
+ * `typeof value === 'object' && !Array.isArray(value)`. A `Date` passes that
+ * test, and `Object.entries(new Date())` is EMPTY (a Date carries no own
+ * enumerable properties) — so the operator loop ran zero times and pushed zero
+ * clauses:
+ *
+ * { closed_at: someDate } → SELECT * FROM "deal" args []
+ * { closed_at: { $gte: someDate } }
+ * → SELECT * FROM "deal" WHERE "closed_at" >= ?
+ * args [ISO]
+ *
+ * The same comparand, one nesting level apart, and the bare spelling compiled
+ * to a FULL TABLE SCAN. #1004 (unknown operator) and #1058 (unbindable
+ * comparand) both closed "valid SQL, silent ZERO rows"; this is the other face
+ * of that family — "valid SQL, silent ALL rows" — and it is the more dangerous
+ * one, because the caller does not get an empty page it can notice: it gets
+ * back exactly the rows the filter was written to exclude.
+ *
+ * A bare `Date` is not an unsupported value form. It is fully compilable —
+ * `serializeComparand`'s allow-list admits it as this transport's ONE declared
+ * object conversion (→ ISO 8601, the storage form `toRemoteWriteForms` wrote).
+ * It was MIS-ROUTED: sent to the operator-map branch when it belongs to the
+ * implicit-equality branch, where `{ $eq: date }` already worked.
+ *
+ * The fix is one routing predicate, and the point of these tests is that the
+ * routing question and the binding question now read the SAME list
+ * (`isBindableObjectComparand`), so no object form can ever again be "a value"
+ * to the serializer and "an operator map" to the router.
+ */
+function transportWithCapturingClient() {
+ const calls: Array<{ sql: string; args: any[] }> = [];
+ const client = {
+ execute: vi.fn(async (stmt: any) => {
+ calls.push({ sql: stmt.sql ?? String(stmt), args: stmt.args ?? [] });
+ return { rows: [], columns: [] };
+ }),
+ close: vi.fn(),
+ };
+ const t = new RemoteTransport();
+ t.setClient(client as any);
+ return { t, calls };
+}
+
+const AT = '2026-04-29T08:30:00.000Z';
+
+describe('RemoteTransport bare-Date comparand routing (#1066)', () => {
+ describe('(a) a bare Date compiles to an equality, not to nothing', () => {
+ it('emits `col = ?` binding the ISO string', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('deal', { where: { closed_at: new Date(AT) } });
+ const { sql, args } = calls[0];
+ expect(sql).toMatch(/WHERE\s+"closed_at"\s*=\s*\?/i);
+ expect(args).toEqual([AT]);
+ });
+
+ it('never compiles a bare Date to a bare table scan', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('deal', { where: { closed_at: new Date(AT) } });
+ // The pre-fix output verbatim: `SELECT * FROM "deal"` with zero args.
+ expect(calls[0].sql).not.toBe('SELECT * FROM "deal"');
+ expect(calls[0].args.length).toBeGreaterThan(0);
+ });
+
+ it('answers identically to the explicit `$eq` spelling of the same comparand', async () => {
+ const bare = transportWithCapturingClient();
+ const explicit = transportWithCapturingClient();
+ await bare.t.find('deal', { where: { closed_at: new Date(AT) } });
+ await explicit.t.find('deal', { where: { closed_at: { $eq: new Date(AT) } } });
+ expect(bare.calls[0]).toEqual(explicit.calls[0]);
+ });
+
+ it('composes with other predicates, in argument order', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('deal', { where: { stage: 'won', closed_at: new Date(AT), amount: { $gt: 10 } } });
+ const { sql, args } = calls[0];
+ expect(sql).toMatch(/"stage"\s*=\s*\?\s+AND\s+"closed_at"\s*=\s*\?\s+AND\s+"amount"\s*>\s*\?/i);
+ expect(args).toEqual(['won', AT, 10]);
+ });
+
+ it('routes a bare Date the same way inside `$and` / `$or`', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('deal', {
+ where: { $or: [{ closed_at: new Date(AT) }, { stage: 'lost' }] },
+ });
+ const { sql, args } = calls[0];
+ expect(sql).toMatch(/"closed_at"\s*=\s*\?/i);
+ expect(sql).toMatch(/OR/i);
+ expect(args).toEqual([AT, 'lost']);
+ });
+
+ it('reaches the same routing through the other WHERE-building entry points', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.count('deal', { where: { closed_at: new Date(AT) } });
+ await t.deleteMany('deal', { where: { closed_at: new Date(AT) } });
+ // `count` and `deleteMany` share `buildWhereSQL`; a predicate that
+ // vanished on a DELETE would empty the table rather than widen a read.
+ expect(calls[0].sql).toMatch(/COUNT\(\*\).+WHERE\s+"closed_at"\s*=\s*\?/i);
+ expect(calls[0].args).toEqual([AT]);
+ expect(calls[1].sql).toMatch(/DELETE FROM "deal"\s+WHERE\s+"closed_at"\s*=\s*\?/i);
+ expect(calls[1].args).toEqual([AT]);
+ });
+ });
+
+ describe('(b) every already-working spelling is byte-identical', () => {
+ it('keeps `$gte` on a Date compiling as before', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('deal', { where: { closed_at: { $gte: new Date(AT) } } });
+ expect(calls[0].sql).toBe('SELECT * FROM "deal" WHERE "closed_at" >= ?');
+ expect(calls[0].args).toEqual([AT]);
+ });
+
+ it('keeps a Date inside `$in` — the array is the operator shape, the Date is an element', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('deal', { where: { closed_at: { $in: [new Date(AT), '2026-07-28'] } } });
+ expect(calls[0].sql).toMatch(/"closed_at"\s+IN\s*\(\?,\s*\?\)/i);
+ expect(calls[0].args).toEqual([AT, '2026-07-28']);
+ });
+
+ it('keeps the operator-map branch for a real operator map on the same field', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('deal', { where: { closed_at: { $gte: new Date(AT), $lt: new Date('2026-07-28T00:00:00.000Z') } } });
+ expect(calls[0].sql).toMatch(/"closed_at"\s*>=\s*\?\s+AND\s+"closed_at"\s*<\s*\?/i);
+ expect(calls[0].args).toEqual([AT, '2026-07-28T00:00:00.000Z']);
+ });
+
+ it('keeps #1058 tight — an unbindable object comparand is still refused, not re-admitted as a value', async () => {
+ const { t } = transportWithCapturingClient();
+ await expect(
+ t.find('deal', { where: { amount: { $gt: { $field: 'budget' } } } }),
+ ).rejects.toThrow(/Cross-field comparison is not supported/);
+ });
+
+ it('keeps #1004 tight — a plain object in bare position is still an operator map, and a bad one throws', async () => {
+ const { t } = transportWithCapturingClient();
+ // The narrowing must be `Date`-shaped, not "any object with no $ keys":
+ // a typo'd operator must still reach the `default:` arm rather than
+ // becoming an implicit-equality comparand.
+ await expect(t.find('deal', { where: { amount: { $gtt: 10 } } })).rejects.toThrow(
+ /Unsupported filter operator "\$gtt"/,
+ );
+ await expect(t.find('deal', { where: { amount: { budget: 10 } } })).rejects.toThrow(
+ /key "budget" is not an operator/,
+ );
+ });
+
+ it('keeps arrays refused in bare position', async () => {
+ const { t } = transportWithCapturingClient();
+ // An array is the one object form the router still leaves alone, so it
+ // reaches the comparand gate and is refused there by FORM (#1058).
+ await expect(t.find('deal', { where: { tags: ['a', 'b'] } })).rejects.toThrow(/is an array/);
+ });
+
+ it('keeps a binary buffer refused — by whichever gate it reaches first', async () => {
+ const { t } = transportWithCapturingClient();
+ // A `Uint8Array` DOES have own enumerable keys (its indices), so it is
+ // routed to the operator map and refused there for carrying a key that is
+ // not an operator. Unchanged by this fix, and pinned so the narrowing
+ // above is never widened from "Date" to "any object that binds": it must
+ // still throw, and it must still be THIS gate that says so.
+ await expect(t.find('deal', { where: { blob: new Uint8Array([1]) } })).rejects.toThrow(
+ /key "0" is not an operator/,
+ );
+ // An EMPTY buffer has no keys at all — pre-fix it vanished exactly like a
+ // Date did; now it is the zero-clause refusal.
+ await expect(t.find('deal', { where: { blob: new Uint8Array([]) } })).rejects.toThrow(
+ /compiles to NO predicate/,
+ );
+ });
+
+ it('keeps null equality on `IS NULL` — null never reaches the comparand gate', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('sys_metadata', { where: { organization_id: null } });
+ expect(calls[0].sql).toMatch(/"organization_id"\s+IS NULL/i);
+ expect(calls[0].args).toEqual([]);
+ });
+
+ it('keeps an absent / empty top-level filter meaning "no WHERE"', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('deal', {});
+ await t.find('deal', { where: {} });
+ // A top-level `{}` is "no filter", which is a different statement from a
+ // FIELD whose filter is `{}` — see the empty-operator-map suite below.
+ expect(calls[0].sql).toBe('SELECT * FROM "deal"');
+ expect(calls[1].sql).toBe('SELECT * FROM "deal"');
+ });
+ });
+
+ describe('(c) an operator map that compiles to no predicate throws', () => {
+ it('refuses `{ field: {} }` rather than dropping the filter', async () => {
+ const { t } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: { closed_at: {} } })).rejects.toThrow(
+ /compiles to NO predicate/,
+ );
+ });
+
+ it('names the object and field, and echoes the comparand', async () => {
+ const { t } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: { closed_at: {} } })).rejects.toThrow(
+ /'deal\.closed_at'/,
+ );
+ });
+
+ it('refuses an object with no own enumerable keys whatever its prototype', async () => {
+ const { t } = transportWithCapturingClient();
+ // `Object.create(null)` and a class instance are the other two ways to
+ // arrive at "an object, zero entries" — the shape that made a Date
+ // disappear in the first place. None of them may compile to nothing.
+ await expect(
+ t.find('deal', { where: { closed_at: Object.create(null) } }),
+ ).rejects.toThrow(/compiles to NO predicate/);
+ await expect(
+ t.find('deal', { where: { closed_at: new (class Marker {})() } }),
+ ).rejects.toThrow(/compiles to NO predicate/);
+ });
+
+ it('refuses it in every WHERE-building entry point, including the writing ones', async () => {
+ const { t } = transportWithCapturingClient();
+ // A vanished predicate on `deleteMany` deletes the whole table. This is
+ // the arm where "throw loudly" is not a style preference.
+ await expect(t.deleteMany('deal', { where: { closed_at: {} } })).rejects.toThrow(
+ /compiles to NO predicate/,
+ );
+ await expect(
+ t.updateMany('deal', { where: { closed_at: {} } }, { stage: 'lost' }),
+ ).rejects.toThrow(/compiles to NO predicate/);
+ await expect(t.count('deal', { where: { closed_at: {} } })).rejects.toThrow(
+ /compiles to NO predicate/,
+ );
+ });
+
+ it('refuses it nested inside `$and` / `$or` too', async () => {
+ const { t } = transportWithCapturingClient();
+ await expect(
+ t.find('deal', { where: { $and: [{ stage: 'won' }, { closed_at: {} }] } }),
+ ).rejects.toThrow(/compiles to NO predicate/);
+ });
+
+ it('does not execute any statement when it refuses', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: { closed_at: {} } })).rejects.toThrow();
+ expect(calls).toEqual([]);
+ });
+ });
+});
+
+/**
+ * (d) The temporal seam is untouched.
+ *
+ * `TursoDriver.toRemoteFieldSpec` sends a bare comparand through
+ * `temporalFilterValue` BEFORE the filter reaches this transport, so a `Date`
+ * on a `datetime`/`date`/`time` column arrives already converted to its
+ * canonical STRING — which is why the common path never showed the bug, and
+ * why it must keep compiling exactly as it did. The blast radius was columns
+ * with no temporal metadata, where `temporalFilterValue` passes the value
+ * through unchanged (framework pins that: `temporalFilterValue(T, 'title',
+ * 'hello') === 'hello'`).
+ *
+ * These are ROW-level assertions against a real SQLite-backed libsql stub,
+ * because a dropped predicate is invisible to a SQL-string assertion: the SQL
+ * stays valid, just wider (the blind spot framework#4081 found one layer up).
+ */
+describe('TursoDriver remote — bare Date through the driver (#1066)', () => {
+ let driver: TursoDriver;
+ let stub: LibsqlSqliteStub;
+ let calls: Array<{ sql: string; args: any[] }>;
+
+ const EARLY = '2026-04-29T08:30:00.000Z';
+ const LATE = '2026-07-28T08:30:00.000Z';
+
+ beforeAll(async () => {
+ stub = makeLibsqlSqliteStub();
+ calls = [];
+ const recording = {
+ ...stub,
+ execute: async (stmt: any) => {
+ calls.push({ sql: stmt?.sql ?? String(stmt), args: stmt?.args ?? [] });
+ return stub.execute(stmt);
+ },
+ };
+ driver = new TursoDriver({ url: 'libsql://routing.turso.io', client: recording as never });
+ await driver.connect();
+ expect(driver.transportMode).toBe('remote');
+ // `at`/`on` carry temporal metadata; `noted_at` deliberately does NOT — an
+ // untyped column is what the issue's blast radius is made of (an object
+ // with no registered field metadata, an external/unmanaged table, or a
+ // caller holding `RemoteTransport` directly).
+ await driver.syncSchema('deal', {
+ name: 'deal',
+ fields: {
+ at: { type: 'datetime' },
+ on: { type: 'date' },
+ noted_at: { type: 'string' },
+ stage: { type: 'string' },
+ },
+ });
+ await driver.create('deal', {
+ id: 'd_early',
+ at: EARLY,
+ on: '2026-04-29',
+ noted_at: EARLY,
+ stage: 'won',
+ });
+ await driver.create('deal', {
+ id: 'd_late',
+ at: LATE,
+ on: '2026-07-28',
+ noted_at: LATE,
+ stage: 'lost',
+ });
+ });
+
+ afterAll(async () => {
+ await driver.disconnect();
+ stub.close();
+ });
+
+ it('a bare Date on a NON-temporal column selects one row, not the whole table', async () => {
+ const rows = (await driver.find('deal', { where: { noted_at: new Date(EARLY) } })) as any[];
+ // Pre-fix this returned BOTH rows — the predicate vanished entirely.
+ expect(rows.map((r) => r.id)).toEqual(['d_early']);
+ });
+
+ it('a bare Date on a TEMPORAL column still resolves through the temporal seam', async () => {
+ const rows = (await driver.find('deal', { where: { at: new Date(LATE) } })) as any[];
+ expect(rows.map((r) => r.id)).toEqual(['d_late']);
+ });
+
+ it('the temporal comparand still arrives PRE-converted — the seam owns it, not this fix', async () => {
+ // The distinguishing column is `date`, not `datetime`: the seam's canonical
+ // form for a `Field.date` is the bare day (`toDateOnly`), while this
+ // transport's own `Date` conversion is full ISO. If the driver ever stopped
+ // pre-converting — or if this fix started converting AHEAD of it — the bound
+ // arg would flip to `2026-07-28T08:30:00.000Z` and stop matching the stored
+ // `2026-07-28`. On `datetime` the two conversions agree, so that column
+ // could not tell the difference.
+ calls.length = 0;
+ const rows = (await driver.find('deal', { where: { on: new Date(LATE) } })) as any[];
+ expect(calls[0].args).toEqual(['2026-07-28']);
+ expect(rows.map((r) => r.id)).toEqual(['d_late']);
+ });
+
+ it('a range window on the temporal column is unchanged', async () => {
+ const rows = (await driver.find('deal', {
+ where: { at: { $gte: new Date(EARLY), $lte: new Date(LATE) } },
+ })) as any[];
+ expect(rows.map((r) => r.id).sort()).toEqual(['d_early', 'd_late']);
+ });
+
+ it('an empty operator map is refused through the driver too', async () => {
+ await expect(driver.find('deal', { where: { at: {} } })).rejects.toThrow(
+ /compiles to NO predicate/,
+ );
+ });
+});
diff --git a/packages/drivers/driver-turso/src/remote-transport-boolean-identity.test.ts b/packages/drivers/driver-turso/src/remote-transport-boolean-identity.test.ts
new file mode 100644
index 0000000000..527b1d9576
--- /dev/null
+++ b/packages/drivers/driver-turso/src/remote-transport-boolean-identity.test.ts
@@ -0,0 +1,437 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
+import { RemoteTransport } from './remote-transport.js';
+import { TursoDriver } from './turso-driver.js';
+import { makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';
+import type { QueryAST } from '@objectstack/spec/data';
+
+/**
+ * Regression: `$and`/`$or` sub-filters that compile to nothing must get the
+ * branch's BOOLEAN IDENTITY ELEMENT, not be skipped (#1073).
+ *
+ * `buildWhereSQL` collected sub-clauses with `if (sc) { … }` and emitted the
+ * group only `if (subClauses.length > 0)`. "Compiled to nothing" therefore meant
+ * "was never written" — and since AND's identity is TRUE while OR's is FALSE,
+ * one rule cannot be right for both. Measured before the fix:
+ *
+ * { $or: [] } → SELECT * FROM "deal" ← every row
+ * { $and: [] } → SELECT * FROM "deal" ← every row
+ * { $or: [{a:1}, {}] } → SELECT * FROM "deal" WHERE (("a" = ?)) ← narrowed
+ *
+ * Row two is right by accident (dropping a conjunct happens to equal TRUE); the
+ * other two are wrong in OPPOSITE directions from the same line of code. `$or:
+ * []` is the empty disjunction — FALSE, zero rows — and compiling it away turned
+ * "select nothing" into a full table scan, which on `deleteMany`/`updateMany` is
+ * a whole-table write. `{}` inside a `$or` is a TRUE disjunct and absorbs the
+ * disjunction, so the correct answer is EVERY row; dropping it returned a strict
+ * subset instead, the #1004/#1058 silent-narrowing failure worn as a widening
+ * one.
+ *
+ * Contract evidence (spec `data/filter.zod.ts`, origin/main):
+ *
+ * $and: z.array(FilterConditionSchema).optional(),
+ * $or: z.array(FilterConditionSchema).optional(),
+ *
+ * — plain `z.array`, no `.nonempty()` / `.min(1)`, and `FilterConditionSchema`
+ * is `z.record(z.string(), z.unknown()).and(z.object({…optional}))`, which `{}`
+ * satisfies. All three shapes are DECLARED LEGAL, so the transport must compile
+ * them correctly rather than refuse them. Framework's `matchesFilterCondition`
+ * (`packages/formula/src/matches-filter.ts`) already evaluates them this way and
+ * pins it — `expect(m(rec, { $or: [] })).toBe(false) // empty OR matches
+ * nothing` — as does `driver-memory`'s matcher (`.some()` over an empty array).
+ * These tests hold the remote transport to the same table.
+ *
+ * The other half of the fix is that "compiles to nothing" now has exactly ONE
+ * cause. An element that is not a filter NODE (null, a scalar, an array, a
+ * `Date`) is refused by form, so `''` can only ever mean "vacuously TRUE" —
+ * never "this transport dropped something". Reading a compilation failure as
+ * TRUE would be a wider bug than the one being fixed.
+ */
+function transportWithCapturingClient() {
+ const calls: Array<{ sql: string; args: any[] }> = [];
+ const client = {
+ execute: vi.fn(async (stmt: any) => {
+ calls.push({ sql: stmt.sql ?? String(stmt), args: stmt.args ?? [] });
+ return { rows: [], columns: [] };
+ }),
+ close: vi.fn(),
+ };
+ const t = new RemoteTransport();
+ t.setClient(client as any);
+ return { t, calls };
+}
+
+/** The SQL a `find` compiled to, plus its bind list. */
+async function compile(where: unknown): Promise<{ sql: string; args: any[] }> {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('deal', { where } as unknown as QueryAST);
+ return calls[0];
+}
+
+/**
+ * Every emitted statement must bind exactly as many args as it has `?`s.
+ *
+ * The mutation this catches: pushing a dropped branch's args anyway. A bind list
+ * that outruns its placeholders is not a compile error in libsql — the extra
+ * args are simply unused, or worse, shift the whole list by one and every
+ * predicate silently compares against its neighbour's value.
+ */
+function expectBindsBalanced(call: { sql: string; args: any[] }) {
+ expect(call.args.length).toBe((call.sql.match(/\?/g) ?? []).length);
+}
+
+const BARE_SCAN = 'SELECT * FROM "deal"';
+
+describe('RemoteTransport $and/$or identity elements (#1073)', () => {
+ describe('(a) the empty disjunction is FALSE, not "no filter"', () => {
+ it('compiles `$or: []` to a predicate that matches zero rows', async () => {
+ const call = await compile({ $or: [] });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE 1 = 0`);
+ expect(call.args).toEqual([]);
+ });
+
+ it('never compiles `$or: []` to a bare table scan', async () => {
+ // The pre-fix output verbatim. This is the assertion that fails if the
+ // group is emitted only `if (subClauses.length > 0)` again.
+ const call = await compile({ $or: [] });
+ expect(call.sql).not.toBe(BARE_SCAN);
+ expect(call.sql).toMatch(/WHERE/i);
+ });
+
+ it('ANDs the FALSE with its sibling keys rather than replacing them', async () => {
+ const call = await compile({ stage: 'won', $or: [] });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE "stage" = ? AND 1 = 0`);
+ expect(call.args).toEqual(['won']);
+ expectBindsBalanced(call);
+ });
+
+ it('carries the FALSE up through a nesting `$and`', async () => {
+ const call = await compile({ $and: [{ $or: [] }] });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE ((1 = 0))`);
+ expect(call.args).toEqual([]);
+ });
+
+ it('keeps a FALSE disjunct as a disjunct — it does not poison its siblings', async () => {
+ const call = await compile({ $or: [{ $or: [] }, { stage: 'won' }] });
+ // FALSE OR x ≡ x, so this could be simplified — but it must never be
+ // simplified in the OTHER direction. Both rows of `stage = 'won'` must
+ // still be reachable.
+ expect(call.sql).toMatch(/1 = 0/);
+ expect(call.sql).toMatch(/OR/i);
+ expect(call.sql).toMatch(/"stage"\s*=\s*\?/);
+ expect(call.args).toEqual(['won']);
+ expectBindsBalanced(call);
+ });
+ });
+
+ describe('(b) the empty conjunction is TRUE — now on purpose', () => {
+ it('compiles `$and: []` to no clause at all', async () => {
+ const call = await compile({ $and: [] });
+ expect(call.sql).toBe(BARE_SCAN);
+ expect(call.args).toEqual([]);
+ });
+
+ it('leaves sibling keys standing alone', async () => {
+ const call = await compile({ stage: 'won', $and: [] });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE "stage" = ?`);
+ expect(call.args).toEqual(['won']);
+ });
+
+ it('drops a TRUE conjunct without touching the others', async () => {
+ const call = await compile({ $and: [{ stage: 'won' }, {}] });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE (("stage" = ?))`);
+ expect(call.args).toEqual(['won']);
+ expectBindsBalanced(call);
+ });
+
+ it('answers `$and: [{x}, {}]` identically to `$and: [{x}]`', async () => {
+ const withVacuous = await compile({ $and: [{ stage: 'won' }, {}] });
+ const without = await compile({ $and: [{ stage: 'won' }] });
+ expect(withVacuous).toEqual(without);
+ });
+ });
+
+ describe('(c) a TRUE disjunct absorbs the whole disjunction', () => {
+ it('compiles `$or: [{a}, {}]` to every row, not to `a` alone', async () => {
+ const call = await compile({ $or: [{ stage: 'won' }, {}] });
+ // Pre-fix: `SELECT * FROM "deal" WHERE (("stage" = ?))` with args ['won'].
+ expect(call.sql).toBe(BARE_SCAN);
+ expect(call.args).toEqual([]);
+ });
+
+ it('does not leak the dropped disjunct\'s binds', async () => {
+ // The mutation: keep pushing `sa` into `args` while skipping the clause.
+ // Nothing in the SQL would look wrong; the statement would just carry a
+ // stray bind.
+ const call = await compile({ $or: [{ stage: 'won' }, {}] });
+ expectBindsBalanced(call);
+ expect(call.args).toHaveLength(0);
+ });
+
+ it('does not leak them on the entry points that bind unconditionally either', async () => {
+ // `find` happens to guard (`if (whereClauses) args.push(...)`), so a leak
+ // there is invisible. `count` / `deleteMany` / `updateMany` hand the WHERE
+ // args to libsql whether or not a WHERE was emitted — there a stray bind
+ // is a real "too many parameter values" failure, not a cosmetic one.
+ const { t, calls } = transportWithCapturingClient();
+ const where = { $or: [{ stage: 'won' }, {}] };
+ await t.count('deal', { where } as unknown as QueryAST);
+ await t.deleteMany('deal', { where } as any);
+ await t.updateMany('deal', { where } as any, { stage: 'lost' });
+ expect(calls[0].args).toEqual([]);
+ expect(calls[1].args).toEqual([]);
+ // Only the SET value, no WHERE args appended behind it.
+ expect(calls[2].args).toEqual(['lost']);
+ for (const call of calls) expectBindsBalanced(call);
+ });
+
+ it('absorbs regardless of where the vacuous branch sits', async () => {
+ const first = await compile({ $or: [{}, { stage: 'won' }] });
+ const last = await compile({ $or: [{ stage: 'won' }, {}] });
+ const middle = await compile({ $or: [{ stage: 'won' }, {}, { stage: 'lost' }] });
+ expect(first.sql).toBe(BARE_SCAN);
+ expect(last.sql).toBe(BARE_SCAN);
+ expect(middle.sql).toBe(BARE_SCAN);
+ expect(middle.args).toEqual([]);
+ });
+
+ it('recognises a disjunct that is TRUE by RECURSION, not just syntactically empty', async () => {
+ // `{ $and: [] }` is not `{}` — it only becomes TRUE after the branch above
+ // has been compiled. A fix that special-cased `Object.keys(sub).length ===
+ // 0` at the call site would pass every test above and fail this one.
+ const call = await compile({ $or: [{ $and: [] }, { stage: 'won' }] });
+ expect(call.sql).toBe(BARE_SCAN);
+ expect(call.args).toEqual([]);
+ });
+
+ it('still ANDs the absorbed `$or` away against its sibling keys', async () => {
+ const call = await compile({ stage: 'won', $or: [{}] });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE "stage" = ?`);
+ expect(call.args).toEqual(['won']);
+ });
+ });
+
+ describe('(d) every non-vacuous shape compiles exactly as before', () => {
+ it('keeps a two-branch `$or` byte-identical', async () => {
+ const call = await compile({ $or: [{ a: 1 }, { b: 2 }] });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE (("a" = ?) OR ("b" = ?))`);
+ expect(call.args).toEqual([1, 2]);
+ });
+
+ it('keeps a two-branch `$and` byte-identical', async () => {
+ const call = await compile({ $and: [{ a: 1 }, { b: 2 }] });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE (("a" = ?) AND ("b" = ?))`);
+ expect(call.args).toEqual([1, 2]);
+ });
+
+ it('keeps each branch ANDing its OWN keys (the #3774 invariant)', async () => {
+ const call = await compile({ $or: [{ a: 'x', b: 'y' }, { a: 'qq' }] });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE (("a" = ? AND "b" = ?) OR ("a" = ?))`);
+ expect(call.args).toEqual(['x', 'y', 'qq']);
+ });
+
+ it('keeps nested combinators and their bind order', async () => {
+ const call = await compile({
+ $or: [{ $and: [{ a: 1 }, { b: 2 }] }, { c: 3 }],
+ });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE (((("a" = ?) AND ("b" = ?))) OR ("c" = ?))`);
+ expect(call.args).toEqual([1, 2, 3]);
+ });
+
+ it('keeps a top-level empty/absent filter meaning "no WHERE"', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('deal', {});
+ await t.find('deal', { where: {} });
+ expect(calls[0].sql).toBe(BARE_SCAN);
+ expect(calls[1].sql).toBe(BARE_SCAN);
+ });
+
+ it('keeps #1071 tight — an empty operator map on a FIELD still throws', async () => {
+ // A field's `{}` has no identity element to fall back on: there is no
+ // "TRUE constraint" on a column, only a missing one. The two empties are
+ // deliberately answered differently, and this pins the pair.
+ const { t } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: { closed_at: {} } })).rejects.toThrow(
+ /compiles to NO predicate/,
+ );
+ await expect(
+ t.find('deal', { where: { $or: [{ stage: 'won' }, { closed_at: {} }] } }),
+ ).rejects.toThrow(/compiles to NO predicate/);
+ });
+
+ it('keeps #1066 tight — a bare Date inside a branch is still an equality', async () => {
+ const at = '2026-04-29T08:30:00.000Z';
+ const call = await compile({ $or: [{ closed_at: new Date(at) }, { stage: 'lost' }] });
+ expect(call.args).toEqual([at, 'lost']);
+ expectBindsBalanced(call);
+ });
+ });
+
+ describe('(e) a branch element that is NOT a filter node is refused, never read as TRUE', () => {
+ const cases: Array<[string, unknown, RegExp]> = [
+ ['null', null, /is null, not a filter condition/],
+ ['undefined', undefined, /is undefined, not a filter condition/],
+ ['a string', 'stage', /is a string, not a filter condition/],
+ ['a number', 7, /is a number, not a filter condition/],
+ ['a boolean', true, /is a boolean, not a filter condition/],
+ ['a nested AST array', [['stage', '=', 'won']], /is an array, not a filter condition/],
+ ['a Date', new Date('2026-04-29T08:30:00.000Z'), /not a filter condition/],
+ ['an empty typed array', new Uint8Array([]), /not a filter condition/],
+ ];
+
+ for (const [label, sub, message] of cases) {
+ it(`refuses ${label} in a $or branch`, async () => {
+ const { t } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: { $or: [{ stage: 'won' }, sub] } as any })).rejects.toThrow(
+ message,
+ );
+ });
+
+ it(`refuses ${label} in a $and branch`, async () => {
+ const { t } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: { $and: [sub] } as any })).rejects.toThrow(message);
+ });
+ }
+
+ it('names the branch and the INDEX so the offending element is findable', async () => {
+ const { t } = transportWithCapturingClient();
+ await expect(
+ t.find('deal', { where: { $or: [{ stage: 'won' }, { stage: 'lost' }, null] } as any }),
+ ).rejects.toThrow(/\$or\[2\] on 'deal'/);
+ });
+
+ it('refuses a non-node nested one level down too', async () => {
+ const { t } = transportWithCapturingClient();
+ await expect(
+ t.find('deal', { where: { $and: [{ $or: [null] }] } as any }),
+ ).rejects.toThrow(/\$or\[0\] on 'deal'/);
+ });
+
+ it('executes no statement when it refuses', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: { $or: [null] } as any })).rejects.toThrow();
+ expect(calls).toEqual([]);
+ });
+
+ it('does NOT refuse the shapes that are legal — `{}` and `[]` still compile', async () => {
+ // The refusal must not swallow the identity cases it sits next to.
+ await expect(compile({ $or: [{}] })).resolves.toBeDefined();
+ await expect(compile({ $or: [] })).resolves.toBeDefined();
+ await expect(compile({ $and: [{}] })).resolves.toBeDefined();
+ await expect(compile({ $or: [Object.create(null)] })).resolves.toBeDefined();
+ });
+ });
+
+ describe('(f) the same answers through every WHERE-building entry point', () => {
+ it('compiles `$or: []` to FALSE on count / deleteMany / updateMany', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.count('deal', { object: 'deal', where: { $or: [] } });
+ await t.deleteMany('deal', { where: { $or: [] } } as any);
+ await t.updateMany('deal', { where: { $or: [] } } as any, { stage: 'lost' });
+ // Pre-fix these were an unfiltered COUNT, a DELETE of the whole table and
+ // an UPDATE of every row. The write paths are why this is not cosmetic.
+ expect(calls[0].sql).toMatch(/COUNT\(\*\).+WHERE 1 = 0/i);
+ expect(calls[1].sql).toBe('DELETE FROM "deal" WHERE 1 = 0');
+ expect(calls[2].sql).toMatch(/^UPDATE "deal" SET .* WHERE 1 = 0$/);
+ });
+
+ it('refuses a non-node branch element on the writing entry points too', async () => {
+ const { t } = transportWithCapturingClient();
+ await expect(t.deleteMany('deal', { where: { $or: [null] } } as any)).rejects.toThrow(
+ /not a filter condition/,
+ );
+ await expect(
+ t.updateMany('deal', { where: { $and: ['stage'] } } as any, { stage: 'lost' }),
+ ).rejects.toThrow(/not a filter condition/);
+ });
+ });
+});
+
+/**
+ * (g) Rows, not SQL strings.
+ *
+ * A mis-applied identity element leaves the SQL valid — just answering a
+ * different question — so the string assertions above cannot tell "matches zero
+ * rows" from "matches every row" on their own. These run the compiled statements
+ * against a real SQLite database wearing the libsql interface and count what
+ * comes back, the same instrument #1066 used.
+ */
+describe('TursoDriver remote — identity elements on real rows (#1073)', () => {
+ let driver: TursoDriver;
+ let stub: LibsqlSqliteStub;
+
+ beforeAll(async () => {
+ stub = makeLibsqlSqliteStub();
+ driver = new TursoDriver({ url: 'libsql://identity.turso.io', client: stub as never });
+ await driver.connect();
+ expect(driver.transportMode).toBe('remote');
+ await driver.syncSchema('deal', {
+ name: 'deal',
+ fields: { stage: { type: 'string' }, amount: { type: 'number' } },
+ });
+ await driver.create('deal', { id: 'd_won', stage: 'won', amount: 10 });
+ await driver.create('deal', { id: 'd_lost', stage: 'lost', amount: 20 });
+ await driver.create('deal', { id: 'd_open', stage: 'open', amount: 30 });
+ });
+
+ afterAll(async () => {
+ await driver.disconnect();
+ stub.close();
+ });
+
+ const ids = async (where: unknown) =>
+ ((await driver.find('deal', { where } as unknown as QueryAST)) as any[]).map((r) => r.id).sort();
+
+ it('`$or: []` returns ZERO rows', async () => {
+ // Pre-fix: all three.
+ expect(await ids({ $or: [] })).toEqual([]);
+ });
+
+ it('`$and: []` returns every row', async () => {
+ expect(await ids({ $and: [] })).toEqual(['d_lost', 'd_open', 'd_won']);
+ });
+
+ it('`$or: [{stage:won}, {}]` returns every row', async () => {
+ // Pre-fix: only `d_won` — the vacuous disjunct, which admits everything,
+ // was dropped and the answer silently narrowed to a strict subset.
+ expect(await ids({ $or: [{ stage: 'won' }, {}] })).toEqual(['d_lost', 'd_open', 'd_won']);
+ });
+
+ it('`$and: [{stage:won}, {}]` still returns only the matching row', async () => {
+ expect(await ids({ $and: [{ stage: 'won' }, {}] })).toEqual(['d_won']);
+ });
+
+ it('`count` with an absorbed `$or` counts every row and binds nothing', async () => {
+ // Executed, not string-matched: a stray bind left over from the absorbed
+ // disjunct makes better-sqlite3 reject the statement outright.
+ expect(await driver.count('deal', { object: 'deal', where: { $or: [{ stage: 'won' }, {}] } })).toBe(3);
+ expect(await driver.count('deal', { object: 'deal', where: { $or: [] } })).toBe(0);
+ });
+
+ it('a real two-branch `$or` is unchanged', async () => {
+ expect(await ids({ $or: [{ stage: 'won' }, { stage: 'lost' }] })).toEqual(['d_lost', 'd_won']);
+ });
+
+ it('`{ stage: won, $or: [] }` returns zero rows — the FALSE wins the AND', async () => {
+ expect(await ids({ stage: 'won', $or: [] })).toEqual([]);
+ });
+
+ it('`deleteMany` with `$or: []` deletes NOTHING', async () => {
+ // The arm that made this a P0 rather than a wrong count: pre-fix the WHERE
+ // vanished and the statement emptied the table.
+ await driver.deleteMany('deal', { where: { $or: [] } } as any);
+ expect(await ids({})).toEqual(['d_lost', 'd_open', 'd_won']);
+ });
+
+ it('`updateMany` with `$or: []` updates NOTHING', async () => {
+ await driver.updateMany('deal', { where: { $or: [] } } as any, { stage: 'zzz' });
+ expect(await ids({ stage: 'zzz' })).toEqual([]);
+ });
+
+ it('a non-node branch element is refused through the driver too', async () => {
+ await expect(driver.find('deal', { where: { $or: [null] } } as unknown as QueryAST)).rejects.toThrow(
+ /not a filter condition/,
+ );
+ });
+});
diff --git a/packages/drivers/driver-turso/src/remote-transport-comparand-refusal.test.ts b/packages/drivers/driver-turso/src/remote-transport-comparand-refusal.test.ts
new file mode 100644
index 0000000000..450b9fd533
--- /dev/null
+++ b/packages/drivers/driver-turso/src/remote-transport-comparand-refusal.test.ts
@@ -0,0 +1,220 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { describe, it, expect, vi } from 'vitest';
+import { RemoteTransport } from './remote-transport.js';
+
+/**
+ * Regression: the VALUE half of #1004 (#1058).
+ *
+ * `buildWhereSQL` bound every KNOWN operator's comparand through
+ * `serializeValue`, which `JSON.stringify`s any object. So a comparison value
+ * this transport cannot compile — the spec's cross-field marker
+ * `{ $field: 'budget' }` being the sample that found it — became a string
+ * literal:
+ *
+ * { amount: { $gt: { $field: 'budget' } } }
+ * → SELECT * FROM "deal" WHERE "amount" > ? args ["{\"$field\":\"budget\"}"]
+ *
+ * Valid SQL, zero rows, zero errors. In SQLite's type ordering every number
+ * sorts below every string, so the predicate is false for EVERY row, and the
+ * caller cannot tell that from "nothing matched".
+ *
+ * #1004 closed the same failure mode in the OPERATOR position — an operator the
+ * transport cannot compile throws instead of degrading to an equality. It left
+ * the VALUE position open, so the identical mistake threw or went silent purely
+ * by nesting depth: `{ amount: { $field: 'x' } }` threw (the `$field` key is not
+ * an operator), while `{ amount: { $gt: { $field: 'x' } } }` returned an empty
+ * page. These tests pin BOTH halves closed, and pin the write path — where an
+ * object IS a JSON column's value — as deliberately unchanged.
+ */
+function transportWithCapturingClient() {
+ const calls: Array<{ sql: string; args: any[] }> = [];
+ const client = {
+ execute: vi.fn(async (stmt: any) => {
+ calls.push({ sql: stmt.sql ?? String(stmt), args: stmt.args ?? [] });
+ return { rows: [], columns: [] };
+ }),
+ close: vi.fn(),
+ };
+ const t = new RemoteTransport();
+ t.setClient(client as any);
+ return { t, calls };
+}
+
+describe('RemoteTransport comparand refusal — the value half of #1004', () => {
+ describe('control: comparands the transport CAN bind still compile', () => {
+ it('binds a literal range comparand', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('deal', { where: { amount: { $gt: 1000 } } });
+ expect(calls[0].sql).toMatch(/"amount"\s*>\s*\?/);
+ expect(calls[0].args).toEqual([1000]);
+ });
+
+ it('binds string, boolean and bigint comparands', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('deal', { where: { name: 'Alice', is_open: true, seq: { $lte: 9n } } });
+ expect(calls[0].args).toEqual(['Alice', true, 9n]);
+ });
+
+ it('keeps the declared `Date` → ISO 8601 conversion', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ const at = new Date('2026-04-29T08:30:00.000Z');
+ await t.find('deal', { where: { closed_at: { $gte: at }, seen_at: { $eq: at } } });
+ expect(calls[0].args).toEqual(['2026-04-29T08:30:00.000Z', '2026-04-29T08:30:00.000Z']);
+ });
+
+ it('keeps null equality on `IS NULL` / `IS NOT NULL` (no bind to gate)', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('sys_metadata', { where: { organization_id: null, a: { $eq: null }, b: { $ne: null } } });
+ expect(calls[0].sql).toMatch(/"organization_id"\s+IS NULL/i);
+ expect(calls[0].sql).toMatch(/"a"\s+IS NULL/i);
+ expect(calls[0].sql).toMatch(/"b"\s+IS NOT NULL/i);
+ expect(calls[0].args).toEqual([]);
+ });
+
+ it('keeps `$in` / `$nin` arrays — the array is the operator shape, its ELEMENTS are the comparands', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('deal', { where: { id: { $in: ['a', 'b'] }, stage: { $nin: ['lost'] } } });
+ expect(calls[0].sql).toMatch(/"id"\s+IN\s*\(\?,\s*\?\)/i);
+ expect(calls[0].sql).toMatch(/"stage"\s+NOT IN\s*\(\?\)/i);
+ expect(calls[0].args).toEqual(['a', 'b', 'lost']);
+ });
+
+ it('keeps the LIKE family compiling a string comparand', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('deal', { where: { name: { $startsWith: 'Alp' } } });
+ expect(calls[0].sql).toMatch(/"name"\s+LIKE\s+\?/i);
+ expect(calls[0].args).toEqual(['Alp%']);
+ });
+ });
+
+ describe('the probe from #1058: a cross-field marker in value position', () => {
+ // Every scalar comparison operator, each with the marker as its comparand.
+ const markerCases: Array<[string, any]> = [
+ ['$gt', { amount: { $gt: { $field: 'budget' } } }],
+ ['$gte', { amount: { $gte: { $field: 'budget' } } }],
+ ['$lt', { amount: { $lt: { $field: 'budget' } } }],
+ ['$lte', { amount: { $lte: { $field: 'budget' } } }],
+ ['$eq', { amount: { $eq: { $field: 'budget' } } }],
+ ['$ne', { amount: { $ne: { $field: 'budget' } } }],
+ ];
+
+ it.each(markerCases)('refuses %s { $field } by name instead of binding its JSON text', async (_op, where) => {
+ const { t, calls } = transportWithCapturingClient();
+ await expect(t.find('deal', { where })).rejects.toThrow(/Cross-field comparison is not supported/i);
+ // Nothing reached the database: a refused filter must not run a query at all.
+ expect(calls).toHaveLength(0);
+ });
+
+ it('names the operator, the column and the offending value in the message', async () => {
+ const { t } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: { amount: { $gt: { $field: 'budget' } } } })).rejects.toThrow(
+ /'deal\.amount' \$gt \{"\$field":"budget"\}/,
+ );
+ });
+
+ it('says what to do instead — not "try another operator", which fails identically', async () => {
+ const { t } = transportWithCapturingClient();
+ const err = await t.find('deal', { where: { amount: { $gt: { $field: 'budget' } } } }).catch((e) => e);
+ expect(err.message).toMatch(/Compare against a literal/i);
+ expect(err.message).toMatch(/matches nothing/i);
+ });
+
+ it('still refuses the BARE marker via the #1004 unknown-operator arm', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: { amount: { $field: 'budget' } } })).rejects.toThrow(
+ /\$field/,
+ );
+ expect(calls).toHaveLength(0);
+ });
+
+ it('refuses the marker inside the LIKE family too (`String(marker)` is "[object Object]")', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: { name: { $contains: { $field: 'code' } } } })).rejects.toThrow(
+ /Cross-field comparison is not supported/i,
+ );
+ expect(calls).toHaveLength(0);
+ });
+
+ it('refuses the marker as an `$in` element, naming which element', async () => {
+ const { t } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: { id: { $in: ['a', { $field: 'other_id' }] } } })).rejects.toThrow(
+ /'deal\.id' \$in\[1\]/,
+ );
+ });
+ });
+
+ describe('any other comparand the transport cannot bind', () => {
+ it('refuses a plain object under $eq, naming its form', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: { payload: { $eq: { a: 1 } } } })).rejects.toThrow(
+ /Filter comparand 'deal\.payload' \$eq \{"a":1\} is an object, which this transport cannot bind/,
+ );
+ expect(calls).toHaveLength(0);
+ });
+
+ it('refuses an array comparand under $eq', async () => {
+ const { t } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: { tags: { $eq: ['a', 'b'] } } })).rejects.toThrow(
+ /'deal\.tags' \$eq \["a","b"\] is an array/,
+ );
+ });
+
+ it('refuses an array in implicit-equality position, the same as under $eq', async () => {
+ const { t } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: { tags: ['a', 'b'] } })).rejects.toThrow(
+ /'deal\.tags' \$eq \["a","b"\] is an array/,
+ );
+ });
+
+ it('lists the comparand forms it DOES accept', async () => {
+ const { t } = transportWithCapturingClient();
+ const err = await t.find('deal', { where: { payload: { $eq: { a: 1 } } } }).catch((e) => e);
+ expect(err.message).toMatch(/must be a string, number, bigint, boolean, null or Date/);
+ expect(err.message).toMatch(/#1004, #1058/);
+ });
+
+ it('truncates a large comparand rather than pasting it into the log', async () => {
+ const { t } = transportWithCapturingClient();
+ const big = { blob: 'x'.repeat(5_000) };
+ const err = await t.find('deal', { where: { payload: { $eq: big } } }).catch((e) => e);
+ expect(err.message).toContain('…');
+ expect(err.message.length).toBeLessThan(600);
+ });
+
+ it('refuses through `$or` / `$and` — a sub-clause cannot degrade quietly either', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await expect(
+ t.find('deal', {
+ where: { $or: [{ amount: { $gt: 1 } }, { amount: { $gt: { $field: 'budget' } } }] },
+ }),
+ ).rejects.toThrow(/Cross-field comparison/i);
+ expect(calls).toHaveLength(0);
+ });
+
+ it('refuses on every write path that takes a filter, not just `find`', async () => {
+ const { t } = transportWithCapturingClient();
+ const where = { amount: { $gt: { $field: 'budget' } } };
+ await expect(t.count('deal', { where })).rejects.toThrow(/Cross-field comparison/i);
+ await expect(t.updateMany('deal', { where }, { stage: 'won' })).rejects.toThrow(/Cross-field comparison/i);
+ await expect(t.deleteMany('deal', { where })).rejects.toThrow(/Cross-field comparison/i);
+ });
+ });
+
+ describe('the write path is deliberately NOT gated', () => {
+ it('still stores an object payload as JSON text', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.create('deal', { id: 'd1', payload: { a: 1 }, tags: ['x'] });
+ const insert = calls[0];
+ expect(insert.sql).toMatch(/^INSERT INTO "deal"/);
+ expect(insert.args).toEqual(['d1', '{"a":1}', '["x"]']);
+ });
+
+ it('still stores an object payload as JSON text on update', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.update('deal', 'd1', { payload: { a: 1 } });
+ expect(calls[0].sql).toMatch(/^UPDATE "deal"/);
+ expect(calls[0].args).toEqual(['{"a":1}', 'd1']);
+ });
+ });
+});
diff --git a/packages/drivers/driver-turso/src/remote-transport-not-operator.test.ts b/packages/drivers/driver-turso/src/remote-transport-not-operator.test.ts
new file mode 100644
index 0000000000..86ef90db99
--- /dev/null
+++ b/packages/drivers/driver-turso/src/remote-transport-not-operator.test.ts
@@ -0,0 +1,512 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
+import { RemoteTransport } from './remote-transport.js';
+import { TursoDriver } from './turso-driver.js';
+import { makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';
+import type { QueryAST } from '@objectstack/spec/data';
+
+/**
+ * Regression: the remote transport must compile the spec's THIRD logical
+ * operator, `$not` (#1076).
+ *
+ * `buildWhereSQL` handled `$and` and `$or` and had no `$not` branch at all (the
+ * only match in the file was `$notContains`), so `$not` fell through to the
+ * FIELD path and was read as a column literally named `$not`. Measured on
+ * `origin/main` before the fix, with the capturing client below:
+ *
+ * { $not: { $eq: 'won' } } → SELECT * FROM "deal" WHERE "$not" = ? ['won']
+ * { $not: null } → SELECT * FROM "deal" WHERE "$not" IS NULL
+ * { $not: { stage: 'won' } } → THREW: Filter on 'deal.$not' has an object
+ * comparand whose key "stage" is not an operator
+ * { $not: { $not: {…} } } → THREW: Unsupported filter operator "$not" on
+ * 'deal.$not'
+ *
+ * Rows one and two are valid SQL against a column that cannot exist (SQLite:
+ * `no such column: $not`); rows three and four report `$not` as a FIELD of
+ * `deal`, sending the reader to `describe_object` to look for it — the #1051
+ * diagnostic detour worn by a different key.
+ *
+ * Contract evidence (spec `data/filter.zod.ts`, framework origin/main):
+ *
+ * $and: z.array(FilterConditionSchema).optional(),
+ * $or: z.array(FilterConditionSchema).optional(),
+ * $not: FilterConditionSchema.optional(),
+ *
+ * — declared in the same object literal as the two that WERE implemented. And
+ * it is a shape real rules produce: `SqlDriver.applyFilterCondition` compiles it
+ * with `whereNot`/`orWhereNot` (framework#2704, added to close this same
+ * silent-filter-bypass family), `driver-memory`'s matcher and
+ * `matchesFilterCondition` both evaluate it, and CEL `!expr` in a permission /
+ * RLS read scope lowers to `{ $not: {…} }` (`formula/src/cel-to-filter.ts`). So
+ * one RLS scope answered correctly on a local SqlDriver and broke on Turso
+ * remote — a local/remote divergence on a declared spec construct.
+ *
+ * Two semantics are deliberate and pinned below, because the three in-tree
+ * implementations do not agree on them (filed as objectstack#5146):
+ *
+ * - **NULL rows follow the SQL family.** `NOT ("stage" = ?)` is UNKNOWN when
+ * `stage` is NULL, so that row is not returned — exactly what Knex's
+ * `whereNot` emits for local mode (`select … where (not (\`stage\` = ?))`,
+ * measured). `driver-memory`/`matchesFilterCondition` return it. Remote mode
+ * is pinned to the family it belongs to, so local and remote SQL agree.
+ * - **`$not: {}` is FALSE.** The inner filter compiles to no SQL, which under
+ * the #1073 invariant can only mean "vacuously TRUE", and `NOT TRUE` is
+ * FALSE. `driver-memory` and `matchesFilterCondition` agree; driver-sql
+ * returns every row there, but only because Knex drops an empty group — the
+ * widening direction of the very bug family #2704 closed.
+ */
+function transportWithCapturingClient() {
+ const calls: Array<{ sql: string; args: any[] }> = [];
+ const client = {
+ execute: vi.fn(async (stmt: any) => {
+ calls.push({ sql: stmt.sql ?? String(stmt), args: stmt.args ?? [] });
+ return { rows: [], columns: [] };
+ }),
+ close: vi.fn(),
+ };
+ const t = new RemoteTransport();
+ t.setClient(client as any);
+ return { t, calls };
+}
+
+/** The SQL a `find` compiled to, plus its bind list. */
+async function compile(where: unknown): Promise<{ sql: string; args: any[] }> {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('deal', { where } as unknown as QueryAST);
+ return calls[0];
+}
+
+/**
+ * Every emitted statement must bind exactly as many args as it has `?`s.
+ *
+ * The mutation this catches: pushing the negated sub-filter's args while
+ * emitting `1 = 0` instead of its clause (or the reverse). A bind list that
+ * outruns its placeholders is not a compile error in libsql — the extra args
+ * are unused, or shift the list and every predicate compares against its
+ * neighbour's value.
+ */
+function expectBindsBalanced(call: { sql: string; args: any[] }) {
+ expect(call.args.length).toBe((call.sql.match(/\?/g) ?? []).length);
+}
+
+const BARE_SCAN = 'SELECT * FROM "deal"';
+
+describe('RemoteTransport $not (#1076)', () => {
+ describe('(a) the two spellings from the issue', () => {
+ it('compiles `$not: { stage: "won" }` to a negation, not to a field named $not', async () => {
+ // Pre-fix: THREW `Filter on 'deal.$not' has an object comparand whose key
+ // "stage" is not an operator`.
+ const call = await compile({ $not: { stage: 'won' } });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE NOT ("stage" = ?)`);
+ expect(call.args).toEqual(['won']);
+ expectBindsBalanced(call);
+ });
+
+ it('never emits a column named `$not` again', async () => {
+ // The one assertion that fails if the `$not` branch is removed or is
+ // guarded so tightly that some spelling slips back onto the field path.
+ const shapes: unknown[] = [
+ { $not: { stage: 'won' } },
+ { $not: { $or: [{ stage: 'won' }, { stage: 'lost' }] } },
+ { $not: { $not: { stage: 'won' } } },
+ { $not: {} },
+ { owner_id: 'u1', $not: { stage: 'lost' } },
+ ];
+ for (const where of shapes) {
+ const call = await compile(where);
+ expect(call.sql).not.toMatch(/"\$not"/);
+ }
+ });
+
+ it('negates a multi-key inner condition as ONE group, like `whereNot` does', async () => {
+ // `NOT (a AND b)`, never `NOT (a) AND b` — the inner object is one
+ // condition and De Morgan is not the caller's intent.
+ const call = await compile({ $not: { stage: 'won', amount: 10 } });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE NOT ("stage" = ? AND "amount" = ?)`);
+ expect(call.args).toEqual(['won', 10]);
+ });
+
+ it('compiles the issue\'s `$not: { $eq: "won" }` as a negation of what the caller wrote', async () => {
+ // Pre-fix: `WHERE "$not" = ?`. `$not` is now the operator it is declared
+ // to be; the residual `"$eq"` column is the CALLER's malformed inner
+ // condition (a field operator written one level too high) and is compiled
+ // byte-identically by the local `SqlDriver`, which also hands a
+ // condition-level `$eq` to Knex as a column name. Tightening that is
+ // #1077, deliberately not this fix — doing it here would diverge remote
+ // from local at a second key while closing the first.
+ const call = await compile({ $not: { $eq: 'won' } });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE NOT ("$eq" = ?)`);
+ expect(call.args).toEqual(['won']);
+ });
+ });
+
+ describe('(b) `$not` of a vacuously TRUE condition is FALSE', () => {
+ it('compiles `$not: {}` to a predicate that matches zero rows', async () => {
+ const call = await compile({ $not: {} });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE 1 = 0`);
+ expect(call.args).toEqual([]);
+ });
+
+ it('never compiles `$not: {}` to a bare table scan', async () => {
+ // The mutation: emit nothing when the sub-filter compiled to nothing.
+ // That says TRUE — the exact inversion of the answer, and on
+ // deleteMany/updateMany a whole-table write.
+ const call = await compile({ $not: {} });
+ expect(call.sql).not.toBe(BARE_SCAN);
+ expect(call.sql).toMatch(/WHERE/i);
+ });
+
+ it('recognises TRUE by RECURSION, not just a syntactically empty object', async () => {
+ // `{ $and: [] }` is TRUE only after #1073's branch has compiled it. A fix
+ // that special-cased `Object.keys(value).length === 0` passes the two
+ // tests above and fails this one.
+ const call = await compile({ $not: { $and: [] } });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE 1 = 0`);
+ expect(call.args).toEqual([]);
+ });
+
+ it('inverts #1073\'s FALSE back to TRUE — `$not: { $or: [] }` matches every row', async () => {
+ const call = await compile({ $not: { $or: [] } });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE NOT (1 = 0)`);
+ expect(call.args).toEqual([]);
+ });
+
+ it('ANDs the FALSE with its sibling keys rather than replacing them', async () => {
+ const call = await compile({ stage: 'won', $not: {} });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE "stage" = ? AND 1 = 0`);
+ expect(call.args).toEqual(['won']);
+ expectBindsBalanced(call);
+ });
+ });
+
+ describe('(c) nesting and double negation compile by recursion', () => {
+ it('compiles `$not: { $not: {…} }` as two nested negations', async () => {
+ // Pre-fix: THREW `Unsupported filter operator "$not" on 'deal.$not'`.
+ const call = await compile({ $not: { $not: { stage: 'won' } } });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE NOT (NOT ("stage" = ?))`);
+ expect(call.args).toEqual(['won']);
+ expectBindsBalanced(call);
+ });
+
+ it('compiles `$not` over a nested `$or`', async () => {
+ const call = await compile({ $not: { $or: [{ stage: 'won' }, { stage: 'lost' }] } });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE NOT ((("stage" = ?) OR ("stage" = ?)))`);
+ expect(call.args).toEqual(['won', 'lost']);
+ expectBindsBalanced(call);
+ });
+
+ it('compiles `$not` over a nested `$and`', async () => {
+ const call = await compile({ $not: { $and: [{ stage: 'won' }, { amount: 10 }] } });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE NOT ((("stage" = ?) AND ("amount" = ?)))`);
+ expect(call.args).toEqual(['won', 10]);
+ });
+
+ it('carries operator maps through the negation with their binds in order', async () => {
+ const call = await compile({ $not: { amount: { $gte: 10, $lt: 100 } } });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE NOT ("amount" >= ? AND "amount" < ?)`);
+ expect(call.args).toEqual([10, 100]);
+ expectBindsBalanced(call);
+ });
+
+ it('keeps the null-equality rule inside a negation (`IS NULL`, never `= NULL`)', async () => {
+ // `NOT ("organization_id" = NULL)` is UNKNOWN for every row, so the
+ // env-wide negation would match nothing at all — the #937 failure with a
+ // NOT in front of it.
+ const call = await compile({ $not: { organization_id: null } });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE NOT ("organization_id" IS NULL)`);
+ expect(call.args).toEqual([]);
+ });
+ });
+
+ describe('(d) the RLS shape: a `$not` sitting inside `$and` with siblings', () => {
+ it('compiles a scope of the shape CEL `!expr` produces', async () => {
+ // What `cel-to-filter.ts` emits for `owner_id == user.id && !(stage ==
+ // 'lost')`. Pre-fix the whole read THREW, so the scope was unusable on
+ // Turso remote while working locally.
+ const call = await compile({ $and: [{ owner_id: 'u1' }, { $not: { stage: 'lost' } }] });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE (("owner_id" = ?) AND (NOT ("stage" = ?)))`);
+ expect(call.args).toEqual(['u1', 'lost']);
+ expectBindsBalanced(call);
+ });
+
+ it('compiles a `$not` inside an `$or` branch', async () => {
+ const call = await compile({ $or: [{ $not: { stage: 'lost' } }, { owner_id: 'u1' }] });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE ((NOT ("stage" = ?)) OR ("owner_id" = ?))`);
+ expect(call.args).toEqual(['lost', 'u1']);
+ });
+
+ it('keeps a `$not` sibling of plain field keys at the same level', async () => {
+ const call = await compile({ owner_id: 'u1', $not: { stage: 'lost' } });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE "owner_id" = ? AND NOT ("stage" = ?)`);
+ expect(call.args).toEqual(['u1', 'lost']);
+ });
+
+ it('lets #1073\'s identity elements survive under a negation', async () => {
+ // `$not` of a group that absorbed a TRUE disjunct is `NOT TRUE` = FALSE.
+ const call = await compile({ $not: { $or: [{ stage: 'won' }, {}] } });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE 1 = 0`);
+ expect(call.args).toEqual([]);
+ });
+ });
+
+ describe('(e) a `$not` operand that is not a filter node is refused, never read as a column', () => {
+ const cases: Array<[string, unknown, RegExp]> = [
+ ['null', null, /is null, not a filter condition/],
+ ['undefined', undefined, /is undefined, not a filter condition/],
+ ['a string', 'won', /is a string, not a filter condition/],
+ ['a number', 7, /is a number, not a filter condition/],
+ ['a boolean', true, /is a boolean, not a filter condition/],
+ ['an array', [{ stage: 'won' }], /is an array, not a filter condition/],
+ ['a Date', new Date('2026-04-29T08:30:00.000Z'), /not a filter condition/],
+ ['a typed array', new Uint8Array([]), /not a filter condition/],
+ ];
+
+ for (const [label, operand, message] of cases) {
+ it(`refuses ${label} as the $not operand`, async () => {
+ const { t } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: { $not: operand } as any })).rejects.toThrow(message);
+ });
+ }
+
+ it('names `$not` without an index — it takes one operand, not a list', async () => {
+ const { t } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: { $not: null } as any })).rejects.toThrow(
+ /\$not on 'deal'/,
+ );
+ });
+
+ it('refuses `$not: null` instead of compiling `"$not" IS NULL`', async () => {
+ // The pre-fix output verbatim: a valid, silently-narrowing predicate on a
+ // column that cannot exist. This is why the branch has no
+ // `&& isFilterNode(value)` guard — a guard sends this shape straight back
+ // to the field path it came from.
+ const { t, calls } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: { $not: null } as any })).rejects.toThrow();
+ expect(calls).toEqual([]);
+ });
+
+ it('says what a negation of "no condition" is written as', async () => {
+ const { t } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: { $not: 'won' } as any })).rejects.toThrow(
+ /\$not takes exactly ONE condition/,
+ );
+ });
+
+ it('keeps refusing what the INNER filter refuses — the negation swallows nothing', async () => {
+ const { t } = transportWithCapturingClient();
+ // #1071: an empty operator map on a field.
+ await expect(t.find('deal', { where: { $not: { closed_at: {} } } } as unknown as QueryAST)).rejects.toThrow(
+ /compiles to NO predicate/,
+ );
+ // #1004: an unknown operator.
+ await expect(
+ t.find('deal', { where: { $not: { stage: { $like: 'w%' } } } } as unknown as QueryAST),
+ ).rejects.toThrow(/Unsupported filter operator "\$like"/);
+ // #1058: an unbindable comparand.
+ await expect(
+ t.find('deal', { where: { $not: { amount: { $gt: { $field: 'budget' } } } } } as unknown as QueryAST),
+ ).rejects.toThrow(/Cross-field comparison is not supported/);
+ // #1073: a non-node element of a nested logical array.
+ await expect(t.find('deal', { where: { $not: { $or: [null] } } } as unknown as QueryAST)).rejects.toThrow(
+ /\$or\[0\] on 'deal'/,
+ );
+ });
+
+ it('executes no statement when it refuses', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: { $not: [{ stage: 'won' }] } as any })).rejects.toThrow();
+ expect(calls).toEqual([]);
+ });
+
+ it('does NOT refuse the shapes that are legal', async () => {
+ await expect(compile({ $not: {} })).resolves.toBeDefined();
+ await expect(compile({ $not: Object.create(null) })).resolves.toBeDefined();
+ await expect(compile({ $not: { stage: 'won' } })).resolves.toBeDefined();
+ });
+ });
+
+ describe('(f) the same answers through every WHERE-building entry point', () => {
+ it('negates on count / deleteMany / updateMany', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ const where = { $not: { stage: 'won' } };
+ await t.count('deal', { where } as unknown as QueryAST);
+ await t.deleteMany('deal', { where } as any);
+ await t.updateMany('deal', { where } as any, { stage: 'lost' });
+ expect(calls[0].sql).toMatch(/COUNT\(\*\).+WHERE NOT \("stage" = \?\)/i);
+ expect(calls[1].sql).toBe('DELETE FROM "deal" WHERE NOT ("stage" = ?)');
+ expect(calls[2].sql).toMatch(/^UPDATE "deal" SET .* WHERE NOT \("stage" = \?\)$/);
+ for (const call of calls) expectBindsBalanced(call);
+ });
+
+ it('compiles `$not: {}` to FALSE on the writing entry points', async () => {
+ // Pre-fix these threw; the mutation to guard against now is the opposite
+ // one — dropping the clause, which empties the table.
+ const { t, calls } = transportWithCapturingClient();
+ await t.deleteMany('deal', { where: { $not: {} } } as any);
+ await t.updateMany('deal', { where: { $not: {} } } as any, { stage: 'lost' });
+ expect(calls[0].sql).toBe('DELETE FROM "deal" WHERE 1 = 0');
+ expect(calls[1].sql).toMatch(/^UPDATE "deal" SET .* WHERE 1 = 0$/);
+ expect(calls[1].args).toEqual(['lost']);
+ for (const call of calls) expectBindsBalanced(call);
+ });
+
+ it('refuses a non-node `$not` operand on the writing entry points too', async () => {
+ const { t } = transportWithCapturingClient();
+ await expect(t.deleteMany('deal', { where: { $not: null } } as any)).rejects.toThrow(
+ /not a filter condition/,
+ );
+ await expect(
+ t.updateMany('deal', { where: { $not: 'won' } } as any, { stage: 'lost' }),
+ ).rejects.toThrow(/not a filter condition/);
+ });
+ });
+});
+
+/**
+ * (g) Rows, not SQL strings.
+ *
+ * A negation that compiles to valid-but-wrong SQL reads the same as a correct
+ * one in a string assertion — the difference only shows up in what comes back.
+ * These run the compiled statements against a real SQLite database wearing the
+ * libsql interface, the instrument #1066/#1073 used.
+ */
+describe('TursoDriver remote — $not on real rows (#1076)', () => {
+ let driver: TursoDriver;
+ let stub: LibsqlSqliteStub;
+
+ beforeAll(async () => {
+ stub = makeLibsqlSqliteStub();
+ driver = new TursoDriver({ url: 'libsql://not-operator.turso.io', client: stub as never });
+ await driver.connect();
+ expect(driver.transportMode).toBe('remote');
+ await driver.syncSchema('deal', {
+ name: 'deal',
+ fields: {
+ stage: { type: 'string' },
+ owner_id: { type: 'string' },
+ amount: { type: 'number' },
+ closed_at: { type: 'datetime' },
+ },
+ });
+ await driver.create('deal', { id: 'd_won', stage: 'won', owner_id: 'u1', amount: 10 });
+ await driver.create('deal', { id: 'd_lost', stage: 'lost', owner_id: 'u1', amount: 20 });
+ await driver.create('deal', { id: 'd_open', stage: 'open', owner_id: 'u2', amount: 30 });
+ // No `stage` at all — the row the SQL family and the JS family disagree
+ // about, pinned in its own test below.
+ await driver.create('deal', { id: 'd_null', owner_id: 'u2', amount: 40 });
+ });
+
+ afterAll(async () => {
+ await driver.disconnect();
+ stub.close();
+ });
+
+ const ids = async (where: unknown) =>
+ ((await driver.find('deal', { where } as unknown as QueryAST)) as any[]).map((r) => r.id).sort();
+
+ it('`$not: { stage: "won" }` returns the other rows, not a `no such column` error', async () => {
+ // Pre-fix: threw before reaching SQLite; had it compiled, SQLite would have
+ // answered `no such column: $not`.
+ expect(await ids({ $not: { stage: 'won' } })).toEqual(['d_lost', 'd_open']);
+ });
+
+ it('drops the NULL-`stage` row — SQL three-valued logic, as `whereNot` does locally', async () => {
+ // `d_null` is absent above and here. This is the SQL family's answer:
+ // `NOT (NULL = 'won')` is UNKNOWN, not TRUE. `driver-memory` and
+ // `matchesFilterCondition` would return it. Remote mode is pinned to local
+ // SqlDriver so the two SQL transports cannot disagree; the cross-family
+ // divergence is objectstack#5146.
+ const rows = await ids({ $not: { stage: 'won' } });
+ expect(rows).not.toContain('d_null');
+ // …and the row IS reachable — it is excluded by the negation's semantics,
+ // not missing from the table.
+ expect(await ids({ stage: null })).toEqual(['d_null']);
+ });
+
+ it('`$not: {}` returns ZERO rows', async () => {
+ expect(await ids({ $not: {} })).toEqual([]);
+ });
+
+ it('`$not: { $or: [] }` returns EVERY row', async () => {
+ expect(await ids({ $not: { $or: [] } })).toEqual(['d_lost', 'd_null', 'd_open', 'd_won']);
+ });
+
+ it('double negation returns what the un-negated filter does', async () => {
+ expect(await ids({ $not: { $not: { stage: 'won' } } })).toEqual(await ids({ stage: 'won' }));
+ });
+
+ it('negates a nested `$or` (De Morgan on real rows)', async () => {
+ expect(await ids({ $not: { $or: [{ stage: 'won' }, { stage: 'lost' }] } })).toEqual(['d_open']);
+ });
+
+ it('answers the RLS shape — owner AND NOT lost', async () => {
+ expect(await ids({ $and: [{ owner_id: 'u1' }, { $not: { stage: 'lost' } }] })).toEqual([
+ 'd_won',
+ ]);
+ });
+
+ it('negates a range without leaking binds', async () => {
+ // Executed, not string-matched: a stray bind makes better-sqlite3 reject
+ // the statement outright.
+ expect(await ids({ $not: { amount: { $gte: 20 } } })).toEqual(['d_won']);
+ });
+
+ it('applies the calendar-day upper-bound rule INSIDE `$not`', async () => {
+ // `toRemoteFilter` recursed into `$and`/`$or` only, so conditions inside a
+ // `$not` reached the transport on the raw path, skipping the ADR-0053 D-E3
+ // seam — which applies at every depth a condition can appear at.
+ //
+ // A bare `YYYY-MM-DD` upper bound on a `datetime` column compiles half-open
+ // (`$lt` next-midnight, framework#3777) so the whole DAY is inside the
+ // bound. Un-lowered it compares an instant against a bare day string and
+ // no timestamp in that day is `<=` it — so the negation, which is the
+ // complement, hands BACK the very rows the day was meant to cover.
+ await driver.updateMany('deal', { where: { id: 'd_won' } } as any, {
+ closed_at: new Date('2026-04-29T08:30:00.000Z'),
+ });
+ await driver.updateMany('deal', { where: { id: 'd_lost' } } as any, {
+ closed_at: new Date('2025-01-15T08:30:00.000Z'),
+ });
+ // `d_lost` closed WITHIN 2025-01-15, so the negation excludes it; `d_won`
+ // closed later, so the negation keeps it. Un-lowered, both come back.
+ expect(await ids({ $not: { closed_at: { $lte: '2025-01-15' } } })).toEqual(['d_won']);
+ });
+
+ it('lowers `$between` inside `$not` instead of refusing it', async () => {
+ // Un-lowered, the transport (correctly) refuses `$between` and names a
+ // lowering step that had been skipped for this depth.
+ await expect(
+ driver.find('deal', { object: 'deal', where: { $not: { amount: { $between: [15, 35] } } } }),
+ ).resolves.toBeDefined();
+ expect(await ids({ $not: { amount: { $between: [15, 35] } } })).toEqual(['d_null', 'd_won']);
+ });
+
+ it('`count` agrees with `find`', async () => {
+ expect(await driver.count('deal', { object: 'deal', where: { $not: { stage: 'won' } } })).toBe(2);
+ expect(await driver.count('deal', { object: 'deal', where: { $not: {} } })).toBe(0);
+ });
+
+ it('`deleteMany` with `$not: {}` deletes NOTHING', async () => {
+ // The arm that makes this more than a wrong count: a dropped clause here
+ // empties the table.
+ const before = await ids({});
+ await driver.deleteMany('deal', { where: { $not: {} } } as any);
+ expect(await ids({})).toEqual(before);
+ });
+
+ it('`updateMany` with a negation touches only the negated rows', async () => {
+ await driver.updateMany('deal', { where: { $not: { owner_id: 'u1' } } } as any, {
+ stage: 'zzz',
+ });
+ expect(await ids({ stage: 'zzz' })).toEqual(['d_null', 'd_open']);
+ // The `u1` rows kept their stages.
+ expect(await ids({ owner_id: 'u1' })).toEqual(['d_lost', 'd_won']);
+ });
+
+ it('a non-node `$not` operand is refused through the driver too', async () => {
+ await expect(driver.find('deal', { where: { $not: null } } as unknown as QueryAST)).rejects.toThrow(
+ /not a filter condition/,
+ );
+ });
+});
diff --git a/packages/drivers/driver-turso/src/remote-transport-null-comparand-refusal.test.ts b/packages/drivers/driver-turso/src/remote-transport-null-comparand-refusal.test.ts
new file mode 100644
index 0000000000..2338f4ed6b
--- /dev/null
+++ b/packages/drivers/driver-turso/src/remote-transport-null-comparand-refusal.test.ts
@@ -0,0 +1,412 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
+import { RemoteTransport } from './remote-transport.js';
+import { TursoDriver } from './turso-driver.js';
+import { makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';
+import type { QueryAST } from '@objectstack/spec/data';
+
+/**
+ * Regression: `$null` takes a BOOLEAN, and a non-boolean is refused (#1116).
+ *
+ * # What was measured
+ *
+ * `@objectstack/spec`'s `FieldOperatorsSchema` declares `$null: z.boolean()`,
+ * and nothing between an authored `where` and this transport validates against
+ * it — so a non-boolean really does arrive at `buildWhereSQL`. Its `$null` arm
+ * was a BISECTION:
+ *
+ * clauses.push(`${column} IS ${opValue === false ? 'NOT NULL' : 'NULL'}`);
+ *
+ * `false` on one side, EVERYTHING ELSE on the other. Measured on real SQLite
+ * (`makeLibsqlSqliteStub`, i.e. better-sqlite3 wearing the libsql interface)
+ * with the two-row fixture below — `('1','won',10)` and `('2',NULL,20)`:
+ *
+ * | filter | compiled to | rows |
+ * |---|---|---|
+ * | `{ stage: { $null: true } }` | `IS NULL` | `["2"]` ✅ |
+ * | `{ stage: { $null: false } }` | `IS NOT NULL` | `["1"]` ✅ |
+ * | `{ stage: { $null: 'yes' } }` | `IS NULL` | `["2"]` ← out of contract |
+ * | `{ stage: { $null: 1 } }` | `IS NULL` | `["2"]` |
+ * | `{ stage: { $null: 0 } }` | `IS NULL` | `["2"]` |
+ * | `{ stage: { $null: null } }` | `IS NULL` | `["2"]` |
+ * | `{ stage: { $null: undefined } }` | `IS NULL` | `["2"]` |
+ * | `{ stage: { $null: {} } }` | `IS NULL` | `["2"]` |
+ * | `{ stage: { $null: 'false' } }` | `IS NULL` | `["2"]` ← the STRING is truthy |
+ *
+ * Every third value answered as though `true` had been written. The last row is
+ * the trap and gets its own named case below: `'false'` is a truthy string, so
+ * the spelling most likely to arrive from a JSON round-trip or a template
+ * concatenation compiled to the exact OPPOSITE of what its author meant.
+ *
+ * # Why refused rather than coerced
+ *
+ * Ruled by the maintainer on the identical shape in objectstack#5347 and landed
+ * across framework's four backends in objectstack#5368 (`9c5abf4e9`). The
+ * reasoning is not "this transport picked the wrong side" — there is no side to
+ * pick. The backends read a non-boolean in OPPOSITE directions: `IS NULL` here,
+ * on `driver-sql`, on `driver-sqlite-wasm` and on Turso LOCAL; `IS NOT NULL` on
+ * `driver-memory`'s query path and on `driver-mongodb`; and `driver-memory`'s
+ * reference matcher dropped the constraint entirely and matched BOTH rows — a
+ * WIDENING, which on an RLS read scope is a permission bypass rather than a
+ * degraded filter. Three answers to one declared operator, none of them a rule
+ * anyone wrote down; all three are just what a two-branch conditional does with
+ * a third value.
+ *
+ * Until this fix, the SAME `TursoDriver` answered one filter two ways depending
+ * only on whether `url` sent it down the local or the remote path.
+ *
+ * # Why the assertions check `code` / `status` and not only "it throws"
+ *
+ * Half of #1116 is the ENVELOPE. Every filter refusal in this transport threw a
+ * bare `Error` — `code` and `status` `undefined` — so the wire identity of the
+ * rejection was carried by English prose alone, while the framework twins that
+ * refuse the very same shapes all speak `INVALID_FILTER` / 400 (ADR-0112). A
+ * refusal only a human reading a log can classify is not much better than a
+ * wrong answer to a caller, so section (e) pins the whole family, not just the
+ * new arm.
+ */
+
+interface WireBearingError extends Error {
+ code?: string;
+ status?: number;
+}
+
+function transportWithCapturingClient() {
+ const calls: Array<{ sql: string; args: any[] }> = [];
+ const client = {
+ execute: vi.fn(async (stmt: any) => {
+ calls.push({ sql: stmt.sql ?? String(stmt), args: stmt.args ?? [] });
+ return { rows: [], columns: [] };
+ }),
+ close: vi.fn(),
+ };
+ const t = new RemoteTransport();
+ t.setClient(client as any);
+ return { t, calls };
+}
+
+/** The refusal a filter produces, or a failure saying it did not refuse. */
+async function refusalOf(where: unknown): Promise {
+ const { t, calls } = transportWithCapturingClient();
+ try {
+ await t.find('deal', { where } as unknown as QueryAST);
+ } catch (e) {
+ // A refused filter must not have run a statement on the way to throwing.
+ expect(calls).toEqual([]);
+ return e as WireBearingError;
+ }
+ throw new Error(
+ `expected ${JSON.stringify(where)} to be refused, but it compiled to ${JSON.stringify(calls[0])}`,
+ );
+}
+
+/** The SQL a `find` compiled to, plus its bind list. */
+async function compile(where: unknown): Promise<{ sql: string; args: any[] }> {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('deal', { where } as unknown as QueryAST);
+ return calls[0];
+}
+
+/**
+ * The exact leading sentence framework's twins produce for this condition,
+ * copied from `driver-sql`'s `nonBooleanNullComparandError` (word-for-word
+ * identical in `driver-memory` and `driver-mongodb`, objectstack#5368).
+ *
+ * A literal rather than an import: cloud must not take a source dependency on
+ * framework's driver internals, so the twin invariant is held by pinning the
+ * other side's wording here. A caller who hits this on Turso must read the same
+ * sentence they would read on Postgres.
+ */
+const FRAMEWORK_LEADING_SENTENCE = (field: string) =>
+ `Operator "$null" on field "${field}" requires a boolean comparand (true or false).`;
+
+/** Every non-boolean comparand from #1116's measured table, in its order. */
+const NON_BOOLEAN: Array<[label: string, value: unknown]> = [
+ ["the string 'yes'", 'yes'],
+ ['the number 1', 1],
+ ['the number 0', 0],
+ ['null', null],
+ ['undefined', undefined],
+ ['an object', {}],
+ // Called out separately below too — it is the likeliest real-world path.
+ ["the STRING 'false'", 'false'],
+];
+
+const BARE_SCAN = 'SELECT * FROM "deal"';
+
+describe('RemoteTransport $null comparand refusal (#1116)', () => {
+ describe('(a) every non-boolean in the measured table is refused, in the ADR-0112 envelope', () => {
+ for (const [label, value] of NON_BOOLEAN) {
+ it(`refuses ${label} with INVALID_FILTER / 400`, async () => {
+ const err = await refusalOf({ stage: { $null: value } });
+ expect(err.code).toBe('INVALID_FILTER');
+ expect(err.status).toBe(400);
+ });
+
+ it(`names the operator, the field and the offending value for ${label}`, async () => {
+ const err = await refusalOf({ stage: { $null: value } });
+ expect(err.message).toContain(FRAMEWORK_LEADING_SENTENCE('stage'));
+ expect(err.message).toContain(`'deal.stage'.$null`);
+ });
+ }
+
+ it("gives the STRING 'false' its own reckoning — it is truthy, so it meant IS NULL", async () => {
+ // The likeliest real-world path into this bug: `"false"` survives a JSON
+ // round trip or a template concatenation looking exactly like the `false`
+ // it was written to mean, and then compiles to the opposite predicate.
+ // Pre-fix this returned the NULL row — the rows `$null: false` excludes.
+ const err = await refusalOf({ stage: { $null: 'false' } });
+ expect(err.code).toBe('INVALID_FILTER');
+ expect(err.message).toContain('Note "false" the STRING is truthy');
+ expect(err.message).toContain('the side opposite the false it was written to mean');
+ });
+
+ it('cites the ruling and the framework landing so both repos are findable', async () => {
+ const err = await refusalOf({ stage: { $null: 'yes' } });
+ expect(err.message).toContain('objectstack#5347');
+ expect(err.message).toContain('objectstack#5368');
+ expect(err.message).toContain('#1116');
+ });
+
+ it('names the spec declaration rather than only complaining', async () => {
+ const err = await refusalOf({ stage: { $null: 1 } });
+ expect(err.message).toContain('FieldOperatorsSchema declares $null as a boolean');
+ });
+
+ it('truncates a large comparand rather than pasting it into the log', async () => {
+ const err = await refusalOf({ stage: { $null: { blob: 'x'.repeat(5_000) } } });
+ expect(err.message).toContain('…');
+ expect(err.message.length).toBeLessThan(1_000);
+ });
+ });
+
+ describe('(b) `$null: true` / `$null: false` are byte-identical to before', () => {
+ it('compiles `$null: true` to IS NULL', async () => {
+ const call = await compile({ stage: { $null: true } });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE "stage" IS NULL`);
+ expect(call.args).toEqual([]);
+ });
+
+ it('compiles `$null: false` to IS NOT NULL', async () => {
+ const call = await compile({ stage: { $null: false } });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE "stage" IS NOT NULL`);
+ expect(call.args).toEqual([]);
+ });
+
+ it('keeps compiling them alongside other predicates and inside combinators', async () => {
+ expect((await compile({ stage: { $null: true }, amount: { $gt: 10 } })).sql).toBe(
+ `${BARE_SCAN} WHERE "stage" IS NULL AND "amount" > ?`,
+ );
+ expect((await compile({ $or: [{ stage: { $null: true } }, { stage: 'won' }] })).sql).toBe(
+ `${BARE_SCAN} WHERE (("stage" IS NULL) OR ("stage" = ?))`,
+ );
+ expect((await compile({ $not: { stage: { $null: false } } })).sql).toBe(
+ `${BARE_SCAN} WHERE NOT ("stage" IS NOT NULL)`,
+ );
+ });
+ });
+
+ describe('(c) the refusal reaches every position and every entry point', () => {
+ it('refuses inside `$and` / `$or` / `$not`, even beside a satisfiable sibling', async () => {
+ // The malformed arm is compiled wherever it sits: a sibling that would
+ // have matched does not buy it a pass, and neither does `$or`'s vacuous
+ // TRUE disjunct.
+ for (const where of [
+ { $and: [{ stage: { $null: 'yes' } }] },
+ { $or: [{ stage: 'won' }, { stage: { $null: 1 } }] },
+ { $not: { stage: { $null: 'yes' } } },
+ { $or: [{}, { stage: { $null: 'false' } }] },
+ ]) {
+ const err = await refusalOf(where);
+ expect(err.code).toBe('INVALID_FILTER');
+ expect(err.message).toContain(FRAMEWORK_LEADING_SENTENCE('stage'));
+ }
+ });
+
+ it('refuses on find / findOne / count / aggregate with NO statement executed', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ const where = { stage: { $null: 'yes' } };
+ await expect(t.find('deal', { where } as unknown as QueryAST)).rejects.toThrow(/requires a boolean comparand/);
+ await expect(t.findOne('deal', { where } as unknown as QueryAST)).rejects.toThrow(/requires a boolean comparand/);
+ await expect(t.count('deal', { where } as unknown as QueryAST)).rejects.toThrow(/requires a boolean comparand/);
+ await expect(
+ t.aggregate('deal', { where, aggregations: [{ function: 'count' }] } as unknown as QueryAST),
+ ).rejects.toThrow(/requires a boolean comparand/);
+ expect(calls).toEqual([]);
+ });
+
+ it('refuses on deleteMany / updateMany — the direction that costs the most', async () => {
+ // Pre-fix these ran against `WHERE "stage" IS NULL`: rows the caller
+ // never named, deleted or overwritten without a word.
+ const { t, calls } = transportWithCapturingClient();
+ const where = { stage: { $null: 'false' } };
+ await expect(t.deleteMany('deal', { where } as any)).rejects.toThrow(
+ /requires a boolean comparand/,
+ );
+ await expect(t.updateMany('deal', { where } as any, { stage: 'archived' })).rejects.toThrow(
+ /requires a boolean comparand/,
+ );
+ expect(calls).toEqual([]);
+ });
+ });
+
+ describe('(d) the scope fence #1116 draws, pinned so it is not crossed by accident', () => {
+ it('leaves `$exists` reading a non-boolean exactly as it did', async () => {
+ // `$exists` carries the identical `=== false` bisection one arm below,
+ // and is deliberately NOT tightened: its divergence is on another axis
+ // (whether "exists" means key-present or has-value — objectstack#5299,
+ // reopened as #5369), so tightening it HERE alone would manufacture a
+ // local/remote fork rather than close one. framework left its twin alone
+ // for the same reason. When #5299 is ruled on, this test is the one to
+ // change — deliberately, not incidentally.
+ expect((await compile({ stage: { $exists: 'yes' } })).sql).toBe(
+ `${BARE_SCAN} WHERE "stage" IS NOT NULL`,
+ );
+ expect((await compile({ stage: { $exists: true } })).sql).toBe(
+ `${BARE_SCAN} WHERE "stage" IS NOT NULL`,
+ );
+ expect((await compile({ stage: { $exists: false } })).sql).toBe(
+ `${BARE_SCAN} WHERE "stage" IS NULL`,
+ );
+ });
+
+ it('leaves the regex family taking a non-string comparand', async () => {
+ // Measured consistent and fail-closed across backends; `driver-sql`'s
+ // #5041 comment excludes the family from its guard by name.
+ expect((await compile({ name: { $startsWith: 42 } })).args).toEqual(['42%']);
+ expect((await compile({ name: { $contains: true } })).args).toEqual(['%true%']);
+ });
+ });
+
+ describe('(e) the envelope: every filter refusal in this transport now carries a wire code', () => {
+ // The other half of #1116. These all threw bare `Error`s — `code` and
+ // `status` `undefined` — so `mapDataError` served them with no `code` at
+ // all, outside the ADR-0112 contract that every sibling rejection on the
+ // same route already speaks. One condition, one wire code.
+ const FILTER_REFUSALS: Array<[label: string, where: unknown]> = [
+ ['an unknown operator (#1004)', { stage: { $bogus: 1 } }],
+ ['a non-operator key in an operator map (#1004)', { stage: { nope: 1 } }],
+ ['$between reaching the transport unlowered (#1003)', { amount: { $between: [1, 2] } }],
+ ['an unbindable comparand (#1058)', { amount: { $gt: { $field: 'budget' } } }],
+ ['a cross-field marker (#1051/#1058)', { amount: { $eq: { $field: 'budget' } } }],
+ ['an operator map that compiles to nothing (#1066/#1071)', { stage: {} }],
+ ['a non-node `$or` element (#1073)', { $or: [null] }],
+ ['a non-node `$not` operand (#1076)', { $not: 'won' }],
+ ['a non-node top-level `where` (#1075)', [['stage', '=', 'won']]],
+ ['a non-boolean `$null` comparand (#1116)', { stage: { $null: 'yes' } }],
+ ];
+
+ for (const [label, where] of FILTER_REFUSALS) {
+ it(`${label} is INVALID_FILTER / 400`, async () => {
+ const err = await refusalOf(where);
+ expect(err.code).toBe('INVALID_FILTER');
+ expect(err.status).toBe(400);
+ // The prose is unchanged — this half of #1116 adds identity, it does
+ // not reword six shipped refusals.
+ expect(err.message).toContain('[RemoteTransport]');
+ });
+ }
+
+ it('leaves NON-filter failures as bare errors — they are not client filter mistakes', async () => {
+ // A transport that is not connected, or an identifier that is unsafe, is
+ // not a malformed filter and must not borrow its 400.
+ const t = new RemoteTransport();
+ const err = (await t.find('deal', {}).catch((e) => e)) as WireBearingError;
+ expect(err.message).toMatch(/is not initialized/);
+ expect(err.code).toBeUndefined();
+ expect(err.status).toBeUndefined();
+ });
+ });
+});
+
+/**
+ * (f) Rows, not SQL strings.
+ *
+ * The string assertions above cannot show what the old behaviour COST: a
+ * bisected `$null` leaves the statement perfectly valid, it just answers a
+ * different question. This is the issue's own harness and its own fixture — a
+ * real SQLite database behind the libsql interface, one row with a value and
+ * one with NULL — so the rows are the evidence.
+ */
+describe('TursoDriver remote — `$null` on rows (#1116)', () => {
+ let driver: TursoDriver;
+ let stub: LibsqlSqliteStub;
+
+ beforeAll(async () => {
+ stub = makeLibsqlSqliteStub();
+ driver = new TursoDriver({ url: 'libsql://null-comparand.turso.io', client: stub as never });
+ await driver.connect();
+ expect(driver.transportMode).toBe('remote');
+ await driver.syncSchema('deal', {
+ name: 'deal',
+ fields: { stage: { type: 'string' }, amount: { type: 'number' } },
+ });
+ // The issue's fixture, verbatim: ('1','won',10) and ('2',NULL,20).
+ await driver.create('deal', { id: '1', stage: 'won', amount: 10 });
+ await driver.create('deal', { id: '2', stage: null, amount: 20 });
+ });
+
+ afterAll(async () => {
+ await driver.disconnect();
+ stub.close();
+ });
+
+ const ids = async (where: unknown): Promise =>
+ ((await driver.find('deal', { where } as unknown as QueryAST)) as any[]).map((r) => String(r.id)).sort();
+
+ const allRows = () =>
+ stub.raw.prepare('SELECT id, stage FROM "deal" ORDER BY id').all() as Array<{
+ id: string;
+ stage: string | null;
+ }>;
+
+ it('`$null: true` still returns the NULL row', async () => {
+ expect(await ids({ stage: { $null: true } })).toEqual(['2']);
+ });
+
+ it('`$null: false` still returns the valued row', async () => {
+ expect(await ids({ stage: { $null: false } })).toEqual(['1']);
+ });
+
+ for (const [label, value] of NON_BOOLEAN) {
+ it(`${label} returns no rows at all — it is refused, not answered`, async () => {
+ // Pre-fix every one of these came back as `["2"]`, i.e. the answer
+ // `$null: true` gives, for a filter that never said `true`.
+ const err = (await driver
+ .find('deal', { where: { stage: { $null: value } } } as unknown as QueryAST)
+ .catch((e) => e)) as WireBearingError;
+ expect(err).toBeInstanceOf(Error);
+ expect(err.code).toBe('INVALID_FILTER');
+ expect(err.status).toBe(400);
+ });
+ }
+
+ it('a refused `deleteMany` deletes NOTHING', async () => {
+ // Pre-fix: `DELETE FROM "deal" WHERE "stage" IS NULL` — row 2 gone, for a
+ // filter whose author wrote a string.
+ await expect(
+ driver.deleteMany('deal', { where: { stage: { $null: 'false' } } } as any),
+ ).rejects.toThrow(/requires a boolean comparand/);
+ expect(allRows().map((r) => r.id)).toEqual(['1', '2']);
+ });
+
+ it('a refused `updateMany` writes NOTHING', async () => {
+ await expect(
+ driver.updateMany('deal', { where: { stage: { $null: 1 } } } as any, { stage: 'archived' }),
+ ).rejects.toThrow(/requires a boolean comparand/);
+ expect(allRows().map((r) => r.stage)).toEqual(['won', null]);
+ });
+
+ it('the surrounding vocabulary still answers on rows', async () => {
+ expect(await ids({ stage: 'won' })).toEqual(['1']);
+ expect(await ids({ $or: [{ stage: { $null: true } }, { stage: 'won' }] })).toEqual(['1', '2']);
+ expect(await ids({ $not: { stage: { $null: true } } })).toEqual(['1']);
+ expect(await ids({})).toEqual(['1', '2']);
+ });
+
+ it('`$exists` still answers on rows, unchanged (the scope fence, on rows)', async () => {
+ expect(await ids({ stage: { $exists: true } })).toEqual(['1']);
+ expect(await ids({ stage: { $exists: false } })).toEqual(['2']);
+ });
+});
diff --git a/packages/drivers/driver-turso/src/remote-transport-null-where.test.ts b/packages/drivers/driver-turso/src/remote-transport-null-where.test.ts
new file mode 100644
index 0000000000..542cc28ad4
--- /dev/null
+++ b/packages/drivers/driver-turso/src/remote-transport-null-where.test.ts
@@ -0,0 +1,59 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { describe, it, expect, vi } from 'vitest';
+import { RemoteTransport } from './remote-transport.js';
+
+/**
+ * Regression: RemoteTransport.buildWhereSQL emitted `col = ?` (bind null) for a
+ * null filter value, producing `col = NULL` — which is always UNKNOWN in SQL and
+ * matches zero rows. Env-wide metadata + drafts are stored with
+ * `organization_id IS NULL`, so EVERY env-wide draft read came back empty even
+ * though the row was written (write used INSERT … NULL, which persists fine).
+ * This silently broke the whole AI authoring loop (draft → review → publish) on
+ * cloud tenant envs running Turso in remote mode — local/replica mode goes
+ * through Knex, which already special-cases null → IS NULL, so it never showed
+ * up locally. Null filters MUST compile to `IS NULL` / `IS NOT NULL`.
+ */
+function transportWithCapturingClient() {
+ const calls: Array<{ sql: string; args: any[] }> = [];
+ const client = {
+ execute: vi.fn(async (stmt: any) => {
+ calls.push({ sql: stmt.sql ?? String(stmt), args: stmt.args ?? [] });
+ return { rows: [], columns: [] };
+ }),
+ close: vi.fn(),
+ };
+ const t = new RemoteTransport();
+ t.setClient(client as any);
+ return { t, calls };
+}
+
+describe('RemoteTransport null-where compilation', () => {
+ it('compiles { col: null } to `IS NULL` (not `= NULL`) and binds no arg', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('sys_metadata', { where: { organization_id: null, state: 'draft' } });
+ const { sql, args } = calls[0];
+ expect(sql).toMatch(/"organization_id"\s+IS NULL/i);
+ expect(sql).not.toMatch(/"organization_id"\s*=\s*\?/);
+ // only the non-null `state` filter contributes a bind arg
+ expect(args).toEqual(['draft']);
+ });
+
+ it('keeps `= ?` for non-null equality', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('sys_metadata', { where: { organization_id: 'org_123', state: 'draft' } });
+ const { sql, args } = calls[0];
+ expect(sql).toMatch(/"organization_id"\s*=\s*\?/);
+ expect(sql).not.toMatch(/IS NULL/i);
+ expect(args).toEqual(['org_123', 'draft']);
+ });
+
+ it('compiles { $eq: null } to `IS NULL` and { $ne: null } to `IS NOT NULL`', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('t', { where: { a: { $eq: null }, b: { $ne: null } } });
+ const { sql, args } = calls[0];
+ expect(sql).toMatch(/"a"\s+IS NULL/i);
+ expect(sql).toMatch(/"b"\s+IS NOT NULL/i);
+ expect(args).toEqual([]);
+ });
+});
diff --git a/packages/drivers/driver-turso/src/remote-transport-text-predicates.test.ts b/packages/drivers/driver-turso/src/remote-transport-text-predicates.test.ts
new file mode 100644
index 0000000000..78e9a183b7
--- /dev/null
+++ b/packages/drivers/driver-turso/src/remote-transport-text-predicates.test.ts
@@ -0,0 +1,253 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Text predicates over the REMOTE transport (#1004) — the text-axis twin of the
+ * temporal seam #937 / #1003 closed.
+ *
+ * `buildWhereSQL` carried exactly one LIKE arm (`$contains`). Every other text
+ * predicate the spec declares — `$startsWith`, `$endsWith`, `$notContains` —
+ * fell through `default:` and was compiled to `"col" = ?` against a SUBSTRING,
+ * which equals nothing, so each returned the empty set on a table where local
+ * mode returned rows. `$exists` fell the same way (`"col" = 1`).
+ *
+ * The measurement instrument is the same one #937 needed and for the same
+ * reason: a mis-compiled predicate leaves the SQL perfectly valid, just wrong,
+ * so the mocked-`execute` suites' string assertions sail straight past it. Rows
+ * are the only witness — hence `makeLibsqlSqliteStub` (better-sqlite3 wearing
+ * the `@libsql/client` interface).
+ *
+ * Two invariants are under test, and the second is the one that let this family
+ * hide for so long:
+ *
+ * 1. every declared operator compiles to the SAME rows local mode returns;
+ * 2. an operator the transport does NOT implement THROWS. Silently degrading to
+ * equality is what turned three missing arms into three empty result sets
+ * instead of three loud errors.
+ */
+
+import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
+import { TursoDriver } from './turso-driver.js';
+import { RemoteTransport } from './remote-transport.js';
+import { makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';
+
+const TEXT_OBJECT = {
+ name: 'widget',
+ fields: { name: { type: 'string' }, note: { type: 'string' } },
+};
+
+/** The fixture from the issue, plus a null-`note` row for `$null` / `$exists`. */
+const ROWS = [
+ { id: 'w1', name: 'Alpha', note: 'first' },
+ { id: 'w2', name: 'Alpine', note: null },
+ { id: 'w3', name: 'Beta', note: 'third' },
+];
+
+/** LIKE metacharacters must match literally, never as wildcards (P0 bypass). */
+const META_ROWS = [
+ { id: 'm1', name: '50% off sale' },
+ { id: 'm2', name: 'plain title' },
+ { id: 'm3', name: 'a_b underscore' },
+ { id: 'm4', name: 'back\\slash' },
+];
+
+async function makeRemoteDriver(schema: Record, rows: Record[]) {
+ const stub = makeLibsqlSqliteStub();
+ const driver = new TursoDriver({ url: 'libsql://text.turso.io', client: stub as never });
+ await driver.connect();
+ expect(driver.transportMode).toBe('remote');
+ await driver.syncSchema(schema.name as string, schema);
+ for (const row of rows) await driver.create(schema.name as string, row);
+ return { driver, stub };
+}
+
+const ids = async (driver: TursoDriver, object: string, where: unknown) =>
+ ((await driver.find(object, { where })) as any[]).map((r) => r.id).sort();
+
+describe('TursoDriver remote — declared text predicates return rows', () => {
+ let driver: TursoDriver;
+ let stub: LibsqlSqliteStub;
+
+ beforeAll(async () => {
+ ({ driver, stub } = await makeRemoteDriver(TEXT_OBJECT, ROWS));
+ });
+
+ afterAll(async () => {
+ await driver.disconnect();
+ stub.close();
+ });
+
+ it('seeded the fixture (the premise)', () => {
+ const seeded = stub.raw.prepare('select id from widget order by id').all();
+ expect(seeded).toHaveLength(3);
+ });
+
+ // The three rows of the issue's measured table. Each returned [] pre-fix.
+ it('$startsWith matches the prefix, not an equality', async () => {
+ expect(await ids(driver, 'widget', { name: { $startsWith: 'Alp' } })).toEqual(['w1', 'w2']);
+ });
+
+ it('$endsWith matches the suffix, not an equality', async () => {
+ expect(await ids(driver, 'widget', { name: { $endsWith: 'ta' } })).toEqual(['w3']);
+ });
+
+ it('$notContains excludes the substring holders', async () => {
+ expect(await ids(driver, 'widget', { name: { $notContains: 'lp' } })).toEqual(['w3']);
+ });
+
+ it('$contains still matches a substring', async () => {
+ expect(await ids(driver, 'widget', { name: { $contains: 'lp' } })).toEqual(['w1', 'w2']);
+ });
+
+ // Not spec-declared: better-auth's adapter emits `$regex` for a plain
+ // substring search, and SqlDriver compiles it as one. Remote must agree or a
+ // Turso-backed auth store answers differently from a local one.
+ it('$regex compiles as the substring search its only producer means', async () => {
+ expect(await ids(driver, 'widget', { name: { $regex: 'lph' } })).toEqual(['w1']);
+ });
+
+ it('$null: true / false select the null and non-null rows', async () => {
+ expect(await ids(driver, 'widget', { note: { $null: true } })).toEqual(['w2']);
+ expect(await ids(driver, 'widget', { note: { $null: false } })).toEqual(['w1', 'w3']);
+ });
+
+ it('$exists: true / false are the inverse pair of $null', async () => {
+ expect(await ids(driver, 'widget', { note: { $exists: true } })).toEqual(['w1', 'w3']);
+ expect(await ids(driver, 'widget', { note: { $exists: false } })).toEqual(['w2']);
+ });
+
+ it('text predicates compose under $and / $or', async () => {
+ expect(
+ await ids(driver, 'widget', {
+ $or: [{ name: { $startsWith: 'Alp' } }, { name: { $endsWith: 'ta' } }],
+ }),
+ ).toEqual(['w1', 'w2', 'w3']);
+ expect(
+ await ids(driver, 'widget', {
+ $and: [{ name: { $startsWith: 'Alp' } }, { name: { $endsWith: 'ne' } }],
+ }),
+ ).toEqual(['w2']);
+ });
+
+ it('count() answers the same predicate find() does', async () => {
+ expect(await driver.count('widget', { where: { name: { $startsWith: 'Alp' } } })).toBe(2);
+ });
+});
+
+describe('TursoDriver remote — LIKE metacharacters match literally', () => {
+ let driver: TursoDriver;
+ let stub: LibsqlSqliteStub;
+
+ beforeAll(async () => {
+ ({ driver, stub } = await makeRemoteDriver({ name: 'meta', fields: { name: { type: 'string' } } }, META_ROWS));
+ });
+
+ afterAll(async () => {
+ await driver.disconnect();
+ stub.close();
+ });
+
+ // Unescaped, `%` expands to `%%%` and matches EVERY row — the filter bypass
+ // framework's `sql-driver-like-escape.test.ts` pins on the local driver.
+ it('a "%" comparand matches only rows holding a literal %', async () => {
+ expect(await ids(driver, 'meta', { name: { $contains: '%' } })).toEqual(['m1']);
+ expect(await ids(driver, 'meta', { name: { $startsWith: '50%' } })).toEqual(['m1']);
+ expect(await ids(driver, 'meta', { name: { $endsWith: '% off sale' } })).toEqual(['m1']);
+ });
+
+ it('a "_" comparand matches a literal _, not any single character', async () => {
+ expect(await ids(driver, 'meta', { name: { $contains: '_' } })).toEqual(['m3']);
+ expect(await ids(driver, 'meta', { name: { $startsWith: 'a_' } })).toEqual(['m3']);
+ });
+
+ it('a backslash comparand matches a literal backslash', async () => {
+ expect(await ids(driver, 'meta', { name: { $contains: '\\' } })).toEqual(['m4']);
+ });
+
+ it('$notContains escapes too — a "%" must not exclude every row', async () => {
+ expect(await ids(driver, 'meta', { name: { $notContains: '%' } })).toEqual(['m2', 'm3', 'm4']);
+ });
+
+ it('an ordinary substring is unaffected by the escaping', async () => {
+ expect(await ids(driver, 'meta', { name: { $contains: 'sale' } })).toEqual(['m1']);
+ });
+});
+
+/**
+ * The invariant the missing arms cost us: declared = enforced. An operator this
+ * transport cannot compile must fail loudly, because the alternative — the old
+ * `default:` equality — is indistinguishable from "no rows matched".
+ */
+describe('RemoteTransport — unknown operators throw instead of degrading', () => {
+ function transportWithCapturingClient() {
+ const calls: Array<{ sql: string; args: any[] }> = [];
+ const client = {
+ execute: vi.fn(async (stmt: any) => {
+ calls.push({ sql: stmt.sql ?? String(stmt), args: stmt.args ?? [] });
+ return { rows: [], columns: [] };
+ }),
+ close: vi.fn(),
+ };
+ const t = new RemoteTransport();
+ t.setClient(client as any);
+ return { t, calls };
+ }
+
+ it('rejects an operator it does not implement', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await expect(t.find('widget', { where: { name: { $bogus: 'x' } } })).rejects.toThrow(
+ /\$bogus.*widget\.name/s,
+ );
+ expect(calls, 'must refuse before executing anything').toHaveLength(0);
+ });
+
+ it('rejects a null-comparand unknown operator too (the IS NULL accident)', async () => {
+ // `{ $bogus: null }` used to land on `IS NULL` and look plausible.
+ const { t } = transportWithCapturingClient();
+ await expect(t.find('widget', { where: { name: { $bogus: null } } })).rejects.toThrow(/\$bogus/);
+ });
+
+ it('rejects $between at the transport, naming the driver that must lower it', async () => {
+ // TursoDriver lowers `$between` to `$gte`/`$lte` (#1003) so the calendar-day
+ // rule is applied once. Reaching the transport means that step was skipped.
+ const { t } = transportWithCapturingClient();
+ await expect(
+ t.find('widget', { where: { at: { $between: ['2026-01-01', '2026-02-01'] } } }),
+ ).rejects.toThrow(/\$between.*TursoDriver/s);
+ });
+
+ it('rejects an unknown operator nested inside $and / $or', async () => {
+ const { t } = transportWithCapturingClient();
+ await expect(
+ t.find('widget', { where: { $or: [{ name: { $eq: 'a' } }, { name: { $bogus: 'b' } }] } }),
+ ).rejects.toThrow(/\$bogus/);
+ });
+
+ it('the throw reaches callers through count / updateMany / deleteMany too', async () => {
+ const { t } = transportWithCapturingClient();
+ const where = { name: { $bogus: 'x' } };
+ await expect(t.count('widget', { where })).rejects.toThrow(/\$bogus/);
+ await expect(t.updateMany('widget', { where }, { name: 'y' })).rejects.toThrow(/\$bogus/);
+ await expect(t.deleteMany('widget', { where })).rejects.toThrow(/\$bogus/);
+ });
+});
+
+describe('RemoteTransport — text predicates bind an explicit ESCAPE', () => {
+ it('emits LIKE … ESCAPE and binds the escaped pattern, not the raw value', async () => {
+ const calls: Array<{ sql: string; args: any[] }> = [];
+ const client = {
+ execute: vi.fn(async (stmt: any) => {
+ calls.push({ sql: stmt.sql ?? String(stmt), args: stmt.args ?? [] });
+ return { rows: [], columns: [] };
+ }),
+ close: vi.fn(),
+ };
+ const t = new RemoteTransport();
+ t.setClient(client as any);
+
+ await t.find('widget', { where: { name: { $startsWith: '50%' } } });
+ const { sql, args } = calls[0];
+ // SQLite honours no default escape character, so the clause must be explicit.
+ expect(sql).toMatch(/"name"\s+LIKE\s+\?\s+ESCAPE\s+'\\'/i);
+ expect(args).toEqual(['50\\%%']);
+ });
+});
diff --git a/packages/drivers/driver-turso/src/remote-transport-top-level-where.test.ts b/packages/drivers/driver-turso/src/remote-transport-top-level-where.test.ts
new file mode 100644
index 0000000000..34a28e707b
--- /dev/null
+++ b/packages/drivers/driver-turso/src/remote-transport-top-level-where.test.ts
@@ -0,0 +1,373 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
+import { RemoteTransport } from './remote-transport.js';
+import { TursoDriver } from './turso-driver.js';
+import { makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';
+import type { QueryAST } from '@objectstack/spec/data';
+
+/**
+ * Regression: a TOP-LEVEL `where` this transport cannot compile must THROW,
+ * never compile to "no WHERE clause at all" (#1075).
+ *
+ * `buildWhereSQL` entered its compile loop only when `filters` was a non-array
+ * object:
+ *
+ * if (typeof filters === 'object' && !Array.isArray(filters)) { … }
+ * return { whereClauses: clauses.join(' AND '), args };
+ *
+ * Everything else walked past the loop to that `return` and came back with an
+ * empty clause string, so the caller emitted no WHERE. Measured on the shipped
+ * build with a capturing client:
+ *
+ * where: [["stage","=","won"]] {"sql":"SELECT * FROM \"deal\"","args":[]}
+ * where: "stage" {"sql":"SELECT * FROM \"deal\"","args":[]}
+ * where: 42 {"sql":"SELECT * FROM \"deal\"","args":[]}
+ * where: [] {"sql":"SELECT * FROM \"deal\"","args":[]}
+ *
+ * The caller asked to filter and silently received the UNFILTERED set — the
+ * outward-failing branch of the family #1004 (unknown operator), #1058
+ * (unbindable comparand), #1066/#1071 (operator map compiling to nothing) and
+ * #1073 (a dropped logical branch) closed one level at a time. Outward is the
+ * expensive direction: on a read it hands back exactly the rows the filter was
+ * written to exclude, and on `deleteMany`/`updateMany` it is a whole-table
+ * write.
+ *
+ * The bare AST array is not a hypothetical. `parseFilterAST()` converts an AST
+ * to the object form only when `isFilterAST()` accepts it; an operator outside
+ * `VALID_AST_OPERATORS` makes it refuse and the RAW array is assigned to
+ * `where` (framework#3948 / `sql-driver-filter-no-silent-drop.test.ts`, whose
+ * fix made `driver-sql` and `driver-memory` throw rather than drop). Until this
+ * fix the same filter answered two ways depending on transport: loud locally,
+ * whole-table remotely.
+ *
+ * Contract evidence (framework `origin/main`, `packages/spec/src/data`):
+ *
+ * query.zod.ts: where: FilterConditionSchema.optional()
+ * filter.zod.ts: FilterConditionSchema = z.lazy(() =>
+ * z.record(z.string(), z.unknown()).and(z.object({ … })))
+ *
+ * — a RECORD, optional. So exactly two things are legal at this position: a
+ * plain object (`{}` included — the vacuous-TRUE control of #1073/#1074), or
+ * nothing at all. Everything else is refused by FORM, before compilation.
+ */
+
+function transportWithCapturingClient() {
+ const calls: Array<{ sql: string; args: any[] }> = [];
+ const client = {
+ execute: vi.fn(async (stmt: any) => {
+ calls.push({ sql: stmt.sql ?? String(stmt), args: stmt.args ?? [] });
+ return { rows: [], columns: [] };
+ }),
+ close: vi.fn(),
+ };
+ const t = new RemoteTransport();
+ t.setClient(client as any);
+ return { t, calls };
+}
+
+/** The SQL a `find` compiled to, plus its bind list. */
+async function compile(where: unknown): Promise<{ sql: string; args: any[] }> {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('deal', { where } as unknown as QueryAST);
+ return calls[0];
+}
+
+const BARE_SCAN = 'SELECT * FROM "deal"';
+
+/**
+ * The four shapes measured in #1075, in the spelling the issue measured them.
+ * Each produced `SELECT * FROM "deal"` with no args before this fix.
+ */
+const MEASURED: Array<[label: string, where: unknown, message: RegExp]> = [
+ ['a bare filter AST array', [['stage', '=', 'won']], /is an array, not a filter condition/],
+ ['a string', 'stage', /is a string, not a filter condition/],
+ ['a number', 42, /is a number, not a filter condition/],
+ ['an empty array', [], /is an array, not a filter condition/],
+];
+
+describe('RemoteTransport top-level `where` refusal (#1075)', () => {
+ describe('(a) the four measured shapes each throw instead of scanning the table', () => {
+ for (const [label, where, message] of MEASURED) {
+ it(`refuses ${label}`, async () => {
+ const { t } = transportWithCapturingClient();
+ await expect(t.find('deal', { where } as unknown as QueryAST)).rejects.toThrow(message);
+ });
+
+ it(`executes no statement for ${label}`, async () => {
+ // The regression itself: before the fix this resolved, having run
+ // `SELECT * FROM "deal"` — every row in the table.
+ const { t, calls } = transportWithCapturingClient();
+ await expect(t.find('deal', { where } as unknown as QueryAST)).rejects.toThrow();
+ expect(calls).toEqual([]);
+ });
+ }
+
+ it('names the object and echoes the filter so the caller is findable', async () => {
+ const { t } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: [['stage', '=', 'won']] } as unknown as QueryAST)).rejects.toThrow(
+ /where on 'deal' is an array, not a filter condition: \[\["stage","=","won"\]\]/,
+ );
+ });
+
+ it('tells an AST-array caller what to write instead', async () => {
+ // The array is the shape that actually arrives (an operator outside
+ // `VALID_AST_OPERATORS` leaves `parseFilterAST()` unconverted), so its
+ // message carries the repair rather than only the complaint.
+ const { t } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: [['stage', 'before', 'x']] } as unknown as QueryAST)).rejects.toThrow(
+ /parseFilterAST/,
+ );
+ });
+
+ it('says which failure it is refusing, in the family\'s words', async () => {
+ const { t } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: 42 } as unknown as QueryAST)).rejects.toThrow(
+ /Refusing rather than compiling it to NO WHERE clause/,
+ );
+ });
+ });
+
+ describe('(b) the shapes that reached the same scan by ACCIDENT', () => {
+ // `Object.keys()` is `[]` for all of these, so each took the "empty filter"
+ // early return — the #1066 mistake one level up, where a form that is not a
+ // filter node was read as one because it happened to enumerate to nothing.
+ const accidental: Array<[string, unknown]> = [
+ ['a Date', new Date('2026-04-29T08:30:00.000Z')],
+ ['an empty typed array', new Uint8Array([])],
+ ['a class instance', new (class Filter { stage = 'won' })()],
+ ['an empty string', ''],
+ ['zero', 0],
+ ['false', false],
+ ];
+
+ for (const [label, where] of accidental) {
+ it(`refuses ${label} rather than reading it as "no filter"`, async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await expect(t.find('deal', { where } as unknown as QueryAST)).rejects.toThrow(
+ /not a filter condition/,
+ );
+ expect(calls).toEqual([]);
+ });
+ }
+
+ it('refuses a class instance even though it carries filter-looking keys', async () => {
+ // It enumerates `{ stage: 'won' }` but is not a plain record, so it is
+ // refused by form rather than compiled — the same line `isFilterNode`
+ // draws for `$and`/`$or` elements (#1073). Compiling it would make the
+ // node test mean two different things at two levels.
+ const { t } = transportWithCapturingClient();
+ await expect(
+ t.find('deal', { where: new (class Filter { stage = 'won' })() } as unknown as QueryAST),
+ ).rejects.toThrow(/is an object, not a filter condition/);
+ });
+ });
+
+ describe('(c) every legitimate spelling of "no filter" still works', () => {
+ it('keeps `where: {}` meaning "no filter" — the vacuous-TRUE control (#1073/#1074)', async () => {
+ const call = await compile({});
+ expect(call.sql).toBe(BARE_SCAN);
+ expect(call.args).toEqual([]);
+ });
+
+ it('keeps an ABSENT where meaning "no filter"', async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await t.find('deal', {});
+ expect(calls[0].sql).toBe(BARE_SCAN);
+ });
+
+ it('keeps `where: undefined` and `where: null` meaning "no filter"', async () => {
+ // What the five call sites actually produce: they all read `query?.where`,
+ // so "the caller supplied none" arrives as `undefined`, and a JSON round
+ // trip of an absent key can make it `null`.
+ expect((await compile(undefined)).sql).toBe(BARE_SCAN);
+ expect((await compile(null)).sql).toBe(BARE_SCAN);
+ });
+
+ it('keeps a null-prototype record compiling', async () => {
+ const where = Object.create(null) as Record;
+ where.stage = 'won';
+ const call = await compile(where);
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE "stage" = ?`);
+ expect(call.args).toEqual(['won']);
+ });
+
+ it('keeps `count` / `deleteMany` / `updateMany` unfiltered when no where is given', async () => {
+ // The refusal must not turn "no filter" into an error on the paths whose
+ // whole-table form is legitimate (`deleteMany` with no filter truncates
+ // on purpose).
+ const { t, calls } = transportWithCapturingClient();
+ await t.count('deal', {});
+ await t.deleteMany('deal', {});
+ await t.updateMany('deal', {}, { stage: 'lost' });
+ expect(calls[0].sql).toMatch(/^SELECT COUNT\(\*\)/i);
+ expect(calls[1].sql).toBe('DELETE FROM "deal"');
+ expect(calls[2].sql).toBe('UPDATE "deal" SET "stage" = ?');
+ expect(calls[2].args).toEqual(['lost']);
+ });
+ });
+
+ describe('(d) the same refusal through every WHERE-building entry point', () => {
+ for (const [label, where] of MEASURED.map(([l, w]) => [l, w] as const)) {
+ it(`refuses ${label} on find / findOne / count / aggregate`, async () => {
+ const { t, calls } = transportWithCapturingClient();
+ await expect(t.find('deal', { where } as unknown as QueryAST)).rejects.toThrow(/not a filter condition/);
+ await expect(t.findOne('deal', { where } as unknown as QueryAST)).rejects.toThrow(/not a filter condition/);
+ await expect(t.count('deal', { where } as unknown as QueryAST)).rejects.toThrow(/not a filter condition/);
+ await expect(
+ t.aggregate('deal', { where, aggregations: [{ function: 'count' }] } as unknown as QueryAST),
+ ).rejects.toThrow(/not a filter condition/);
+ expect(calls).toEqual([]);
+ });
+
+ it(`refuses ${label} on deleteMany / updateMany, with NO statement executed`, async () => {
+ // The direction that costs the most: pre-fix these compiled to
+ // `DELETE FROM "deal"` and `UPDATE "deal" SET …` — the whole table.
+ const { t, calls } = transportWithCapturingClient();
+ await expect(t.deleteMany('deal', { where } as any)).rejects.toThrow(
+ /not a filter condition/,
+ );
+ await expect(t.updateMany('deal', { where } as any, { stage: 'lost' })).rejects.toThrow(
+ /not a filter condition/,
+ );
+ expect(calls).toEqual([]);
+ });
+ }
+ });
+
+ describe('(e) nothing that already compiled changes', () => {
+ it('keeps a plain equality byte-identical', async () => {
+ const call = await compile({ stage: 'won' });
+ expect(call.sql).toBe(`${BARE_SCAN} WHERE "stage" = ?`);
+ expect(call.args).toEqual(['won']);
+ });
+
+ it('keeps operator maps, logical groups and `$not` byte-identical', async () => {
+ expect((await compile({ amount: { $gt: 10 } })).sql).toBe(`${BARE_SCAN} WHERE "amount" > ?`);
+ expect((await compile({ $or: [{ a: 1 }, { b: 2 }] })).sql).toBe(
+ `${BARE_SCAN} WHERE (("a" = ?) OR ("b" = ?))`,
+ );
+ expect((await compile({ $and: [] })).sql).toBe(BARE_SCAN);
+ expect((await compile({ $or: [] })).sql).toBe(`${BARE_SCAN} WHERE 1 = 0`);
+ expect((await compile({ $not: { stage: 'won' } })).sql).toBe(
+ `${BARE_SCAN} WHERE NOT ("stage" = ?)`,
+ );
+ expect((await compile({ closed_at: null })).sql).toBe(`${BARE_SCAN} WHERE "closed_at" IS NULL`);
+ });
+
+ it('leaves an ARRAY in a field-comparand position with its own message (#1058)', async () => {
+ // `{ stage: [...] }` is a comparand, not a top-level filter: the array is
+ // refused by the comparand gate, which names the field. The new gate must
+ // not swallow that distinction.
+ const { t } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: { stage: ['won'] } } as unknown as QueryAST)).rejects.toThrow(
+ /Filter comparand 'deal\.stage'/,
+ );
+ });
+
+ it('leaves a non-node SUB-filter with its own message (#1073/#1076)', async () => {
+ // The sub-filter gate names the branch and index; the top-level gate says
+ // `where`. Two levels, two messages, so a log tells you which one it was.
+ const { t } = transportWithCapturingClient();
+ await expect(t.find('deal', { where: { $or: [null] } } as unknown as QueryAST)).rejects.toThrow(
+ /\$or\[0\] on 'deal'/,
+ );
+ await expect(t.find('deal', { where: { $not: 'won' } } as unknown as QueryAST)).rejects.toThrow(
+ /\$not on 'deal'/,
+ );
+ });
+
+ it('keeps refusing a nested AST array inside a logical branch as a BRANCH error', async () => {
+ const { t } = transportWithCapturingClient();
+ await expect(
+ t.find('deal', { where: { $and: [[['stage', '=', 'won']]] } } as unknown as QueryAST),
+ ).rejects.toThrow(/\$and\[0\] on 'deal' is an array/);
+ });
+ });
+});
+
+/**
+ * (f) Rows, not SQL strings.
+ *
+ * A silently-dropped WHERE leaves the statement perfectly valid — it just
+ * answers a different question — so the string assertions above cannot show
+ * what the old behaviour COST. These run against a real SQLite database wearing
+ * the libsql interface: the reads must not hand back excluded rows, and the
+ * refused mutations must leave every row exactly as it was.
+ */
+describe('TursoDriver remote — a refused top-level where touches no rows (#1075)', () => {
+ let driver: TursoDriver;
+ let stub: LibsqlSqliteStub;
+
+ beforeAll(async () => {
+ stub = makeLibsqlSqliteStub();
+ driver = new TursoDriver({ url: 'libsql://top-level-where.turso.io', client: stub as never });
+ await driver.connect();
+ expect(driver.transportMode).toBe('remote');
+ await driver.syncSchema('deal', {
+ name: 'deal',
+ fields: { stage: { type: 'string' }, amount: { type: 'number' } },
+ });
+ await driver.create('deal', { id: 'd_won', stage: 'won', amount: 10 });
+ await driver.create('deal', { id: 'd_lost', stage: 'lost', amount: 20 });
+ await driver.create('deal', { id: 'd_open', stage: 'open', amount: 30 });
+ });
+
+ afterAll(async () => {
+ await driver.disconnect();
+ stub.close();
+ });
+
+ const allRows = () =>
+ (stub.raw.prepare('SELECT id, stage FROM "deal" ORDER BY id').all() as Array<{
+ id: string;
+ stage: string;
+ }>);
+
+ const ids = async (where: unknown) =>
+ ((await driver.find('deal', { where } as unknown as QueryAST)) as any[]).map((r) => r.id).sort();
+
+ it('a bare AST array no longer returns the whole table from a read', async () => {
+ // Pre-fix: all three rows came back for a filter naming exactly one.
+ await expect(driver.find('deal', { where: [['stage', '=', 'won']] } as unknown as QueryAST)).rejects.toThrow(
+ /not a filter condition/,
+ );
+ });
+
+ it('a refused `deleteMany` deletes NOTHING', async () => {
+ // Pre-fix: `DELETE FROM "deal"` — the entire table, for a filter that named
+ // one row.
+ await expect(driver.deleteMany('deal', { where: [['stage', '=', 'won']] } as any)).rejects.toThrow(
+ /not a filter condition/,
+ );
+ expect(allRows().map((r) => r.id)).toEqual(['d_lost', 'd_open', 'd_won']);
+ });
+
+ it('a refused `updateMany` writes NOTHING', async () => {
+ // Pre-fix: every row's stage became 'archived'.
+ await expect(
+ driver.updateMany('deal', { where: 'stage' } as any, { stage: 'archived' }),
+ ).rejects.toThrow(/not a filter condition/);
+ expect(allRows().map((r) => r.stage).sort()).toEqual(['lost', 'open', 'won']);
+ });
+
+ it('a refused `deleteMany` with an EMPTY array deletes nothing either', async () => {
+ // `[]` is the shape most likely to be built by a loop that never ran, and
+ // the one whose old answer ("no filter") was the whole table.
+ await expect(driver.deleteMany('deal', { where: [] } as any)).rejects.toThrow(
+ /not a filter condition/,
+ );
+ expect(allRows()).toHaveLength(3);
+ });
+
+ it('the legitimate whole-table spellings still work on rows', async () => {
+ expect(await ids({})).toEqual(['d_lost', 'd_open', 'd_won']);
+ expect(await ids(undefined)).toEqual(['d_lost', 'd_open', 'd_won']);
+ expect(await driver.count('deal', { object: 'deal', where: {} })).toBe(3);
+ });
+
+ it('a well-formed filter still narrows to its rows', async () => {
+ expect(await ids({ stage: 'won' })).toEqual(['d_won']);
+ expect(await ids({ $or: [{ stage: 'won' }, { stage: 'lost' }] })).toEqual(['d_lost', 'd_won']);
+ });
+});
diff --git a/packages/drivers/driver-turso/src/remote-transport-unknown-select.test.ts b/packages/drivers/driver-turso/src/remote-transport-unknown-select.test.ts
new file mode 100644
index 0000000000..27b95e87a8
--- /dev/null
+++ b/packages/drivers/driver-turso/src/remote-transport-unknown-select.test.ts
@@ -0,0 +1,68 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { describe, it, expect, vi } from 'vitest';
+import { RemoteTransport } from './remote-transport.js';
+
+/**
+ * Regression: a `$select` projection naming a column the table lacks made the
+ * WHOLE remote query fail, and find() swallowed the "no such column" error into
+ * an empty array. The objectui list renderer auto-requests view-binding fields
+ * (status/due_date/image/priority/start_date/end_date) for every object, so an
+ * AI-built `product` without those fields rendered "Nothing here yet" even
+ * though its seed rows were in the env DB. The remote Turso path overrides
+ * SqlDriver.find(), so it needs its own retry-with-SELECT-* backstop.
+ */
+function transportWithClient(execute: (stmt: any) => Promise) {
+ const calls: Array<{ sql: string; args: any[] }> = [];
+ const client = {
+ execute: vi.fn(async (stmt: any) => {
+ calls.push({ sql: stmt.sql ?? String(stmt), args: stmt.args ?? [] });
+ return execute(stmt);
+ }),
+ close: vi.fn(),
+ };
+ const t = new RemoteTransport();
+ t.setClient(client as any);
+ return { t, calls };
+}
+
+describe('RemoteTransport unknown-$select column', () => {
+ it('returns rows by retrying with SELECT * when a projected column is unknown', async () => {
+ const rows = [
+ { id: '1', product_name: 'Widget' },
+ { id: '2', product_name: 'Gadget' },
+ ];
+ const { t, calls } = transportWithClient(async (stmt) => {
+ // The projection naming `status`/`due_date` fails; SELECT * succeeds.
+ if (/"status"|"due_date"/.test(stmt.sql)) {
+ throw new Error('SQLITE_ERROR: no such column: status');
+ }
+ return { rows, columns: ['id', 'product_name'] };
+ });
+
+ const result = await t.find('product', {
+ fields: ['id', 'product_name', 'status', 'due_date'],
+ limit: 100,
+ });
+
+ expect(result).toHaveLength(2);
+ // First attempt used the projection; the retry fell back to SELECT *.
+ expect(calls[0].sql).toMatch(/"status"/);
+ expect(calls[1].sql).toMatch(/SELECT \* FROM "product"/);
+ });
+
+ it('still returns empty when even SELECT * fails (e.g. unknown table)', async () => {
+ const { t } = transportWithClient(async () => {
+ throw new Error('SQLITE_ERROR: no such column: status');
+ });
+ const result = await t.find('ghost', { fields: ['id', 'status'], limit: 10 });
+ expect(result).toEqual([]);
+ });
+
+ it('propagates non-column errors instead of hiding them as empty', async () => {
+ const { t } = transportWithClient(async () => {
+ throw new Error('SQLITE_BUSY: database is locked');
+ });
+ await expect(t.find('product', { fields: ['id'] })).rejects.toThrow(/locked/);
+ });
+});
diff --git a/packages/drivers/driver-turso/src/remote-transport.ts b/packages/drivers/driver-turso/src/remote-transport.ts
new file mode 100644
index 0000000000..00f54890a3
--- /dev/null
+++ b/packages/drivers/driver-turso/src/remote-transport.ts
@@ -0,0 +1,1726 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Remote Transport for TursoDriver
+ *
+ * Implements IDataDriver CRUD operations using @libsql/client for
+ * remote-only (libsql://, https://) connections. No local SQLite or
+ * Knex dependency — all queries execute via HTTP/WebSocket against
+ * the remote Turso database.
+ *
+ * This transport is used internally by TursoDriver when the connection
+ * URL is a remote-only endpoint without a local file backend.
+ */
+
+import type { Client, InStatement, ResultSet } from '@libsql/client';
+import { StandardErrorCode } from '@objectstack/spec/api';
+import { nanoid } from 'nanoid';
+
+/**
+ * Default ID length for auto-generated IDs.
+ */
+const DEFAULT_ID_LENGTH = 16;
+
+/**
+ * Columns created unconditionally by syncSchema — skip when iterating fields.
+ */
+const BUILTIN_COLUMNS = new Set(['id', 'created_at', 'updated_at']);
+
+/**
+ * Pattern for valid SQL identifiers (table and column names).
+ * Prevents SQL injection in DDL statements where parameterized queries
+ * are not supported (e.g. PRAGMA, CREATE TABLE, ALTER TABLE).
+ */
+const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
+
+/**
+ * Every filter operator `buildWhereSQL` compiles — the vocabulary this
+ * transport CLAIMS to speak, and (since #1004) the exact set it accepts.
+ *
+ * It is the spec's `FieldOperatorsSchema` list minus `$between`, which the
+ * driver lowers before the filter gets here (see {@link unsupportedOperator}),
+ * plus `$regex`, which is not spec-declared but is what better-auth's adapter
+ * emits for a substring search and what `SqlDriver` therefore compiles.
+ */
+const SUPPORTED_FILTER_OPERATORS = [
+ '$eq',
+ '$ne',
+ '$gt',
+ '$gte',
+ '$lt',
+ '$lte',
+ '$in',
+ '$nin',
+ '$contains',
+ '$notContains',
+ '$startsWith',
+ '$endsWith',
+ '$regex',
+ '$null',
+ '$exists',
+] as const;
+
+/**
+ * Where the wildcard goes in a LIKE pattern: `contains` → `%v%`,
+ * `starts` → `v%`, `ends` → `%v`.
+ */
+type LikeShape = 'contains' | 'starts' | 'ends';
+
+/**
+ * The SQL comparison each range operator compiles to. One table rather than one
+ * arm apiece so the four share a single comparand-binding path (#1058) — the
+ * operator is the ONLY thing that differs between them.
+ */
+const RANGE_SQL_OPERATOR: Record = {
+ $gt: '>',
+ $gte: '>=',
+ $lt: '<',
+ $lte: '<=',
+};
+
+/**
+ * The dialect's canonical FALSE, as a predicate (#1073).
+ *
+ * SQLite has no boolean literal, so the empty disjunction — `$or: []`, whose
+ * boolean identity element is FALSE — is spelled as a constant comparison that
+ * is false for every row. It is emitted rather than omitted because omitting it
+ * says TRUE, which is the opposite answer. TRUE needs no such spelling: a WHERE
+ * clause that is missing a conjunct already MEANS "no constraint".
+ *
+ * `$not` of a vacuously-TRUE sub-filter (`$not: {}`) is the second way to reach
+ * it (#1076): `NOT TRUE` is FALSE, and FALSE has to be *written*.
+ */
+const SQL_FALSE = '1 = 0';
+
+/**
+ * Is this comparand the spec's cross-field marker, `{ $field: 'other_column' }`?
+ *
+ * Recognised only to give it a message of its own — it is refused either way
+ * (see {@link RemoteTransport.uncompilableComparand}). Deliberately shallow: a
+ * `$field` key is the whole marker per `FieldReferenceSchema`, and anything
+ * carrying one is asking for a cross-field comparison whatever else it carries.
+ */
+function isFieldReference(value: unknown): boolean {
+ return typeof value === 'object' && value !== null && !Array.isArray(value) && '$field' in value;
+}
+
+/**
+ * Is this value one `serializeComparand` binds AS A VALUE, even though `typeof`
+ * calls it an object?
+ *
+ * The ONE list both halves of the filter path consult — the ROUTER (is this
+ * field's comparand an operator map, or a value?) and the SERIALIZER (can this
+ * comparand be bound?). Keeping it in one place is the whole fix for #1066: the
+ * two questions had drifted, so a `Date` was "a bindable value" to
+ * {@link RemoteTransport.serializeComparand} (its allow-list names it as this
+ * transport's one declared object conversion) and "an operator map" to
+ * `buildWhereSQL`'s routing test. Since `Object.entries(new Date())` is empty,
+ * that map had no operators in it, the loop never ran, and `{ closed_at: date }`
+ * compiled to a WHERE-less full table scan — the mirror of #1058's silent zero
+ * rows, and worse, because the caller gets back the very rows the filter was
+ * written to exclude.
+ *
+ * `Date` is the only entry today, matching the allow-list exactly. Adding an
+ * object form there means adding it here, and the `value is Date` narrowing
+ * makes the compiler say so.
+ */
+function isBindableObjectComparand(value: unknown): value is Date {
+ return value instanceof Date;
+}
+
+/**
+ * Is this field's comparand an operator map (`{ $gt: 18 }`) rather than a value?
+ *
+ * Every non-array object EXCEPT the value forms above — including plain objects
+ * with no `$` keys, which reach the operator loop and are refused there by name
+ * (#1004) rather than being quietly read as equality comparands.
+ */
+function isOperatorMap(value: unknown): boolean {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ !Array.isArray(value) &&
+ !isBindableObjectComparand(value)
+ );
+}
+
+/**
+ * Is this a filter NODE — the record shape `$and`/`$or` elements must have?
+ *
+ * A plain record, i.e. `{…}` / `Object.create(null)`. Not a `Date`, not a typed
+ * array, not a class instance, not an array, not `null`, not a scalar.
+ *
+ * The narrowness is the point (#1073). A sub-filter that compiles to no SQL is
+ * read as the branch's identity element (TRUE), so "compiles to nothing" has to
+ * be provable from the INPUT rather than inferred from the output. Every form
+ * excluded here has own-enumerable-key behaviour that would make it compile to
+ * nothing *by accident* — `Object.entries(new Date())` and
+ * `Object.entries(new Uint8Array([]))` are both `[]`, which is how #1066
+ * happened one level down — and "accidentally empty" read as TRUE is the full
+ * table scan this fix exists to prevent. Refusing them by FORM keeps the two
+ * cases apart with no guessing.
+ */
+function isFilterNode(value: unknown): value is Record {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
+ const proto = Object.getPrototypeOf(value);
+ return proto === Object.prototype || proto === null;
+}
+
+/** How long a refused comparand may be echoed back in an error message. */
+const COMPARAND_PREVIEW_LIMIT = 120;
+
+/**
+ * The refused comparand, as written, for the error message — truncated, because
+ * a filter value can be arbitrarily large and an error is read in a log.
+ */
+function preview(value: unknown): string {
+ let text: string;
+ try {
+ text = JSON.stringify(value) ?? String(value);
+ } catch {
+ // Cyclic or otherwise unserialisable — the shape is what matters here.
+ text = Object.prototype.toString.call(value);
+ }
+ return text.length > COMPARAND_PREVIEW_LIMIT ? `${text.slice(0, COMPARAND_PREVIEW_LIMIT)}…` : text;
+}
+
+/** A human name for the refused comparand's FORM (`an array`, `an object`, …). */
+function describeValue(value: unknown): string {
+ if (Array.isArray(value)) return 'an array';
+ if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return 'a binary buffer';
+ if (typeof value === 'object') return 'an object';
+ return `a ${typeof value}`;
+}
+
+/**
+ * A filter this transport refuses to COMPILE, in the ADR-0112 envelope (#1116).
+ *
+ * Every refusal below — #1004's unknown operator, #1058's unbindable comparand,
+ * #1066/#1071's empty operator map, #1073/#1076's non-node sub-filter, #1075's
+ * non-node top-level `where`, and now #1116's non-boolean `$null` — describes
+ * the SAME condition: the caller sent a filter this transport cannot compile.
+ * Each of them threw a bare `Error`, so `code` and `status` were `undefined`
+ * and the wire identity of the refusal was carried by ENGLISH PROSE alone.
+ *
+ * That is the gap this closes. `mapDataError` reads `error.code` / `error.status`
+ * to build the response envelope; with neither set, these fell through to its
+ * default branch and shipped `{ "error": "" }` with no `code` at all —
+ * while the framework twins that refuse the very same shapes (`driver-sql`,
+ * `driver-sqlite-wasm`, `driver-memory`, `driver-mongodb`) all speak
+ * `INVALID_FILTER` / 400 (objectstack#4436, #5368). One condition answered by
+ * two spellings depending on whether the driver was local or remote is the same
+ * class of local/remote fork #1075/#1076/#1116 exist to close, one layer up:
+ * a caller could not branch on the refusal without string-matching it, and a
+ * refusal only a human reading a log can classify is not much better than a
+ * wrong answer.
+ *
+ * `INVALID_FILTER` is the catalogued `StandardErrorCode` for the condition and
+ * the one `metadata-protocol` already emits for a filter that fails to parse
+ * upstream — one condition, one wire code, however the caller reached it. The
+ * enum member is referenced rather than the string literal so that a rename in
+ * `@objectstack/spec` breaks this build instead of silently shipping a code the
+ * schema no longer knows.
+ *
+ * `status: 400` is the other half: it puts the rejection on `@objectstack/rest`'s
+ * `isExpectedQueryRejection` list, so a client's malformed filter stops being
+ * logged as an unhandled SERVER error once per request.
+ *
+ * The `[RemoteTransport]` prefix on the existing messages is deliberately left
+ * as it is — #1116 asked for the missing `code`/`status`, and rewording six
+ * shipped refusals is a separate, user-visible change. (framework dropped its
+ * `[sql-driver]` prefix when it did this same work; that this transport still
+ * carries one is noted in #1116 and belongs with the rest of #1077's envelope
+ * pass, not here.)
+ */
+function invalidFilterError(message: string): Error {
+ const err = new Error(message) as Error & { code?: string; status?: number };
+ err.code = StandardErrorCode.enum.INVALID_FILTER;
+ err.status = 400;
+ return err;
+}
+
+/**
+ * How a filtered column must be READ so it is in the same storage form the
+ * comparand was coerced into — the column half of the driver's temporal seam
+ * (`SqlDriver.temporalFilterColumnSql`), injected by TursoDriver.
+ *
+ * Takes the already-quoted column reference this transport was going to emit
+ * and returns the SQL to emit instead. Returning it unchanged is the answer for
+ * every column whose stored form already matches the comparand.
+ */
+export type FilterColumnSqlResolver = (
+ object: string,
+ field: string,
+ columnSql: string,
+) => string;
+
+/**
+ * Remote transport that executes all queries via @libsql/client.
+ *
+ * Handles SQL generation, filter compilation, and result mapping for
+ * remote-only Turso connections. Designed to be used as a delegate
+ * inside TursoDriver — not exposed directly to users.
+ */
+export class RemoteTransport {
+ private client: Client | null = null;
+
+ /**
+ * Factory function for lazy (re)connection.
+ *
+ * When set, `ensureConnected()` will invoke this factory to create a
+ * @libsql/client instance on-demand — recovering from cold-start failures,
+ * transient network errors, or serverless recycling without requiring the
+ * caller to explicitly call `connect()` again.
+ */
+ private connectFactory: (() => Promise) | null = null;
+
+ /**
+ * Tracks whether a lazy-connect attempt is already in progress to prevent
+ * concurrent reconnection storms under high concurrency.
+ */
+ private connectPromise: Promise | null = null;
+
+ /**
+ * The driver's storage-form rule for a filtered column — see
+ * {@link setFilterColumnSql}. Absent (the default) means "the plain
+ * identifier is already correct", which is what every non-temporal column
+ * and every backfilled one resolves to anyway.
+ */
+ private filterColumnSql: FilterColumnSqlResolver | null = null;
+
+ /**
+ * Set the @libsql/client instance used for all queries.
+ */
+ setClient(client: Client): void {
+ this.client = client;
+ }
+
+ /**
+ * Register a factory function for lazy (re)connection.
+ *
+ * TursoDriver calls this during construction so that the transport can
+ * self-heal when the initial `connect()` call fails or when the client
+ * becomes unavailable (e.g., serverless cold-start, transient error).
+ */
+ setConnectFactory(factory: () => Promise): void {
+ this.connectFactory = factory;
+ }
+
+ /**
+ * Register the driver's rule for reading a filtered column in the same
+ * storage form the comparand was coerced into.
+ *
+ * The comparand half of that pair already goes through the driver
+ * (`temporalFilterValue`, applied in `TursoDriver.toRemoteFilter` before the
+ * filter reaches this class). Its own docs say coercing the value is
+ * "necessary but NOT sufficient — a caller that binds `temporalFilterValue`
+ * must wrap its column with this too, or it keeps half the bug": a column
+ * that still holds PRE-convention values (a zone-naive `datetime('now')`
+ * default, an offset-bearing string, a full ISO timestamp in a `Field.time`
+ * column) only compares correctly once the column is read through the
+ * driver's repair expression.
+ *
+ * This transport does not decide when that applies — it asks. Keeping the
+ * rule on the driver side is the point of ADR-0053 D-A1: one dialect-aware
+ * implementation, never a second one re-derived from the value's shape.
+ */
+ setFilterColumnSql(resolver: FilterColumnSqlResolver): void {
+ this.filterColumnSql = resolver;
+ }
+
+ /**
+ * Get the current @libsql/client instance.
+ */
+ getClient(): Client | null {
+ return this.client;
+ }
+
+ /**
+ * Close the client and release resources.
+ */
+ close(): void {
+ if (this.client) {
+ this.client.close();
+ this.client = null;
+ }
+ }
+
+ // ===================================
+ // Health Check
+ // ===================================
+
+ async checkHealth(): Promise {
+ try {
+ const client = await this.ensureConnected();
+ await client.execute('SELECT 1');
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ // ===================================
+ // Raw Execution
+ // ===================================
+
+ async execute(command: unknown, params?: unknown[]): Promise {
+ await this.ensureConnected();
+ if (typeof command !== 'string') return command;
+
+ const stmt: InStatement = params && params.length > 0
+ ? { sql: command, args: params as any[] }
+ : command;
+
+ const result = await this.client!.execute(stmt);
+ return result.rows;
+ }
+
+ // ===================================
+ // CRUD Operations
+ // ===================================
+
+ async find(object: string, query: any): Promise[]> {
+ await this.ensureConnected();
+
+ const { sql, args } = this.buildSelectSQL(object, query);
+
+ try {
+ const result = await this.client!.execute({ sql, args });
+ return this.mapRows(result);
+ } catch (error: any) {
+ const isUnknownColumn =
+ error.message &&
+ (error.message.includes('no such column') ||
+ (error.message.includes('column') && error.message.includes('does not exist')));
+ if (isUnknownColumn) {
+ // A `$select` projection naming a column the table lacks (e.g. a
+ // generic list view auto-requesting status/due_date/image on an object
+ // without them) makes the WHOLE query fail. Swallowing that into an
+ // empty result — the old behavior — reads to the UI as "no records
+ // exist" even though the rows are there: a silent data-loss footgun
+ // that left published AI-built apps looking empty. When the failure
+ // came from the projection, retry once selecting all columns so the
+ // real rows still come back; the unknown field is simply absent from
+ // each row (it never existed). Mirrors the SqlDriver backstop — the
+ // remote Turso path overrides find(), so it needs its own copy.
+ if (query?.fields && Array.isArray(query.fields) && query.fields.length > 0) {
+ try {
+ const fallback = this.buildSelectSQL(object, { ...query, fields: undefined });
+ const result = await this.client!.execute({ sql: fallback.sql, args: fallback.args });
+ return this.mapRows(result);
+ } catch {
+ return [];
+ }
+ }
+ return [];
+ }
+ throw error;
+ }
+ }
+
+ /**
+ * An id lookup is spelled as the query it is: `{ where: { id } }`.
+ *
+ * This used to carry an extra `typeof query === 'string' | 'number'` branch
+ * that ran its own `WHERE id = ?`. framework#4311 removed the identical
+ * undeclared branch from `SqlDriver.findOne`, which left `TursoDriver`
+ * answering the SAME call two ways — remote resolved the id, local silently
+ * returned `null` — a divergence no caller could see until a row went
+ * missing. Neither the contract nor any caller outside these tests spelled it
+ * that way, so the branch is gone here too: one driver, one spelling.
+ */
+ async findOne(object: string, query: any): Promise | null> {
+ if (query && typeof query === 'object') {
+ const results = await this.find(object, { ...query, limit: 1 });
+ return results[0] || null;
+ }
+
+ return null;
+ }
+
+ // `findStream` retired with the contract method in spec 17.0.0
+ // (objectstack#4484). It read the whole result set through `find()` before
+ // yielding, so it never streamed anything either; its only caller was
+ // `TursoDriver.findStream`, which went at the same time.
+
+ async aggregate(object: string, query: any): Promise[]> {
+ await this.ensureConnected();
+ this.assertSafeIdentifier(object);
+
+ const selectParts: string[] = [];
+ const groupBy: string[] = Array.isArray(query?.groupBy) ? query.groupBy : [];
+
+ for (const field of groupBy) {
+ this.assertSafeIdentifier(field);
+ selectParts.push(`"${field}"`);
+ }
+
+ const aggregations = query?.aggregations || query?.aggregate || [];
+ for (const agg of aggregations) {
+ const funcRaw = String(agg.function || agg.func || '').toLowerCase();
+ if (!['count', 'sum', 'avg', 'min', 'max'].includes(funcRaw)) {
+ throw new Error(`Unsupported aggregate function: ${funcRaw}`);
+ }
+ const field = agg.field || '*';
+ let fieldSql: string;
+ if (field === '*') {
+ fieldSql = '*';
+ } else {
+ this.assertSafeIdentifier(field);
+ fieldSql = `"${field}"`;
+ }
+ const alias = agg.alias || `${funcRaw}_${field === '*' ? 'all' : field}`;
+ this.assertSafeIdentifier(alias);
+ selectParts.push(`${funcRaw}(${fieldSql}) AS "${alias}"`);
+ }
+
+ if (selectParts.length === 0) selectParts.push('*');
+
+ let sql = `SELECT ${selectParts.join(', ')} FROM "${object}"`;
+ const args: any[] = [];
+
+ const { whereClauses, args: whereArgs } = this.buildWhereSQL(object, query?.where);
+ if (whereClauses) {
+ sql += ` WHERE ${whereClauses}`;
+ args.push(...whereArgs);
+ }
+
+ if (groupBy.length > 0) {
+ sql += ` GROUP BY ${groupBy.map((f) => `"${f}"`).join(', ')}`;
+ }
+
+ try {
+ const result = await this.client!.execute({ sql, args });
+ return this.mapRows(result);
+ } catch (error: any) {
+ if (
+ error.message &&
+ (error.message.includes('no such table') ||
+ error.message.includes('no such column'))
+ ) {
+ return [];
+ }
+ throw error;
+ }
+ }
+
+ async create(object: string, data: Record): Promise> {
+ await this.ensureConnected();
+
+ const { _id, ...rest } = data as any;
+ const toInsert = { ...rest };
+
+ if (_id !== undefined && toInsert.id === undefined) {
+ toInsert.id = _id;
+ } else if (toInsert.id === undefined) {
+ toInsert.id = nanoid(DEFAULT_ID_LENGTH);
+ }
+
+ const columns = Object.keys(toInsert);
+ const placeholders = columns.map(() => '?').join(', ');
+ const values = columns.map((col) => this.serializeValue(toInsert[col]));
+
+ const sql = `INSERT INTO "${object}" (${columns.map((c) => `"${c}"`).join(', ')}) VALUES (${placeholders})`;
+ await this.client!.execute({ sql, args: values });
+
+ // Fetch the inserted row to return complete record
+ const result = await this.client!.execute({
+ sql: `SELECT * FROM "${object}" WHERE "id" = ?`,
+ args: [toInsert.id],
+ });
+ const rows = this.mapRows(result);
+ return rows[0] || toInsert;
+ }
+
+ async update(object: string, id: string | number, data: Record): Promise> {
+ await this.ensureConnected();
+
+ const columns = Object.keys(data);
+ const setClauses = columns.map((col) => `"${col}" = ?`).join(', ');
+ const values = columns.map((col) => this.serializeValue(data[col]));
+
+ const sql = `UPDATE "${object}" SET ${setClauses} WHERE "id" = ?`;
+ await this.client!.execute({ sql, args: [...values, id] });
+
+ // Fetch updated row
+ const result = await this.client!.execute({
+ sql: `SELECT * FROM "${object}" WHERE "id" = ?`,
+ args: [id],
+ });
+ const rows = this.mapRows(result);
+ return rows[0] || { id, ...data };
+ }
+
+ async upsert(object: string, data: Record, conflictKeys?: string[]): Promise> {
+ await this.ensureConnected();
+
+ const { _id, ...rest } = data as any;
+ const toUpsert = { ...rest };
+
+ if (_id !== undefined && toUpsert.id === undefined) {
+ toUpsert.id = _id;
+ } else if (toUpsert.id === undefined) {
+ toUpsert.id = nanoid(DEFAULT_ID_LENGTH);
+ }
+
+ const columns = Object.keys(toUpsert);
+ const placeholders = columns.map(() => '?').join(', ');
+ const values = columns.map((col) => this.serializeValue(toUpsert[col]));
+ const mergeKeys = conflictKeys && conflictKeys.length > 0 ? conflictKeys : ['id'];
+
+ // Build ON CONFLICT ... DO UPDATE SET
+ const updateCols = columns.filter((c) => !mergeKeys.includes(c));
+ const updateClauses = updateCols.map((col) => `"${col}" = excluded."${col}"`).join(', ');
+
+ let sql = `INSERT INTO "${object}" (${columns.map((c) => `"${c}"`).join(', ')}) VALUES (${placeholders})`;
+ sql += ` ON CONFLICT(${mergeKeys.map((k) => `"${k}"`).join(', ')})`;
+ if (updateClauses) {
+ sql += ` DO UPDATE SET ${updateClauses}`;
+ } else {
+ sql += ` DO NOTHING`;
+ }
+
+ await this.client!.execute({ sql, args: values });
+
+ // Fetch the result row
+ const result = await this.client!.execute({
+ sql: `SELECT * FROM "${object}" WHERE "id" = ?`,
+ args: [toUpsert.id],
+ });
+ const rows = this.mapRows(result);
+ return rows[0] || toUpsert;
+ }
+
+ async delete(object: string, id: string | number): Promise {
+ await this.ensureConnected();
+ const result = await this.client!.execute({
+ sql: `DELETE FROM "${object}" WHERE "id" = ?`,
+ args: [id],
+ });
+ return result.rowsAffected > 0;
+ }
+
+ async count(object: string, query?: any): Promise {
+ await this.ensureConnected();
+
+ const { whereClauses, args } = this.buildWhereSQL(object, query?.where);
+ let sql = `SELECT COUNT(*) as count FROM "${object}"`;
+ if (whereClauses) sql += ` WHERE ${whereClauses}`;
+
+ const result = await this.client!.execute({ sql, args });
+ if (result.rows.length > 0) {
+ // Use result.columns to find the count column dynamically
+ const row = result.rows[0] as any;
+ const countCol = result.columns.find((c) => c.toLowerCase().includes('count'));
+ return Number(countCol ? row[countCol] : row.count ?? 0);
+ }
+ return 0;
+ }
+
+ // ===================================
+ // Bulk Operations
+ // ===================================
+
+ async bulkCreate(object: string, dataArray: Record[]): Promise[]> {
+ const results: Record[] = [];
+ for (const data of dataArray) {
+ const created = await this.create(object, data);
+ results.push(created);
+ }
+ return results;
+ }
+
+ async bulkUpdate(object: string, updates: Array<{ id: string | number; data: Record }>): Promise[]> {
+ const results: Record[] = [];
+ for (const { id, data } of updates) {
+ const updated = await this.update(object, id, data);
+ if (updated) results.push(updated);
+ }
+ return results;
+ }
+
+ async bulkDelete(object: string, ids: Array): Promise {
+ await this.ensureConnected();
+ if (ids.length === 0) return;
+
+ const placeholders = ids.map(() => '?').join(', ');
+ await this.client!.execute({
+ sql: `DELETE FROM "${object}" WHERE "id" IN (${placeholders})`,
+ args: ids as any[],
+ });
+ }
+
+ async updateMany(object: string, query: any, data: Record): Promise {
+ await this.ensureConnected();
+
+ const columns = Object.keys(data);
+ const setClauses = columns.map((col) => `"${col}" = ?`).join(', ');
+ const setValues = columns.map((col) => this.serializeValue(data[col]));
+
+ const { whereClauses, args: whereArgs } = this.buildWhereSQL(object, query?.where);
+ let sql = `UPDATE "${object}" SET ${setClauses}`;
+ if (whereClauses) sql += ` WHERE ${whereClauses}`;
+
+ const result = await this.client!.execute({ sql, args: [...setValues, ...whereArgs] });
+ return result.rowsAffected;
+ }
+
+ async deleteMany(object: string, query: any): Promise {
+ await this.ensureConnected();
+
+ const { whereClauses, args } = this.buildWhereSQL(object, query?.where);
+ let sql = `DELETE FROM "${object}"`;
+ if (whereClauses) sql += ` WHERE ${whereClauses}`;
+
+ const result = await this.client!.execute({ sql, args });
+ return result.rowsAffected;
+ }
+
+ // ===================================
+ // Transactions
+ // ===================================
+
+ async beginTransaction(): Promise {
+ await this.ensureConnected();
+ return this.client!.transaction();
+ }
+
+ async commit(transaction: any): Promise {
+ await transaction.commit();
+ }
+
+ async rollback(transaction: any): Promise {
+ await transaction.rollback();
+ }
+
+ // ===================================
+ // Schema Management
+ // ===================================
+
+ async syncSchema(object: string, schema: any): Promise {
+ await this.ensureConnected();
+
+ const objectDef = schema as { name: string; fields?: Record };
+ const tableName = object;
+ this.assertSafeIdentifier(tableName);
+
+ // Check if table exists
+ const checkResult = await this.client!.execute({
+ sql: `SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
+ args: [tableName],
+ });
+ const exists = checkResult.rows.length > 0;
+
+ if (!exists) {
+ await this.client!.execute(this.buildCreateTableSQL(tableName, objectDef));
+ } else {
+ // ALTER TABLE — add missing columns
+ if (objectDef.fields) {
+ const columnsResult = await this.client!.execute({
+ sql: `PRAGMA table_info("${tableName}")`,
+ args: [],
+ });
+ const existingColumns = new Set(columnsResult.rows.map((r: any) => r.name));
+
+ for (const [name, field] of Object.entries(objectDef.fields)) {
+ if (existingColumns.has(name)) continue;
+ const type = (field as any).type || 'string';
+ if (type === 'formula') continue; // Virtual — no column
+ this.assertSafeIdentifier(name);
+ const colType = this.mapFieldTypeToSQL(field);
+ await this.client!.execute(`ALTER TABLE "${tableName}" ADD COLUMN "${name}" ${colType}`);
+ }
+ }
+ }
+ }
+
+ /**
+ * Batch-synchronize multiple object schemas using batched libsql calls.
+ *
+ * Collects all DDL statements (CREATE TABLE / ALTER TABLE ADD COLUMN)
+ * for every schema and uses `client.batch()` to minimize network
+ * round-trips. The process may perform up to three batch calls:
+ * one to introspect existing tables, one to introspect columns for
+ * existing tables, and one to apply DDL statements.
+ *
+ * This method does not implement an internal fallback to sequential
+ * `syncSchema()`. Any fallback behavior is expected to be handled
+ * by the caller if a batch operation is not supported or fails.
+ */
+ async syncSchemasBatch(schemas: Array<{ object: string; schema: any }>): Promise {
+ await this.ensureConnected();
+ if (schemas.length === 0) return;
+
+ // Validate all identifiers up-front
+ for (const s of schemas) {
+ this.assertSafeIdentifier(s.object);
+ }
+
+ // Phase 1: introspect all tables in one batch
+ const introspectStmts: InStatement[] = schemas.map((s) => ({
+ sql: `SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
+ args: [s.object],
+ }));
+ const introspectResults = await this.client!.batch(introspectStmts, 'read');
+
+ // Separate new tables from existing tables
+ const newSchemas: Array<{ object: string; schema: any }> = [];
+ const existingSchemas: Array<{ object: string; schema: any }> = [];
+
+ for (let i = 0; i < schemas.length; i++) {
+ if (introspectResults[i].rows.length > 0) {
+ existingSchemas.push(schemas[i]);
+ } else {
+ newSchemas.push(schemas[i]);
+ }
+ }
+
+ // Phase 2a: build CREATE TABLE statements for new tables
+ const ddlStatements: InStatement[] = [];
+
+ for (const { object, schema } of newSchemas) {
+ const objectDef = schema as { name: string; fields?: Record };
+ ddlStatements.push(this.buildCreateTableSQL(object, objectDef));
+ }
+
+ // Phase 2b: for existing tables, introspect columns in one batch
+ if (existingSchemas.length > 0) {
+ const pragmaStmts: InStatement[] = existingSchemas.map((s) => ({
+ sql: `PRAGMA table_info("${s.object}")`,
+ args: [],
+ }));
+ const pragmaResults = await this.client!.batch(pragmaStmts, 'read');
+
+ for (let i = 0; i < existingSchemas.length; i++) {
+ const { object, schema } = existingSchemas[i];
+ const objectDef = schema as { name: string; fields?: Record };
+ if (!objectDef.fields) continue;
+
+ const existingColumns = new Set(pragmaResults[i].rows.map((r: any) => r.name));
+
+ for (const [name, field] of Object.entries(objectDef.fields)) {
+ if (existingColumns.has(name)) continue;
+ const type = (field as any).type || 'string';
+ if (type === 'formula') continue;
+ this.assertSafeIdentifier(name);
+ const colType = this.mapFieldTypeToSQL(field);
+ ddlStatements.push(`ALTER TABLE "${object}" ADD COLUMN "${name}" ${colType}`);
+ }
+ }
+ }
+
+ // Phase 3: execute all DDL in a single batch
+ if (ddlStatements.length > 0) {
+ await this.client!.batch(ddlStatements, 'write');
+ }
+ }
+
+ async dropTable(object: string): Promise {
+ await this.ensureConnected();
+ await this.client!.execute(`DROP TABLE IF EXISTS "${object}"`);
+ }
+
+ // ===================================
+ // Internal Helpers
+ // ===================================
+
+ /**
+ * Ensure the @libsql/client is initialized, attempting lazy connect if a
+ * factory was registered and the client is not yet available.
+ *
+ * Uses a singleton promise to prevent concurrent reconnection storms:
+ * multiple callers that race into this method while a connect is in flight
+ * will all await the same promise.
+ */
+ private async ensureConnected(): Promise {
+ if (this.client) return this.client;
+
+ if (this.connectFactory) {
+ // De-duplicate concurrent connect attempts
+ if (!this.connectPromise) {
+ this.connectPromise = this.connectFactory()
+ .then((client) => {
+ this.client = client;
+ this.connectPromise = null;
+ return client;
+ })
+ .catch((err) => {
+ this.connectPromise = null;
+ throw new Error(
+ `RemoteTransport: lazy connect failed: ${err instanceof Error ? err.message : String(err)}`
+ );
+ });
+ }
+ return this.connectPromise;
+ }
+
+ throw new Error('RemoteTransport: @libsql/client is not initialized. Call connect() first.');
+ }
+
+ /**
+ * Validate that a string is a safe SQL identifier.
+ * Prevents injection in DDL where parameterized queries are unsupported.
+ */
+ private assertSafeIdentifier(name: string): void {
+ if (!SAFE_IDENTIFIER.test(name)) {
+ throw new Error(`RemoteTransport: unsafe identifier rejected: "${name}"`);
+ }
+ }
+
+ /**
+ * Build a CREATE TABLE SQL string for the given object definition.
+ * Shared by syncSchema() and syncSchemasBatch() to avoid duplication.
+ */
+ private buildCreateTableSQL(tableName: string, objectDef: { fields?: Record }): string {
+ let sql = `CREATE TABLE "${tableName}" ("id" TEXT PRIMARY KEY, "created_at" TEXT DEFAULT (datetime('now')), "updated_at" TEXT DEFAULT (datetime('now'))`;
+
+ if (objectDef.fields) {
+ for (const [name, field] of Object.entries(objectDef.fields)) {
+ if (BUILTIN_COLUMNS.has(name)) continue;
+ const type = (field as any).type || 'string';
+ if (type === 'formula') continue; // Virtual — no column
+ this.assertSafeIdentifier(name);
+ const colType = this.mapFieldTypeToSQL(field);
+ sql += `, "${name}" ${colType}`;
+ }
+ }
+
+ sql += ')';
+ return sql;
+ }
+
+ /**
+ * Map ObjectStack field types to SQLite column types for DDL.
+ */
+ private mapFieldTypeToSQL(field: any): string {
+ if (field.multiple) return 'TEXT'; // JSON array stored as text
+
+ const type = field.type || 'string';
+ switch (type) {
+ case 'string':
+ case 'email':
+ case 'url':
+ case 'phone':
+ case 'password':
+ case 'text':
+ case 'textarea':
+ case 'html':
+ case 'markdown':
+ case 'lookup':
+ case 'auto_number':
+ return 'TEXT';
+ case 'integer':
+ case 'int':
+ return 'INTEGER';
+ case 'float':
+ case 'number':
+ case 'currency':
+ case 'percent':
+ case 'summary':
+ return 'REAL';
+ case 'boolean':
+ return 'INTEGER'; // SQLite: 0/1
+ case 'date':
+ case 'datetime':
+ case 'time':
+ return 'TEXT';
+ case 'json':
+ case 'object':
+ case 'array':
+ case 'image':
+ case 'file':
+ case 'avatar':
+ case 'location':
+ return 'TEXT'; // JSON stored as text
+ case 'formula':
+ return ''; // Virtual — should not be created
+ default:
+ return 'TEXT';
+ }
+ }
+
+ /**
+ * Build a SELECT SQL statement from a QueryAST-like object.
+ */
+ private buildSelectSQL(object: string, query: any): { sql: string; args: any[] } {
+ const fields = query.fields && Array.isArray(query.fields) && query.fields.length > 0
+ ? query.fields.map((f: string) => `"${this.mapSortField(f)}"`).join(', ')
+ : '*';
+
+ let sql = `SELECT ${fields} FROM "${object}"`;
+ const allArgs: any[] = [];
+
+ // WHERE
+ const { whereClauses, args: whereArgs } = this.buildWhereSQL(object, query.where);
+ if (whereClauses) {
+ sql += ` WHERE ${whereClauses}`;
+ allArgs.push(...whereArgs);
+ }
+
+ // ORDER BY
+ if (query.orderBy && Array.isArray(query.orderBy)) {
+ const orderParts = query.orderBy
+ .filter((item: any) => item.field)
+ .map((item: any) => `"${this.mapSortField(item.field)}" ${(item.order || 'asc').toUpperCase()}`);
+ if (orderParts.length > 0) {
+ sql += ` ORDER BY ${orderParts.join(', ')}`;
+ }
+ }
+
+ // PAGINATION
+ if (query.limit !== undefined) {
+ sql += ` LIMIT ?`;
+ allArgs.push(query.limit);
+ }
+ if (query.offset !== undefined) {
+ sql += ` OFFSET ?`;
+ allArgs.push(query.offset);
+ }
+
+ return { sql, args: allArgs };
+ }
+
+ /**
+ * The SQL to put on the LEFT of a comparison for `key`.
+ *
+ * `column` — the raw quoted identifier — stays the answer for predicates
+ * whose result cannot depend on the stored FORM (`IS NULL`) or which are
+ * asked about the raw text the user typed against (`LIKE`), matching which
+ * predicates SqlDriver leaves on the plain column locally. Everything that
+ * compares a VALUE goes through the driver's rule.
+ */
+ private comparisonColumn(object: string, key: string, column: string): string {
+ return this.filterColumnSql ? this.filterColumnSql(object, key, column) : column;
+ }
+
+ /**
+ * Build WHERE clause from MongoDB-style filter object.
+ *
+ * `object` is threaded through (and into every `$and`/`$or`/`$not` sub-filter)
+ * because the storage form of a column is a property of the OBJECT's field
+ * metadata — the transport cannot ask the driver about a column without
+ * saying which object it belongs to.
+ *
+ * **Invariant (#1073): an empty `whereClauses` means the filter is vacuously
+ * TRUE — never "something was dropped".** That is what makes the identity
+ * elements in the `$and`/`$or`/`$not` branches below safe to apply: they read
+ * `''` from a sub-filter as "this branch imposes no constraint" (and `$not`
+ * inverts it to FALSE), so every OTHER way of compiling to nothing has to be
+ * impossible. It is, by enumeration:
+ * the operator-map branch throws when its map yields zero clauses (#1071),
+ * the comparand gate throws on a value it cannot bind (#1058), an unknown
+ * operator throws (#1004), a sub-filter that is not a filter NODE is refused
+ * by {@link buildSubFilterSQL} rather than compiled to `''`, and — since
+ * #1075 — a TOP-LEVEL filter that is not a filter node is refused by the same
+ * test before the loop runs. The only remaining sources of `''` are
+ * genuinely-empty inputs (`{}`, `$and: []`, an `$or` with a TRUE disjunct) —
+ * all of which ARE TRUE.
+ */
+ private buildWhereSQL(object: string, filters: any): { whereClauses: string; args: any[] } {
+ // "No filter" is spelled by ABSENCE, and that is the only spelling. All
+ // five call sites hand this method `query?.where` (`query.where` in
+ // `buildSelectSQL`), so a caller that supplied no filter arrives as
+ // `undefined`; `null` is accepted as its equivalent because that is what a
+ // JSON round-trip of an absent key produces.
+ if (filters === null || filters === undefined) {
+ return { whereClauses: '', args: [] };
+ }
+
+ // Everything else must be a filter NODE (#1075). This test used to be a
+ // `typeof filters === 'object' && !Array.isArray(filters)` guard around the
+ // loop below, so any OTHER shape fell straight through it to the `return`
+ // at the bottom and compiled to NO WHERE CLAUSE — the caller asked to
+ // filter and silently received the unfiltered table. Measured on the
+ // shipped build: `[['stage','=','won']]`, `'stage'`, `42` and `[]` each
+ // produced `SELECT * FROM "deal"` with no args, and the same emptiness on
+ // `deleteMany`/`updateMany` is a whole-table write.
+ //
+ // The bare AST array is the shape that actually arrives: `isFilterAST()`
+ // refuses an operator outside `VALID_AST_OPERATORS` (8 of the canonical
+ // view operators are in that position — `before`, `after`, `equals`, …), so
+ // `parseFilterAST()` never converts it and the raw array is assigned to
+ // `where` (framework's `sql-driver-filter-no-silent-drop`, #3948).
+ //
+ // Refused rather than compiled: the spec declares `where` as an OBJECT
+ // (`QueryASTSchema.where: FilterConditionSchema`, itself
+ // `z.record(z.string(), z.unknown()).and(…)`), so the array dialect is not
+ // this transport's language, and growing a second implementation of it here
+ // is exactly the divergence ADR-0053 D-A1 forbids. `driver-sql` still
+ // compiles its legacy INFIX array form; on this transport that form now
+ // says so out loud instead of answering a different question.
+ if (!isFilterNode(filters)) {
+ throw this.uncompilableWhere(object, filters);
+ }
+
+ // `{}` — the one empty that is legal. It means "no filter at all", the
+ // vacuous-TRUE control of #1073/#1074, and it keeps its early return.
+ // Reaching it only AFTER the node test is what stops the accidental
+ // empties from borrowing its meaning: `Object.keys()` is also `[]` for
+ // `new Date()`, `new Uint8Array([])` and `[]` itself, each of which used to
+ // return here as "no filter" (the #1066 mistake, one level up).
+ if (Object.keys(filters).length === 0) {
+ return { whereClauses: '', args: [] };
+ }
+
+ const clauses: string[] = [];
+ const args: any[] = [];
+
+ for (const [key, value] of Object.entries(filters)) {
+ if (key === '$and' && Array.isArray(value)) {
+ const subClauses: string[] = [];
+ const subArgs: any[] = [];
+ for (const [index, sub] of value.entries()) {
+ const { whereClauses: sc, args: sa } = this.buildSubFilterSQL(object, key, index, sub);
+ // A TRUE conjunct is AND's identity element: `x AND TRUE ≡ x`, so
+ // dropping it loses nothing. This is the ONE direction the old
+ // "skip whatever compiled to nothing" rule happened to get right.
+ if (!sc) continue;
+ subClauses.push(`(${sc})`);
+ subArgs.push(...sa);
+ }
+ if (subClauses.length > 0) {
+ clauses.push(`(${subClauses.join(' AND ')})`);
+ args.push(...subArgs);
+ }
+ // Every conjunct TRUE (`$and: []`, `$and: [{}]`) → the conjunction is
+ // TRUE → emit no clause, which is exactly how TRUE is spelled in a
+ // list of AND-ed clauses.
+ } else if (key === '$or' && Array.isArray(value)) {
+ const subClauses: string[] = [];
+ const subArgs: any[] = [];
+ // `TRUE OR x ≡ TRUE`: one vacuous disjunct absorbs the disjunction,
+ // whatever its siblings say. Tracked rather than folded into
+ // `subClauses.length` because "no disjuncts at all" is the OPPOSITE
+ // truth value — which is the asymmetry #1073 is about.
+ let hasTrueDisjunct = false;
+ for (const [index, sub] of value.entries()) {
+ const { whereClauses: sc, args: sa } = this.buildSubFilterSQL(object, key, index, sub);
+ if (!sc) {
+ hasTrueDisjunct = true;
+ continue;
+ }
+ subClauses.push(`(${sc})`);
+ subArgs.push(...sa);
+ }
+ if (hasTrueDisjunct) {
+ // The whole disjunction is TRUE → no clause, and deliberately no
+ // args either: `subArgs` belong to clauses that are not emitted, and
+ // a bind list that outruns its `?`s is a different bug again.
+ } else if (subClauses.length === 0) {
+ // The empty disjunction. Boolean OR's identity is FALSE, so `$or:
+ // []` matches ZERO rows — it does NOT mean "no filter". Compiling it
+ // away (the old behaviour) turned a query that selects nothing into
+ // a full table scan, and on `deleteMany`/`updateMany` into a
+ // whole-table write.
+ clauses.push(SQL_FALSE);
+ } else {
+ clauses.push(`(${subClauses.join(' OR ')})`);
+ args.push(...subArgs);
+ }
+ } else if (key === '$not') {
+ // The third logical operator the spec declares (#1076). `$not` was
+ // missing here while `$and`/`$or` were handled, so it fell through to
+ // the FIELD path and was read as a column literally named `$not`:
+ // `{ $not: { $eq: 'won' } }` compiled to `WHERE "$not" = ?` (SQLite:
+ // `no such column`), `{ $not: null }` to `WHERE "$not" IS NULL`, and
+ // `{ $not: { stage: 'won' } }` threw an error naming `$not` as a
+ // FIELD — sending the reader to `describe_object` to look for a
+ // column that cannot exist, the #1051 diagnostic detour.
+ //
+ // It is not an exotic shape: `SqlDriver.applyFilterCondition` compiles
+ // it with `whereNot` (framework#2704), `driver-memory` and
+ // `matchesFilterCondition` both evaluate it, and CEL `!expr` in a
+ // permission / RLS read scope lowers to `{ $not: {…} }`
+ // (`formula/src/cel-to-filter.ts`). Without this branch the SAME scope
+ // answered correctly on a local SqlDriver and failed on Turso remote.
+ //
+ // Deliberately NOT guarded by a value-shape test (`&& isFilterNode`):
+ // a guard would send `$not: null` / `$not: 'won'` back down the field
+ // path, i.e. straight back into the bug. Every `$not` is compiled
+ // here, and a value that is not a filter node is refused BY NAME.
+ //
+ // NULL semantics are SQL's, matching what `whereNot` emits locally:
+ // `NOT ("stage" = ?)` is UNKNOWN for a row whose `stage` is NULL, so
+ // that row is not returned. `driver-memory`/`matchesFilterCondition`
+ // return it (JS `undefined !== 'won'`). The divergence is the SQL
+ // family's, not this transport's, and remote mode is pinned to the
+ // family it belongs to — filed as objectstack#5146.
+ const { whereClauses: sc, args: sa } = this.buildSubFilterSQL(object, key, null, value);
+ if (sc) {
+ clauses.push(`NOT (${sc})`);
+ args.push(...sa);
+ } else {
+ // The inner filter imposes no constraint — it is vacuously TRUE
+ // (`$not: {}`, `$not: { $and: [] }`), and `NOT TRUE` is FALSE. The
+ // #1073 invariant above is what licenses reading `''` that way: it
+ // can ONLY mean TRUE, never "something was dropped". Emitting no
+ // clause here would say TRUE — the exact inversion of the answer.
+ clauses.push(SQL_FALSE);
+ }
+ } else if (isOperatorMap(value)) {
+ // Field-level operators: { age: { $gt: 18 } }
+ const column = `"${this.mapSortField(key)}"`;
+ const field = this.comparisonColumn(object, key, column);
+ // How many clauses this field's map is required to produce (#1066):
+ // a comparand that compiles to NOTHING is the same failure family as
+ // #1004/#1058 approached from a third direction, and the loudest of
+ // the three — the predicate does not match zero rows, it DISAPPEARS,
+ // widening the statement to every row in the table.
+ const clausesBefore = clauses.length;
+ for (const [op, opValue] of Object.entries(value as Record)) {
+ switch (op) {
+ case '$eq':
+ if (opValue === null || opValue === undefined) {
+ clauses.push(`${column} IS NULL`);
+ } else {
+ const bind = this.serializeComparand(object, key, op, opValue);
+ clauses.push(`${field} = ?`);
+ args.push(bind);
+ }
+ break;
+ case '$ne':
+ if (opValue === null || opValue === undefined) {
+ clauses.push(`${column} IS NOT NULL`);
+ } else {
+ const bind = this.serializeComparand(object, key, op, opValue);
+ clauses.push(`${field} <> ?`);
+ args.push(bind);
+ }
+ break;
+ case '$gt':
+ case '$gte':
+ case '$lt':
+ case '$lte': {
+ const bind = this.serializeComparand(object, key, op, opValue);
+ clauses.push(`${field} ${RANGE_SQL_OPERATOR[op]} ?`);
+ args.push(bind);
+ break;
+ }
+ // `$in` / `$nin` take the one comparand form that IS a container:
+ // the array is the operator's own shape, and each ELEMENT is a
+ // comparand in its own right — so the element is what must be
+ // bindable. An element that is not (`$in: [{ $field: 'x' }]`)
+ // degrades exactly like a scalar operator's object comparand did,
+ // just one row of the IN list at a time.
+ case '$in': {
+ const inVals = opValue as any[];
+ const binds = inVals.map((v: any, i: number) =>
+ this.serializeComparand(object, key, `${op}[${i}]`, v),
+ );
+ clauses.push(`${field} IN (${binds.map(() => '?').join(', ')})`);
+ args.push(...binds);
+ break;
+ }
+ case '$nin': {
+ const ninVals = opValue as any[];
+ const binds = ninVals.map((v: any, i: number) =>
+ this.serializeComparand(object, key, `${op}[${i}]`, v),
+ );
+ clauses.push(`${field} NOT IN (${binds.map(() => '?').join(', ')})`);
+ args.push(...binds);
+ break;
+ }
+ // ── Text predicates ──────────────────────────────────────────
+ // All five are asked about the raw stored text, not about an
+ // instant, so they stay on the plain `column` — which is what
+ // SqlDriver does with the LIKE family locally.
+ //
+ // Their comparand goes through the same gate (#1058) before
+ // `pushLike` stringifies it: `String({ $field: 'x' })` is
+ // `'[object Object]'`, so an uncompilable value here produced the
+ // same valid-SQL-matching-nothing as the scalar operators did —
+ // and refusing it in one family while tolerating it in the other
+ // would leave the failure mode alive at a different spelling.
+ case '$contains':
+ // `$regex` is not spec-declared: it reaches SQL only via the
+ // better-auth adapter, which emits it for a `contains` search (a
+ // plain substring, not a real regex). SqlDriver compiles it as
+ // that substring LIKE, so remote mode must too — otherwise a
+ // Turso-backed auth store answers differently from a local one.
+ case '$regex':
+ this.pushLike(clauses, args, column, this.serializeComparand(object, key, op, opValue), 'contains');
+ break;
+ case '$notContains':
+ this.pushLike(clauses, args, column, this.serializeComparand(object, key, op, opValue), 'contains', true);
+ break;
+ case '$startsWith':
+ this.pushLike(clauses, args, column, this.serializeComparand(object, key, op, opValue), 'starts');
+ break;
+ case '$endsWith':
+ this.pushLike(clauses, args, column, this.serializeComparand(object, key, op, opValue), 'ends');
+ break;
+ // ── Existence ────────────────────────────────────────────────
+ // `{ $null: true }` → IS NULL, `{ $null: false }` → IS NOT NULL;
+ // `$exists` is its inverse. Both compare the presence of a value,
+ // which no storage form can change, so both read the plain column
+ // (same reasoning as the `$eq: null` arm above).
+ case '$null':
+ // A THIRD value is not a third answer (#1116). The emitter below
+ // asks `opValue === false`, so every non-boolean — `'yes'`, `1`,
+ // `0`, `null`, `undefined`, `{}` and the string `'false'` — fell
+ // to the `NULL` side and compiled to `IS NULL`. Refused instead,
+ // per objectstack#5347 / #5368; see
+ // {@link nonBooleanNullComparand} for why.
+ if (typeof opValue !== 'boolean') {
+ throw this.nonBooleanNullComparand(object, key, opValue);
+ }
+ // Written as a TOTAL choice over the two booleans rather than as
+ // `=== false ? … : …`. The two spell the same thing only while
+ // the guard above holds; the bisecting spelling has a DEFAULT
+ // side, and a default side is what silently resumed answering
+ // for a third value. framework tightened its twin the same way
+ // and for the same reason (objectstack#5368).
+ clauses.push(`${column} IS ${opValue ? 'NULL' : 'NOT NULL'}`);
+ break;
+ case '$exists':
+ // Deliberately NOT given the same guard (#1116's scope fence).
+ // `$exists` carries the identical `=== false` bisection, but its
+ // divergence is on another axis — whether "exists" means "key
+ // present" or "has a value" is objectstack#5299's open question,
+ // reopened as #5369 — and framework left it alone for exactly
+ // this reason. Tightening it here ALONE would manufacture a
+ // local/remote fork rather than close one.
+ clauses.push(`${column} IS ${opValue === false ? 'NULL' : 'NOT NULL'}`);
+ break;
+ default:
+ // Declared = enforced. This arm used to compile ANY unknown
+ // operator to `column = ?` against its comparand — so a
+ // `$startsWith` prefix, an `$exists` boolean or a typo'd
+ // operator each produced valid SQL that matched nothing, which
+ // reads exactly like "no rows matched" (#1004). An operator this
+ // transport cannot compile is a programming error and must say
+ // so.
+ throw this.unsupportedOperator(object, key, op);
+ }
+ }
+ if (clauses.length === clausesBefore) {
+ throw this.emptyFieldFilter(object, key, value);
+ }
+ } else if (value === null || value === undefined) {
+ // Null equality MUST use `IS NULL` — `col = NULL` is always UNKNOWN
+ // in SQL, so it matches zero rows. Env-wide metadata (and drafts) are
+ // stored with `organization_id IS NULL`; emitting `= ?` here is what
+ // made every env-wide draft read come back empty even though the row
+ // was written. (Knex special-cases this; this hand-rolled builder did not.)
+ const column = `"${this.mapSortField(key)}"`;
+ clauses.push(`${column} IS NULL`);
+ } else {
+ // Simple equality: { name: 'Alice' }, and every comparand that is a
+ // VALUE despite being an object — today `Date` (#1066), which lands
+ // here rather than in the operator-map branch above and compiles to
+ // exactly what its explicit `{ $eq: date }` spelling always did.
+ //
+ // What can still arrive here and NOT be bindable is an array (the one
+ // object form the router leaves alone, since `$in`'s array is an
+ // OPERATOR's shape, not a comparand). It is no more bindable in
+ // implicit-equality position than in explicit `$eq` position, so it
+ // takes the same gate (#1058); a plain object was read as an operator
+ // map above and a bad one already threw (#1004).
+ const column = `"${this.mapSortField(key)}"`;
+ const bind = this.serializeComparand(object, key, '$eq', value);
+ clauses.push(`${this.comparisonColumn(object, key, column)} = ?`);
+ args.push(bind);
+ }
+ }
+
+ return {
+ whereClauses: clauses.join(' AND '),
+ args,
+ };
+ }
+
+ /**
+ * Append one parameterized `LIKE` / `NOT LIKE` predicate.
+ *
+ * The LIKE metacharacters `%` / `_` (and the escape character `\` itself) are
+ * escaped in the COMPARAND so they match literally: unescaped, a value of `%`
+ * expands to `%%%` and matches every row — a filter bypass, and a P0 the
+ * framework already paid for (`sql-driver-like-escape.test.ts`). The escape
+ * clause is written explicitly because SQLite honours no default escape
+ * character; it is a literal here rather than a bind (as `SqlDriver` does)
+ * only because this transport is SQLite-by-construction and keeping the bind
+ * list to comparands keeps the arg arithmetic in every caller unchanged.
+ *
+ * This is the ONE place the LIKE family is built. That is the point: five
+ * operators sharing one escape rule is the opposite of the second
+ * implementation ADR-0053 D-A1 warns about — the rule cannot drift between
+ * `$contains` and `$startsWith` because there is only one of it. It does
+ * restate the framework's rule (which lives in `SqlDriver.applyLike`, a
+ * private Knex-builder method with no reusable export), so the two must be
+ * read together; the row-level suite in
+ * `remote-transport-text-predicates.test.ts` is what pins them to the same
+ * answers.
+ */
+ private pushLike(
+ clauses: string[],
+ args: any[],
+ column: string,
+ value: unknown,
+ shape: LikeShape,
+ negate = false,
+ ): void {
+ const escaped = String(value).replace(/[\\%_]/g, '\\$&');
+ const pattern = shape === 'starts' ? `${escaped}%` : shape === 'ends' ? `%${escaped}` : `%${escaped}%`;
+ clauses.push(`${column} ${negate ? 'NOT LIKE' : 'LIKE'} ? ESCAPE '\\'`);
+ args.push(pattern);
+ }
+
+ /**
+ * The error for a `$null` whose comparand is not a boolean (#1116).
+ *
+ * `@objectstack/spec`'s `FieldOperatorsSchema` declares `$null: z.boolean()`,
+ * and nothing between an authored `where` and this transport validates against
+ * it — so a non-boolean really does arrive here. The emitter asked
+ * `opValue === false`, which is a BISECTION: `false` on one side, everything
+ * else on the other. Measured on real SQLite (`makeLibsqlSqliteStub`) against
+ * one row with `stage: 'won'` and one with `stage: null`:
+ *
+ * | filter | compiled to | rows |
+ * |---|---|---|
+ * | `{ $null: true }` | `IS NULL` | the NULL row |
+ * | `{ $null: false }` | `IS NOT NULL` | the valued row |
+ * | `{ $null: 'yes' }` / `1` / `0` / `null` / `undefined` / `{}` / `'false'` | `IS NULL` | the NULL row |
+ *
+ * Every third value answered as if `true` had been written. The trap in that
+ * list is the STRING `'false'`: it is truthy, so the one spelling most likely
+ * to arrive from a JSON round-trip or a template concatenation compiled to the
+ * exact OPPOSITE of what its author meant.
+ *
+ * Refused rather than coerced, per the maintainer's ruling on the identical
+ * shape in objectstack#5347 — landed across framework's four backends in
+ * objectstack#5368. The reasoning is not "this transport picked the wrong
+ * side": there is no side to pick. The backends read a non-boolean in
+ * OPPOSITE directions (this transport, `driver-sql`, `driver-sqlite-wasm` and
+ * Turso LOCAL said `IS NULL`; `driver-memory`'s query path and
+ * `driver-mongodb` said `IS NOT NULL`; `driver-memory`'s reference matcher
+ * dropped the constraint entirely and matched BOTH rows — a widening, i.e. a
+ * bypass on an RLS read scope). Three answers to one declared operator, none
+ * of them a rule anyone wrote down; all three are just what a two-branch
+ * conditional does with a third value. So the transport stops guessing.
+ *
+ * Until #5368 this transport was one of two faces of the SAME `TursoDriver`
+ * giving different answers to one filter depending only on whether `url` sent
+ * it down the local or the remote path. This closes it from the remote side;
+ * the local side inherits `SqlDriver`'s refusal the moment `.objectstack-sha`
+ * moves past `9c5abf4e9`.
+ *
+ * The leading sentence is copied VERBATIM from the framework twins (identical
+ * word-for-word across `driver-sql`, `driver-memory` and `driver-mongodb`), so
+ * a caller who hits this on Turso reads the same sentence they would read on
+ * Postgres. Only the location is spelled in this transport's own convention —
+ * it names `'object.field'` rather than threading a `filter.…` path.
+ */
+ private nonBooleanNullComparand(object: string, field: string, value: unknown): Error {
+ // `describeValue` calls `null` "an object" and `undefined` "a undefined" —
+ // both are the two comparands most likely to arrive here, so they are named
+ // outright, exactly as {@link uncompilableSubFilter} does one level up.
+ const shown = value === null ? 'null' : value === undefined ? 'undefined' : describeValue(value);
+ return invalidFilterError(
+ `[RemoteTransport] Operator "$null" on field "${field}" requires a boolean comparand (true or ` +
+ `false). Received ${shown} (${preview(value)}) at '${object}.${field}'.$null. ` +
+ `@objectstack/spec FieldOperatorsSchema declares $null as a boolean. It is refused rather than ` +
+ `coerced because the backends read a non-boolean in OPPOSITE directions — this transport ` +
+ `(with driver-sql, driver-sqlite-wasm and Turso local) compiled IS NULL (anything but false), ` +
+ `driver-memory's query path and driver-mongodb compiled IS NOT NULL (anything but true), and ` +
+ `driver-memory's matcher dropped the constraint entirely. Note "false" the STRING is truthy, ` +
+ `so it landed on the side opposite the false it was written to mean ` +
+ `(objectstack#5347, objectstack#5368, #1116).`,
+ );
+ }
+
+ /**
+ * The error for an operator this transport does not compile.
+ *
+ * `$between` gets its own sentence because it is not missing by oversight:
+ * `TursoDriver.toRemoteFieldSpec` lowers it to `$gte`/`$lte` (#1003) so the
+ * calendar-day upper-bound rule is applied in exactly one place. Growing a
+ * `$between` arm here would be that second implementation, silently without
+ * the rule — so reaching this point means the lowering step was bypassed, and
+ * saying which step it was is the whole value of the message.
+ */
+ private unsupportedOperator(object: string, field: string, op: string): Error {
+ const target = `'${object}.${field}'`;
+ if (op === '$between') {
+ return invalidFilterError(
+ `[RemoteTransport] $between on ${target} must be lowered to $gte/$lte before it reaches the ` +
+ `transport — TursoDriver.toRemoteFieldSpec does that so the calendar-day upper-bound rule is ` +
+ `applied exactly once (#1003). Refusing rather than compiling a second, rule-free range.`,
+ );
+ }
+ if (!op.startsWith('$')) {
+ return invalidFilterError(
+ `[RemoteTransport] Filter on ${target} has an object comparand whose key "${op}" is not an ` +
+ `operator. A field's filter must be a scalar (equality) or an object of $-operators ` +
+ `(${SUPPORTED_FILTER_OPERATORS.join(', ')}).`,
+ );
+ }
+ return invalidFilterError(
+ `[RemoteTransport] Unsupported filter operator "${op}" on ${target} in remote mode. Supported: ` +
+ `${SUPPORTED_FILTER_OPERATORS.join(', ')}. Refusing rather than compiling it to an equality — a ` +
+ `silent degradation is indistinguishable from "no rows matched" (#1004).`,
+ );
+ }
+
+ /**
+ * Compile ONE sub-filter of a logical operator — an element of `$and` / `$or`
+ * (#1073), or the whole operand of `$not` (#1076).
+ *
+ * The gate that gives `buildWhereSQL`'s empty return its meaning. A filter
+ * NODE that compiles to no SQL is vacuously TRUE and the caller applies the
+ * operator's rule to it (drop it under `$and`, absorb the group under `$or`,
+ * invert it to FALSE under `$not`); ANYTHING ELSE that compiles to no SQL is a
+ * shape this transport failed to compile, and must not borrow that meaning —
+ * `$or: [null]` silently became `$or: []` before this, and would silently
+ * become "match every row" after it, which is worse.
+ *
+ * So the two are separated by FORM, before compilation: a non-node operand is
+ * refused here, and everything that gets past is a node whose `''` is provably
+ * TRUE (see the invariant on {@link buildWhereSQL}).
+ *
+ * `index` is the element's position for the array operators, and `null` for
+ * `$not`, which takes a single operand rather than a list.
+ */
+ private buildSubFilterSQL(
+ object: string,
+ branch: '$and' | '$or' | '$not',
+ index: number | null,
+ sub: unknown,
+ ): { whereClauses: string; args: any[] } {
+ if (!isFilterNode(sub)) throw this.uncompilableSubFilter(object, branch, index, sub);
+ return this.buildWhereSQL(object, sub);
+ }
+
+ /**
+ * The error for a logical operator's operand that is not a filter node.
+ *
+ * Same family as {@link emptyFieldFilter}, one level up: the old code pushed a
+ * sub-clause only `if (sc)`, so a `null`, a bare string, a nested AST array or
+ * a `Date` in a logical array left NO trace — the branch compiled as if that
+ * element had never been written. In `$or` that silently NARROWS the result
+ * (the missing disjunct's rows disappear); in `$and` it silently WIDENS it.
+ * Both are "valid SQL, zero errors, wrong answer" (#1004, #1058, #1066).
+ *
+ * Under `$not` (#1076) the same non-node operand has a THIRD ending, and it is
+ * why the branch above compiles every `$not` rather than only the well-shaped
+ * ones: left to fall through, `$not: null` became `WHERE "$not" IS NULL` and
+ * `$not: 'won'` became `WHERE "$not" = ?` — a predicate on a column that
+ * cannot exist, reported (if at all) as a missing FIELD.
+ */
+ private uncompilableSubFilter(
+ object: string,
+ branch: '$and' | '$or' | '$not',
+ index: number | null,
+ sub: unknown,
+ ): Error {
+ const shown = sub === null ? 'null' : sub === undefined ? 'undefined' : describeValue(sub);
+ const location = index === null ? branch : `${branch}[${index}]`;
+ const requirement =
+ branch === '$not'
+ ? `$not takes exactly ONE condition, and it must be a plain object of conditions (spec ` +
+ `FilterConditionSchema declares \`$not: FilterConditionSchema\`). Refusing rather than ` +
+ `reading '${branch}' as a COLUMN NAME — that is the bug this branch exists to close: it ` +
+ `compiled a predicate against a column that cannot exist, and named it as a field of ` +
+ `'${object}' when it complained (#1051, #1076). A negation of "no condition" is written ` +
+ `\`{ $not: {} }\`, which matches zero rows.`
+ : `Every element of ${branch} must be a plain object of conditions (spec ` +
+ `FilterConditionSchema). Refusing rather than skipping it — a dropped element leaves ` +
+ `valid SQL that answers a DIFFERENT question: narrower in $or, wider in $and (#1004, ` +
+ `#1058, #1066, #1073). An intentionally unconstrained branch is written \`{}\`.`;
+ return invalidFilterError(
+ `[RemoteTransport] ${location} on '${object}' is ${shown}, not a filter condition: ` +
+ `${preview(sub)}. ${requirement}`,
+ );
+ }
+
+ /**
+ * The error for a TOP-LEVEL `where` that is not a filter condition (#1075).
+ *
+ * The same refusal as {@link uncompilableSubFilter}, one level UP — and the
+ * level where the consequence is largest, because there is no surrounding
+ * filter left to constrain the statement: the WHERE clause does not lose a
+ * conjunct, it never exists. `find`/`count` hand back the rows the filter was
+ * written to exclude, and `deleteMany`/`updateMany` run against every row in
+ * the table.
+ *
+ * An ARRAY gets its own sentence because it is the shape that actually
+ * arrives rather than a typo. `parseFilterAST()` converts a filter AST to the
+ * object form ONLY when `isFilterAST()` accepts it; an operator outside
+ * `VALID_AST_OPERATORS` — `before`, `after`, `equals` and five more canonical
+ * view operators — makes it refuse, and the raw array is then assigned to
+ * `where` unconverted (framework#3948, whose fix made `driver-sql` and
+ * `driver-memory` throw on the shapes they could not compile). `driver-sql`
+ * does still compile its own legacy INFIX array form, so a caller may find
+ * one that works locally and throws here: that is deliberate. The spec
+ * declares `where` as an object (`QueryASTSchema.where: FilterConditionSchema`
+ * — `z.record(z.string(), z.unknown())` and friends), and re-implementing an
+ * undeclared array dialect inside this transport would be the second,
+ * drifting implementation ADR-0053 D-A1 exists to prevent. Answering out loud
+ * is the repairable failure; answering with the whole table is not.
+ */
+ private uncompilableWhere(object: string, filters: unknown): Error {
+ const requirement = Array.isArray(filters)
+ ? `A query's \`where\` must be a plain object of conditions (spec QueryASTSchema declares ` +
+ `\`where: FilterConditionSchema\`, a record) — this transport does not compile the filter ` +
+ `AST array form. Convert it first (\`parseFilterAST\`), or write the object spelling: ` +
+ `[["stage","=","won"]] is { stage: 'won' }.`
+ : `A query's \`where\` must be a plain object of conditions (spec QueryASTSchema declares ` +
+ `\`where: FilterConditionSchema\`, a record).`;
+ return invalidFilterError(
+ `[RemoteTransport] where on '${object}' is ${describeValue(filters)}, not a filter condition: ` +
+ `${preview(filters)}. ${requirement} Refusing rather than compiling it to NO WHERE clause — ` +
+ `a filter that vanishes WIDENS the statement to the whole table, so a read returns exactly ` +
+ `the rows the filter was written to exclude and deleteMany/updateMany touch every row ` +
+ `(#1004, #1058, #1066, #1073, #1075). "No filter" is written \`{}\`, or by omitting \`where\`.`,
+ );
+ }
+
+ /**
+ * The error for a field filter that compiles to no predicate at all.
+ *
+ * The third face of #1004's rule, and the one that fails OPEN. An unknown
+ * operator (#1004) and an unbindable comparand (#1058) both produced valid SQL
+ * that matched NOTHING; an operator map with no operators in it produces no
+ * SQL — the WHERE clause loses a conjunct, and a statement missing a conjunct
+ * is WIDER than the one that was asked for. On a read that is rows the caller
+ * filtered out; on `deleteMany`/`updateMany` it is rows the caller never meant
+ * to touch.
+ *
+ * `{}` is the spelling that gets here on purpose. A bare `Date` used to get
+ * here by accident — `Object.entries(new Date())` is empty — which is what
+ * #1066 was; that is now routed to implicit equality, so anything still
+ * reaching this point genuinely carries no operator. A top-level `where: {}`
+ * is a different statement ("no filter at all") and keeps its early return.
+ */
+ private emptyFieldFilter(object: string, field: string, value: unknown): Error {
+ return invalidFilterError(
+ `[RemoteTransport] Filter on '${object}.${field}' is an object comparand that compiles to NO ` +
+ `predicate: ${preview(value)}. An operator map must carry at least one operator ` +
+ `(${SUPPORTED_FILTER_OPERATORS.join(', ')}); to compare against a value, write the value ` +
+ `itself. Refusing rather than dropping the condition — a predicate that vanishes WIDENS the ` +
+ `statement to the whole table, so the caller gets back exactly the rows the filter was ` +
+ `written to exclude (#1004, #1058, #1066).`,
+ );
+ }
+
+ /**
+ * A filter comparand in the form this transport can BIND — or an error.
+ *
+ * The read half of {@link serializeValue}'s job, split off because the two
+ * halves want opposite answers for the same input (#1058). On the WRITE path
+ * an object is a JSON column's value and `JSON.stringify` is exactly right.
+ * On the FILTER path it is a value the transport cannot compile: stringifying
+ * it produced valid SQL that binds the JSON TEXT of the object, and in
+ * SQLite's type ordering every number sorts below every string, so
+ * `"amount" > '{"$field":"budget"}'` is false for every row — zero rows, zero
+ * errors, indistinguishable from "nothing matched".
+ *
+ * That is the failure mode #1004 already refused in the OPERATOR position
+ * (`default:` above). It stayed open in the VALUE position, so the same
+ * mistake threw or degraded depending only on how deeply it was nested:
+ * `{ amount: { $field: 'budget' } }` threw, `{ amount: { $gt: { $field:
+ * 'budget' } } }` returned an empty page. Declared = enforced applies to both
+ * halves of a comparison.
+ *
+ * The accepted set is an ALLOW-list of what libsql binds and SQLite compares
+ * meaningfully — `string` / `number` / `bigint` / `boolean` / `null`, plus
+ * `Date` as the one declared conversion (→ ISO 8601, the storage form
+ * `TursoDriver.toRemoteWriteForms` wrote). Everything else — plain objects,
+ * arrays, `Uint8Array`, functions — is refused by name. An allow-list is the
+ * point: a deny-list would silently re-admit whatever value form is invented
+ * next, which is precisely how this bug survived #1004.
+ *
+ * The object half of that allow-list is {@link isBindableObjectComparand},
+ * shared with `buildWhereSQL`'s routing test so a form cannot be a VALUE here
+ * and an OPERATOR MAP there (#1066).
+ */
+ private serializeComparand(object: string, field: string, op: string, value: unknown): any {
+ if (value === null || value === undefined) return null;
+ if (isBindableObjectComparand(value)) return value.toISOString();
+ if (
+ typeof value === 'string' ||
+ typeof value === 'number' ||
+ typeof value === 'bigint' ||
+ typeof value === 'boolean'
+ ) {
+ return value;
+ }
+ throw this.uncompilableComparand(object, field, op, value);
+ }
+
+ /**
+ * The error for a comparison value this transport cannot bind.
+ *
+ * `{ $field: … }` gets its own sentence for the same reason `$between` does in
+ * {@link unsupportedOperator}: it is not a typo but a spec-declared construct
+ * (`FieldReferenceSchema`, `data/filter.zod.ts`) that asks for a cross-field
+ * comparison — `amount > budget` rather than `amount > 1000`. No executor
+ * cloud runs compiles it (#1051), so the repair is never "use a different
+ * operator" (every one fails identically); it is to compare against a literal,
+ * or to compare the two columns after the rows come back. `service-ai` refuses
+ * the same shape by name at the tool boundary (#1059) — this is the same
+ * refusal for every OTHER entry point (REST filters, RLS pushdown, internal
+ * calls), which is why both exist.
+ */
+ private uncompilableComparand(object: string, field: string, op: string, value: unknown): Error {
+ const target = `'${object}.${field}'`;
+ const shown = `${op} ${preview(value)}`;
+ if (isFieldReference(value)) {
+ return invalidFilterError(
+ `[RemoteTransport] Cross-field comparison is not supported in remote mode: ${target} ${shown} ` +
+ `compares a column against another column instead of against a value. The query DSL declares ` +
+ `this form (spec FieldReferenceSchema) but no executor compiles it, so it is refused here ` +
+ `rather than bound as the marker's JSON text — which is valid SQL that matches nothing ` +
+ `(#1058). Compare against a literal, or select both columns and compare after retrieval.`,
+ );
+ }
+ return invalidFilterError(
+ `[RemoteTransport] Filter comparand ${target} ${shown} is ${describeValue(value)}, which this ` +
+ `transport cannot bind. A comparison value must be a string, number, bigint, boolean, null or ` +
+ `Date. Refusing rather than binding its JSON text — that compiles to valid SQL matching zero ` +
+ `rows, which is indistinguishable from "no rows matched" (#1004, #1058).`,
+ );
+ }
+
+ /**
+ * Map camelCase field names to snake_case DB columns.
+ */
+ private mapSortField(field: string): string {
+ if (field === 'createdAt') return 'created_at';
+ if (field === 'updatedAt') return 'updated_at';
+ return field;
+ }
+
+ /**
+ * Serialize a WRITTEN value for @libsql/client args.
+ * - `Date` → ISO 8601 string (avoids libsql HTTP transport coercing the
+ * value to a REAL column and round-tripping it as `".0"`).
+ * - JSON objects/arrays are stringified.
+ * - booleans are kept as-is (libsql handles them).
+ *
+ * INSERT / UPDATE / UPSERT payloads only. A comparison value goes through
+ * {@link serializeComparand}, which refuses the object forms this one
+ * stringifies: on the write path an object is a JSON column's value, on the
+ * filter path it is a value that cannot be compiled (#1058).
+ */
+ private serializeValue(value: unknown): any {
+ if (value === null || value === undefined) return null;
+ if (value instanceof Date) return value.toISOString();
+ if (typeof value === 'object') {
+ return JSON.stringify(value);
+ }
+ return value;
+ }
+
+ /**
+ * Convert a ResultSet from @libsql/client into plain Record objects.
+ */
+ private mapRows(result: ResultSet): Record[] {
+ return result.rows.map((row) => {
+ const record: Record = {};
+ for (const col of result.columns) {
+ record[col] = (row as any)[col];
+ }
+ return record;
+ });
+ }
+}
diff --git a/packages/drivers/driver-turso/src/spec/turso.test.ts b/packages/drivers/driver-turso/src/spec/turso.test.ts
new file mode 100644
index 0000000000..ba3f824333
--- /dev/null
+++ b/packages/drivers/driver-turso/src/spec/turso.test.ts
@@ -0,0 +1,270 @@
+import { describe, it, expect } from 'vitest';
+import { TursoConfigSchema, TursoSyncConfigSchema, TursoDriverSpec } from './turso.zod';
+import { TursoDriver } from '../turso-driver';
+
+describe('TursoConfigSchema', () => {
+ it('should accept minimal remote config', () => {
+ const config = TursoConfigSchema.parse({
+ url: 'libsql://my-db-orgname.turso.io',
+ authToken: 'eyJhbGciOi...',
+ });
+
+ expect(config.url).toBe('libsql://my-db-orgname.turso.io');
+ expect(config.authToken).toBe('eyJhbGciOi...');
+ expect(config.concurrency).toBe(20);
+ });
+
+ it('should accept local file config', () => {
+ const config = TursoConfigSchema.parse({
+ url: 'file:./local.db',
+ });
+
+ expect(config.url).toBe('file:./local.db');
+ expect(config.authToken).toBeUndefined();
+ });
+
+ it('should accept in-memory config', () => {
+ const config = TursoConfigSchema.parse({
+ url: ':memory:',
+ });
+
+ expect(config.url).toBe(':memory:');
+ });
+
+ it('should accept embedded replica config', () => {
+ const config = TursoConfigSchema.parse({
+ url: 'file:./local-replica.db',
+ syncUrl: 'libsql://my-db-orgname.turso.io',
+ authToken: 'eyJhbGciOi...',
+ localPath: './local-replica.db',
+ sync: {
+ intervalSeconds: 30,
+ onConnect: true,
+ },
+ });
+
+ expect(config.syncUrl).toBe('libsql://my-db-orgname.turso.io');
+ expect(config.localPath).toBe('./local-replica.db');
+ expect(config.sync).toBeDefined();
+ expect(config.sync!.intervalSeconds).toBe(30);
+ expect(config.sync!.onConnect).toBe(true);
+ });
+
+ it('should accept config with all fields', () => {
+ const config = TursoConfigSchema.parse({
+ url: 'libsql://my-db-orgname.turso.io',
+ authToken: 'eyJhbGciOi...',
+ encryptionKey: 'my-secret-key-256',
+ concurrency: 50,
+ syncUrl: 'libsql://my-db-orgname.turso.io',
+ localPath: '/data/replica.db',
+ sync: {
+ intervalSeconds: 120,
+ onConnect: false,
+ },
+ timeout: 30000,
+ wasm: true,
+ });
+
+ expect(config.encryptionKey).toBe('my-secret-key-256');
+ expect(config.concurrency).toBe(50);
+ expect(config.timeout).toBe(30000);
+ expect(config.wasm).toBe(true);
+ });
+
+ it('should apply correct defaults', () => {
+ const config = TursoConfigSchema.parse({
+ url: 'libsql://my-db.turso.io',
+ });
+
+ expect(config.concurrency).toBe(20);
+ expect(config.authToken).toBeUndefined();
+ expect(config.encryptionKey).toBeUndefined();
+ expect(config.syncUrl).toBeUndefined();
+ expect(config.localPath).toBeUndefined();
+ expect(config.sync).toBeUndefined();
+ expect(config.timeout).toBeUndefined();
+ expect(config.wasm).toBeUndefined();
+ });
+
+ it('should accept https URL', () => {
+ const config = TursoConfigSchema.parse({
+ url: 'https://my-db-orgname.turso.io',
+ authToken: 'token',
+ });
+
+ expect(config.url).toBe('https://my-db-orgname.turso.io');
+ });
+
+ it('should accept ws/wss URL', () => {
+ const config = TursoConfigSchema.parse({
+ url: 'wss://my-db-orgname.turso.io',
+ authToken: 'token',
+ });
+
+ expect(config.url).toBe('wss://my-db-orgname.turso.io');
+ });
+
+ it('should reject config without url', () => {
+ expect(() => TursoConfigSchema.parse({})).toThrow();
+ expect(() => TursoConfigSchema.parse({ authToken: 'token' })).toThrow();
+ });
+
+ it('should reject config with invalid concurrency', () => {
+ expect(() => TursoConfigSchema.parse({
+ url: ':memory:',
+ concurrency: 0,
+ })).toThrow();
+ });
+
+ it('should reject config with invalid concurrency type', () => {
+ expect(() => TursoConfigSchema.parse({
+ url: ':memory:',
+ concurrency: 'ten',
+ })).toThrow();
+ });
+
+ it('should reject config with negative timeout', () => {
+ expect(() => TursoConfigSchema.parse({
+ url: ':memory:',
+ timeout: -1,
+ })).toThrow();
+ });
+
+ it('should accept config with environment variable patterns', () => {
+ const config = TursoConfigSchema.parse({
+ url: '${TURSO_DATABASE_URL}',
+ authToken: '${TURSO_AUTH_TOKEN}',
+ });
+
+ expect(config.url).toBe('${TURSO_DATABASE_URL}');
+ expect(config.authToken).toBe('${TURSO_AUTH_TOKEN}');
+ });
+
+ it('should accept config with custom concurrency', () => {
+ const config = TursoConfigSchema.parse({
+ url: ':memory:',
+ concurrency: 100,
+ });
+
+ expect(config.concurrency).toBe(100);
+ });
+
+ it('should accept zero timeout (no timeout)', () => {
+ const config = TursoConfigSchema.parse({
+ url: ':memory:',
+ timeout: 0,
+ });
+
+ expect(config.timeout).toBe(0);
+ });
+});
+
+describe('TursoSyncConfigSchema', () => {
+ it('should accept valid sync config', () => {
+ const config = TursoSyncConfigSchema.parse({
+ intervalSeconds: 30,
+ onConnect: true,
+ });
+
+ expect(config.intervalSeconds).toBe(30);
+ expect(config.onConnect).toBe(true);
+ });
+
+ it('should apply defaults', () => {
+ const config = TursoSyncConfigSchema.parse({});
+
+ expect(config.intervalSeconds).toBe(60);
+ expect(config.onConnect).toBe(true);
+ });
+
+ it('should accept zero intervalSeconds (manual sync)', () => {
+ const config = TursoSyncConfigSchema.parse({
+ intervalSeconds: 0,
+ });
+
+ expect(config.intervalSeconds).toBe(0);
+ });
+
+ it('should reject negative intervalSeconds', () => {
+ expect(() => TursoSyncConfigSchema.parse({
+ intervalSeconds: -1,
+ })).toThrow();
+ });
+
+ it('should accept onConnect false', () => {
+ const config = TursoSyncConfigSchema.parse({
+ onConnect: false,
+ });
+
+ expect(config.onConnect).toBe(false);
+ });
+});
+
+describe('TursoDriverSpec', () => {
+ it('should have correct id', () => {
+ expect(TursoDriverSpec.id).toBe('turso');
+ });
+
+ it('should have correct label', () => {
+ expect(TursoDriverSpec.label).toBe('Turso (libSQL)');
+ });
+
+ it('should have a description', () => {
+ expect(TursoDriverSpec.description).toBeDefined();
+ expect(typeof TursoDriverSpec.description).toBe('string');
+ expect(TursoDriverSpec.description).toContain('SQLite');
+ expect(TursoDriverSpec.description).toContain('edge');
+ });
+
+ it('should have an icon', () => {
+ expect(TursoDriverSpec.icon).toBe('database');
+ });
+
+ // `datasource.capabilities` was removed in @objectstack/spec 17.0.0 (#4583,
+ // ADR-0049) and the definition schema is strict since #4001, so declaring the
+ // block again throws at import — which is how it was caught. The six
+ // assertions that used to read the flags are replaced by one that pins the
+ // absence, so a re-added block fails here rather than only in CI.
+ it('declares no capabilities block — the key is retired, not optional', () => {
+ expect('capabilities' in TursoDriverSpec).toBe(false);
+ });
+
+ // The retired block's assertions checked that a DECLARATION existed, never
+ // that any behaviour followed from it — precisely the "declared but
+ // unenforced" shape ADR-0049 removes. Pinning the absence (above) stops it
+ // coming back; these two re-ask the original questions where the answer
+ // actually changes what the engine does — the runtime driver's `supports`.
+ it('advertises its real capability surface on the DRIVER, not the catalog entry', () => {
+ const driver = new TursoDriver({ url: 'file:./capability-probe.db' });
+
+ // The bit this driver claims for itself, and the only one whose value the
+ // engine acts on here: batched DDL, ANDed with `syncSchemasBatch`'s
+ // presence before the engine will use the batch path.
+ expect(driver.supports.batchSchemaSync).toBe(true);
+ expect(typeof driver.syncSchemasBatch).toBe('function');
+
+ // What this test used to assert — fullTextSearch / jsonQuery / queryCTE /
+ // savepoints / connectionPooling — was accurate about libSQL and consulted
+ // by nobody, so objectstack#4634 retired those bits from
+ // `DriverCapabilities` together with 26 more (ADR-0049). Same lesson as the
+ // catalog `capabilities` block above, one layer down: a flag no dispatcher
+ // reads is not a capability, it is a comment with a type.
+ });
+
+ it('drops native date bucketing in REMOTE mode, where the transport cannot do it', () => {
+ // Not a cosmetic flag: the analytics engine trusts
+ // `supports.queryDateGranularity` and pushes a structured groupBy down to a
+ // remote transport that cannot bucket, which is how a dashboard widget
+ // hangs on a 500. Local keeps native bucketing; remote falls back to
+ // in-memory, which the contract guarantees is always correct.
+ const local = new TursoDriver({ url: 'file:./local.db' }).supports;
+ const remote = new TursoDriver({
+ url: 'libsql://db-org.turso.io',
+ authToken: 'token',
+ }).supports;
+
+ expect(Object.keys(remote.queryDateGranularity ?? {})).toHaveLength(0);
+ expect(Object.keys(local.queryDateGranularity ?? {}).length).toBeGreaterThan(0);
+ });
+});
diff --git a/packages/drivers/driver-turso/src/spec/turso.zod.ts b/packages/drivers/driver-turso/src/spec/turso.zod.ts
new file mode 100644
index 0000000000..5b7ff63231
--- /dev/null
+++ b/packages/drivers/driver-turso/src/spec/turso.zod.ts
@@ -0,0 +1,151 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { z } from 'zod';
+import { DriverDefinitionSchema } from '@objectstack/spec/data';
+
+/**
+ * Turso / libSQL Driver Configuration Schema
+ *
+ * Defines the connection settings specific to Turso (libSQL) — a SQLite-compatible
+ * edge database supporting embedded replicas, global distribution, and offline-first
+ * architectures.
+ *
+ * Turso supports three connection modes:
+ * 1. **Remote** — Connect to a Turso cloud or self-hosted libSQL server via HTTPS/WSS
+ * 2. **Local** — Use a local SQLite/libSQL file for embedded or serverless workloads
+ * 3. **Embedded Replica** — Local SQLite file that syncs with a remote Turso primary
+ *
+ * @see https://docs.turso.tech/sdk/ts/reference
+ */
+
+// ==========================================================================
+// 1. Sync Configuration (Embedded Replicas)
+// ==========================================================================
+
+/**
+ * Embedded Replica Sync Configuration.
+ * Controls how the local embedded replica synchronizes with the remote primary.
+ */
+import { lazySchema } from '@objectstack/spec/shared';
+export const TursoSyncConfigSchema = lazySchema(() => z.object({
+ /**
+ * Sync interval in seconds.
+ * The local replica will periodically pull changes from the remote primary.
+ * Set to 0 to disable periodic sync (manual sync only).
+ */
+ intervalSeconds: z.number().min(0).default(60).describe('Periodic sync interval in seconds (0 = manual only)'),
+
+ /**
+ * Sync on connect.
+ * When true, the driver performs a sync immediately upon connection.
+ */
+ onConnect: z.boolean().default(true).describe('Sync immediately on connect'),
+}).describe('Embedded replica sync configuration'));
+
+// ==========================================================================
+// 2. Connection Configuration
+// ==========================================================================
+
+export const TursoConfigSchema = lazySchema(() => z.object({
+ /**
+ * Database URL.
+ * Supports multiple protocols:
+ * - `libsql://` or `https://` for remote Turso cloud databases
+ * - `ws://` or `wss://` for WebSocket connections
+ * - `file:` for local SQLite/libSQL files
+ * - `:memory:` for in-memory database
+ */
+ url: z.string().describe('Database URL (libsql://, https://, file:, or :memory:)'),
+
+ /**
+ * Authentication Token.
+ * Required for remote Turso databases; optional for local files.
+ * Typically a JWT issued by Turso platform or self-hosted libSQL server.
+ */
+ authToken: z.string().optional().describe('Authentication token for remote database'),
+
+ /**
+ * Encryption Key.
+ * When provided, encrypts the local database file at rest using AES-256.
+ * Applies to both local-only and embedded replica modes.
+ */
+ encryptionKey: z.string().optional().describe('Encryption key for local database file (AES-256)'),
+
+ /**
+ * Concurrency Limit.
+ * Maximum number of concurrent requests to the database.
+ * Defaults to 20 for remote connections.
+ */
+ concurrency: z.number().int().min(1).default(20).describe('Maximum concurrent requests'),
+
+ /**
+ * Embedded Replica Configuration.
+ * When provided, enables embedded replica mode: a local SQLite file that
+ * syncs with the remote primary specified in `url`.
+ */
+ syncUrl: z.string().optional().describe('Remote sync URL for embedded replica mode'),
+
+ /**
+ * Local file path for the embedded replica.
+ * Required when using embedded replica mode (syncUrl is provided).
+ * The local file serves reads with microsecond latency while writes
+ * propagate to the remote primary.
+ */
+ localPath: z.string().optional().describe('Local file path for embedded replica'),
+
+ /**
+ * Sync configuration for embedded replicas.
+ */
+ sync: TursoSyncConfigSchema.optional().describe('Sync settings for embedded replica mode'),
+
+ /**
+ * Timeout for database operations in milliseconds.
+ */
+ timeout: z.number().int().min(0).optional().describe('Operation timeout in milliseconds'),
+
+ /**
+ * Enable WASM mode.
+ * When true, uses the WASM build of libSQL for browser or edge runtime
+ * environments that cannot run native bindings (e.g., Cloudflare Workers).
+ */
+ wasm: z.boolean().optional().describe('Use WASM build for edge/browser environments'),
+}).describe('Turso/libSQL Connection Configuration'));
+
+// ==========================================================================
+// 3. Driver Definition (Metadata)
+// ==========================================================================
+
+/**
+ * The static definition of the Turso driver's identity and default metadata.
+ * Implements the `DriverDefinitionSchema` contract.
+ *
+ * Turso/libSQL is a SQLite-compatible database with:
+ * - Full ACID transactions (interactive + batch)
+ * - Standard SQL query support (WHERE, ORDER BY, LIMIT/OFFSET, aggregations)
+ * - JSON field support via SQLite JSON1 extension
+ * - Full-text search via FTS5
+ * - No native JOIN push-down limitations (full SQL joins supported)
+ * - No window functions, subqueries, CTEs limitations (full SQLite SQL support)
+ * - Embedded replica sync for edge deployments
+ *
+ * No `capabilities` block: `datasource.capabilities` was removed in
+ * @objectstack/spec 17.0.0 (#4583, ADR-0049). The eleven flags were declared
+ * and read by nobody — pushdown is decided by the runtime driver's own
+ * `supports.*`, not by this metadata, so the block never changed which engine
+ * path ran. The list above is the honest, non-executable statement of what
+ * this driver can do.
+ */
+export const TursoDriverSpec = DriverDefinitionSchema.parse({
+ id: 'turso',
+ label: 'Turso (libSQL)',
+ description: 'SQLite-compatible edge database with embedded replicas, global distribution, and offline-first support. Built on libSQL, a fork of SQLite.',
+ icon: 'database',
+ configSchema: {},
+});
+
+// ==========================================================================
+// 4. Derived Types
+// ==========================================================================
+
+export type TursoConfig = z.infer;
+export type TursoSyncConfig = z.infer;
diff --git a/packages/drivers/driver-turso/src/turso-driver.test.ts b/packages/drivers/driver-turso/src/turso-driver.test.ts
new file mode 100644
index 0000000000..a4fca76913
--- /dev/null
+++ b/packages/drivers/driver-turso/src/turso-driver.test.ts
@@ -0,0 +1,1129 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { isDeepStrictEqual } from 'node:util';
+import { TursoDriver } from '../src/turso-driver.js';
+import { SqlDriver } from '@objectstack/driver-sql';
+
+/**
+ * The capability keys on which TursoDriver's `supports` differs from the
+ * SqlDriver baseline it spreads — i.e. exactly what this subclass CLAIMS on
+ * its own behalf.
+ *
+ * Computed by invoking SqlDriver's own `supports` getter with the Turso
+ * instance as `this` (what `super.supports` does inside the override) and
+ * diffing it against the instance's `supports`. Deliberately no hard-coded
+ * list of baseline bits: `DriverCapabilities` is a moving target (objectstack
+ * #4634 retired 31 zero-reader bits under ADR-0049), and a test that spells
+ * out inherited bits pins the BASE class's contract from the wrong repo — it
+ * goes red on a framework pin bump that changed nothing about Turso. This
+ * diff pins only what Turso is responsible for.
+ */
+function ownCapabilityClaims(driver: TursoDriver): string[] {
+ const accessor = Object.getOwnPropertyDescriptor(SqlDriver.prototype, 'supports')?.get;
+ if (!accessor) {
+ throw new Error('SqlDriver.prototype.supports is no longer a getter — update this helper');
+ }
+ const base = accessor.call(driver) as Record;
+ const own = driver.supports as unknown as Record;
+ return [...new Set([...Object.keys(base), ...Object.keys(own)])]
+ .filter((key) => !isDeepStrictEqual(base[key], own[key]))
+ .sort();
+}
+
+// ── TursoDriver Core ─────────────────────────────────────────────────────────
+
+describe('TursoDriver (SQLite Integration)', () => {
+ let driver: TursoDriver;
+
+ beforeEach(async () => {
+ driver = new TursoDriver({ url: ':memory:' });
+
+ // Access the inherited Knex instance for test setup
+ const k = (driver as any).knex;
+
+ await k.schema.createTable('users', (t: any) => {
+ t.string('id').primary();
+ t.string('name');
+ t.integer('age');
+ });
+
+ await k('users').insert([
+ { id: '1', name: 'Alice', age: 25 },
+ { id: '2', name: 'Bob', age: 17 },
+ { id: '3', name: 'Charlie', age: 30 },
+ { id: '4', name: 'Dave', age: 17 },
+ ]);
+ });
+
+ afterEach(async () => {
+ await driver.disconnect();
+ });
+
+ // ── Instantiation & Metadata ─────────────────────────────────────────────
+
+ it('should be instantiable', () => {
+ expect(driver).toBeDefined();
+ expect(driver).toBeInstanceOf(TursoDriver);
+ });
+
+ it('should extend SqlDriver', () => {
+ expect(driver).toBeInstanceOf(SqlDriver);
+ });
+
+ it('should have turso-specific name and version', () => {
+ expect(driver.name).toBe('com.objectstack.driver.turso');
+ expect(driver.version).toBe('1.0.0');
+ });
+
+ // The capability assertions that used to sit here — fullTextSearch /
+ // jsonQuery / queryCTE / savepoints / indexes / connectionPooling — are gone
+ // with the bits themselves (objectstack#4634). See the `TursoDriver
+ // Capabilities` block below for what replaced them.
+
+ it('should expose turso config', () => {
+ const config = driver.getTursoConfig();
+ expect(config.url).toBe(':memory:');
+ });
+
+ // ── CRUD (inherited from SqlDriver) ──────────────────────────────────────
+
+ it('should find records with filters', async () => {
+ const results = await driver.find('users', {
+ fields: ['name', 'age'],
+ where: { age: { $gt: 18 } },
+ orderBy: [{ field: 'name', order: 'asc' }],
+ });
+
+ expect(results.length).toBe(2);
+ expect(results.map((r: any) => r.name)).toEqual(['Alice', 'Charlie']);
+ });
+
+ it('should apply $or filters', async () => {
+ const results = await driver.find('users', {
+ where: {
+ $or: [{ age: 17 }, { age: { $gt: 29 } }],
+ },
+ });
+ const names = results.map((r: any) => r.name).sort();
+ expect(names).toEqual(['Bob', 'Charlie', 'Dave']);
+ });
+
+ it('should find one record by id', async () => {
+ const [alice] = await driver.find('users', { where: { name: 'Alice' } });
+ expect(alice).toBeDefined();
+
+ const fetched = await driver.findOne('users', { where: { id: alice.id } });
+ expect(fetched).toBeDefined();
+ expect(fetched!.name).toBe('Alice');
+ });
+
+ it('should create a record', async () => {
+ await driver.create('users', { name: 'Eve', age: 22 });
+
+ const [eve] = await driver.find('users', { where: { name: 'Eve' } });
+ expect(eve).toBeDefined();
+ expect(eve.age).toBe(22);
+ });
+
+ it('should auto-generate id on create', async () => {
+ const created = await driver.create('users', { name: 'Frank', age: 35 });
+ expect(created.id).toBeDefined();
+ expect(typeof created.id).toBe('string');
+ expect((created.id as string).length).toBeGreaterThan(0);
+ });
+
+ it('should update a record', async () => {
+ const [bob] = await driver.find('users', { where: { name: 'Bob' } });
+ await driver.update('users', bob.id as string, { age: 18 });
+
+ const updated = await driver.findOne('users', { where: { id: bob.id } });
+ expect(updated!.age).toBe(18);
+ });
+
+ it('should delete a record', async () => {
+ const [charlie] = await driver.find('users', { where: { name: 'Charlie' } });
+ const result = await driver.delete('users', charlie.id as string);
+ expect(result).toBe(true);
+
+ const deleted = await driver.findOne('users', { where: { id: charlie.id } });
+ expect(deleted).toBeNull();
+ });
+
+ it('should count records', async () => {
+ const count = await driver.count('users', { object: 'users', where: { age: 17 } });
+ expect(count).toBe(2);
+ });
+
+ it('should count all records', async () => {
+ const count = await driver.count('users');
+ expect(count).toBe(4);
+ });
+
+ // ── Upsert ───────────────────────────────────────────────────────────────
+
+ it('should upsert (insert) a new record', async () => {
+ const result = await driver.upsert('users', { id: 'new-1', name: 'Grace', age: 28 });
+ expect(result.name).toBe('Grace');
+
+ const count = await driver.count('users');
+ expect(count).toBe(5);
+ });
+
+ it('should upsert (update) an existing record', async () => {
+ await driver.upsert('users', { id: '1', name: 'Alice Updated', age: 26 });
+
+ const updated = await driver.findOne('users', { where: { id: '1' } });
+ expect(updated!.name).toBe('Alice Updated');
+ expect(updated!.age).toBe(26);
+ });
+
+ // ── Bulk Operations ──────────────────────────────────────────────────────
+
+ it('should bulk create records', async () => {
+ const data = [
+ { id: 'b1', name: 'Bulk1', age: 10 },
+ { id: 'b2', name: 'Bulk2', age: 20 },
+ ];
+ const result = await driver.bulkCreate('users', data);
+ expect(result.length).toBe(2);
+ });
+
+ it('should bulk update records', async () => {
+ const updates = [
+ { id: '1', data: { age: 99 } },
+ { id: '2', data: { age: 88 } },
+ ];
+ const result = await driver.bulkUpdate('users', updates);
+ expect(result.length).toBe(2);
+ expect(result[0].age).toBe(99);
+ expect(result[1].age).toBe(88);
+ });
+
+ it('should bulk delete records', async () => {
+ await driver.bulkDelete('users', ['1', '2']);
+ const count = await driver.count('users');
+ expect(count).toBe(2);
+ });
+
+ // ── Transactions ─────────────────────────────────────────────────────────
+
+ it('should support transactions with commit', async () => {
+ const trx = await driver.beginTransaction();
+ await driver.create('users', { name: 'TrxUser', age: 40 }, { transaction: trx });
+ await driver.commit(trx);
+
+ const found = await driver.find('users', { where: { name: 'TrxUser' } });
+ expect(found.length).toBe(1);
+ });
+
+ it('should support transactions with rollback', async () => {
+ const trx = await driver.beginTransaction();
+ await driver.create('users', { name: 'RollbackUser', age: 41 }, { transaction: trx });
+ await driver.rollback(trx);
+
+ const found = await driver.find('users', { where: { name: 'RollbackUser' } });
+ expect(found.length).toBe(0);
+ });
+
+ // ── Schema Sync (inherited) ──────────────────────────────────────────────
+
+ it('should sync schema and create tables', async () => {
+ await driver.syncSchema('products', {
+ name: 'products',
+ fields: {
+ title: { type: 'string' },
+ price: { type: 'float' },
+ active: { type: 'boolean' },
+ metadata: { type: 'json' },
+ },
+ });
+
+ const created = await driver.create('products', {
+ title: 'Widget',
+ price: 9.99,
+ active: true,
+ metadata: { category: 'tools' },
+ });
+
+ expect(created.title).toBe('Widget');
+ expect(created.price).toBe(9.99);
+ });
+
+ it('should batch-sync multiple schemas in local mode (sequential fallback)', async () => {
+ await driver.syncSchemasBatch([
+ {
+ object: 'local_orders',
+ schema: {
+ name: 'local_orders',
+ fields: {
+ product: { type: 'string' },
+ quantity: { type: 'integer' },
+ },
+ },
+ },
+ {
+ object: 'local_invoices',
+ schema: {
+ name: 'local_invoices',
+ fields: {
+ amount: { type: 'float' },
+ },
+ },
+ },
+ ]);
+
+ // Verify tables were created
+ const order = await driver.create('local_orders', { product: 'Gadget', quantity: 3 });
+ expect(order.product).toBe('Gadget');
+
+ const invoice = await driver.create('local_invoices', { amount: 42.0 });
+ expect(invoice.amount).toBe(42.0);
+ });
+
+ // ── Raw Execution ────────────────────────────────────────────────────────
+
+ it('should execute raw SQL', async () => {
+ const result = await driver.execute('SELECT COUNT(*) as count FROM users');
+ expect(result).toBeDefined();
+ });
+
+ // ── Health Check ─────────────────────────────────────────────────────────
+
+ it('should report healthy connection', async () => {
+ const healthy = await driver.checkHealth();
+ expect(healthy).toBe(true);
+ });
+
+ // ── Pagination ───────────────────────────────────────────────────────────
+
+ it('should support limit and offset', async () => {
+ const results = await driver.find('users', {
+ orderBy: [{ field: 'name', order: 'asc' }],
+ limit: 2,
+ offset: 1,
+ });
+ expect(results.length).toBe(2);
+ expect(results[0].name).toBe('Bob');
+ expect(results[1].name).toBe('Charlie');
+ });
+
+ // ── updateMany / deleteMany ──────────────────────────────────────────────
+
+ it('should updateMany records matching a query', async () => {
+ const count = await driver.updateMany!('users', { where: { age: 17 } }, { age: 18 });
+ expect(count).toBe(2);
+
+ const updated = await driver.find('users', { where: { age: 18 } });
+ expect(updated.length).toBe(2);
+ });
+
+ it('should deleteMany records matching a query', async () => {
+ const count = await driver.deleteMany!('users', { where: { age: 17 } });
+ expect(count).toBe(2);
+
+ const remaining = await driver.count('users');
+ expect(remaining).toBe(2);
+ });
+
+ // ── Sorting ──────────────────────────────────────────────────────────────
+
+ it('should sort results', async () => {
+ const results = await driver.find('users', {
+ orderBy: [{ field: 'age', order: 'desc' }],
+ });
+ expect(results[0].name).toBe('Charlie');
+ expect(results[results.length - 1].age).toBe(17);
+ });
+
+ // ── Edge Cases ───────────────────────────────────────────────────────────
+
+ it('should return empty array for no matches', async () => {
+ const results = await driver.find('users', { where: { age: 999 } });
+ expect(results).toEqual([]);
+ });
+
+ it('should return null for findOne with no match', async () => {
+ const result = await driver.findOne('users', { where: { name: 'NonExistent' } });
+ expect(result).toBeNull();
+ });
+
+ it('should return false when deleting non-existent record', async () => {
+ const result = await driver.delete('users', 'non-existent-id');
+ expect(result).toBe(false);
+ });
+});
+
+// ── Sync Configuration ───────────────────────────────────────────────────────
+
+describe('TursoDriver Sync Configuration', () => {
+ it('should report sync not enabled for memory mode', () => {
+ const driver = new TursoDriver({ url: ':memory:' });
+ expect(driver.isSyncEnabled()).toBe(false);
+ });
+
+ it('should return null libsql client when sync not configured', () => {
+ const driver = new TursoDriver({ url: ':memory:' });
+ expect(driver.getLibsqlClient()).toBeNull();
+ });
+
+ it('should handle sync() gracefully when not configured', async () => {
+ const driver = new TursoDriver({ url: ':memory:' });
+ // Should not throw
+ await driver.sync();
+ });
+});
+
+// ── URL Parsing & Validation ─────────────────────────────────────────────────
+
+describe('TursoDriver URL Parsing', () => {
+ it('should parse file: URL correctly', () => {
+ const driver = new TursoDriver({ url: 'file:./data/test.db' });
+ expect(driver.getTursoConfig().url).toBe('file:./data/test.db');
+ });
+
+ it('should handle :memory: URL', () => {
+ const driver = new TursoDriver({ url: ':memory:' });
+ expect(driver.getTursoConfig().url).toBe(':memory:');
+ });
+
+ it('should auto-detect remote mode for remote-only URL', () => {
+ const driver = new TursoDriver({
+ url: 'libsql://test-db.turso.io',
+ authToken: 'test-token',
+ });
+ expect(driver.transportMode).toBe('remote');
+ expect(driver.isRemote).toBe(true);
+ });
+
+ it('should accept remote URL when syncUrl is provided', () => {
+ // Should not throw — embedded replica mode
+ const driver = new TursoDriver({
+ url: 'libsql://test-db.turso.io',
+ syncUrl: 'libsql://test-db.turso.io',
+ authToken: 'test-token',
+ });
+ expect(driver.getTursoConfig().syncUrl).toBe('libsql://test-db.turso.io');
+ });
+});
+
+// ── Capabilities ─────────────────────────────────────────────────────────────
+
+describe('TursoDriver Capabilities', () => {
+ // This block used to re-assert ~17 inherited bits (create / read / bulk* /
+ // transactions / queryFilters / schemaSync …). Every one of them was a
+ // SqlDriver constant restated here, and objectstack#4634 retired the lot as
+ // zero-reader — so the assertions pinned the base class's old shape from the
+ // consumer repo, and went red on a framework pin bump that changed nothing
+ // about Turso. What is worth pinning is the DIFF: which bits this subclass
+ // claims for itself, in each transport mode.
+
+ it('claims only batchSchemaSync in local mode', () => {
+ const driver = new TursoDriver({ url: ':memory:' });
+ expect(driver.transportMode).toBe('local');
+ expect(ownCapabilityClaims(driver)).toEqual(['batchSchemaSync']);
+
+ // The bit is load-bearing only in tandem with the method — the engine
+ // requires both before it will use the batch DDL path.
+ expect(driver.supports.batchSchemaSync).toBe(true);
+ expect(typeof driver.syncSchemasBatch).toBe('function');
+ });
+
+ it('claims batchSchemaSync + an emptied queryDateGranularity in remote mode', () => {
+ const remote = new TursoDriver({ url: 'libsql://test-db.turso.io', authToken: 'test-token' });
+ expect(remote.transportMode).toBe('remote');
+ expect(ownCapabilityClaims(remote)).toEqual(['batchSchemaSync', 'queryDateGranularity']);
+ });
+
+ it('does NOT advertise native date-granularity in remote mode (avoids the "[object Object]" aggregate 500)', () => {
+ // Remote mode delegates aggregate() to RemoteTransport, which accepts only
+ // string group-by identifiers and has no date bucketing. If remote inherited
+ // SqlDriver's queryDateGranularity, the analytics engine would push a
+ // structured { field, dateGranularity } groupBy down to it and hit
+ // "RemoteTransport: unsafe identifier rejected: [object Object]" (a dashboard
+ // widget stuck loading). Remote must report no native granularity so the
+ // engine falls back to find() + in-memory bucketing.
+ const remote = new TursoDriver({ url: 'libsql://test-db.turso.io', authToken: 'test-token' });
+ expect(remote.transportMode).toBe('remote');
+ // No granularity advertised as natively supported -> engine falls back.
+ expect(remote.supports.queryDateGranularity).toEqual({});
+ expect(remote.supports.queryDateGranularity?.month).not.toBe(true);
+
+ // Local/replica keep native bucketing inherited from SqlDriver.
+ const local = new TursoDriver({ url: ':memory:' });
+ expect(local.transportMode).toBe('local');
+ expect(Object.keys(local.supports.queryDateGranularity ?? {}).length).toBeGreaterThan(0);
+ });
+});
+
+// ── Transport Mode Detection ─────────────────────────────────────────────────
+
+describe('TursoDriver Transport Mode Detection', () => {
+ it('should detect local mode for :memory: URL', () => {
+ const driver = new TursoDriver({ url: ':memory:' });
+ expect(driver.transportMode).toBe('local');
+ expect(driver.isRemote).toBe(false);
+ });
+
+ it('should detect local mode for file: URL', () => {
+ const driver = new TursoDriver({ url: 'file:./data/test.db' });
+ expect(driver.transportMode).toBe('local');
+ expect(driver.isRemote).toBe(false);
+ });
+
+ it('should detect replica mode for file: URL with syncUrl', () => {
+ const driver = new TursoDriver({
+ url: 'file:./data/replica.db',
+ syncUrl: 'libsql://test.turso.io',
+ authToken: 'test-token',
+ });
+ expect(driver.transportMode).toBe('replica');
+ expect(driver.isRemote).toBe(false);
+ });
+
+ it('should detect replica mode for :memory: with syncUrl', () => {
+ const driver = new TursoDriver({
+ url: ':memory:',
+ syncUrl: 'libsql://test.turso.io',
+ authToken: 'test-token',
+ });
+ expect(driver.transportMode).toBe('replica');
+ expect(driver.isRemote).toBe(false);
+ });
+
+ it('should detect remote mode for libsql:// URL', () => {
+ const driver = new TursoDriver({
+ url: 'libsql://test-db.turso.io',
+ authToken: 'test-token',
+ });
+ expect(driver.transportMode).toBe('remote');
+ expect(driver.isRemote).toBe(true);
+ });
+
+ it('should detect remote mode for https:// URL', () => {
+ const driver = new TursoDriver({
+ url: 'https://test-db.turso.io',
+ authToken: 'test-token',
+ });
+ expect(driver.transportMode).toBe('remote');
+ expect(driver.isRemote).toBe(true);
+ });
+
+ it('should detect remote mode for wss:// URL', () => {
+ const driver = new TursoDriver({
+ url: 'wss://test-db.turso.io',
+ authToken: 'test-token',
+ });
+ expect(driver.transportMode).toBe('remote');
+ expect(driver.isRemote).toBe(true);
+ });
+
+ // Plaintext schemes for self-hosted / local-dev endpoints (ObjectBase gateway
+ // without TLS, sqld on localhost). Regression: `http://` previously fell
+ // through to the local-SQLite fallback and silently wrote to an ephemeral
+ // in-memory DB instead of the remote, so seeded system tables / users never
+ // reached the backend and were lost on every kernel restart.
+ it('should detect remote mode for http:// URL (self-hosted, no TLS)', () => {
+ const driver = new TursoDriver({
+ url: 'http://p-deadbeef.default.localhost:3000',
+ authToken: 'test-token',
+ });
+ expect(driver.transportMode).toBe('remote');
+ expect(driver.isRemote).toBe(true);
+ });
+
+ it('should detect remote mode for ws:// URL (self-hosted, no TLS)', () => {
+ const driver = new TursoDriver({
+ url: 'ws://127.0.0.1:8080',
+ authToken: 'test-token',
+ });
+ expect(driver.transportMode).toBe('remote');
+ expect(driver.isRemote).toBe(true);
+ });
+
+ it('should respect explicit mode override', () => {
+ // Force remote mode even with a file: URL
+ const driver = new TursoDriver({
+ url: 'file:./data/test.db',
+ mode: 'remote',
+ });
+ expect(driver.transportMode).toBe('remote');
+ expect(driver.isRemote).toBe(true);
+ });
+
+ it('should expose remote transport in remote mode', () => {
+ const driver = new TursoDriver({
+ url: 'libsql://test-db.turso.io',
+ authToken: 'test-token',
+ });
+ expect(driver.getRemoteTransport()).not.toBeNull();
+ });
+
+ it('should not expose remote transport in local mode', () => {
+ const driver = new TursoDriver({ url: ':memory:' });
+ expect(driver.getRemoteTransport()).toBeNull();
+ });
+
+ it('should detect replica mode for libsql:// URL with syncUrl', () => {
+ const driver = new TursoDriver({
+ url: 'libsql://test-db.turso.io',
+ syncUrl: 'libsql://test-db.turso.io',
+ authToken: 'test-token',
+ });
+ expect(driver.transportMode).toBe('replica');
+ expect(driver.isRemote).toBe(false);
+ });
+});
+
+// ── Remote Mode with @libsql/client ──────────────────────────────────────────
+
+describe('TursoDriver Remote Mode (via @libsql/client)', () => {
+ let driver: TursoDriver;
+
+ beforeEach(async () => {
+ // Use @libsql/client in local mode (file::memory:) to test remote transport
+ // without actually connecting to a real Turso cloud instance.
+ // We create a libsql client in memory, then pass it to TursoDriver as a
+ // pre-configured client to exercise the RemoteTransport code path.
+ const { createClient } = await import('@libsql/client');
+ const memClient = createClient({ url: 'file::memory:' });
+
+ // Pre-create the test table
+ await memClient.execute(`
+ CREATE TABLE IF NOT EXISTS users (
+ id TEXT PRIMARY KEY,
+ name TEXT,
+ age INTEGER
+ )
+ `);
+ await memClient.execute({ sql: `INSERT INTO users (id, name, age) VALUES (?, ?, ?)`, args: ['1', 'Alice', 25] });
+ await memClient.execute({ sql: `INSERT INTO users (id, name, age) VALUES (?, ?, ?)`, args: ['2', 'Bob', 17] });
+ await memClient.execute({ sql: `INSERT INTO users (id, name, age) VALUES (?, ?, ?)`, args: ['3', 'Charlie', 30] });
+ await memClient.execute({ sql: `INSERT INTO users (id, name, age) VALUES (?, ?, ?)`, args: ['4', 'Dave', 17] });
+
+ driver = new TursoDriver({
+ url: 'libsql://test.turso.io', // Trigger remote mode
+ authToken: 'test-token',
+ client: memClient, // Inject pre-configured client
+ });
+ await driver.connect();
+ });
+
+ afterEach(async () => {
+ await driver.disconnect();
+ });
+
+ // ── Instantiation & Metadata ─────────────────────────────────────────────
+
+ it('should be in remote mode', () => {
+ expect(driver.transportMode).toBe('remote');
+ expect(driver.isRemote).toBe(true);
+ });
+
+ it('should still be a TursoDriver instance', () => {
+ expect(driver).toBeInstanceOf(TursoDriver);
+ expect(driver).toBeInstanceOf(SqlDriver);
+ });
+
+ it('should have the same name and version', () => {
+ expect(driver.name).toBe('com.objectstack.driver.turso');
+ expect(driver.version).toBe('1.0.0');
+ });
+
+ it('should expose the injected libsql client', () => {
+ expect(driver.getLibsqlClient()).not.toBeNull();
+ });
+
+ // ── Health Check ─────────────────────────────────────────────────────────
+
+ it('should report healthy connection', async () => {
+ const healthy = await driver.checkHealth();
+ expect(healthy).toBe(true);
+ });
+
+ // ── CRUD Operations ──────────────────────────────────────────────────────
+
+ it('should find all records', async () => {
+ const results = await driver.find('users', {});
+ expect(results.length).toBe(4);
+ });
+
+ it('should find records with equality filter', async () => {
+ const results = await driver.find('users', {
+ where: { age: 17 },
+ });
+ expect(results.length).toBe(2);
+ const names = results.map((r: any) => r.name).sort();
+ expect(names).toEqual(['Bob', 'Dave']);
+ });
+
+ it('should find records with $gt filter', async () => {
+ const results = await driver.find('users', {
+ where: { age: { $gt: 18 } },
+ orderBy: [{ field: 'name', order: 'asc' }],
+ });
+ expect(results.length).toBe(2);
+ expect(results.map((r: any) => r.name)).toEqual(['Alice', 'Charlie']);
+ });
+
+ it('should find records with $or filter', async () => {
+ const results = await driver.find('users', {
+ where: {
+ $or: [{ age: 17 }, { age: { $gt: 29 } }],
+ },
+ });
+ const names = results.map((r: any) => r.name).sort();
+ expect(names).toEqual(['Bob', 'Charlie', 'Dave']);
+ });
+
+ it('should find records with field selection', async () => {
+ const results = await driver.find('users', {
+ fields: ['name'],
+ where: { id: '1' },
+ });
+ expect(results.length).toBe(1);
+ expect(results[0].name).toBe('Alice');
+ });
+
+ it('should support limit and offset', async () => {
+ const results = await driver.find('users', {
+ orderBy: [{ field: 'name', order: 'asc' }],
+ limit: 2,
+ offset: 1,
+ });
+ expect(results.length).toBe(2);
+ expect(results[0].name).toBe('Bob');
+ expect(results[1].name).toBe('Charlie');
+ });
+
+ it('should findOne by id', async () => {
+ const result = await driver.findOne('users', { where: { id: '1' } });
+ expect(result).not.toBeNull();
+ expect(result!.name).toBe('Alice');
+ });
+
+ it('should findOne by query', async () => {
+ const result = await driver.findOne('users', { where: { name: 'Bob' } });
+ expect(result).not.toBeNull();
+ expect(result!.age).toBe(17);
+ });
+
+ it('should return null for findOne with no match', async () => {
+ const result = await driver.findOne('users', { where: { name: 'NonExistent' } });
+ expect(result).toBeNull();
+ });
+
+ it('should create a record', async () => {
+ const created = await driver.create('users', { name: 'Eve', age: 22 });
+ expect(created.id).toBeDefined();
+ expect(created.name).toBe('Eve');
+ expect(created.age).toBe(22);
+ });
+
+ it('should auto-generate id on create', async () => {
+ const created = await driver.create('users', { name: 'Frank', age: 35 });
+ expect(created.id).toBeDefined();
+ expect(typeof created.id).toBe('string');
+ expect((created.id as string).length).toBeGreaterThan(0);
+ });
+
+ it('should update a record', async () => {
+ await driver.update('users', '2', { age: 18 });
+ const updated = await driver.findOne('users', { where: { id: '2' } });
+ expect(updated!.age).toBe(18);
+ });
+
+ it('should delete a record', async () => {
+ const result = await driver.delete('users', '3');
+ expect(result).toBe(true);
+
+ const deleted = await driver.findOne('users', { where: { id: '3' } });
+ expect(deleted).toBeNull();
+ });
+
+ it('should return false when deleting non-existent record', async () => {
+ const result = await driver.delete('users', 'non-existent-id');
+ expect(result).toBe(false);
+ });
+
+ it('should count all records', async () => {
+ const count = await driver.count('users');
+ expect(count).toBe(4);
+ });
+
+ it('should count records with filter', async () => {
+ const count = await driver.count('users', { object: 'users', where: { age: 17 } });
+ expect(count).toBe(2);
+ });
+
+ it('should return empty array for no matches', async () => {
+ const results = await driver.find('users', { where: { age: 999 } });
+ expect(results).toEqual([]);
+ });
+
+ // ── Upsert ───────────────────────────────────────────────────────────────
+
+ it('should upsert (insert) a new record', async () => {
+ const result = await driver.upsert('users', { id: 'new-1', name: 'Grace', age: 28 });
+ expect(result.name).toBe('Grace');
+
+ const count = await driver.count('users');
+ expect(count).toBe(5);
+ });
+
+ it('should upsert (update) an existing record', async () => {
+ await driver.upsert('users', { id: '1', name: 'Alice Updated', age: 26 });
+
+ const updated = await driver.findOne('users', { where: { id: '1' } });
+ expect(updated!.name).toBe('Alice Updated');
+ expect(updated!.age).toBe(26);
+ });
+
+ // ── Bulk Operations ──────────────────────────────────────────────────────
+
+ it('should bulk create records', async () => {
+ const data = [
+ { id: 'b1', name: 'Bulk1', age: 10 },
+ { id: 'b2', name: 'Bulk2', age: 20 },
+ ];
+ const result = await driver.bulkCreate('users', data);
+ expect(result.length).toBe(2);
+ });
+
+ it('should bulk update records', async () => {
+ const updates = [
+ { id: '1', data: { age: 99 } },
+ { id: '2', data: { age: 88 } },
+ ];
+ const result = await driver.bulkUpdate('users', updates);
+ expect(result.length).toBe(2);
+ expect(result[0].age).toBe(99);
+ expect(result[1].age).toBe(88);
+ });
+
+ it('should bulk delete records', async () => {
+ await driver.bulkDelete('users', ['1', '2']);
+ const count = await driver.count('users');
+ expect(count).toBe(2);
+ });
+
+ // ── updateMany / deleteMany ──────────────────────────────────────────────
+
+ it('should updateMany records matching a query', async () => {
+ const count = await driver.updateMany!('users', { where: { age: 17 } }, { age: 18 });
+ expect(count).toBe(2);
+
+ const updated = await driver.find('users', { where: { age: 18 } });
+ expect(updated.length).toBe(2);
+ });
+
+ it('should deleteMany records matching a query', async () => {
+ const count = await driver.deleteMany!('users', { where: { age: 17 } });
+ expect(count).toBe(2);
+
+ const remaining = await driver.count('users');
+ expect(remaining).toBe(2);
+ });
+
+ // ── Raw Execution ────────────────────────────────────────────────────────
+
+ it('should execute raw SQL', async () => {
+ const result = await driver.execute('SELECT COUNT(*) as count FROM users');
+ expect(result).toBeDefined();
+ });
+
+ // ── Schema Sync ──────────────────────────────────────────────────────────
+
+ it('should sync schema and create tables', async () => {
+ await driver.syncSchema('products', {
+ name: 'products',
+ fields: {
+ title: { type: 'string' },
+ price: { type: 'float' },
+ active: { type: 'boolean' },
+ },
+ });
+
+ const created = await driver.create('products', {
+ title: 'Widget',
+ price: 9.99,
+ active: 1, // SQLite stores boolean as integer
+ });
+
+ expect(created.title).toBe('Widget');
+ expect(created.price).toBe(9.99);
+ });
+
+ it('should drop a table', async () => {
+ await driver.syncSchema('temp_table', {
+ name: 'temp_table',
+ fields: { value: { type: 'string' } },
+ });
+
+ await driver.create('temp_table', { value: 'test' });
+ await driver.dropTable('temp_table');
+
+ // After drop, creating a record should fail
+ await expect(driver.create('temp_table', { value: 'test' })).rejects.toThrow();
+ });
+
+ // ── Sorting ──────────────────────────────────────────────────────────────
+
+ it('should sort results', async () => {
+ const results = await driver.find('users', {
+ orderBy: [{ field: 'age', order: 'desc' }],
+ });
+ expect(results[0].name).toBe('Charlie');
+ expect(results[results.length - 1].age).toBe(17);
+ });
+
+ // ── Batch Schema Sync ──────────────────────────────────────────────────
+
+ it('should advertise batchSchemaSync capability', () => {
+ expect(driver.supports.batchSchemaSync).toBe(true);
+ });
+
+ it('should batch-sync multiple schemas in one call', async () => {
+ await driver.syncSchemasBatch([
+ {
+ object: 'orders',
+ schema: {
+ name: 'orders',
+ fields: {
+ product: { type: 'string' },
+ quantity: { type: 'integer' },
+ },
+ },
+ },
+ {
+ object: 'invoices',
+ schema: {
+ name: 'invoices',
+ fields: {
+ amount: { type: 'float' },
+ paid: { type: 'boolean' },
+ },
+ },
+ },
+ ]);
+
+ // Verify tables were created
+ const order = await driver.create('orders', { product: 'Widget', quantity: 5 });
+ expect(order.product).toBe('Widget');
+
+ const invoice = await driver.create('invoices', { amount: 99.99, paid: 1 });
+ expect(invoice.amount).toBe(99.99);
+ });
+
+ it('should batch-sync add columns to existing tables', async () => {
+ // First create a table
+ await driver.syncSchema('items', {
+ name: 'items',
+ fields: {
+ title: { type: 'string' },
+ },
+ });
+
+ // Now batch-sync with a new column
+ await driver.syncSchemasBatch([
+ {
+ object: 'items',
+ schema: {
+ name: 'items',
+ fields: {
+ title: { type: 'string' },
+ description: { type: 'text' },
+ },
+ },
+ },
+ ]);
+
+ // Verify the new column works
+ const item = await driver.create('items', { title: 'Test', description: 'A description' });
+ expect(item.title).toBe('Test');
+ expect(item.description).toBe('A description');
+ });
+
+ it('should handle empty batch gracefully', async () => {
+ await expect(driver.syncSchemasBatch([])).resolves.not.toThrow();
+ });
+
+ // ── initObjects (remote-mode override — must NOT touch better-sqlite3) ──
+ //
+ // Regression: in serverless environments (e.g. Vercel) the native
+ // `better-sqlite3` binding is unavailable. The base `SqlDriver.initObjects`
+ // uses Knex (better-sqlite3) for `hasTable`/`createTable`, which crashes
+ // with "Could not locate the bindings file". TursoDriver must route
+ // `initObjects` through RemoteTransport instead.
+ it('should provision tables via RemoteTransport (no better-sqlite3) in remote mode', async () => {
+ await driver.initObjects([
+ {
+ name: 'remote_init_a',
+ fields: {
+ title: { type: 'string' },
+ qty: { type: 'integer' },
+ },
+ },
+ {
+ name: 'remote_init_b',
+ fields: {
+ name: { type: 'string' },
+ },
+ },
+ ]);
+
+ // If routed via RemoteTransport, both tables exist on the libsql client.
+ const a = await driver.create('remote_init_a', { title: 'X', qty: 1 });
+ expect(a.title).toBe('X');
+ const b = await driver.create('remote_init_b', { name: 'Y' });
+ expect(b.name).toBe('Y');
+ });
+
+ it('should handle empty initObjects list gracefully in remote mode', async () => {
+ await expect(driver.initObjects([])).resolves.not.toThrow();
+ });
+});
+
+// ── Lazy Connect (self-healing for serverless cold starts) ───────────────────
+
+describe('TursoDriver Remote Mode — Lazy Connect', () => {
+ it('should lazy-connect on first find when connect() was never called', async () => {
+ const { createClient } = await import('@libsql/client');
+ const memClient = createClient({ url: 'file::memory:' });
+
+ await memClient.execute(`
+ CREATE TABLE IF NOT EXISTS users (
+ id TEXT PRIMARY KEY,
+ name TEXT
+ )
+ `);
+ await memClient.execute({ sql: `INSERT INTO users (id, name) VALUES (?, ?)`, args: ['1', 'Alice'] });
+
+ // Create driver but intentionally do NOT call connect()
+ const driver = new TursoDriver({
+ url: 'libsql://test.turso.io',
+ authToken: 'test-token',
+ client: memClient,
+ });
+
+ // The first CRUD operation should trigger lazy connect via the factory
+ const results = await driver.find('users', {});
+ expect(results.length).toBe(1);
+ expect((results[0] as any).name).toBe('Alice');
+
+ // Client should now be connected
+ expect(driver.getLibsqlClient()).not.toBeNull();
+ await driver.disconnect();
+ });
+
+ it('should lazy-connect on first create when connect() was never called', async () => {
+ const { createClient } = await import('@libsql/client');
+ const memClient = createClient({ url: 'file::memory:' });
+
+ await memClient.execute(`
+ CREATE TABLE IF NOT EXISTS items (
+ id TEXT PRIMARY KEY,
+ title TEXT
+ )
+ `);
+
+ const driver = new TursoDriver({
+ url: 'libsql://test.turso.io',
+ authToken: 'test-token',
+ client: memClient,
+ });
+
+ // No connect() — should lazy-connect
+ const item = await driver.create('items', { id: 'x', title: 'Test' });
+ expect(item.title).toBe('Test');
+ await driver.disconnect();
+ });
+
+ it('should lazy-connect on checkHealth when connect() was never called', async () => {
+ const { createClient } = await import('@libsql/client');
+ const memClient = createClient({ url: 'file::memory:' });
+
+ const driver = new TursoDriver({
+ url: 'libsql://test.turso.io',
+ authToken: 'test-token',
+ client: memClient,
+ });
+
+ // checkHealth should trigger lazy connect and succeed
+ const healthy = await driver.checkHealth();
+ expect(healthy).toBe(true);
+ await driver.disconnect();
+ });
+
+ it('should de-duplicate concurrent lazy-connect attempts', async () => {
+ const { createClient } = await import('@libsql/client');
+ const memClient = createClient({ url: 'file::memory:' });
+
+ await memClient.execute(`
+ CREATE TABLE IF NOT EXISTS users (
+ id TEXT PRIMARY KEY,
+ name TEXT
+ )
+ `);
+ await memClient.execute({ sql: `INSERT INTO users (id, name) VALUES (?, ?)`, args: ['1', 'Alice'] });
+
+ const driver = new TursoDriver({
+ url: 'libsql://test.turso.io',
+ authToken: 'test-token',
+ client: memClient,
+ });
+
+ // Fire multiple operations concurrently without calling connect()
+ const [r1, r2, r3] = await Promise.all([
+ driver.find('users', {}),
+ driver.find('users', {}),
+ driver.count('users'),
+ ]);
+
+ expect(r1.length).toBe(1);
+ expect(r2.length).toBe(1);
+ expect(r3).toBe(1);
+ await driver.disconnect();
+ });
+
+ it('should recover when transport client is cleared', async () => {
+ const { createClient } = await import('@libsql/client');
+
+ // We create two separate clients: one to simulate the "lost" state, and
+ // a fresh one that the lazy factory should produce on reconnect.
+ const memClient1 = createClient({ url: 'file::memory:' });
+ const memClient2 = createClient({ url: 'file::memory:' });
+
+ await memClient2.execute(`
+ CREATE TABLE IF NOT EXISTS users (
+ id TEXT PRIMARY KEY,
+ name TEXT
+ )
+ `);
+ await memClient2.execute({ sql: `INSERT INTO users (id, name) VALUES (?, ?)`, args: ['1', 'Bob'] });
+
+ const driver = new TursoDriver({
+ url: 'libsql://test.turso.io',
+ authToken: 'test-token',
+ client: memClient1,
+ });
+
+ await driver.connect();
+ expect(driver.getLibsqlClient()).not.toBeNull();
+
+ // Clear only the transport's reference (simulates stale state) and point
+ // the factory at a fresh, working client.
+ const transport = driver.getRemoteTransport()!;
+ transport.setClient(null as unknown as any);
+ // Override the factory to return the second client
+ transport.setConnectFactory(async () => memClient2);
+
+ // Next operation should re-connect via the factory
+ const results = await driver.find('users', {});
+ expect(results.length).toBe(1);
+ expect((results[0] as any).name).toBe('Bob');
+
+ memClient1.close();
+ memClient2.close();
+ });
+});
diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts
new file mode 100644
index 0000000000..56487dddeb
--- /dev/null
+++ b/packages/drivers/driver-turso/src/turso-driver.ts
@@ -0,0 +1,915 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Turso/libSQL Driver for ObjectStack
+ *
+ * Dual-transport architecture supporting:
+ * - **Local mode:** file-based or in-memory SQLite via SqlDriver (Knex + better-sqlite3)
+ * - **Replica mode:** local SQLite + embedded replica sync via @libsql/client
+ * - **Remote mode:** pure remote queries via @libsql/client (HTTP/WebSocket)
+ *
+ * In local/replica mode, all CRUD, schema, query, filter, and introspection
+ * logic is inherited from SqlDriver. In remote mode, TursoDriver delegates
+ * all operations to RemoteTransport which uses @libsql/client directly.
+ *
+ * The transport mode is auto-detected from the URL:
+ * - `file:` or `:memory:` → local
+ * - `file:` or `:memory:` + `syncUrl` → replica
+ * - `libsql://`, `https://`, `http://`, `wss://` or `ws://` (no syncUrl) → remote
+ * (`http://` / `ws://` = plaintext, for self-hosted / local-dev endpoints)
+ */
+
+import { SqlDriver, type SqlDriverConfig } from '@objectstack/driver-sql';
+import type { Client } from '@libsql/client';
+import { RemoteTransport } from './remote-transport.js';
+
+// ── Transport Mode ───────────────────────────────────────────────────────────
+
+/**
+ * Transport mode for TursoDriver.
+ *
+ * - `local`: File-based or in-memory SQLite via Knex + better-sqlite3
+ * - `replica`: Local SQLite + embedded replica sync from remote Turso
+ * - `remote`: Pure remote queries via @libsql/client (no local DB)
+ */
+export type TursoTransportMode = 'local' | 'replica' | 'remote';
+
+// ── Configuration Types ──────────────────────────────────────────────────────
+
+/**
+ * Turso driver configuration.
+ *
+ * Supports the following connection modes:
+ * 1. **Local (Embedded):** `url: 'file:./data/local.db'`
+ * 2. **In-memory (Ephemeral):** `url: ':memory:'`
+ * 3. **Embedded Replica (Hybrid):** `url` (local file or `:memory:`) +
+ * `syncUrl` (remote `libsql://` / `https://` Turso endpoint)
+ * 4. **Remote (Cloud):** `url: 'libsql://...'` — pure remote queries
+ * via @libsql/client, no local SQLite needed
+ *
+ * In local/replica modes, the primary query engine runs against a local
+ * SQLite database (via SqlDriver / Knex + better-sqlite3). In remote mode,
+ * all operations use @libsql/client SDK (HTTP/WebSocket) directly.
+ *
+ * Transport mode is auto-detected from the URL, or can be forced via `mode`.
+ */
+export interface TursoDriverConfig {
+ /**
+ * Database URL.
+ *
+ * - `file:./data/local.db` → local mode
+ * - `:memory:` → local mode (ephemeral)
+ * - `libsql://my-db.turso.io` → remote mode (cloud-only)
+ * - `https://my-db.turso.io` → remote mode (cloud-only)
+ */
+ url: string;
+
+ /** JWT auth token for the remote Turso database */
+ authToken?: string;
+
+ /**
+ * AES-256 encryption key for the local database file.
+ * Only effective in local/replica modes.
+ */
+ encryptionKey?: string;
+
+ /**
+ * Maximum concurrent requests to the remote database.
+ * Effective in replica and remote modes.
+ * Default: 20
+ */
+ concurrency?: number;
+
+ /** Remote sync URL for embedded replica mode (`libsql://` or `https://`) */
+ syncUrl?: string;
+
+ /** Sync configuration for embedded replica mode (requires `syncUrl`) */
+ sync?: {
+ /** Periodic sync interval in seconds (0 = manual only). Default: 60 */
+ intervalSeconds?: number;
+ /** Sync immediately on connect. Default: true */
+ onConnect?: boolean;
+ };
+
+ /**
+ * Operation timeout in milliseconds for remote operations.
+ * Effective in replica and remote modes.
+ */
+ timeout?: number;
+
+ /**
+ * Force a specific transport mode. If not provided, mode is auto-detected
+ * from the URL:
+ *
+ * - `file:` or `:memory:` without syncUrl → `'local'`
+ * - `file:` or `:memory:` with syncUrl → `'replica'`
+ * - `libsql://` / `https://` / `http://` / `wss://` / `ws://` without syncUrl → `'remote'`
+ */
+ mode?: TursoTransportMode;
+
+ /**
+ * Pre-configured @libsql/client instance. When provided, TursoDriver uses
+ * this client directly instead of creating its own. Useful for custom
+ * caching, connection pooling, or testing.
+ *
+ * Only effective in remote and replica modes.
+ */
+ client?: Client;
+}
+
+// ── Turso Driver ─────────────────────────────────────────────────────────────
+
+/**
+ * Turso/libSQL Driver for ObjectStack.
+ *
+ * Dual-transport architecture:
+ *
+ * - **Local/Replica modes:** Extends SqlDriver (Knex + better-sqlite3) for
+ * all CRUD, schema, filtering, aggregation — zero duplicated logic.
+ * - **Remote mode:** Delegates all operations to RemoteTransport which
+ * uses @libsql/client SDK directly (HTTP/WebSocket). No local SQLite needed.
+ *
+ * Transport mode is auto-detected from the URL or forced via `config.mode`.
+ *
+ * @example Local mode
+ * ```typescript
+ * const driver = new TursoDriver({ url: 'file:./data/app.db' });
+ * await driver.connect();
+ * ```
+ *
+ * @example In-memory mode (testing)
+ * ```typescript
+ * const driver = new TursoDriver({ url: ':memory:' });
+ * await driver.connect();
+ * ```
+ *
+ * @example Embedded replica mode
+ * ```typescript
+ * const driver = new TursoDriver({
+ * url: 'file:./data/replica.db',
+ * syncUrl: 'libsql://my-db-orgname.turso.io',
+ * authToken: process.env.TURSO_AUTH_TOKEN,
+ * sync: { intervalSeconds: 60, onConnect: true },
+ * });
+ * await driver.connect();
+ * ```
+ *
+ * @example Remote mode (cloud-only)
+ * ```typescript
+ * const driver = new TursoDriver({
+ * url: 'libsql://my-db-orgname.turso.io',
+ * authToken: process.env.TURSO_AUTH_TOKEN,
+ * });
+ * await driver.connect();
+ * ```
+ */
+export class TursoDriver extends SqlDriver {
+ // IDataDriver metadata
+ public override readonly name: string = 'com.objectstack.driver.turso';
+ public override readonly version: string = '1.0.0';
+
+ public override get supports() {
+ // Inherit the SqlDriver capability baseline (incl. autonumber via the
+ // shared `_objectstack_sequences` mechanism, and any future flags) and
+ // override only where Turso/libSQL genuinely differs. Spreading the base
+ // keeps this in lock-step with `DriverCapabilities` so a new base flag can
+ // never make this override an incomplete type again.
+ //
+ // Only bits the engine actually READS belong here. `savepoints` /
+ // `queryCTE` / `fullTextSearch` / `jsonQuery` / `connectionPooling` used to
+ // be declared true/false right below — accurate statements about libSQL
+ // that nothing ever consumed, and objectstack#4634 (ADR-0049
+ // enforce-or-remove) retired them from `DriverCapabilities` along with 26
+ // other zero-reader bits. Do not re-add a bit here to "document" an engine
+ // feature: a capability flag is a promise the engine dispatches on, and one
+ // nobody reads is a claim that can silently go false. Documentation goes in
+ // prose; a real dispatch point goes in the spec first.
+ return {
+ ...super.supports,
+
+ // Turso/libSQL batches DDL over one round-trip — see `syncSchemasBatch`.
+ // The engine ANDs this bit with that method's presence, which is why the
+ // bit is load-bearing rather than inferable: this class inherits the
+ // method shape from SqlDriver, whose transport genuinely cannot batch.
+ batchSchemaSync: true,
+
+ // Remote transport does NOT do native date bucketing. SqlDriver's
+ // `aggregate` — which emits `date_trunc`/`strftime` for structured
+ // `{ field, dateGranularity }` groupBy items — is only reached in
+ // local/replica mode; remote mode delegates `aggregate` to
+ // `RemoteTransport.aggregate`, which accepts only string group-by
+ // identifiers and has no bucketing. Inheriting SqlDriver's
+ // `queryDateGranularity` in remote mode is therefore a FALSE capability:
+ // the analytics engine (NativeSQLStrategy declines granularity →
+ // ObjectQLStrategy → engine.aggregate) trusts `supports.queryDateGranularity`
+ // and pushes the structured groupBy down to `RemoteTransport.aggregate`,
+ // which stringifies the object to "[object Object]" and rejects it
+ // (500 ANALYTICS_QUERY_FAILED — a whole dashboard widget stuck loading).
+ // Advertise no native granularity in remote mode so the engine falls back
+ // to `find()` (works remotely) + in-memory `bucketDateValue()` bucketing,
+ // which the contract guarantees is always correct. See
+ // @objectstack/spec `driver.zod.ts` (`queryDateGranularity`: "missing keys
+ // fall back to in-memory bucketing") and objectql `engine.ts` aggregate
+ // dispatch. Local/replica keep native bucketing via SqlDriver.
+ // (Empty record — not `undefined` — to stay assignable to SqlDriver's
+ // inferred `Record` supports type; every granularity is
+ // absent, so all fall back.)
+ ...(this.transportMode === 'remote'
+ ? { queryDateGranularity: {} as Record }
+ : {}),
+ };
+ }
+
+ private tursoConfig: TursoDriverConfig;
+ private libsqlClient: Client | null = null;
+ private syncIntervalId: ReturnType | null = null;
+
+ /**
+ * The resolved transport mode for this driver instance.
+ * Set during construction based on URL and config.
+ */
+ public readonly transportMode: TursoTransportMode;
+
+ /**
+ * Remote transport delegate — only initialized in remote mode.
+ */
+ private remoteTransport: RemoteTransport | null = null;
+
+ constructor(config: TursoDriverConfig) {
+ const mode = TursoDriver.detectMode(config);
+ const knexConfig = TursoDriver.toKnexConfig(config, mode);
+ super(knexConfig);
+ this.tursoConfig = config;
+ this.transportMode = mode;
+
+ if (mode === 'remote') {
+ this.remoteTransport = new RemoteTransport();
+
+ // The COLUMN half of the temporal seam (ADR-0053 D-A1). `toRemoteFilter`
+ // below puts the comparand into storage form via `temporalFilterValue`;
+ // its inherited companion `temporalFilterColumnSql` says how the column
+ // must be READ so the two are in the same form, and its own contract
+ // calls coercing the value "necessary but NOT sufficient — a caller that
+ // binds `temporalFilterValue` must wrap its column with this too, or it
+ // keeps half the bug". Handing the transport the rule (not a copy of it)
+ // is what keeps a single dialect-aware implementation.
+ this.remoteTransport.setFilterColumnSql((object, field, columnSql) =>
+ this.temporalFilterColumnSql(object, field, columnSql),
+ );
+
+ // Register a lazy-connect factory so the transport can self-heal when
+ // connect() was never called, failed on first attempt, or the client
+ // was lost (e.g. serverless cold-start, transient network error).
+ this.remoteTransport.setConnectFactory(async () => {
+ if (this.tursoConfig.client) {
+ this.libsqlClient = this.tursoConfig.client;
+ } else {
+ const { createClient } = await import('@libsql/client');
+ this.libsqlClient = createClient({
+ url: this.tursoConfig.url,
+ authToken: this.tursoConfig.authToken,
+ concurrency: this.tursoConfig.concurrency,
+ });
+ }
+ return this.libsqlClient;
+ });
+ }
+ }
+
+ /**
+ * Detect the transport mode from the URL and config.
+ */
+ static detectMode(config: TursoDriverConfig): TursoTransportMode {
+ // Explicit mode override
+ if (config.mode) return config.mode;
+
+ const url = config.url;
+
+ // Local modes: file: or :memory:
+ if (url === ':memory:' || url.startsWith('file:')) {
+ return config.syncUrl ? 'replica' : 'local';
+ }
+
+ // Remote URL (libsql://, https://, http://, wss://, ws://).
+ // `http://` and `ws://` are plaintext transports used against a
+ // self-hosted / local-dev Turso-compatible endpoint (e.g. an ObjectBase
+ // gateway with no TLS termination, or sqld on localhost). @libsql/client
+ // natively accepts these schemes; they MUST be classified as remote so
+ // queries go over the wire — otherwise the URL falls through to the
+ // local-SQLite fallback below and silently writes to an ephemeral
+ // in-memory DB (data never reaches the remote, lost on every restart).
+ if (
+ url.startsWith('libsql://') ||
+ url.startsWith('https://') ||
+ url.startsWith('http://') ||
+ url.startsWith('wss://') ||
+ url.startsWith('ws://')
+ ) {
+ // When both url and syncUrl are remote, @libsql/client operates in
+ // embedded replica mode with an in-memory local cache. The remote URL
+ // serves as the primary database and syncUrl configures the sync target.
+ if (config.syncUrl) return 'replica';
+ return 'remote';
+ }
+
+ // Fallback: treat as local
+ return 'local';
+ }
+
+ /**
+ * Convert TursoDriverConfig to a Knex-compatible SqlDriverConfig.
+ * Extracts the file path from the URL for local/embedded modes.
+ * In remote mode, uses a dummy :memory: config (Knex is not used).
+ */
+ private static toKnexConfig(config: TursoDriverConfig, mode: TursoTransportMode): SqlDriverConfig {
+ // Remote mode: All CRUD/schema operations delegate to RemoteTransport
+ // (via @libsql/client). Knex is never used for queries, but the SqlDriver
+ // base class constructor requires a valid config. We provide a minimal
+ // :memory: config that initializes Knex without side effects.
+ if (mode === 'remote') {
+ return {
+ client: 'better-sqlite3',
+ connection: { filename: ':memory:' },
+ useNullAsDefault: true,
+ };
+ }
+
+ if (config.url === ':memory:') {
+ return {
+ client: 'better-sqlite3',
+ connection: { filename: ':memory:' },
+ useNullAsDefault: true,
+ };
+ }
+
+ if (config.url.startsWith('file:')) {
+ return {
+ client: 'better-sqlite3',
+ connection: { filename: config.url.replace(/^file:/, '') },
+ useNullAsDefault: true,
+ };
+ }
+
+ // Remote URL with syncUrl (replica mode) — use :memory: as local backend
+ return {
+ client: 'better-sqlite3',
+ connection: { filename: ':memory:' },
+ useNullAsDefault: true,
+ };
+ }
+
+ /**
+ * Check if this driver instance is in remote mode.
+ */
+ get isRemote(): boolean {
+ return this.transportMode === 'remote';
+ }
+
+ /**
+ * Get the Turso-specific configuration.
+ */
+ getTursoConfig(): Readonly {
+ return this.tursoConfig;
+ }
+
+ // ===================================
+ // Lifecycle (Turso-specific overrides)
+ // ===================================
+
+ /**
+ * Connect the driver.
+ *
+ * **Local/Replica modes:**
+ * 1. Initializes the Knex/better-sqlite3 connection (via SqlDriver.connect)
+ * 2. If syncUrl is configured, creates a @libsql/client for sync operations
+ * 3. Triggers initial sync if configured
+ * 4. Starts periodic sync interval if configured
+ *
+ * **Remote mode:**
+ * 1. Creates a @libsql/client for remote queries
+ * 2. Skips Knex initialization (not needed)
+ */
+ override async connect(): Promise {
+ if (this.isRemote) {
+ // Remote mode: initialize @libsql/client only
+ if (this.tursoConfig.client) {
+ this.libsqlClient = this.tursoConfig.client;
+ } else {
+ const { createClient } = await import('@libsql/client');
+ this.libsqlClient = createClient({
+ url: this.tursoConfig.url,
+ authToken: this.tursoConfig.authToken,
+ concurrency: this.tursoConfig.concurrency,
+ });
+ }
+ this.remoteTransport!.setClient(this.libsqlClient);
+ return;
+ }
+
+ // Local/Replica mode: initialize Knex first
+ await super.connect();
+
+ // Initialize libSQL client for embedded replica sync
+ if (this.tursoConfig.syncUrl) {
+ if (this.tursoConfig.client) {
+ this.libsqlClient = this.tursoConfig.client;
+ } else {
+ const { createClient } = await import('@libsql/client');
+ this.libsqlClient = createClient({
+ url: this.tursoConfig.url,
+ authToken: this.tursoConfig.authToken,
+ encryptionKey: this.tursoConfig.encryptionKey,
+ syncUrl: this.tursoConfig.syncUrl,
+ concurrency: this.tursoConfig.concurrency,
+ });
+ }
+
+ // Sync on connect if configured (default: true)
+ if (this.tursoConfig.sync?.onConnect !== false) {
+ await this.sync();
+ }
+
+ // Start periodic sync if configured
+ const interval = this.tursoConfig.sync?.intervalSeconds;
+ if (interval && interval > 0) {
+ this.syncIntervalId = setInterval(() => {
+ this.sync().catch(() => {
+ /* background sync failure is non-fatal */
+ });
+ }, interval * 1000);
+ }
+ }
+ }
+
+ /**
+ * Disconnect the driver, clean up sync intervals, and close libSQL client.
+ */
+ override async disconnect(): Promise {
+ if (this.syncIntervalId) {
+ clearInterval(this.syncIntervalId);
+ this.syncIntervalId = null;
+ }
+
+ if (this.isRemote) {
+ // Remote mode: only clean up remoteTransport / libsqlClient
+ if (this.remoteTransport) {
+ this.remoteTransport.close();
+ }
+ this.libsqlClient = null;
+ return;
+ }
+
+ // Local/Replica mode: clean up libSQL client then Knex
+ if (this.libsqlClient) {
+ this.libsqlClient.close();
+ this.libsqlClient = null;
+ }
+
+ await super.disconnect();
+ }
+
+ /**
+ * Check connection health.
+ */
+ override async checkHealth(): Promise {
+ if (this.isRemote) {
+ return this.remoteTransport!.checkHealth();
+ }
+ return super.checkHealth();
+ }
+
+ // ===================================
+ // CRUD (remote mode overrides)
+ // ===================================
+
+ override async find(object: string, query: any, options?: any): Promise {
+ if (this.isRemote) return this.formatRemoteRows(object, await this.remoteTransport!.find(object, this.toRemoteQuery(object, query)));
+ return super.find(object, query, options);
+ }
+
+ override async findOne(object: string, query: any, options?: any): Promise {
+ if (this.isRemote) return this.formatRemoteRow(object, await this.remoteTransport!.findOne(object, this.toRemoteQuery(object, query)));
+ return super.findOne(object, query, options);
+ }
+
+ // `findStream` was retired from `IDataDriver` in spec 17.0.0
+ // (objectstack#4484): a required method with no caller anywhere, whose SQL and
+ // memory implementations awaited `find()` for the whole result set before
+ // yielding — the opposite of the memory guarantee it was declared for. This
+ // override went with the base method; page `find()` with `limit`/`offset`.
+
+ override async create(object: string, data: Record, options?: any): Promise {
+ if (this.isRemote) return this.formatRemoteRow(object, await this.remoteTransport!.create(object, this.toRemoteWriteForms(object, data)));
+ return super.create(object, data, options);
+ }
+
+ override async update(object: string, id: string | number, data: Record, options?: any): Promise {
+ if (this.isRemote) return this.formatRemoteRow(object, await this.remoteTransport!.update(object, id, this.toRemoteWriteForms(object, data)));
+ return super.update(object, id, data, options);
+ }
+
+ override async upsert(object: string, data: Record, conflictKeys?: string[], options?: any): Promise> {
+ if (this.isRemote) return this.formatRemoteRow(object, await this.remoteTransport!.upsert(object, this.toRemoteWriteForms(object, data), conflictKeys));
+ return super.upsert(object, data, conflictKeys, options);
+ }
+
+ override async delete(object: string, id: string | number, options?: any): Promise {
+ if (this.isRemote) return this.remoteTransport!.delete(object, id);
+ return super.delete(object, id, options);
+ }
+
+ override async count(object: string, query?: any, options?: any): Promise {
+ if (this.isRemote) return this.remoteTransport!.count(object, this.toRemoteQuery(object, query));
+ return super.count(object, query, options);
+ }
+
+ override async aggregate(object: string, query: any, options?: any): Promise {
+ if (this.isRemote) return this.remoteTransport!.aggregate(object, this.toRemoteQuery(object, query));
+ return super.aggregate(object, query, options);
+ }
+
+ // ===================================
+ // Remote read-coercion helpers
+ // ===================================
+ //
+ // The remote transport (`@libsql/client`) returns raw SQLite column values —
+ // booleans as 0/1, JSON as text, dates as raw strings — because it has no
+ // field-type metadata. Local/replica mode routes reads through
+ // `SqlDriver.formatOutput()`, which coerces those back to their declared
+ // types. These helpers run the SAME `formatOutput()` on remote rows so both
+ // transports agree on what a value *is*; without them a CEL guard like
+ // `field != true` sees `1 != true` (always true) on cloud/Turso but not
+ // locally — the exact divergence behind the case_escalation incident.
+
+ // ===================================
+ // Remote temporal seam (ADR-0053 D-A1, #937)
+ // ===================================
+ //
+ // `formatOutput` above closed the READ half of the transport asymmetry. The
+ // WRITE and FILTER halves were still open: `RemoteTransport` builds its own
+ // SQL (`buildWhereSQL`, `serializeValue`) and never touches the SqlDriver
+ // seam, which is exactly the surface ADR-0053 D-A1 legislates about —
+ //
+ // any surface that binds a filter comparand into raw SQL MUST coerce
+ // through the driver's dialect-aware temporal coercion, never re-derive a
+ // type from the value's textual shape
+ //
+ // — and measurement matched the prediction: a bare-day `$lte` dropped the
+ // whole final day (framework#3777's shape, worse than pre-fix local because
+ // even the midnight row went), `$between` fell through `buildWhereSQL`'s
+ // `default:` arm into an equality against a JSON-stringified array and
+ // matched NOTHING, and a `Field.date` written as a `Date` was serialised to
+ // full ISO while a REST write of the same day stored `YYYY-MM-DD`, so one
+ // column held two forms (framework#1874 / D-E1 all over again).
+ //
+ // The fix is not to grow `buildWhereSQL` a matching set of operator arms —
+ // that is the second implementation D-A1 exists to prevent. It is to put the
+ // payload and the comparands into the driver's storage form BEFORE they
+ // reach the transport, reusing the inherited members. `RemoteTransport` stays
+ // the dumb SQL builder it is documented to be.
+
+ /**
+ * Put a write payload into the storage form the filter path reads against —
+ * the write half `RemoteTransport` skipped by never reaching
+ * `SqlDriver.create`.
+ *
+ * This is `formatInput`, the same function local mode applies, so the two
+ * transports cannot disagree about what a written value becomes. Remote mode
+ * runs on libsql (SQLite), and the base config is `better-sqlite3`, so every
+ * dialect branch inside it resolves the way local mode's does.
+ *
+ * The write-column map is deliberately NOT applied: a managed object is its
+ * own physical table here (see `registerRemoteFieldMetadata`), so the mapping
+ * is a no-op, and the transport addresses columns by object-field name.
+ */
+ private toRemoteWriteForms(object: string, data: T): T {
+ if (!data || typeof data !== 'object') return data;
+ return this.formatInput(object, data) as T;
+ }
+
+ /**
+ * Compile a filter into the storage forms and operator shapes the remote
+ * transport can bind correctly.
+ *
+ * Three things happen here, and the ORDER of the last two is load-bearing
+ * (ADR-0053 D-E3): the calendar-day widening is a *calendar* operation and
+ * must run on the bare-day STRING, with only the resulting bound converted to
+ * storage form. Converting first would hand `nextUtcCalendarDay` an instant,
+ * which it correctly refuses to widen — silently narrowing a whole-day window
+ * back to a midnight one.
+ */
+ private toRemoteFilter(object: string, where: unknown): unknown {
+ if (where == null || typeof where !== 'object') return where;
+ if (Array.isArray(where)) return where.map((w) => this.toRemoteFilter(object, w));
+ const out: Record = {};
+ for (const [key, val] of Object.entries(where as Record)) {
+ if ((key === '$and' || key === '$or') && Array.isArray(val)) {
+ out[key] = val.map((sub) => this.toRemoteFilter(object, sub));
+ continue;
+ }
+ // `$not` carries ONE nested filter condition, so it recurses like the two
+ // array combinators rather than passing through as an opaque `$`-key
+ // (#1076). Skipping it left every condition INSIDE a negation on the raw
+ // path, i.e. short of the seam this method IS: `$between` was never
+ // lowered (so `{ $not: { amount: { $between: […] } } }` reached a
+ // transport that correctly refuses un-lowered `$between`, naming a step
+ // that had in fact been skipped), and a bare `YYYY-MM-DD` upper bound was
+ // never widened to the whole calendar day (framework#3777) — which under
+ // a negation hands BACK exactly the rows the day was meant to cover.
+ // Comparand storage form and the operator lowerings apply at every depth
+ // a condition can appear at, so the recursion has to reach all of them.
+ if (key === '$not') {
+ out[key] = this.toRemoteFilter(object, val);
+ continue;
+ }
+ out[key] = key.startsWith('$') ? val : this.toRemoteFieldSpec(object, key, val);
+ }
+ return out;
+ }
+
+ /** One field's comparand(s), in storage form and with `$between` lowered. */
+ private toRemoteFieldSpec(object: string, field: string, spec: unknown): unknown {
+ if (spec == null) return spec;
+ // A bare scalar / `Date` is implicit equality; a bare array is not a valid
+ // field spec for this transport, so it is left for `buildWhereSQL` to
+ // handle exactly as before.
+ if (typeof spec !== 'object' || spec instanceof Date) {
+ return this.temporalFilterValue(object, field, spec);
+ }
+ if (Array.isArray(spec)) return spec;
+
+ const out: Record = {};
+ for (const [op, raw] of Object.entries(spec as Record)) {
+ switch (op) {
+ case '$between': {
+ // Lowered to its two bounds rather than given an operator of its own
+ // (framework#4081): the upper-bound arm below already carries the
+ // whole-day calendar rule, so a range's max inherits it by
+ // construction instead of via a second implementation.
+ if (!Array.isArray(raw) || raw.length !== 2) {
+ throw new Error(
+ `[TursoDriver] $between on '${object}.${field}' needs exactly two bounds, got ` +
+ `${JSON.stringify(raw)}. Refusing rather than widening the query silently.`,
+ );
+ }
+ out.$gte = this.temporalFilterValue(object, field, raw[0]);
+ Object.assign(out, this.toRemoteUpperBound(object, field, '$lte', raw[1]));
+ break;
+ }
+ case '$lte':
+ Object.assign(out, this.toRemoteUpperBound(object, field, op, raw));
+ break;
+ case '$in':
+ case '$nin':
+ out[op] = Array.isArray(raw)
+ ? raw.map((v) => this.temporalFilterValue(object, field, v))
+ : raw;
+ break;
+ // Text predicates and existence checks take no temporal comparand.
+ // (`$regex` is the better-auth adapter's spelling of a substring search
+ // — a comparand the temporal coercion must likewise keep its hands off.)
+ case '$contains':
+ case '$notContains':
+ case '$startsWith':
+ case '$endsWith':
+ case '$regex':
+ case '$null':
+ case '$exists':
+ out[op] = raw;
+ break;
+ default:
+ out[op] = this.temporalFilterValue(object, field, raw);
+ }
+ }
+ return out;
+ }
+
+ /**
+ * An inclusive upper bound, compiled half-open when the comparand is a bare
+ * calendar day on a `datetime` column (ADR-0053 D-D1, framework#3777).
+ *
+ * `calendarDayUpperBoundRewrite` is the inherited authority for that rule and
+ * already scopes itself to `datetime`, so `date`/`time` columns compile
+ * byte-identically to before.
+ */
+ private toRemoteUpperBound(
+ object: string,
+ field: string,
+ op: string,
+ raw: unknown,
+ ): Record {
+ const rewritten = this.calendarDayUpperBoundRewrite(object, field, op, raw);
+ if (rewritten) return { [rewritten.op]: rewritten.value };
+ return { [op]: this.temporalFilterValue(object, field, raw) };
+ }
+
+ /** A query with its `where` compiled through {@link toRemoteFilter}. */
+ private toRemoteQuery(object: string, query: any): any {
+ if (!query || typeof query !== 'object' || query.where == null) return query;
+ return { ...query, where: this.toRemoteFilter(object, query.where) };
+ }
+
+ /** Apply the inherited read-coercion to a single remote row (in place). */
+ private formatRemoteRow(object: string, row: T): T {
+ if (row && typeof row === 'object') this.formatOutput(object, row as any);
+ return row;
+ }
+
+ /** Apply read-coercion to every remote row (in place). */
+ private formatRemoteRows(object: string, rows: T): T {
+ if (Array.isArray(rows)) for (const row of rows) this.formatRemoteRow(object, row);
+ return rows;
+ }
+
+ /**
+ * Populate the read-coercion registries (boolean/json/date/numeric/…) for a
+ * managed object in REMOTE mode WITHOUT running any DDL. `SqlDriver.initObjects`
+ * normally does this, but TursoDriver's remote path routes DDL through
+ * `RemoteTransport` and never reaches it, leaving the registries empty so
+ * `formatOutput()` had nothing to coerce. Reuse the base `registerExternalObject`,
+ * whose sole documented job is exactly this — it classifies fields with the
+ * canonical logic, so the two can never drift. A managed object is its own
+ * physical table, so the default `remoteName === name` mapping is a no-op for
+ * the RemoteTransport SQL (which addresses tables by object name directly).
+ */
+ private registerRemoteFieldMetadata(obj: { name: string; fields?: Record }): void {
+ try {
+ this.registerExternalObject({ name: obj.name, fields: obj.fields, tenancy: (obj as any).tenancy });
+ } catch {
+ /* metadata registration is best-effort; never block schema sync on it */
+ }
+ }
+
+ // ===================================
+ // Bulk Operations (remote mode overrides)
+ // ===================================
+
+ override async bulkCreate(object: string, data: any[], options?: any): Promise {
+ if (this.isRemote) {
+ const formatted = Array.isArray(data) ? data.map((d) => this.toRemoteWriteForms(object, d)) : data;
+ return this.formatRemoteRows(object, await this.remoteTransport!.bulkCreate(object, formatted));
+ }
+ return super.bulkCreate(object, data, options);
+ }
+
+ override async bulkUpdate(object: string, updates: Array<{ id: string | number; data: Record }>, options?: any): Promise[]> {
+ if (this.isRemote) {
+ const formatted = Array.isArray(updates)
+ ? updates.map((u) => ({ ...u, data: this.toRemoteWriteForms(object, u.data) }))
+ : updates;
+ return this.formatRemoteRows(object, await this.remoteTransport!.bulkUpdate(object, formatted));
+ }
+ return super.bulkUpdate(object, updates, options);
+ }
+
+ override async bulkDelete(object: string, ids: Array, options?: any): Promise {
+ if (this.isRemote) return this.remoteTransport!.bulkDelete(object, ids);
+ return super.bulkDelete(object, ids, options);
+ }
+
+ override async updateMany(object: string, query: any, data: any, options?: any): Promise {
+ if (this.isRemote) {
+ return this.remoteTransport!.updateMany(object, this.toRemoteQuery(object, query), this.toRemoteWriteForms(object, data));
+ }
+ return super.updateMany(object, query, data, options);
+ }
+
+ override async deleteMany(object: string, query: any, options?: any): Promise {
+ if (this.isRemote) return this.remoteTransport!.deleteMany(object, this.toRemoteQuery(object, query));
+ return super.deleteMany(object, query, options);
+ }
+
+ // ===================================
+ // Raw Execution (remote mode override)
+ // ===================================
+
+ override async execute(command: any, params?: any[], options?: any): Promise {
+ if (this.isRemote) return this.remoteTransport!.execute(command, params);
+ return super.execute(command, params, options);
+ }
+
+ // ===================================
+ // Transactions (remote mode overrides)
+ // ===================================
+
+ override async beginTransaction(): Promise {
+ if (this.isRemote) return this.remoteTransport!.beginTransaction();
+ return super.beginTransaction();
+ }
+
+ override async commit(transaction: unknown): Promise {
+ if (this.isRemote) return this.remoteTransport!.commit(transaction);
+ return super.commit(transaction);
+ }
+
+ override async rollback(transaction: unknown): Promise {
+ if (this.isRemote) return this.remoteTransport!.rollback(transaction);
+ return super.rollback(transaction);
+ }
+
+ // ===================================
+ // Schema Management (remote mode overrides)
+ // ===================================
+
+ override async syncSchema(object: string, schema: unknown, options?: any): Promise {
+ if (this.isRemote) {
+ await this.remoteTransport!.syncSchema(object, schema);
+ // See initObjects(): populate the read-coercion registries for remote mode.
+ // Key strictly by `object` (what find()/formatOutput look up) — never let a
+ // stray `schema.name` shadow it.
+ this.registerRemoteFieldMetadata({ ...(schema as Record), name: object });
+ return;
+ }
+ return super.syncSchema(object, schema, options);
+ }
+
+ /**
+ * Provision (CREATE/ALTER) physical tables for the given object definitions.
+ *
+ * In **remote** mode the base `SqlDriver.initObjects()` cannot be used because
+ * it relies on Knex (`better-sqlite3`) to introspect and emit DDL — that
+ * native binding is unavailable in serverless environments such as Vercel
+ * Lambdas. Instead we route DDL through `RemoteTransport.syncSchemasBatch()`,
+ * which uses `@libsql/client.batch()` against the Turso endpoint directly.
+ *
+ * In local / replica modes the existing Knex-based path remains in effect.
+ */
+ override async initObjects(
+ objects: Array<{ name: string; fields?: Record }>,
+ ): Promise {
+ if (this.isRemote) {
+ if (objects.length === 0) return;
+ await this.remoteTransport!.syncSchemasBatch(
+ objects.map((obj) => ({ object: obj.name, schema: obj })),
+ );
+ // Remote DDL bypasses SqlDriver.initObjects, which is what normally
+ // populates the boolean/json/date/numeric read-coercion registries.
+ // Register the field-type metadata explicitly (no DDL) so remote reads
+ // run the same formatOutput() coercion as local/replica mode — otherwise
+ // a boolean reads back as raw 0/1, JSON as a string, dates as raw text.
+ // (Root cause of the 2026-07-06 case_escalation `1 != true` incident.)
+ for (const obj of objects) this.registerRemoteFieldMetadata(obj);
+ return;
+ }
+ return super.initObjects(objects);
+ }
+
+ /**
+ * Batch-synchronize multiple schemas in a single round-trip.
+ *
+ * In remote mode, delegates to `RemoteTransport.syncSchemasBatch()` which
+ * uses `client.batch()` to submit all DDL as one network call.
+ * In local/replica mode, falls back to sequential `syncSchema()` calls
+ * (Knex + better-sqlite3 is already local, so batching has no benefit).
+ */
+ async syncSchemasBatch(schemas: Array<{ object: string; schema: unknown }>, options?: any): Promise {
+ if (this.isRemote) {
+ return this.remoteTransport!.syncSchemasBatch(schemas);
+ }
+ // Local/replica fallback: sequential sync (already fast with local SQLite)
+ for (const { object, schema } of schemas) {
+ await super.syncSchema(object, schema, options);
+ }
+ }
+
+ override async dropTable(object: string, options?: any): Promise {
+ if (this.isRemote) return this.remoteTransport!.dropTable(object);
+ return super.dropTable(object, options);
+ }
+
+ // ===================================
+ // Turso-specific: Embedded Replica Sync
+ // ===================================
+
+ /**
+ * Trigger manual sync of the embedded replica with the remote primary.
+ * No-op if no syncUrl is configured or libSQL client is not initialized.
+ */
+ async sync(): Promise {
+ if (this.libsqlClient && this.tursoConfig.syncUrl) {
+ await this.libsqlClient.sync();
+ }
+ }
+
+ /**
+ * Check if embedded replica sync is configured and active.
+ */
+ isSyncEnabled(): boolean {
+ return !!this.tursoConfig.syncUrl && this.libsqlClient !== null;
+ }
+
+ /**
+ * Get the underlying @libsql/client instance (if available).
+ * Available in both remote and replica modes after connect().
+ */
+ getLibsqlClient(): Client | null {
+ return this.libsqlClient;
+ }
+
+ /**
+ * Get the RemoteTransport instance (only available in remote mode).
+ */
+ getRemoteTransport(): RemoteTransport | null {
+ return this.remoteTransport;
+ }
+}
diff --git a/packages/drivers/driver-turso/src/turso-remote-temporal-conformance.test.ts b/packages/drivers/driver-turso/src/turso-remote-temporal-conformance.test.ts
new file mode 100644
index 0000000000..cd801afa84
--- /dev/null
+++ b/packages/drivers/driver-turso/src/turso-remote-temporal-conformance.test.ts
@@ -0,0 +1,331 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Temporal conformance for TursoDriver's REMOTE transport (#937, framework#4191).
+ *
+ * The local-mode twin of this suite passes by INHERITANCE: `TursoDriver extends
+ * SqlDriver`, so the whole temporal seam comes along. Remote mode inherits
+ * nothing — `RemoteTransport` builds its own SQL (`buildSelectSQL`,
+ * `buildWhereSQL`, `serializeValue`) via `@libsql/client` — which is precisely
+ * the surface ADR-0053 D-A1 legislates about, and it failed the shared table on
+ * every axis before this change:
+ *
+ * | Case | Measured, pre-fix |
+ * |---|---|
+ * | bare-day `$lte` window (framework#3777's default dashboard shape) | kept ONE row of four — worse than pre-fix local, since even the midnight row went |
+ * | `$between` | **zero rows** — no arm in `buildWhereSQL`, so it fell through `default:` to an equality against a JSON-stringified array |
+ * | `date` equality (framework#1874's shape) | dropped the `Date`-written rows: no `formatInput`, so `serializeValue` stored full ISO for one writer and `YYYY-MM-DD` for the other |
+ *
+ * The point of running the SHARED table rather than bespoke cases is that
+ * remote and local must answer identically. A Turso deployment can be either,
+ * chosen by URL alone; a filter whose result depends on that choice is the
+ * dialect-divergence the matrix exists to prevent.
+ *
+ * ## Four sweeps, like the local twin
+ *
+ * The local suite runs the matrix twice over canonical storage (`datetime`/
+ * `date`, then `Field.time`) and twice more over UN-BACKFILLED legacy storage,
+ * because a Turso database is exactly the long-lived cloud database whose
+ * backfill may never have run (D-B3 lets it fail without taking boot down).
+ * Remote mode needs those legacy sweeps MORE, not less: `backfillCanonical-
+ * Datetimes` is a Knex path, so in remote mode it never runs at all and the
+ * pre-convention rows stay pre-convention forever.
+ *
+ * The legacy sweeps below therefore seed rows RAW — straight into SQLite,
+ * under the transport, so no write path can converge them — and require the
+ * same expected row-id sets. Making them pass is the COLUMN half of the seam
+ * (`temporalFilterColumnSql`, wired in `TursoDriver`'s constructor): coercing
+ * only the comparand matches whichever storage form the writer happened to
+ * produce, which its own contract calls "necessary but NOT sufficient … or it
+ * keeps half the bug".
+ *
+ * ### Why the legacy forms differ from the local suite's
+ *
+ * They are the forms this transport's own tables can hold. `RemoteTransport`
+ * declares every temporal column `TEXT` (`mapFieldTypeToSQL`), so TEXT affinity
+ * converts a bound epoch INTEGER to text on the way in and a remote column
+ * simply cannot hold the INTEGER population the local sweep injects into its
+ * NUMERIC-affinity column. What it CAN hold — and what a pre-convention writer
+ * actually left there — is text: the zone-naive `datetime('now')` output this
+ * transport still uses as its own column default, offset-bearing ISO from a
+ * non-UTC writer, and (for `Field.time`) the full timestamp the pre-#937 write
+ * path produced from a bound `Date`. Those are what get seeded.
+ *
+ * ## Why a SQLite-backed client stub
+ *
+ * These are row-result assertions, and a dropped predicate is invisible to the
+ * SQL-string assertions the other remote suites make — the SQL stays valid,
+ * just wider (the same blind spot framework#4081 found one layer up). libsql is
+ * SQLite, so `makeLibsqlSqliteStub` gives the transport real affinity and
+ * ordering semantics. The network is out of scope here and stays covered by the
+ * mocked-`execute` suites.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import {
+ TEMPORAL_CASES,
+ TEMPORAL_NOW,
+ TEMPORAL_ROWS,
+ TEMPORAL_TIME_CASES,
+ TEMPORAL_TIME_ROWS,
+} from '@objectstack/spec/data';
+import { resolveFilterTokens } from '@objectstack/core';
+import { TursoDriver } from './turso-driver.js';
+import { makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';
+
+const resolveTokens = (filter: T): T =>
+ resolveFilterTokens(filter, { now: new Date(TEMPORAL_NOW) });
+
+const CONFORMANCE_OBJECT = {
+ name: 'conformance',
+ fields: {
+ at: { type: 'datetime' },
+ on: { type: 'date' },
+ why: { type: 'string' },
+ },
+};
+
+const TIME_CONFORMANCE_OBJECT = {
+ name: 'time_conformance',
+ fields: { at: { type: 'time' }, why: { type: 'string' } },
+};
+
+async function makeRemoteDriver(schema: Record) {
+ const stub = makeLibsqlSqliteStub();
+ const driver = new TursoDriver({ url: 'libsql://conformance.turso.io', client: stub as never });
+ await driver.connect();
+ expect(driver.transportMode).toBe('remote');
+ // `syncSchema` is what registers the field-type metadata in remote mode
+ // (`registerRemoteFieldMetadata`) — and therefore what makes the temporal
+ // seam know `at` is an instant and `on` a calendar day.
+ await driver.syncSchema(schema.name as string, schema);
+ return { driver, stub };
+}
+
+describe('TursoDriver remote — temporal conformance', () => {
+ let driver: TursoDriver;
+ let stub: LibsqlSqliteStub;
+
+ beforeAll(async () => {
+ ({ driver, stub } = await makeRemoteDriver(CONFORMANCE_OBJECT));
+ for (const r of TEMPORAL_ROWS) {
+ await driver.create('conformance', {
+ id: r.id,
+ // The mixed-writer axis (D-E4): a `Date` write and a wire-text write
+ // of the same instant must converge. Both `at` AND `on` are seeded
+ // natively for the `native` rows — a `Field.date` handed a `Date` is
+ // exactly what stored full ISO before this change.
+ at: r.writerForm === 'native' ? new Date(r.at) : r.at,
+ on: r.writerForm === 'native' ? new Date(r.on) : r.on,
+ why: r.why,
+ });
+ }
+ });
+
+ afterAll(async () => {
+ await driver.disconnect();
+ stub.close();
+ });
+
+ it('converged both writer populations to one storage form per column (the premise)', () => {
+ const rows = stub.raw.prepare('select id, at, "on" from conformance order by id').all() as Array<{
+ id: string;
+ at: string;
+ on: string;
+ }>;
+ expect(rows).toHaveLength(TEMPORAL_ROWS.length);
+ for (const row of rows) {
+ const expected = TEMPORAL_ROWS.find((r) => r.id === row.id)!;
+ // Canonical UTC ISO text for `datetime`, bare-day text for `date` —
+ // identical to what local mode writes, which is the claim that makes the
+ // two transports one backend rather than two.
+ expect(row.at, `${row.id}.at`).toBe(expected.at);
+ expect(row.on, `${row.id}.on`).toBe(expected.on);
+ }
+ });
+
+ for (const c of TEMPORAL_CASES) {
+ it(c.name, async () => {
+ const rows = await driver.find('conformance', { where: c.filter });
+ const got = (rows as any[]).map((r) => r.id).sort();
+ expect(got, c.note).toEqual([...c.expected].sort());
+ });
+
+ if (c.tokenFilter) {
+ it(`${c.name} — via relative tokens`, async () => {
+ const rows = await driver.find('conformance', { where: resolveTokens(c.tokenFilter) });
+ const got = (rows as any[]).map((r) => r.id).sort();
+ expect(got, c.note).toEqual([...c.expected].sort());
+ });
+ }
+ }
+
+ it('count() answers the same window find() does', async () => {
+ const window = { at: { $gte: '2026-04-29', $lte: '2026-07-28' } };
+ expect(await driver.count('conformance', { where: window })).toBe(4);
+ });
+
+ it('refuses a malformed $between instead of widening the query', async () => {
+ await expect(
+ driver.find('conformance', { where: { at: { $between: ['2026-04-29'] } } }),
+ ).rejects.toThrow(/\$between/);
+ });
+});
+
+describe('TursoDriver remote — Field.time conformance', () => {
+ let driver: TursoDriver;
+ let stub: LibsqlSqliteStub;
+
+ beforeAll(async () => {
+ ({ driver, stub } = await makeRemoteDriver(TIME_CONFORMANCE_OBJECT));
+ for (const r of TEMPORAL_TIME_ROWS) {
+ await driver.create('time_conformance', {
+ id: r.id,
+ // A wall clock handed a `Date` used to be serialised as a full
+ // timestamp here — the mixed column framework#3994 measured on SQL.
+ at: r.writerForm === 'native' ? new Date(`1970-01-01T${r.at}Z`) : r.at,
+ why: r.why,
+ });
+ }
+ });
+
+ afterAll(async () => {
+ await driver.disconnect();
+ stub.close();
+ });
+
+ it('converged both writer populations to canonical wall-clock text (the premise)', () => {
+ const rows = stub.raw.prepare('select id, at from time_conformance order by id').all() as Array<{
+ id: string;
+ at: string;
+ }>;
+ for (const row of rows) {
+ expect(row.at, `${row.id}.at`).toBe(TEMPORAL_TIME_ROWS.find((r) => r.id === row.id)!.at);
+ }
+ });
+
+ for (const c of TEMPORAL_TIME_CASES) {
+ it(c.name, async () => {
+ const rows = await driver.find('time_conformance', { where: c.filter });
+ const got = (rows as any[]).map((r) => r.id).sort();
+ expect(got, c.note).toEqual([...c.expected].sort());
+ });
+ }
+});
+
+/**
+ * Sweep 3 — the same matrix over un-backfilled legacy `datetime` storage.
+ *
+ * Seeded through the stub's raw handle: BELOW the transport, so no write path
+ * can converge the forms (the remote twin of the local suite's
+ * `LegacyStorageTursoDriver`, which inserts through Knex for the same reason).
+ * Nothing needs un-marking afterwards the way local does — remote mode has no
+ * backfill to mark a column canonical in the first place, which is exactly why
+ * this sweep is not hypothetical here.
+ */
+describe('TursoDriver remote — temporal conformance on un-backfilled legacy storage', () => {
+ let driver: TursoDriver;
+ let stub: LibsqlSqliteStub;
+
+ beforeAll(async () => {
+ ({ driver, stub } = await makeRemoteDriver(CONFORMANCE_OBJECT));
+ const insert = stub.raw.prepare(
+ 'insert into conformance (id, at, "on", why) values (?, ?, ?, ?)',
+ );
+ for (const r of TEMPORAL_ROWS) {
+ // Two pre-convention TEXT populations, split by the writer-form tag:
+ // native → offset-bearing ISO, what a writer in a non-UTC zone left
+ // behind (+08:00 here, so the repair has to normalise the
+ // zone and not merely reformat the characters);
+ // wire → the zone-naive, space-separated spelling `datetime('now')`
+ // produces — the default this transport's own DDL still puts
+ // on `created_at`/`updated_at`.
+ // `on` stays canonical, as in the local legacy sweep: the axis under
+ // test is `datetime` storage, and a `date` column has one form.
+ const at =
+ r.writerForm === 'native'
+ ? new Date(new Date(r.at).getTime() + 8 * 3600_000)
+ .toISOString()
+ .replace('Z', '+08:00')
+ : r.at.replace('T', ' ').replace('Z', '');
+ insert.run(r.id, at, r.on, r.why);
+ }
+ });
+
+ afterAll(async () => {
+ await driver.disconnect();
+ stub.close();
+ });
+
+ it('holds the mixed legacy forms it claims to (the premise, so the sweep cannot pass vacuously)', () => {
+ const rows = stub.raw.prepare('select id, at from conformance order by id').all() as Array<{
+ id: string;
+ at: string;
+ }>;
+ expect(rows).toHaveLength(TEMPORAL_ROWS.length);
+ for (const row of rows) {
+ const r = TEMPORAL_ROWS.find((x) => x.id === row.id)!;
+ // Neither population is canonical, and no row is stored in the form the
+ // canonical sweep asserts — otherwise this would be sweep 1 again.
+ expect(row.at, `${row.id}.at`).not.toBe(r.at);
+ expect(row.at, `${row.id}.at`).toMatch(r.writerForm === 'native' ? /\+08:00$/ : / /);
+ }
+ });
+
+ // Literal spellings only, as in the local legacy sweep: the token axis is
+ // orthogonal to storage form and already swept above.
+ for (const c of TEMPORAL_CASES) {
+ it(c.name, async () => {
+ const rows = await driver.find('conformance', { where: c.filter });
+ const got = (rows as any[]).map((r) => r.id).sort();
+ expect(got, c.note).toEqual([...c.expected].sort());
+ });
+ }
+});
+
+/** Sweep 4 — the same, for un-backfilled legacy `Field.time` storage. */
+describe('TursoDriver remote — Field.time conformance on un-backfilled legacy storage', () => {
+ let driver: TursoDriver;
+ let stub: LibsqlSqliteStub;
+
+ beforeAll(async () => {
+ ({ driver, stub } = await makeRemoteDriver(TIME_CONFORMANCE_OBJECT));
+ const insert = stub.raw.prepare('insert into time_conformance (id, at, why) values (?, ?, ?)');
+ for (const r of TEMPORAL_TIME_ROWS) {
+ // native → the full ISO timestamp the pre-#937 remote write path itself
+ // produced from a bound `Date` (`serializeValue` → toISOString),
+ // i.e. the mixed column this issue measured, still on disk;
+ // wire → the same wall clock as a zone-naive timestamp, the other
+ // pre-#3994 spelling.
+ const at =
+ r.writerForm === 'native' ? `2026-07-28T${r.at}Z` : `2026-07-28 ${r.at}`;
+ insert.run(r.id, at, r.why);
+ }
+ });
+
+ afterAll(async () => {
+ await driver.disconnect();
+ stub.close();
+ });
+
+ it('holds the mixed legacy forms it claims to (the premise, so the sweep cannot pass vacuously)', () => {
+ const rows = stub.raw.prepare('select id, at from time_conformance order by id').all() as Array<{
+ id: string;
+ at: string;
+ }>;
+ expect(rows).toHaveLength(TEMPORAL_TIME_ROWS.length);
+ for (const row of rows) {
+ // Every row carries a whole date it has no business carrying — the
+ // defect this axis is about — so none of them is canonical wall clock.
+ expect(row.at, `${row.id}.at`).toMatch(/^2026-07-28[T ]/);
+ }
+ });
+
+ for (const c of TEMPORAL_TIME_CASES) {
+ it(c.name, async () => {
+ const rows = await driver.find('time_conformance', { where: c.filter });
+ const got = (rows as any[]).map((r) => r.id).sort();
+ expect(got, c.note).toEqual([...c.expected].sort());
+ });
+ }
+});
diff --git a/packages/drivers/driver-turso/src/turso-temporal-conformance.test.ts b/packages/drivers/driver-turso/src/turso-temporal-conformance.test.ts
new file mode 100644
index 0000000000..e6e5a9d904
--- /dev/null
+++ b/packages/drivers/driver-turso/src/turso-temporal-conformance.test.ts
@@ -0,0 +1,260 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Temporal conformance for TursoDriver (ADR-0053 D-A3, framework#4081 /
+ * framework#4191).
+ *
+ * `TursoDriver extends SqlDriver`, so in LOCAL (and replica — same local
+ * engine) mode the whole temporal seam is inherited, not re-implemented:
+ * canonical datetime storage (framework#3912), the `Field.time` convention
+ * (framework#3994), the calendar-day bound rewrites (framework#3777), the
+ * comparand coercion, and the read-repair for un-backfilled legacy rows. This
+ * suite runs the shared `@objectstack/spec/data` matrix against the real
+ * local engine so it fails if this driver ever stops sharing that seam — the
+ * same consumer `driver-sqlite-wasm` keeps on the framework side, for the
+ * same inheritance reason.
+ *
+ * Four sweeps, mirroring the framework's `driver-sql` consumer:
+ * 1. canonical `datetime`/`date` — rows seeded through `create()` in their
+ * tagged writer forms (D-E4 mixed-writer axis), plus the same cases
+ * spelled in relative tokens resolved at the pinned `TEMPORAL_NOW`;
+ * 2. canonical `Field.time`;
+ * 3. legacy `datetime` storage — rows injected RAW below the write path in
+ * the pre-framework#3912 forms (INTEGER epoch ms / zone-naive TEXT) with
+ * the known-canonical marker cleared, so the read-repair answers the
+ * same table (the storage-form axis, framework#4191);
+ * 4. legacy `Field.time` storage — the pre-framework#3994 forms (INTEGER
+ * epoch ms / full-timestamp TEXT), same rule.
+ *
+ * The legacy sweeps matter MORE here than for an ordinary embedded SQLite:
+ * a Turso deployment's data lives in the cloud and is exactly the kind of
+ * long-lived database whose backfill may never have run (D-B3 lets it fail
+ * without taking boot down) — correctness must not be contingent on it.
+ *
+ * REMOTE mode gets the same matrix, in `turso-remote-temporal-conformance.
+ * test.ts` — it used to be excluded here because `RemoteTransport` compiles
+ * filters itself and failed every window (#937); since that transport routes
+ * its payloads and comparands through the driver's seam, the two modes are
+ * held to one table, which is the whole point of the axis.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import {
+ TEMPORAL_CASES,
+ TEMPORAL_NOW,
+ TEMPORAL_ROWS,
+ TEMPORAL_TIME_CASES,
+ TEMPORAL_TIME_ROWS,
+} from '@objectstack/spec/data';
+import { resolveFilterTokens } from '@objectstack/core';
+import { TursoDriver } from './turso-driver.js';
+
+const resolveTokens = (filter: T): T =>
+ resolveFilterTokens(filter, { now: new Date(TEMPORAL_NOW) });
+
+const CONFORMANCE_OBJECT = {
+ name: 'conformance',
+ fields: {
+ at: { type: 'datetime' },
+ on: { type: 'date' },
+ why: { type: 'string' },
+ },
+};
+
+const TIME_CONFORMANCE_OBJECT = {
+ name: 'time_conformance',
+ fields: { at: { type: 'time' }, why: { type: 'string' } },
+};
+
+/**
+ * The Turso twin of driver-sql's `LegacyStorageDriver` testkit (deliberately
+ * unexported there — test-only): insert rows bypassing `formatInput` so each
+ * temporal value lands in the raw pre-convention form it is given, then clear
+ * the column's known-canonical marker so the read paths apply their repair —
+ * the state of an un-migrated (or backfill-failed, D-B3) database.
+ */
+class LegacyStorageTursoDriver extends TursoDriver {
+ async seedLegacyRows(table: string, field: string, rows: Array>): Promise {
+ await this.knex(table).insert(rows);
+ this.canonicalDatetimeFields[table]?.delete(field);
+ }
+
+ async seedLegacyTimeRows(table: string, field: string, rows: Array>): Promise {
+ await this.knex(table).insert(rows);
+ this.canonicalTimeFields[table]?.delete(field);
+ }
+
+ /** Raw stored form of a column, for asserting the fixture's premise. */
+ async storedForms(table: string, field: string): Promise> {
+ const res: any = await this.knex.raw(`select id, typeof(??) as t from ?? order by id`, [field, table]);
+ const rows = Array.isArray(res) ? res : (res?.rows ?? []);
+ return rows.map((r: any) => ({ id: r.id, type: r.t }));
+ }
+}
+
+describe('TursoDriver — temporal conformance (local mode)', () => {
+ let driver: TursoDriver;
+
+ beforeAll(async () => {
+ driver = new TursoDriver({ url: ':memory:' });
+ // Local mode is the SqlDriver-inherited engine — the mode this suite is
+ // about. Replica shares it; remote does not (see module doc).
+ expect(driver.transportMode).toBe('local');
+ await driver.initObjects([CONFORMANCE_OBJECT]);
+ for (const r of TEMPORAL_ROWS) {
+ await driver.create(
+ 'conformance',
+ {
+ id: r.id,
+ // The mixed-writer axis (D-E4): both shapes must converge on write.
+ at: r.writerForm === 'native' ? new Date(r.at) : r.at,
+ on: r.on,
+ why: r.why,
+ },
+ { bypassTenantAudit: true } as any,
+ );
+ }
+ });
+
+ afterAll(async () => {
+ await driver.disconnect();
+ });
+
+ for (const c of TEMPORAL_CASES) {
+ it(c.name, async () => {
+ const rows = await driver.find('conformance', { object: 'conformance', where: c.filter });
+ const got = (rows as any[]).map((r) => r.id).sort();
+ expect(got, c.note).toEqual([...c.expected].sort());
+ });
+
+ if (c.tokenFilter) {
+ it(`${c.name} — via relative tokens`, async () => {
+ const rows = await driver.find('conformance', { object: 'conformance', where: resolveTokens(c.tokenFilter) });
+ const got = (rows as any[]).map((r) => r.id).sort();
+ expect(got, c.note).toEqual([...c.expected].sort());
+ });
+ }
+ }
+});
+
+describe('TursoDriver — Field.time conformance (local mode)', () => {
+ let driver: TursoDriver;
+
+ beforeAll(async () => {
+ driver = new TursoDriver({ url: ':memory:' });
+ await driver.initObjects([TIME_CONFORMANCE_OBJECT]);
+ for (const r of TEMPORAL_TIME_ROWS) {
+ await driver.create(
+ 'time_conformance',
+ {
+ id: r.id,
+ // The mixed-writer axis for wall clocks (framework#3994 measured
+ // this exact column): a bound `Date` and canonical text converge.
+ at: r.writerForm === 'native' ? new Date(`1970-01-01T${r.at}Z`) : r.at,
+ why: r.why,
+ },
+ { bypassTenantAudit: true } as any,
+ );
+ }
+ });
+
+ afterAll(async () => {
+ await driver.disconnect();
+ });
+
+ for (const c of TEMPORAL_TIME_CASES) {
+ it(c.name, async () => {
+ const rows = await driver.find('time_conformance', { object: 'time_conformance', where: c.filter });
+ const got = (rows as any[]).map((r) => r.id).sort();
+ expect(got, c.note).toEqual([...c.expected].sort());
+ });
+ }
+});
+
+describe('TursoDriver — temporal conformance on un-backfilled legacy storage', () => {
+ let driver: LegacyStorageTursoDriver;
+
+ beforeAll(async () => {
+ driver = new LegacyStorageTursoDriver({ url: ':memory:' });
+ await driver.initObjects([CONFORMANCE_OBJECT]);
+ // The two pre-framework#3912 storage forms, split by the writer-form tag
+ // (the storage-form axis): `native` writes landed as INTEGER epoch ms,
+ // `wire` writes as zone-naive TEXT. The inherited read-repair must answer
+ // the same table the canonical sweep does.
+ await driver.seedLegacyRows(
+ 'conformance',
+ 'at',
+ TEMPORAL_ROWS.map((r) => ({
+ id: r.id,
+ at: r.writerForm === 'native' ? Date.parse(r.at) : r.at.replace('T', ' ').replace('Z', ''),
+ on: r.on,
+ why: r.why,
+ })),
+ );
+ });
+
+ afterAll(async () => {
+ await driver.disconnect();
+ });
+
+ it('holds the mixed legacy forms it claims to (the premise, so the sweep cannot pass vacuously)', async () => {
+ const forms = await driver.storedForms('conformance', 'at');
+ const byId = Object.fromEntries(forms.map((f) => [f.id, f.type]));
+ for (const r of TEMPORAL_ROWS) {
+ expect(byId[r.id], r.id).toBe(r.writerForm === 'native' ? 'integer' : 'text');
+ }
+ });
+
+ // Literal spellings only: the token axis is orthogonal to storage form and
+ // already swept above — a divergence here is a repair-path bug by construction.
+ for (const c of TEMPORAL_CASES) {
+ it(c.name, async () => {
+ const rows = await driver.find('conformance', { object: 'conformance', where: c.filter });
+ const got = (rows as any[]).map((r) => r.id).sort();
+ expect(got, c.note).toEqual([...c.expected].sort());
+ });
+ }
+});
+
+describe('TursoDriver — Field.time conformance on un-backfilled legacy storage', () => {
+ let driver: LegacyStorageTursoDriver;
+
+ beforeAll(async () => {
+ driver = new LegacyStorageTursoDriver({ url: ':memory:' });
+ await driver.initObjects([TIME_CONFORMANCE_OBJECT]);
+ // The pre-framework#3994 forms (framework#4191): `native` → INTEGER epoch
+ // ms of the wall clock on the epoch day (a_midnight = the measured
+ // hazard, INTEGER 0, which sorts before every TEXT row), `wire` →
+ // full-timestamp TEXT pinned to the fixture's boundary day so every
+ // consumer of this axis seeds the same bytes.
+ await driver.seedLegacyTimeRows(
+ 'time_conformance',
+ 'at',
+ TEMPORAL_TIME_ROWS.map((r) => ({
+ id: r.id,
+ at: r.writerForm === 'native' ? Date.parse(`1970-01-01T${r.at}Z`) : `2026-07-28T${r.at}Z`,
+ why: r.why,
+ })),
+ );
+ });
+
+ afterAll(async () => {
+ await driver.disconnect();
+ });
+
+ it('holds the mixed legacy forms it claims to (the premise, so the sweep cannot pass vacuously)', async () => {
+ const forms = await driver.storedForms('time_conformance', 'at');
+ const byId = Object.fromEntries(forms.map((f) => [f.id, f.type]));
+ for (const r of TEMPORAL_TIME_ROWS) {
+ expect(byId[r.id], r.id).toBe(r.writerForm === 'native' ? 'integer' : 'text');
+ }
+ });
+
+ for (const c of TEMPORAL_TIME_CASES) {
+ it(c.name, async () => {
+ const rows = await driver.find('time_conformance', { object: 'time_conformance', where: c.filter });
+ const got = (rows as any[]).map((r) => r.id).sort();
+ expect(got, c.note).toEqual([...c.expected].sort());
+ });
+ }
+});
diff --git a/packages/drivers/driver-turso/tsconfig.json b/packages/drivers/driver-turso/tsconfig.json
new file mode 100644
index 0000000000..5bb764a0d7
--- /dev/null
+++ b/packages/drivers/driver-turso/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "extends": "../../../tsconfig.json",
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ES2020",
+ "moduleResolution": "bundler",
+ "declaration": true,
+ "outDir": "./dist",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "forceConsistentCasingInFileNames": true,
+ "types": ["node"],
+ "rootDir": "./src"
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts
index 4adaf64711..d37cf0578d 100644
--- a/packages/rest/src/rest-server.ts
+++ b/packages/rest/src/rest-server.ts
@@ -404,7 +404,7 @@ const CLIENT_MESSAGE_MAX = 500;
* "contains SQL", a 200-character driver dump passed the old gate untouched,
* and these messages have already cleared `looksLikeInternalErrorLeak` /
* `isSqlLeak` before reaching here. Same shape as the drivers' own
- * `safeShapePreview` (`packages/plugins/driver-sql`), which previews rather
+ * `safeShapePreview` (`packages/drivers/driver-sql`), which previews rather
* than erases.
*
* [#5437] That last paragraph turned out to be the other branch's bug report:
diff --git a/packages/services/service-analytics/src/like-pattern.ts b/packages/services/service-analytics/src/like-pattern.ts
index c7a1508d12..91b12d418d 100644
--- a/packages/services/service-analytics/src/like-pattern.ts
+++ b/packages/services/service-analytics/src/like-pattern.ts
@@ -61,7 +61,7 @@
* ## Relationship to `driver-sql`'s `applyLike`
*
* This is deliberately the same transform `SqlDriver.applyLike`
- * (`packages/plugins/driver-sql/src/sql-driver.ts`) applies — same escaped
+ * (`packages/drivers/driver-sql/src/sql-driver.ts`) applies — same escaped
* character class, same three wildcard shapes, same bound `ESCAPE` — and its
* TSDoc points back here. It is a SECOND implementation on purpose, not an
* oversight:
diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json
index 900826ea8d..ede05b4e63 100644
--- a/packages/spec/liveness/field.json
+++ b/packages/spec/liveness/field.json
@@ -32,7 +32,7 @@
},
"storage": {
"status": "live",
- "evidence": "packages/plugins/driver-sql/src/sql-driver.ts createColumn (notNullable from storage.notNull); packages/plugins/driver-sql/src/schema-drift.ts diffManagedTable (nullability drift compares against storage.notNull); proof: schema-drift.nullability.test.ts posture matrix",
+ "evidence": "packages/drivers/driver-sql/src/sql-driver.ts createColumn (notNullable from storage.notNull); packages/drivers/driver-sql/src/schema-drift.ts diffManagedTable (nullability drift compares against storage.notNull); proof: schema-drift.nullability.test.ts posture matrix",
"verifiedAt": "2026-07-30",
"note": "ADR-0113: the explicit physical constraint — owns the NOT NULL DDL and the destructive drift ceremony that required used to imply. Orthogonal to required (all four combinations legitimate). storage.notNull × requiredWhen rejected at the parse seam (FieldSchema.superRefine)."
},
@@ -148,7 +148,7 @@
},
"unique": {
"status": "live",
- "evidence": "packages/plugins/driver-sql/src/sql-driver.ts:1853",
+ "evidence": "packages/drivers/driver-sql/src/sql-driver.ts:1853",
"note": "CAVEAT — DDL-only; NOT validated on the write path (violations surface as raw driver errors)."
},
"precision": {
diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json
index 23ebd20925..fd0aa0e577 100644
--- a/packages/spec/liveness/object.json
+++ b/packages/spec/liveness/object.json
@@ -4,7 +4,7 @@
"props": {
"name": {
"status": "live",
- "evidence": "packages/plugins/driver-sql/src/sql-driver.ts",
+ "evidence": "packages/drivers/driver-sql/src/sql-driver.ts",
"note": "table name; objectql registry key."
},
"label": {
@@ -77,7 +77,7 @@
},
"indexes": {
"status": "live",
- "evidence": "packages/plugins/driver-sql/src/sql-driver.ts:1181",
+ "evidence": "packages/drivers/driver-sql/src/sql-driver.ts:1181",
"note": "DDL."
},
"validations": {
@@ -143,11 +143,11 @@
"children": {
"enabled": {
"status": "live",
- "evidence": "packages/plugins/driver-sql/src/sql-driver.ts:1081"
+ "evidence": "packages/drivers/driver-sql/src/sql-driver.ts:1081"
},
"tenantField": {
"status": "live",
- "evidence": "packages/plugins/driver-sql/src/sql-driver.ts",
+ "evidence": "packages/drivers/driver-sql/src/sql-driver.ts",
"note": "row-level tenant scoping (org-scoping plugin path) reads tenancy.tenantField — audit called it inert; corrected. strategy/crossTenantAccess were REMOVED after spec 15.0 (#2763): zero consumers; tenancy block is now .strict() with tombstone guidance."
}
}
diff --git a/packages/spec/liveness/query.json b/packages/spec/liveness/query.json
index 7ee2f81eae..ac10cf02c8 100644
--- a/packages/spec/liveness/query.json
+++ b/packages/spec/liveness/query.json
@@ -10,12 +10,12 @@
"fields": {
"status": "live",
"verifiedAt": "2026-07-31",
- "evidence": "packages/plugins/driver-sql/src/sql-driver.ts:1353-1354 (select projection); driver-memory projectFields; objectql formula projection + known-field filters (closed end-to-end in the #4196 verification)"
+ "evidence": "packages/drivers/driver-sql/src/sql-driver.ts:1353-1354 (select projection); driver-memory projectFields; objectql formula projection + known-field filters (closed end-to-end in the #4196 verification)"
},
"where": {
"status": "live",
"verifiedAt": "2026-07-31",
- "evidence": "packages/plugins/driver-sql/src/sql-driver.ts:1330-1331 (applyFilters); every driver's find() applies it"
+ "evidence": "packages/drivers/driver-sql/src/sql-driver.ts:1330-1331 (applyFilters); every driver's find() applies it"
},
"search": {
"status": "live",
@@ -32,17 +32,17 @@
"orderBy": {
"status": "live",
"verifiedAt": "2026-07-31",
- "evidence": "packages/plugins/driver-sql/src/sql-driver.ts:1335-1342; every driver's find() sorts by it"
+ "evidence": "packages/drivers/driver-sql/src/sql-driver.ts:1335-1342; every driver's find() sorts by it"
},
"limit": {
"status": "live",
"verifiedAt": "2026-07-31",
- "evidence": "packages/plugins/driver-sql/src/sql-driver.ts:1345"
+ "evidence": "packages/drivers/driver-sql/src/sql-driver.ts:1345"
},
"offset": {
"status": "live",
"verifiedAt": "2026-07-31",
- "evidence": "packages/plugins/driver-sql/src/sql-driver.ts:1344"
+ "evidence": "packages/drivers/driver-sql/src/sql-driver.ts:1344"
},
"top": {
"status": "live",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fa99aaa4bc..32f0d88ee4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -186,7 +186,7 @@ importers:
version: link:../../packages/connectors/connector-slack
'@objectstack/driver-sql':
specifier: workspace:*
- version: link:../../packages/plugins/driver-sql
+ version: link:../../packages/drivers/driver-sql
'@objectstack/runtime':
specifier: workspace:*
version: link:../../packages/runtime
@@ -223,7 +223,7 @@ importers:
version: link:../../packages/client
'@objectstack/driver-sqlite-wasm':
specifier: workspace:^
- version: link:../../packages/plugins/driver-sqlite-wasm
+ version: link:../../packages/drivers/driver-sqlite-wasm
'@objectstack/knowledge-memory':
specifier: workspace:*
version: link:../../packages/plugins/knowledge-memory
@@ -263,7 +263,7 @@ importers:
dependencies:
'@objectstack/driver-memory':
specifier: workspace:*
- version: link:../../packages/plugins/driver-memory
+ version: link:../../packages/drivers/driver-memory
'@objectstack/objectql':
specifier: workspace:*
version: link:../../packages/objectql
@@ -388,16 +388,16 @@ importers:
version: link:../core
'@objectstack/driver-memory':
specifier: workspace:^
- version: link:../plugins/driver-memory
+ version: link:../drivers/driver-memory
'@objectstack/driver-mongodb':
specifier: workspace:^
- version: link:../plugins/driver-mongodb
+ version: link:../drivers/driver-mongodb
'@objectstack/driver-sql':
specifier: workspace:^
- version: link:../plugins/driver-sql
+ version: link:../drivers/driver-sql
'@objectstack/driver-sqlite-wasm':
specifier: workspace:^
- version: link:../plugins/driver-sqlite-wasm
+ version: link:../drivers/driver-sqlite-wasm
'@objectstack/formula':
specifier: workspace:*
version: link:../formula
@@ -586,7 +586,7 @@ importers:
version: 2.0.12(hono@4.12.34)
'@objectstack/driver-sqlite-wasm':
specifier: workspace:*
- version: link:../plugins/driver-sqlite-wasm
+ version: link:../drivers/driver-sqlite-wasm
'@objectstack/hono':
specifier: workspace:*
version: link:../adapters/hono
@@ -811,6 +811,174 @@ importers:
specifier: ^4.1.10
version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@26.1.2)(typescript@6.0.3))(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))
+ packages/drivers/driver-memory:
+ dependencies:
+ '@objectstack/core':
+ specifier: workspace:*
+ version: link:../../core
+ '@objectstack/spec':
+ specifier: workspace:*
+ version: link:../../spec
+ mingo:
+ specifier: ^7.2.2
+ version: 7.2.2
+ devDependencies:
+ '@types/node':
+ specifier: ^26.1.2
+ version: 26.1.2
+ typescript:
+ specifier: ^6.0.3
+ version: 6.0.3
+ vitest:
+ specifier: ^4.1.10
+ version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@26.1.2)(typescript@6.0.3))(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))
+
+ packages/drivers/driver-mongodb:
+ dependencies:
+ '@objectstack/core':
+ specifier: workspace:*
+ version: link:../../core
+ '@objectstack/spec':
+ specifier: workspace:*
+ version: link:../../spec
+ '@objectstack/types':
+ specifier: workspace:*
+ version: link:../../types
+ mongodb:
+ specifier: ^7.5.0
+ version: 7.5.0(socks@2.8.9)
+ nanoid:
+ specifier: ^6.0.0
+ version: 6.0.0
+ devDependencies:
+ '@types/node':
+ specifier: ^26.1.2
+ version: 26.1.2
+ mongodb-memory-server:
+ specifier: ^11.2.0
+ version: 11.2.0(socks@2.8.9)
+ typescript:
+ specifier: ^6.0.3
+ version: 6.0.3
+ vitest:
+ specifier: ^4.1.10
+ version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@26.1.2)(typescript@6.0.3))(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))
+
+ packages/drivers/driver-sql:
+ dependencies:
+ '@objectstack/core':
+ specifier: workspace:*
+ version: link:../../core
+ '@objectstack/observability':
+ specifier: workspace:*
+ version: link:../../observability
+ '@objectstack/spec':
+ specifier: workspace:*
+ version: link:../../spec
+ '@objectstack/types':
+ specifier: workspace:*
+ version: link:../../types
+ knex:
+ specifier: ^3.3.0
+ version: 3.3.0(better-sqlite3@13.0.2)(mysql2@3.23.1(@types/node@26.1.2))(pg@8.22.0)(tedious@18.6.2)
+ mysql2:
+ specifier: ^3.0.0
+ version: 3.23.1(@types/node@26.1.2)
+ nanoid:
+ specifier: ^6.0.0
+ version: 6.0.0
+ pg:
+ specifier: ^8.0.0
+ version: 8.22.0
+ tedious:
+ specifier: ^18.0.0
+ version: 18.6.2
+ devDependencies:
+ '@types/node':
+ specifier: ^26.1.2
+ version: 26.1.2
+ typescript:
+ specifier: ^6.0.3
+ version: 6.0.3
+ vitest:
+ specifier: ^4.1.10
+ version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@26.1.2)(typescript@6.0.3))(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))
+ optionalDependencies:
+ better-sqlite3:
+ specifier: ^13.0.2
+ version: 13.0.2
+
+ packages/drivers/driver-sqlite-wasm:
+ dependencies:
+ '@objectstack/core':
+ specifier: workspace:*
+ version: link:../../core
+ '@objectstack/driver-sql':
+ specifier: workspace:*
+ version: link:../driver-sql
+ '@objectstack/spec':
+ specifier: workspace:*
+ version: link:../../spec
+ knex:
+ specifier: ^3.3.0
+ version: 3.3.0(better-sqlite3@13.0.2)(mysql2@3.23.1(@types/node@26.1.2))(pg@8.22.0)(tedious@18.6.2)
+ nanoid:
+ specifier: ^6.0.0
+ version: 6.0.0
+ sql.js:
+ specifier: ^1.14.1
+ version: 1.14.1
+ devDependencies:
+ '@types/node':
+ specifier: ^26.1.2
+ version: 26.1.2
+ '@types/sql.js':
+ specifier: ^1.4.11
+ version: 1.4.11
+ typescript:
+ specifier: ^6.0.3
+ version: 6.0.3
+ vitest:
+ specifier: ^4.1.10
+ version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@26.1.2)(typescript@6.0.3))(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))
+
+ packages/drivers/driver-turso:
+ dependencies:
+ '@libsql/client':
+ specifier: ^0.17.3
+ version: 0.17.4
+ '@objectstack/core':
+ specifier: workspace:*
+ version: link:../../core
+ '@objectstack/driver-sql':
+ specifier: workspace:*
+ version: link:../driver-sql
+ '@objectstack/spec':
+ specifier: workspace:*
+ version: link:../../spec
+ nanoid:
+ specifier: ^6.0.0
+ version: 6.0.0
+ zod:
+ specifier: ^4.4.3
+ version: 4.4.3
+ devDependencies:
+ '@objectstack/verify':
+ specifier: workspace:*
+ version: link:../../verify
+ '@types/node':
+ specifier: ^26.1.2
+ version: 26.1.2
+ better-sqlite3:
+ specifier: ^13.0.2
+ version: 13.0.2
+ typescript:
+ specifier: ^6.0.3
+ version: 6.0.3
+ vitest:
+ specifier: ^4.1.10
+ version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@26.1.2)(typescript@6.0.3))(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))
+
packages/formula:
dependencies:
'@marcbachmann/cel-js':
@@ -927,7 +1095,7 @@ importers:
devDependencies:
'@objectstack/driver-sqlite-wasm':
specifier: workspace:*
- version: link:../plugins/driver-sqlite-wasm
+ version: link:../drivers/driver-sqlite-wasm
'@types/js-yaml':
specifier: ^4.0.9
version: 4.0.9
@@ -1103,137 +1271,6 @@ importers:
specifier: ^4.1.10
version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@26.1.2)(typescript@6.0.3))(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))
- packages/plugins/driver-memory:
- dependencies:
- '@objectstack/core':
- specifier: workspace:*
- version: link:../../core
- '@objectstack/spec':
- specifier: workspace:*
- version: link:../../spec
- mingo:
- specifier: ^7.2.2
- version: 7.2.2
- devDependencies:
- '@types/node':
- specifier: ^26.1.2
- version: 26.1.2
- typescript:
- specifier: ^6.0.3
- version: 6.0.3
- vitest:
- specifier: ^4.1.10
- version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@26.1.2)(typescript@6.0.3))(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))
-
- packages/plugins/driver-mongodb:
- dependencies:
- '@objectstack/core':
- specifier: workspace:*
- version: link:../../core
- '@objectstack/spec':
- specifier: workspace:*
- version: link:../../spec
- '@objectstack/types':
- specifier: workspace:*
- version: link:../../types
- mongodb:
- specifier: ^7.5.0
- version: 7.5.0(socks@2.8.9)
- nanoid:
- specifier: ^6.0.0
- version: 6.0.0
- devDependencies:
- '@types/node':
- specifier: ^26.1.2
- version: 26.1.2
- mongodb-memory-server:
- specifier: ^11.2.0
- version: 11.2.0(socks@2.8.9)
- typescript:
- specifier: ^6.0.3
- version: 6.0.3
- vitest:
- specifier: ^4.1.10
- version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@26.1.2)(typescript@6.0.3))(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))
-
- packages/plugins/driver-sql:
- dependencies:
- '@objectstack/core':
- specifier: workspace:*
- version: link:../../core
- '@objectstack/observability':
- specifier: workspace:*
- version: link:../../observability
- '@objectstack/spec':
- specifier: workspace:*
- version: link:../../spec
- '@objectstack/types':
- specifier: workspace:*
- version: link:../../types
- knex:
- specifier: ^3.3.0
- version: 3.3.0(better-sqlite3@13.0.2)(mysql2@3.23.1(@types/node@26.1.2))(pg@8.22.0)(tedious@18.6.2)
- mysql2:
- specifier: ^3.0.0
- version: 3.23.1(@types/node@26.1.2)
- nanoid:
- specifier: ^6.0.0
- version: 6.0.0
- pg:
- specifier: ^8.0.0
- version: 8.22.0
- tedious:
- specifier: ^18.0.0
- version: 18.6.2
- devDependencies:
- '@types/node':
- specifier: ^26.1.2
- version: 26.1.2
- typescript:
- specifier: ^6.0.3
- version: 6.0.3
- vitest:
- specifier: ^4.1.10
- version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@26.1.2)(typescript@6.0.3))(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))
- optionalDependencies:
- better-sqlite3:
- specifier: ^13.0.2
- version: 13.0.2
-
- packages/plugins/driver-sqlite-wasm:
- dependencies:
- '@objectstack/core':
- specifier: workspace:*
- version: link:../../core
- '@objectstack/driver-sql':
- specifier: workspace:*
- version: link:../driver-sql
- '@objectstack/spec':
- specifier: workspace:*
- version: link:../../spec
- knex:
- specifier: ^3.3.0
- version: 3.3.0(better-sqlite3@13.0.2)(mysql2@3.23.1(@types/node@26.1.2))(pg@8.22.0)(tedious@18.6.2)
- nanoid:
- specifier: ^6.0.0
- version: 6.0.0
- sql.js:
- specifier: ^1.14.1
- version: 1.14.1
- devDependencies:
- '@types/node':
- specifier: ^26.1.2
- version: 26.1.2
- '@types/sql.js':
- specifier: ^1.4.11
- version: 1.4.11
- typescript:
- specifier: ^6.0.3
- version: 6.0.3
- vitest:
- specifier: ^4.1.10
- version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@26.1.2)(typescript@6.0.3))(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))
-
packages/plugins/embedder-openai:
dependencies:
'@objectstack/spec':
@@ -1421,7 +1458,7 @@ importers:
version: link:../../core
'@objectstack/driver-memory':
specifier: workspace:^
- version: link:../driver-memory
+ version: link:../../drivers/driver-memory
'@objectstack/objectql':
specifier: workspace:^
version: link:../../objectql
@@ -1742,13 +1779,13 @@ importers:
version: link:../../core
'@objectstack/driver-memory':
specifier: workspace:*
- version: link:../../plugins/driver-memory
+ version: link:../../drivers/driver-memory
'@objectstack/driver-sql':
specifier: workspace:*
- version: link:../../plugins/driver-sql
+ version: link:../../drivers/driver-sql
'@objectstack/driver-sqlite-wasm':
specifier: workspace:*
- version: link:../../plugins/driver-sqlite-wasm
+ version: link:../../drivers/driver-sqlite-wasm
'@types/node':
specifier: ^26.1.2
version: 26.1.2
@@ -1780,7 +1817,7 @@ importers:
devDependencies:
'@objectstack/driver-sqlite-wasm':
specifier: workspace:*
- version: link:../../plugins/driver-sqlite-wasm
+ version: link:../../drivers/driver-sqlite-wasm
'@objectstack/objectql':
specifier: workspace:*
version: link:../../objectql
@@ -1856,13 +1893,13 @@ importers:
version: link:../core
'@objectstack/driver-memory':
specifier: workspace:*
- version: link:../plugins/driver-memory
+ version: link:../drivers/driver-memory
'@objectstack/driver-sql':
specifier: workspace:*
- version: link:../plugins/driver-sql
+ version: link:../drivers/driver-sql
'@objectstack/driver-sqlite-wasm':
specifier: workspace:*
- version: link:../plugins/driver-sqlite-wasm
+ version: link:../drivers/driver-sqlite-wasm
'@objectstack/formula':
specifier: workspace:*
version: link:../formula
@@ -1936,7 +1973,7 @@ importers:
optionalDependencies:
'@objectstack/driver-mongodb':
specifier: workspace:*
- version: link:../plugins/driver-mongodb
+ version: link:../drivers/driver-mongodb
packages/sdui-parser:
devDependencies:
@@ -2077,16 +2114,16 @@ importers:
devDependencies:
'@objectstack/driver-memory':
specifier: workspace:*
- version: link:../../plugins/driver-memory
+ version: link:../../drivers/driver-memory
'@objectstack/driver-mongodb':
specifier: workspace:*
- version: link:../../plugins/driver-mongodb
+ version: link:../../drivers/driver-mongodb
'@objectstack/driver-sql':
specifier: workspace:*
- version: link:../../plugins/driver-sql
+ version: link:../../drivers/driver-sql
'@objectstack/driver-sqlite-wasm':
specifier: workspace:*
- version: link:../../plugins/driver-sqlite-wasm
+ version: link:../../drivers/driver-sqlite-wasm
'@objectstack/plugin-hono-server':
specifier: workspace:*
version: link:../../plugins/plugin-hono-server
@@ -3479,6 +3516,63 @@ packages:
'@js-joda/core@5.7.0':
resolution: {integrity: sha512-WBu4ULVVxySLLzK1Ppq+OdfP+adRS4ntmDQT915rzDJ++i95gc2jZkM5B6LWEAwN3lGXpfie3yPABozdD3K3Vg==}
+ '@libsql/client@0.17.4':
+ resolution: {integrity: sha512-lYayFWasDV78A+TjlEhr6ubb3odBV6OHjb+wdp8VQcyWWAEIjuwbCHaraEUS4m4yWoo0BvZo96It4VdzZRmRWw==}
+
+ '@libsql/core@0.17.4':
+ resolution: {integrity: sha512-LqF9gIvnJ38nmAH1y/ChizHqDO/MO1wLgA96XrraulEEbqXxLjleSH92YWTolbuJKgPUmGu4aJk9W3UnAcxLOQ==}
+
+ '@libsql/darwin-arm64@0.5.29':
+ resolution: {integrity: sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@libsql/darwin-x64@0.5.29':
+ resolution: {integrity: sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@libsql/hrana-client@0.10.0':
+ resolution: {integrity: sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw==}
+
+ '@libsql/isomorphic-ws@0.1.5':
+ resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==}
+
+ '@libsql/linux-arm-gnueabihf@0.5.29':
+ resolution: {integrity: sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==}
+ cpu: [arm]
+ os: [linux]
+
+ '@libsql/linux-arm-musleabihf@0.5.29':
+ resolution: {integrity: sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==}
+ cpu: [arm]
+ os: [linux]
+
+ '@libsql/linux-arm64-gnu@0.5.29':
+ resolution: {integrity: sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@libsql/linux-arm64-musl@0.5.29':
+ resolution: {integrity: sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@libsql/linux-x64-gnu@0.5.29':
+ resolution: {integrity: sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==}
+ cpu: [x64]
+ os: [linux]
+
+ '@libsql/linux-x64-musl@0.5.29':
+ resolution: {integrity: sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==}
+ cpu: [x64]
+ os: [linux]
+
+ '@libsql/win32-x64-msvc@0.5.29':
+ resolution: {integrity: sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==}
+ cpu: [x64]
+ os: [win32]
+
'@manypkg/find-root@1.1.0':
resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==}
@@ -3520,6 +3614,9 @@ packages:
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3
+ '@neon-rs/load@0.0.4':
+ resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==}
+
'@next/env@16.2.12':
resolution: {integrity: sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==}
@@ -5836,6 +5933,10 @@ packages:
resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==}
engines: {node: '>=8'}
+ detect-libc@2.0.2:
+ resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==}
+ engines: {node: '>=8'}
+
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -6790,6 +6891,9 @@ packages:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
+ js-base64@3.9.2:
+ resolution: {integrity: sha512-6zayE8QlUdiweYI6cETD/XBSqFcoCUlufn/29PJR99r82x1yDnIprRca0YvAYpAW+ez0GuQkVBC6xG5QkD7OjA==}
+
js-md4@0.3.2:
resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==}
@@ -6934,6 +7038,11 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
+ libsql@0.5.29:
+ resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==}
+ cpu: [x64, arm64, wasm32, arm]
+ os: [darwin, linux, win32]
+
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
@@ -8086,6 +8195,9 @@ packages:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
+ promise-limit@2.7.0:
+ resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==}
+
property-information@7.2.0:
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
@@ -10333,6 +10445,64 @@ snapshots:
'@js-joda/core@5.7.0': {}
+ '@libsql/client@0.17.4':
+ dependencies:
+ '@libsql/core': 0.17.4
+ '@libsql/hrana-client': 0.10.0
+ js-base64: 3.9.2
+ libsql: 0.5.29
+ promise-limit: 2.7.0
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ '@libsql/core@0.17.4':
+ dependencies:
+ js-base64: 3.9.2
+
+ '@libsql/darwin-arm64@0.5.29':
+ optional: true
+
+ '@libsql/darwin-x64@0.5.29':
+ optional: true
+
+ '@libsql/hrana-client@0.10.0':
+ dependencies:
+ '@libsql/isomorphic-ws': 0.1.5
+ js-base64: 3.9.2
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ '@libsql/isomorphic-ws@0.1.5':
+ dependencies:
+ '@types/ws': 8.18.1
+ ws: 8.21.1
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ '@libsql/linux-arm-gnueabihf@0.5.29':
+ optional: true
+
+ '@libsql/linux-arm-musleabihf@0.5.29':
+ optional: true
+
+ '@libsql/linux-arm64-gnu@0.5.29':
+ optional: true
+
+ '@libsql/linux-arm64-musl@0.5.29':
+ optional: true
+
+ '@libsql/linux-x64-gnu@0.5.29':
+ optional: true
+
+ '@libsql/linux-x64-musl@0.5.29':
+ optional: true
+
+ '@libsql/win32-x64-msvc@0.5.29':
+ optional: true
+
'@manypkg/find-root@1.1.0':
dependencies:
'@babel/runtime': 7.29.7
@@ -10428,6 +10598,8 @@ snapshots:
'@tybys/wasm-util': 0.10.3
optional: true
+ '@neon-rs/load@0.0.4': {}
+
'@next/env@16.2.12': {}
'@next/swc-darwin-arm64@16.2.12':
@@ -11584,7 +11756,6 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
'@types/node': 26.1.2
- optional: true
'@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
@@ -12079,7 +12250,6 @@ snapshots:
better-sqlite3@13.0.2:
dependencies:
node-addon-api: 8.9.1
- optional: true
bidi-js@1.0.3:
dependencies:
@@ -12643,6 +12813,8 @@ snapshots:
detect-indent@6.1.0: {}
+ detect-libc@2.0.2: {}
+
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
@@ -13751,6 +13923,8 @@ snapshots:
joycon@3.1.1: {}
+ js-base64@3.9.2: {}
+
js-md4@0.3.2: {}
js-tokens@10.0.0: {}
@@ -13910,6 +14084,21 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
+ libsql@0.5.29:
+ dependencies:
+ '@neon-rs/load': 0.0.4
+ detect-libc: 2.0.2
+ optionalDependencies:
+ '@libsql/darwin-arm64': 0.5.29
+ '@libsql/darwin-x64': 0.5.29
+ '@libsql/linux-arm-gnueabihf': 0.5.29
+ '@libsql/linux-arm-musleabihf': 0.5.29
+ '@libsql/linux-arm64-gnu': 0.5.29
+ '@libsql/linux-arm64-musl': 0.5.29
+ '@libsql/linux-x64-gnu': 0.5.29
+ '@libsql/linux-x64-musl': 0.5.29
+ '@libsql/win32-x64-msvc': 0.5.29
+
lie@3.3.0:
dependencies:
immediate: 3.0.6
@@ -14849,8 +15038,7 @@ snapshots:
node-addon-api@4.3.0:
optional: true
- node-addon-api@8.9.1:
- optional: true
+ node-addon-api@8.9.1: {}
node-rsa@1.1.1:
dependencies:
@@ -15179,6 +15367,8 @@ snapshots:
process@0.11.10: {}
+ promise-limit@2.7.0: {}
+
property-information@7.2.0: {}
proxy-addr@2.0.7:
@@ -16413,8 +16603,7 @@ snapshots:
wrappy@1.0.2: {}
- ws@8.21.1:
- optional: true
+ ws@8.21.1: {}
wsl-utils@0.1.0:
dependencies:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 261a00b74d..6318a47754 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,6 +1,7 @@
packages:
- packages/*
- packages/apps/*
+ - packages/drivers/*
- packages/plugins/*
- packages/qa/*
- packages/triggers/*
diff --git a/scripts/adr-anchors.json b/scripts/adr-anchors.json
index eb0c33f305..ea475df87b 100644
--- a/scripts/adr-anchors.json
+++ b/scripts/adr-anchors.json
@@ -176,14 +176,14 @@
"invariant": "The uniqueness rules judge SPELLINGS only, never inferred tenancy or posture (authoring-time tenancy inference is impossible — `organization_id` is kernel-injected; the dead end is documented on #4698). `unique/unscoped-declared-index` (D5a) fires on bare declared `unique: true` — warning in 17.x, the protocol-18 gate rejects the spelling (#5082). `unique/double-declaration` (D5b) is the four-quadrant scope matrix: cross-scope = contradiction (the installation-wide side wins physically), same-scope = redundancy. Fix texts speak the `'organization'`/`'global'` vocabulary — never resurrect the hand-written `['organization_id', …]` advice (not NULL-safe, #5030). `unique/legacy-organization-composite` (D5c) is the S6 respelling NUDGE and stays ADVISORY forever: the legacy hand-written composite is valid indefinitely and forces ZERO drift, so this rule must never gain an auto-fix or an `error` severity — opting in is a physical tightening that goes through the D4 duplicate pre-flight."
},
{
- "file": "packages/plugins/driver-sql/src/schema-drift.ts",
+ "file": "packages/drivers/driver-sql/src/schema-drift.ts",
"adrs": [
"ADR-0120"
],
"invariant": "The organization key part of every organization-scoped unique index is the NULL-safe COALESCE form — `COALESCE(, '__global__')` — never the bare column: SQL UNIQUE is NULL-distinct, so the bare composite enforces NOTHING on NULL-organization rows, which on a single-tenant stack is every row (#5030). Declared-index `unique: 'global'`/bare `true` stays VERBATIM (the #3696 contract, now the 'global' arm of the vocabulary); `'organization'` prepends the key part at registration. Expected and physical sides compare through the SAME normalization, literal-agnostic on the COALESCE literal — two spellings of the literal are one constraint, never drift (#4884)."
},
{
- "file": "packages/plugins/driver-sql/src/sql-driver.ts",
+ "file": "packages/drivers/driver-sql/src/sql-driver.ts",
"adrs": [
"ADR-0120"
],
@@ -207,7 +207,7 @@
"invariant": "The ADR-0120 D5e gate runs at the INSTALL seam only, BEFORE hot-register and before any ledger write — a stopped install must leave the runtime exactly as it found it so the installer can respell to `'organization'` and retry without an uninstall. The confirmation is recorded in the install manifest (`InstalledManifestEntry.globalUniqueAttestation`, ADR-0104 attestation style: fact + who + when + under which posture) and CARRIED ACROSS reinstalls, which is the whole '之后不复问' half of the decision — moving it to memory would re-ask on every process boot, i.e. exactly the #4884 boot-time nagging the ADR forbids. Confirmations accumulate; a partial confirmation still stops on the remainder. Rehydrate at `kernel:ready` never evaluates the gate."
},
{
- "file": "packages/plugins/driver-sql/src/adr0120-three-posture-conformance.test.ts",
+ "file": "packages/drivers/driver-sql/src/adr0120-three-posture-conformance.test.ts",
"adrs": [
"ADR-0120",
"ADR-0105"
diff --git a/scripts/check-driver-conformance.mjs b/scripts/check-driver-conformance.mjs
index 5110afec24..7f1350d538 100644
--- a/scripts/check-driver-conformance.mjs
+++ b/scripts/check-driver-conformance.mjs
@@ -23,7 +23,7 @@
//
// ## Scope: the IDataDriver implementers
//
-// `packages/plugins/driver-*` -- discovered from disk, never listed here, so a
+// `packages/drivers/*` -- discovered from disk, never listed here, so a
// new driver package is in scope the moment it exists. Other consumers of the
// same case-sets (`packages/formula`'s `matchesFilter`, service-analytics'
// native-SQL strategy) are DELIBERATELY out of scope: they are not drivers,
@@ -94,7 +94,7 @@ import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
-const DRIVERS_DIR = join(ROOT, 'packages', 'plugins');
+const DRIVERS_DIR = join(ROOT, 'packages', 'drivers');
const CASE_SETS_DIR = join(ROOT, 'packages', 'spec', 'src', 'data');
// ── The case-sets ───────────────────────────────────────────────────────────
@@ -164,7 +164,7 @@ const CASE_SETS = [
// name the marker export. That is deliberate (see "What 'covered' means"), and it
// is why no entry below changed when #5517 made the mongodb suites that need a
// real mongod OPT-IN (`OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1`, gate in
-// `packages/plugins/driver-mongodb/src/test-mongod.ts`): the files still exist
+// `packages/drivers/driver-mongodb/src/test-mongod.ts`): the files still exist
// and still import the markers, so CONSUMED still passes — honestly, but about
// less than it did. Recorded here rather than as a ledger entry because an entry
// for a covered cell fails RECONCILED; this is the only place the fact fits.
@@ -191,7 +191,54 @@ const CASE_SETS = [
// investment is frozen (#5499). Un-freezing it is what should re-run these cells
// in CI; until then, this note is the honest state of the mongo column.
-const LEDGER = [];
+// ## driver-turso arrived from `objectstack-ai/cloud` with three cells open (#4645)
+//
+// The package migrated into `packages/drivers/driver-turso` in Phase A of #4645
+// and entered this matrix the moment it landed on disk -- which is the gate
+// working as documented ("a new driver package is in scope the moment it
+// exists"), not a surprise. Measured on arrival: TEMPORAL_CASES and
+// TEMPORAL_TIME_CASES are genuinely covered, twice over
+// (`turso-temporal-conformance.test.ts` for the local transport,
+// `turso-remote-temporal-conformance.test.ts` for the remote one). The other
+// three had no suite in cloud either.
+//
+// They are DEBT rather than EXEMPT, and the reason is the driver's dual
+// transport. Local/replica mode does inherit SqlDriver's filter compiler and
+// paging -- but remote mode does not go through Knex at all:
+// `src/remote-transport.ts` carries its own `buildWhereSQL` (combinator
+// nesting, operator vocabulary, comparand refusal) and its own ORDER BY /
+// LIMIT / OFFSET assembly. That is an independent Nth backend, which is
+// precisely what #3774 and #4363 wrote the shared case-sets for. "Inherits,
+// therefore fine" is the assumption those suites exist to disprove -- the same
+// sentence driver-sqlite-wasm's cleared entry carried, and it was cleared by a
+// suite, not by the sentence.
+//
+// Clearing these: write the suites (both transports, hermetic -- the remote
+// half over `libsql-sqlite-stub.testkit.ts`), then delete these entries in the
+// same PR. Tracked as #5590.
+const LEDGER = [
+ {
+ driver: 'driver-turso',
+ marker: 'FILTER_LOGIC_CASES',
+ kind: 'DEBT',
+ why: "remote transport compiles its own WHERE (`src/remote-transport.ts` buildWhereSQL) instead of inheriting SqlDriver's, so combinator nesting is an independent implementation with no suite; local/replica inherits but is untested against the shared cases too.",
+ issue: '#5590',
+ },
+ {
+ driver: 'driver-turso',
+ marker: 'PAGINATION_CASES',
+ kind: 'DEBT',
+ why: 'remote transport assembles its own ORDER BY / LIMIT / OFFSET; no suite drives the sorted-partition property against either transport.',
+ issue: '#5590',
+ },
+ {
+ driver: 'driver-turso',
+ marker: 'PAGINATION_UNORDERED_CASES',
+ kind: 'DEBT',
+ why: 'same seam as PAGINATION_CASES, unsorted arm: the remote LIMIT/OFFSET path has no suite pinning that an unsorted paged read is still a partition.',
+ issue: '#5590',
+ },
+];
// ── Discovery ───────────────────────────────────────────────────────────────
@@ -210,7 +257,7 @@ class DeadRootError extends Error {
* Resolve every declared scan root before discovering anything; throw naming the
* ones that are not directories.
*
- * Deliberately no whitelist and no `optional: true` marker. `packages/plugins`,
+ * Deliberately no whitelist and no `optional: true` marker. `packages/drivers`,
* `packages/spec/src/data` and every driver's `src/` are git-tracked directories
* with tracked files in them, so any checkout that can run
* `pnpm check:driver-conformance` has all of them. An optional marker "just in