From a526581c91f36f30017c34952563a49d9d04a5b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 20:35:16 +0000 Subject: [PATCH 1/3] feat(drivers): migrate driver-turso back in-repo and collect all IDataDriver packages under packages/drivers/ (#4645) Phase A of the maintainer-ruled two-repo migration (objectstack half). - git mv packages/plugins/driver-{memory,mongodb,sql,sqlite-wasm} -> packages/drivers/* (content unchanged; only repository.directory follows the move) - new packages/drivers/driver-turso, migrated from objectstack-ai/cloud @ bc372e3b, published Apache-2.0 / access: public. multi-tenant routing and vector-poc stay in cloud. - pnpm-workspace glob, check-driver-conformance DRIVERS_DIR, both CI workflows, liveness evidence paths, adr-anchors, docs and source comments follow the move - driver-turso enters the conformance matrix with 3 measured DEBT cells (#5590) - CLI turso refusal message no longer claims the package ships elsewhere (#5602) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WyvqvKMG6asi9aXjKE6xtx --- .changeset/config.json | 1 + .changeset/drivers-dir-and-turso-migration.md | 38 + .claude/skills/pm-dispatch/SKILL.md | 2 +- .github/workflows/ci.yml | 2 +- .github/workflows/lint.yml | 5 +- ARCHITECTURE.md | 7 +- README.md | 9 +- content/docs/data-modeling/drivers.mdx | 11 +- .../docs/deployment/environment-variables.mdx | 2 +- content/docs/deployment/self-hosting.mdx | 2 +- content/docs/getting-started/glossary.mdx | 4 +- content/docs/plugins/packages.mdx | 26 +- content/docs/protocol/objectql/index.mdx | 2 +- .../docs/protocol/objectql/query-syntax.mdx | 2 +- .../0015-external-datasource-federation.md | 6 +- docs/adr/0025-plugin-package-distribution.md | 2 +- docs/adr/0062-external-datasource-runtime.md | 2 +- ...0104-field-runtime-value-shape-contract.md | 2 +- ...le-transactions-and-honest-atomic-batch.md | 5 +- ...abulary-and-null-safe-tenant-uniqueness.md | 2 +- .../external-datasource-federation-impl.md | 4 +- examples/app-crm/README.md | 2 +- .../test/external-datasource.test.ts | 2 +- packages/cli/src/commands/serve.ts | 4 +- packages/cli/src/utils/storage-driver.test.ts | 29 +- packages/cli/src/utils/storage-driver.ts | 36 +- .../driver-memory/CHANGELOG.md | 0 .../driver-memory/LICENSE | 0 .../driver-memory/README.md | 0 .../driver-memory/objectstack.config.ts | 0 .../driver-memory/package.json | 2 +- .../driver-memory/src/filter-refusal.ts | 0 .../driver-memory/src/in-memory-strategy.ts | 0 .../driver-memory/src/index.ts | 0 .../memory-analytics-filter-refusal.test.ts | 0 .../src/memory-analytics.test.ts | 0 .../driver-memory/src/memory-analytics.ts | 0 .../src/memory-datetime-storage.test.ts | 0 ...ry-driver-calendar-day-upper-bound.test.ts | 0 .../src/memory-driver-document-not.test.ts | 0 ...ry-driver-filter-logic-conformance.test.ts | 0 .../driver-memory/src/memory-driver.test.ts | 0 .../driver-memory/src/memory-driver.ts | 0 .../src/memory-empty-field-constraint.test.ts | 0 .../src/memory-filter-ast-vocabulary.test.ts | 0 .../memory-filter-refusal-envelope.test.ts | 0 .../memory-filter-vocabulary-refusal.test.ts | 0 .../src/memory-matcher-not-null-safe.test.ts | 0 .../src/memory-matcher-or-semantics.test.ts | 0 .../driver-memory/src/memory-matcher.ts | 0 .../src/memory-null-comparand-refusal.test.ts | 0 .../src/memory-pagination-conformance.test.ts | 0 .../src/memory-temporal-conformance.test.ts | 0 .../driver-memory/src/memory-temporal.ts | 0 .../src/persistence/file-adapter.ts | 0 .../driver-memory/src/persistence/index.ts | 0 .../src/persistence/local-storage-adapter.ts | 0 .../src/persistence/persistence.test.ts | 2 +- .../driver-memory/tsconfig.json | 0 .../driver-memory/vitest.config.ts | 0 .../driver-mongodb/CHANGELOG.md | 0 .../driver-mongodb/LICENSE | 0 .../driver-mongodb/README.md | 0 .../driver-mongodb/package.json | 2 +- .../driver-mongodb/src/index.ts | 0 .../src/mongodb-aggregation.test.ts | 0 .../driver-mongodb/src/mongodb-aggregation.ts | 0 .../src/mongodb-datetime-storage.test.ts | 0 .../driver-mongodb/src/mongodb-driver.test.ts | 0 .../driver-mongodb/src/mongodb-driver.ts | 0 .../mongodb-filter-boolean-identity.test.ts | 0 .../mongodb-filter-logic-conformance.test.ts | 0 .../mongodb-filter-logic-translation.test.ts | 0 .../driver-mongodb/src/mongodb-filter.test.ts | 0 .../driver-mongodb/src/mongodb-filter.ts | 0 .../src/mongodb-findone-cases.ts | 0 .../src/mongodb-findone-options.test.ts | 0 .../src/mongodb-findone-query.test.ts | 0 .../src/mongodb-memory-server-gate.test.ts | 0 .../mongodb-null-comparand-refusal.test.ts | 0 .../mongodb-pagination-conformance.test.ts | 0 .../driver-mongodb/src/mongodb-schema.ts | 0 .../src/mongodb-temporal-conformance.test.ts | 0 .../driver-mongodb/src/mongodb-temporal.ts | 0 .../src/mongodb-tenancy-guard.test.ts | 0 .../src/mongodb-tenancy-guard.ts | 0 .../src/mongodb-time-storage.test.ts | 0 .../driver-mongodb/src/test-mongod.ts | 0 .../driver-mongodb/tsconfig.json | 0 .../driver-mongodb/vitest.config.ts | 0 .../driver-sql/CHANGELOG.md | 0 .../{plugins => drivers}/driver-sql/LICENSE | 0 .../{plugins => drivers}/driver-sql/README.md | 2 +- .../driver-sql/package.json | 2 +- .../adr0120-three-posture-conformance.test.ts | 0 .../driver-sql/src/index.ts | 0 .../src/legacy-datetime-storage.testkit.ts | 0 .../src/live-dialect-matrix.testkit.ts | 0 .../src/schema-drift.nullability.test.ts | 0 .../driver-sql/src/schema-drift.ts | 0 .../src/sql-driver-advanced.test.ts | 0 ...l-driver-aggregate-datetime-window.test.ts | 0 ...l-driver-aggregate-temporal-output.test.ts | 0 .../src/sql-driver-analytics-datetime.test.ts | 0 .../src/sql-driver-array-fields.test.ts | 0 .../sql-driver-autonumber-persistence.test.ts | 0 .../src/sql-driver-autonumber-tokens.test.ts | 0 .../src/sql-driver-autonumber-tx.test.ts | 0 .../src/sql-driver-autonumber.test.ts | 0 .../src/sql-driver-boolean-identity.test.ts | 0 .../src/sql-driver-bulk-json.test.ts | 0 ...ql-driver-calendar-day-upper-bound.test.ts | 0 .../src/sql-driver-connect-bound.test.ts | 0 .../sql-driver-cross-field-reference.test.ts | 0 .../sql-driver-date-bucket-storage.test.ts | 0 .../src/sql-driver-date-bucket.test.ts | 0 .../sql-driver-date-now-default-live.test.ts | 0 .../src/sql-driver-date-only.test.ts | 0 ...-driver-datetime-canonical-storage.test.ts | 0 ...river-datetime-filter-text-storage.test.ts | 0 .../src/sql-driver-datetime-filter.test.ts | 0 .../sql-driver-datetime-mysql-storage.test.ts | 0 ...-driver-datetime-postgres-timezone.test.ts | 0 .../src/sql-driver-ddl-gate.test.ts | 0 ...iver-deferred-datetime-convergence.test.ts | 0 .../src/sql-driver-deferred-ddl.test.ts | 0 .../sql-driver-empty-field-constraint.test.ts | 0 .../src/sql-driver-external-columnmap.test.ts | 0 .../sql-driver-external-remote-name.test.ts | 0 .../sql-driver-filter-no-silent-drop.test.ts | 0 ...sql-driver-filter-refusal-envelope.test.ts | 0 .../src/sql-driver-index-drift.test.ts | 0 .../src/sql-driver-introspection.test.ts | 0 .../src/sql-driver-like-escape.test.ts | 0 .../src/sql-driver-multiwrite-tx.test.ts | 0 .../src/sql-driver-nested-and-filter.test.ts | 0 .../src/sql-driver-not-null-safe.test.ts | 0 .../src/sql-driver-null-operators.test.ts | 0 .../src/sql-driver-numeric-fidelity.test.ts | 0 .../src/sql-driver-or-filter.test.ts | 0 ...river-out-of-contract-filter-input.test.ts | 0 .../sql-driver-overlay-index-drift.test.ts | 0 .../sql-driver-pagination-conformance.test.ts | 0 .../src/sql-driver-queryast.test.ts | 0 .../src/sql-driver-retention-prune.test.ts | 0 .../src/sql-driver-rotation.test.ts | 0 .../sql-driver-runtime-token-default.test.ts | 0 .../src/sql-driver-schema-drift.test.ts | 0 .../src/sql-driver-schema-sync-stats.test.ts | 0 .../driver-sql/src/sql-driver-schema.test.ts | 0 .../src/sql-driver-server-timing.test.ts | 0 .../sql-driver-sqlite-journal-mode.test.ts | 0 .../src/sql-driver-sqlite-tx-guard.test.ts | 0 .../sql-driver-temporal-conformance.test.ts | 0 .../src/sql-driver-temporal-dialect.test.ts | 0 .../sql-driver-tenant-audit-posture.test.ts | 0 .../src/sql-driver-tenant-scope.test.ts | 0 .../sql-driver-time-canonical-storage.test.ts | 0 .../src/sql-driver-time-live-dialects.test.ts | 0 .../src/sql-driver-time-of-day.test.ts | 0 .../src/sql-driver-timestamp-format.test.ts | 0 .../src/sql-driver-unique-tenancy.test.ts | 0 ...sql-driver-unknown-column-recovery.test.ts | 0 ...river-user-datetime-default-format.test.ts | 0 .../driver-sql/src/sql-driver.test.ts | 0 .../driver-sql/src/sql-driver.ts | 0 .../driver-sql/tsconfig.json | 0 .../driver-sql/vitest.config.ts | 0 .../driver-sqlite-wasm/CHANGELOG.md | 0 .../driver-sqlite-wasm/LICENSE | 0 .../driver-sqlite-wasm/README.md | 0 .../driver-sqlite-wasm/STACKBLITZ.md | 0 .../driver-sqlite-wasm/package.json | 2 +- .../scripts/.restart-proof-id | 0 .../scripts/restart-proof.mjs | 0 .../driver-sqlite-wasm/src/index.ts | 0 .../src/knex-wasm-dialect.ts | 0 .../src/sqlite-wasm-driver-advanced.test.ts | 0 .../src/sqlite-wasm-driver-autonumber.test.ts | 0 ...sm-driver-calendar-day-upper-bound.test.ts | 0 .../sqlite-wasm-driver-date-bucket.test.ts | 0 .../src/sqlite-wasm-driver-durability.test.ts | 0 .../sqlite-wasm-driver-introspection.test.ts | 0 .../sqlite-wasm-driver-journal-mode.test.ts | 0 .../src/sqlite-wasm-driver-queryast.test.ts | 0 ...lite-wasm-driver-returning-persist.test.ts | 0 .../src/sqlite-wasm-driver-schema.test.ts | 0 .../sqlite-wasm-driver-tenant-scope.test.ts | 0 ...te-wasm-driver-transaction-persist.test.ts | 0 .../src/sqlite-wasm-driver.test.ts | 0 .../src/sqlite-wasm-driver.ts | 0 ...sqlite-wasm-empty-field-constraint.test.ts | 0 ...lite-wasm-filter-logic-conformance.test.ts | 0 ...-wasm-out-of-contract-filter-input.test.ts | 0 ...sqlite-wasm-pagination-conformance.test.ts | 0 .../sqlite-wasm-temporal-conformance.test.ts | 0 .../driver-sqlite-wasm/src/wasm-connection.ts | 0 .../driver-sqlite-wasm/tsconfig.json | 0 packages/drivers/driver-turso/CHANGELOG.md | 262 +++ packages/drivers/driver-turso/LICENSE | 202 ++ packages/drivers/driver-turso/README.md | 265 +++ packages/drivers/driver-turso/package.json | 73 + .../src/date-bucket-parity.test.ts | 135 ++ packages/drivers/driver-turso/src/index.ts | 92 + .../src/libsql-sqlite-stub.testkit.ts | 87 + .../src/remote-read-coercion.test.ts | 135 ++ ...remote-transport-bare-date-routing.test.ts | 364 ++++ .../remote-transport-boolean-identity.test.ts | 436 +++++ ...remote-transport-comparand-refusal.test.ts | 220 +++ .../src/remote-transport-not-operator.test.ts | 511 +++++ ...e-transport-null-comparand-refusal.test.ts | 411 ++++ .../src/remote-transport-null-where.test.ts | 59 + .../remote-transport-text-predicates.test.ts | 253 +++ .../remote-transport-top-level-where.test.ts | 372 ++++ .../remote-transport-unknown-select.test.ts | 68 + .../driver-turso/src/remote-transport.ts | 1726 +++++++++++++++++ .../driver-turso/src/spec/turso.test.ts | 270 +++ .../driver-turso/src/spec/turso.zod.ts | 151 ++ .../driver-turso/src/turso-driver.test.ts | 1129 +++++++++++ .../drivers/driver-turso/src/turso-driver.ts | 915 +++++++++ .../turso-remote-temporal-conformance.test.ts | 331 ++++ .../src/turso-temporal-conformance.test.ts | 260 +++ packages/drivers/driver-turso/tsconfig.json | 20 + packages/rest/src/rest-server.ts | 2 +- packages/spec/liveness/field.json | 4 +- packages/spec/liveness/object.json | 8 +- packages/spec/liveness/query.json | 10 +- pnpm-lock.yaml | 507 +++-- pnpm-workspace.yaml | 1 + scripts/adr-anchors.json | 6 +- scripts/check-driver-conformance.mjs | 57 +- 231 files changed, 9312 insertions(+), 251 deletions(-) create mode 100644 .changeset/drivers-dir-and-turso-migration.md rename packages/{plugins => drivers}/driver-memory/CHANGELOG.md (100%) rename packages/{plugins => drivers}/driver-memory/LICENSE (100%) rename packages/{plugins => drivers}/driver-memory/README.md (100%) rename packages/{plugins => drivers}/driver-memory/objectstack.config.ts (100%) rename packages/{plugins => drivers}/driver-memory/package.json (96%) rename packages/{plugins => drivers}/driver-memory/src/filter-refusal.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/in-memory-strategy.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/index.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/memory-analytics-filter-refusal.test.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/memory-analytics.test.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/memory-analytics.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/memory-datetime-storage.test.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/memory-driver-calendar-day-upper-bound.test.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/memory-driver-document-not.test.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/memory-driver-filter-logic-conformance.test.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/memory-driver.test.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/memory-driver.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/memory-empty-field-constraint.test.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/memory-filter-ast-vocabulary.test.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/memory-filter-refusal-envelope.test.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/memory-filter-vocabulary-refusal.test.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/memory-matcher-not-null-safe.test.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/memory-matcher-or-semantics.test.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/memory-matcher.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/memory-null-comparand-refusal.test.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/memory-pagination-conformance.test.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/memory-temporal-conformance.test.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/memory-temporal.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/persistence/file-adapter.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/persistence/index.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/persistence/local-storage-adapter.ts (100%) rename packages/{plugins => drivers}/driver-memory/src/persistence/persistence.test.ts (99%) rename packages/{plugins => drivers}/driver-memory/tsconfig.json (100%) rename packages/{plugins => drivers}/driver-memory/vitest.config.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/CHANGELOG.md (100%) rename packages/{plugins => drivers}/driver-mongodb/LICENSE (100%) rename packages/{plugins => drivers}/driver-mongodb/README.md (100%) rename packages/{plugins => drivers}/driver-mongodb/package.json (96%) rename packages/{plugins => drivers}/driver-mongodb/src/index.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/mongodb-aggregation.test.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/mongodb-aggregation.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/mongodb-datetime-storage.test.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/mongodb-driver.test.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/mongodb-driver.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/mongodb-filter-boolean-identity.test.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/mongodb-filter-logic-conformance.test.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/mongodb-filter-logic-translation.test.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/mongodb-filter.test.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/mongodb-filter.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/mongodb-findone-cases.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/mongodb-findone-options.test.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/mongodb-findone-query.test.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/mongodb-memory-server-gate.test.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/mongodb-null-comparand-refusal.test.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/mongodb-pagination-conformance.test.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/mongodb-schema.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/mongodb-temporal-conformance.test.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/mongodb-temporal.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/mongodb-tenancy-guard.test.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/mongodb-tenancy-guard.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/mongodb-time-storage.test.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/src/test-mongod.ts (100%) rename packages/{plugins => drivers}/driver-mongodb/tsconfig.json (100%) rename packages/{plugins => drivers}/driver-mongodb/vitest.config.ts (100%) rename packages/{plugins => drivers}/driver-sql/CHANGELOG.md (100%) rename packages/{plugins => drivers}/driver-sql/LICENSE (100%) rename packages/{plugins => drivers}/driver-sql/README.md (98%) rename packages/{plugins => drivers}/driver-sql/package.json (97%) rename packages/{plugins => drivers}/driver-sql/src/adr0120-three-posture-conformance.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/index.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/legacy-datetime-storage.testkit.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/live-dialect-matrix.testkit.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/schema-drift.nullability.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/schema-drift.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-advanced.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-aggregate-datetime-window.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-aggregate-temporal-output.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-analytics-datetime.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-array-fields.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-autonumber-persistence.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-autonumber-tokens.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-autonumber-tx.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-autonumber.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-boolean-identity.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-bulk-json.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-calendar-day-upper-bound.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-connect-bound.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-cross-field-reference.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-date-bucket-storage.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-date-bucket.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-date-now-default-live.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-date-only.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-datetime-canonical-storage.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-datetime-filter-text-storage.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-datetime-filter.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-datetime-mysql-storage.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-datetime-postgres-timezone.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-ddl-gate.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-deferred-datetime-convergence.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-deferred-ddl.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-empty-field-constraint.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-external-columnmap.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-external-remote-name.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-filter-no-silent-drop.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-filter-refusal-envelope.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-index-drift.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-introspection.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-like-escape.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-multiwrite-tx.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-nested-and-filter.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-not-null-safe.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-null-operators.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-numeric-fidelity.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-or-filter.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-out-of-contract-filter-input.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-overlay-index-drift.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-pagination-conformance.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-queryast.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-retention-prune.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-rotation.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-runtime-token-default.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-schema-drift.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-schema-sync-stats.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-schema.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-server-timing.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-sqlite-journal-mode.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-sqlite-tx-guard.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-temporal-conformance.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-temporal-dialect.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-tenant-audit-posture.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-tenant-scope.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-time-canonical-storage.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-time-live-dialects.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-time-of-day.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-timestamp-format.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-unique-tenancy.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-unknown-column-recovery.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver-user-datetime-default-format.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver.test.ts (100%) rename packages/{plugins => drivers}/driver-sql/src/sql-driver.ts (100%) rename packages/{plugins => drivers}/driver-sql/tsconfig.json (100%) rename packages/{plugins => drivers}/driver-sql/vitest.config.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/CHANGELOG.md (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/LICENSE (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/README.md (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/STACKBLITZ.md (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/package.json (96%) rename packages/{plugins => drivers}/driver-sqlite-wasm/scripts/.restart-proof-id (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/scripts/restart-proof.mjs (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/src/index.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/src/knex-wasm-dialect.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/src/sqlite-wasm-driver-advanced.test.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/src/sqlite-wasm-driver-autonumber.test.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/src/sqlite-wasm-driver-calendar-day-upper-bound.test.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/src/sqlite-wasm-driver-date-bucket.test.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/src/sqlite-wasm-driver-durability.test.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/src/sqlite-wasm-driver-introspection.test.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/src/sqlite-wasm-driver-journal-mode.test.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/src/sqlite-wasm-driver-queryast.test.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/src/sqlite-wasm-driver-returning-persist.test.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/src/sqlite-wasm-driver-schema.test.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/src/sqlite-wasm-driver-transaction-persist.test.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/src/sqlite-wasm-driver.test.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/src/sqlite-wasm-driver.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/src/sqlite-wasm-empty-field-constraint.test.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/src/sqlite-wasm-filter-logic-conformance.test.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/src/sqlite-wasm-out-of-contract-filter-input.test.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/src/sqlite-wasm-pagination-conformance.test.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/src/sqlite-wasm-temporal-conformance.test.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/src/wasm-connection.ts (100%) rename packages/{plugins => drivers}/driver-sqlite-wasm/tsconfig.json (100%) create mode 100644 packages/drivers/driver-turso/CHANGELOG.md create mode 100644 packages/drivers/driver-turso/LICENSE create mode 100644 packages/drivers/driver-turso/README.md create mode 100644 packages/drivers/driver-turso/package.json create mode 100644 packages/drivers/driver-turso/src/date-bucket-parity.test.ts create mode 100644 packages/drivers/driver-turso/src/index.ts create mode 100644 packages/drivers/driver-turso/src/libsql-sqlite-stub.testkit.ts create mode 100644 packages/drivers/driver-turso/src/remote-read-coercion.test.ts create mode 100644 packages/drivers/driver-turso/src/remote-transport-bare-date-routing.test.ts create mode 100644 packages/drivers/driver-turso/src/remote-transport-boolean-identity.test.ts create mode 100644 packages/drivers/driver-turso/src/remote-transport-comparand-refusal.test.ts create mode 100644 packages/drivers/driver-turso/src/remote-transport-not-operator.test.ts create mode 100644 packages/drivers/driver-turso/src/remote-transport-null-comparand-refusal.test.ts create mode 100644 packages/drivers/driver-turso/src/remote-transport-null-where.test.ts create mode 100644 packages/drivers/driver-turso/src/remote-transport-text-predicates.test.ts create mode 100644 packages/drivers/driver-turso/src/remote-transport-top-level-where.test.ts create mode 100644 packages/drivers/driver-turso/src/remote-transport-unknown-select.test.ts create mode 100644 packages/drivers/driver-turso/src/remote-transport.ts create mode 100644 packages/drivers/driver-turso/src/spec/turso.test.ts create mode 100644 packages/drivers/driver-turso/src/spec/turso.zod.ts create mode 100644 packages/drivers/driver-turso/src/turso-driver.test.ts create mode 100644 packages/drivers/driver-turso/src/turso-driver.ts create mode 100644 packages/drivers/driver-turso/src/turso-remote-temporal-conformance.test.ts create mode 100644 packages/drivers/driver-turso/src/turso-temporal-conformance.test.ts create mode 100644 packages/drivers/driver-turso/tsconfig.json 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 c0fae7d1e0..d303fb2e4c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -600,8 +600,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 ad567d94bd..afe389f37a 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 6bf0e06694..769cd59f11 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..481a0fe3b5 --- /dev/null +++ b/packages/drivers/driver-turso/src/remote-transport-boolean-identity.test.ts @@ -0,0 +1,436 @@ +// 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: `$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 any); + 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 any); + 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', { where: { $or: [] } } as any); + 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 any)) 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', { where: { $or: [{ stage: 'won' }, {}] } } as any)).toBe(3); + expect(await driver.count('deal', { where: { $or: [] } } as any)).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 any)).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..ac2eca60e0 --- /dev/null +++ b/packages/drivers/driver-turso/src/remote-transport-not-operator.test.ts @@ -0,0 +1,511 @@ +// 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 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 any); + 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 any)).rejects.toThrow( + /compiles to NO predicate/, + ); + // #1004: an unknown operator. + await expect( + t.find('deal', { where: { $not: { stage: { $like: 'w%' } } } } as any), + ).rejects.toThrow(/Unsupported filter operator "\$like"/); + // #1058: an unbindable comparand. + await expect( + t.find('deal', { where: { $not: { amount: { $gt: { $field: 'budget' } } } } } as any), + ).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 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: { $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 any); + 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 any)) 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', { where: { $not: { amount: { $between: [15, 35] } } } } as any), + ).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', { where: { $not: { stage: 'won' } } } as any)).toBe(2); + expect(await driver.count('deal', { where: { $not: {} } } as any)).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 any)).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..1a01c4c27a --- /dev/null +++ b/packages/drivers/driver-turso/src/remote-transport-null-comparand-refusal.test.ts @@ -0,0 +1,411 @@ +// 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: `$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 any); + } 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 any); + 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 any)).rejects.toThrow(/requires a boolean comparand/); + await expect(t.findOne('deal', { where } as any)).rejects.toThrow(/requires a boolean comparand/); + await expect(t.count('deal', { where } as any)).rejects.toThrow(/requires a boolean comparand/); + await expect( + t.aggregate('deal', { where, aggregations: [{ function: 'count' }] } as any), + ).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 any)) 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 any) + .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..308f4970b0 --- /dev/null +++ b/packages/drivers/driver-turso/src/remote-transport-top-level-where.test.ts @@ -0,0 +1,372 @@ +// 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: 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 any); + 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 any)).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 any)).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 any)).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 any)).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 any)).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 any)).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 any), + ).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 any)).rejects.toThrow(/not a filter condition/); + await expect(t.findOne('deal', { where } as any)).rejects.toThrow(/not a filter condition/); + await expect(t.count('deal', { where } as any)).rejects.toThrow(/not a filter condition/); + await expect( + t.aggregate('deal', { where, aggregations: [{ function: 'count' }] } as any), + ).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 any)).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 any)).rejects.toThrow( + /\$or\[0\] on 'deal'/, + ); + await expect(t.find('deal', { where: { $not: 'won' } } as any)).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 any), + ).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 any)) 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 any)).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', { where: {} } as any)).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..306d42e49a --- /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', { where: { age: 17 } } as any); + 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', { where: { age: 17 } } as any); + 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..5e970401d0 --- /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', { where: c.filter } as any); + 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) } as any); + 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', { where: c.filter } as any); + 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', { where: c.filter } as any); + 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', { where: c.filter } as any); + 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 69d72b781d..c644613a4e 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/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 From 7f383419857f16967090ab1453b64c20e8a49b2d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 20:58:16 +0000 Subject: [PATCH 2/3] fix(service-analytics): point the incoming applyLike cross-reference at packages/drivers/driver-sql (#4645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `like-pattern.ts` arrived with origin/main (#5567) naming `packages/plugins/driver-sql/src/sql-driver.ts` — the pre-#4645 path. The merge was textually clean because the file is new on main and the move is on this branch, which is the jointly-wrong shape AGENTS.md §10 warns about: nothing conflicts, and the cross-reference the header leans on lands nowhere. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WyvqvKMG6asi9aXjKE6xtx --- packages/services/service-analytics/src/like-pattern.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From 6842674b60fc4436e7df2691f8c7d01e04ae873a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 21:21:03 +0000 Subject: [PATCH 3/3] fix(driver-turso): satisfy the #4918 query-options-erasure ratchet on the migrated tests (#4645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 15 turso test files arrived from cloud, which predates this repo's `query-options/no-any-erasure` rule (#5600, landed on main after the first merge), and added 52 sites — test surface 267 -> 319. Every one is inside `packages/drivers/driver-turso/src/*.test.ts`; nothing outside turso moved. Both dispositions the rule names, chosen per site, with no assertion touched: - 14 sites TYPED. Ordinary on-contract reads whose only defect was the missing required `QueryAST.object` key (`driver.count('users', { where: { age: 17 } })`, the TEMPORAL_CASES/TEMPORAL_TIME_CASES conformance reads, and the `$not`/`$or` spellings the transport is asserted to ANSWER). Declaring `object` is what driver-memory's conformance suite already does, and it puts `where` back under tsc. - 38 sites `as unknown as QueryAST`. Deliberately off-contract input: values flowing through the suites' `unknown`-typed helpers (`compile`, `refusalOf`, `ids`) and inline literals whose whole point is refusal — `where: 42`, `[]`, `''`, a class instance, bare AST arrays, `{ $or: [null] }`, `{ $not: null }`, non-boolean `$null` comparands. These assert the transport REJECTS them, so the type must be bypassed by name rather than erased. Baseline untouched: 267 is met, not raised. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WyvqvKMG6asi9aXjKE6xtx --- .../remote-transport-boolean-identity.test.ts | 15 +++---- .../src/remote-transport-not-operator.test.ts | 23 +++++------ ...e-transport-null-comparand-refusal.test.ts | 17 ++++---- .../remote-transport-top-level-where.test.ts | 39 ++++++++++--------- .../driver-turso/src/turso-driver.test.ts | 4 +- .../src/turso-temporal-conformance.test.ts | 10 ++--- 6 files changed, 56 insertions(+), 52 deletions(-) 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 index 481a0fe3b5..527b1d9576 100644 --- a/packages/drivers/driver-turso/src/remote-transport-boolean-identity.test.ts +++ b/packages/drivers/driver-turso/src/remote-transport-boolean-identity.test.ts @@ -4,6 +4,7 @@ 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 @@ -64,7 +65,7 @@ function transportWithCapturingClient() { /** 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 any); + await t.find('deal', { where } as unknown as QueryAST); return calls[0]; } @@ -175,7 +176,7 @@ describe('RemoteTransport $and/$or identity elements (#1073)', () => { // 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 any); + 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([]); @@ -325,7 +326,7 @@ describe('RemoteTransport $and/$or identity elements (#1073)', () => { 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', { where: { $or: [] } } as any); + 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 @@ -380,7 +381,7 @@ describe('TursoDriver remote — identity elements on real rows (#1073)', () => }); const ids = async (where: unknown) => - ((await driver.find('deal', { where } as any)) as any[]).map((r) => r.id).sort(); + ((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. @@ -404,8 +405,8 @@ describe('TursoDriver remote — identity elements on real rows (#1073)', () => 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', { where: { $or: [{ stage: 'won' }, {}] } } as any)).toBe(3); - expect(await driver.count('deal', { where: { $or: [] } } as any)).toBe(0); + 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 () => { @@ -429,7 +430,7 @@ describe('TursoDriver remote — identity elements on real rows (#1073)', () => }); it('a non-node branch element is refused through the driver too', async () => { - await expect(driver.find('deal', { where: { $or: [null] } } as any)).rejects.toThrow( + 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-not-operator.test.ts b/packages/drivers/driver-turso/src/remote-transport-not-operator.test.ts index ac2eca60e0..86ef90db99 100644 --- a/packages/drivers/driver-turso/src/remote-transport-not-operator.test.ts +++ b/packages/drivers/driver-turso/src/remote-transport-not-operator.test.ts @@ -4,6 +4,7 @@ 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 @@ -72,7 +73,7 @@ function transportWithCapturingClient() { /** 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 any); + await t.find('deal', { where } as unknown as QueryAST); return calls[0]; } @@ -295,19 +296,19 @@ describe('RemoteTransport $not (#1076)', () => { 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 any)).rejects.toThrow( + 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 any), + 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 any), + 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 any)).rejects.toThrow( + await expect(t.find('deal', { where: { $not: { $or: [null] } } } as unknown as QueryAST)).rejects.toThrow( /\$or\[0\] on 'deal'/, ); }); @@ -329,7 +330,7 @@ describe('RemoteTransport $not (#1076)', () => { it('negates on count / deleteMany / updateMany', async () => { const { t, calls } = transportWithCapturingClient(); const where = { $not: { stage: 'won' } }; - await t.count('deal', { where } as any); + 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); @@ -402,7 +403,7 @@ describe('TursoDriver remote — $not on real rows (#1076)', () => { }); const ids = async (where: unknown) => - ((await driver.find('deal', { where } as any)) as any[]).map((r) => r.id).sort(); + ((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 @@ -476,14 +477,14 @@ describe('TursoDriver remote — $not on real rows (#1076)', () => { // Un-lowered, the transport (correctly) refuses `$between` and names a // lowering step that had been skipped for this depth. await expect( - driver.find('deal', { where: { $not: { amount: { $between: [15, 35] } } } } as any), + 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', { where: { $not: { stage: 'won' } } } as any)).toBe(2); - expect(await driver.count('deal', { where: { $not: {} } } as any)).toBe(0); + 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 () => { @@ -504,7 +505,7 @@ describe('TursoDriver remote — $not on real rows (#1076)', () => { }); it('a non-node `$not` operand is refused through the driver too', async () => { - await expect(driver.find('deal', { where: { $not: null } } as any)).rejects.toThrow( + 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 index 1a01c4c27a..2338f4ed6b 100644 --- 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 @@ -4,6 +4,7 @@ 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). @@ -89,7 +90,7 @@ function transportWithCapturingClient() { async function refusalOf(where: unknown): Promise { const { t, calls } = transportWithCapturingClient(); try { - await t.find('deal', { where } as any); + 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([]); @@ -103,7 +104,7 @@ async function refusalOf(where: unknown): Promise { /** 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 any); + await t.find('deal', { where } as unknown as QueryAST); return calls[0]; } @@ -226,11 +227,11 @@ describe('RemoteTransport $null comparand refusal (#1116)', () => { 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 any)).rejects.toThrow(/requires a boolean comparand/); - await expect(t.findOne('deal', { where } as any)).rejects.toThrow(/requires a boolean comparand/); - await expect(t.count('deal', { where } as any)).rejects.toThrow(/requires a boolean comparand/); + 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 any), + t.aggregate('deal', { where, aggregations: [{ function: 'count' }] } as unknown as QueryAST), ).rejects.toThrow(/requires a boolean comparand/); expect(calls).toEqual([]); }); @@ -352,7 +353,7 @@ describe('TursoDriver remote — `$null` on rows (#1116)', () => { }); const ids = async (where: unknown): Promise => - ((await driver.find('deal', { where } as any)) as any[]).map((r) => String(r.id)).sort(); + ((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<{ @@ -373,7 +374,7 @@ describe('TursoDriver remote — `$null` on rows (#1116)', () => { // 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 any) + .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'); 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 index 308f4970b0..34a28e707b 100644 --- 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 @@ -4,6 +4,7 @@ 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, @@ -68,7 +69,7 @@ function transportWithCapturingClient() { /** 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 any); + await t.find('deal', { where } as unknown as QueryAST); return calls[0]; } @@ -90,21 +91,21 @@ describe('RemoteTransport top-level `where` refusal (#1075)', () => { for (const [label, where, message] of MEASURED) { it(`refuses ${label}`, async () => { const { t } = transportWithCapturingClient(); - await expect(t.find('deal', { where } as any)).rejects.toThrow(message); + 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 any)).rejects.toThrow(); + 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 any)).rejects.toThrow( + 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"\]\]/, ); }); @@ -114,14 +115,14 @@ describe('RemoteTransport top-level `where` refusal (#1075)', () => { // `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 any)).rejects.toThrow( + 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 any)).rejects.toThrow( + await expect(t.find('deal', { where: 42 } as unknown as QueryAST)).rejects.toThrow( /Refusing rather than compiling it to NO WHERE clause/, ); }); @@ -143,7 +144,7 @@ describe('RemoteTransport top-level `where` refusal (#1075)', () => { 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 any)).rejects.toThrow( + await expect(t.find('deal', { where } as unknown as QueryAST)).rejects.toThrow( /not a filter condition/, ); expect(calls).toEqual([]); @@ -157,7 +158,7 @@ describe('RemoteTransport top-level `where` refusal (#1075)', () => { // node test mean two different things at two levels. const { t } = transportWithCapturingClient(); await expect( - t.find('deal', { where: new (class Filter { stage = 'won' })() } as any), + t.find('deal', { where: new (class Filter { stage = 'won' })() } as unknown as QueryAST), ).rejects.toThrow(/is an object, not a filter condition/); }); }); @@ -210,11 +211,11 @@ describe('RemoteTransport top-level `where` refusal (#1075)', () => { 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 any)).rejects.toThrow(/not a filter condition/); - await expect(t.findOne('deal', { where } as any)).rejects.toThrow(/not a filter condition/); - await expect(t.count('deal', { where } as any)).rejects.toThrow(/not a filter condition/); + 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 any), + t.aggregate('deal', { where, aggregations: [{ function: 'count' }] } as unknown as QueryAST), ).rejects.toThrow(/not a filter condition/); expect(calls).toEqual([]); }); @@ -259,7 +260,7 @@ describe('RemoteTransport top-level `where` refusal (#1075)', () => { // 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 any)).rejects.toThrow( + await expect(t.find('deal', { where: { stage: ['won'] } } as unknown as QueryAST)).rejects.toThrow( /Filter comparand 'deal\.stage'/, ); }); @@ -268,10 +269,10 @@ describe('RemoteTransport top-level `where` refusal (#1075)', () => { // 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 any)).rejects.toThrow( + 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 any)).rejects.toThrow( + await expect(t.find('deal', { where: { $not: 'won' } } as unknown as QueryAST)).rejects.toThrow( /\$not on 'deal'/, ); }); @@ -279,7 +280,7 @@ describe('RemoteTransport top-level `where` refusal (#1075)', () => { 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 any), + t.find('deal', { where: { $and: [[['stage', '=', 'won']]] } } as unknown as QueryAST), ).rejects.toThrow(/\$and\[0\] on 'deal' is an array/); }); }); @@ -324,11 +325,11 @@ describe('TursoDriver remote — a refused top-level where touches no rows (#107 }>); const ids = async (where: unknown) => - ((await driver.find('deal', { where } as any)) as any[]).map((r) => r.id).sort(); + ((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 any)).rejects.toThrow( + await expect(driver.find('deal', { where: [['stage', '=', 'won']] } as unknown as QueryAST)).rejects.toThrow( /not a filter condition/, ); }); @@ -362,7 +363,7 @@ describe('TursoDriver remote — a refused top-level where touches no rows (#107 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', { where: {} } as any)).toBe(3); + expect(await driver.count('deal', { object: 'deal', where: {} })).toBe(3); }); it('a well-formed filter still narrows to its rows', async () => { diff --git a/packages/drivers/driver-turso/src/turso-driver.test.ts b/packages/drivers/driver-turso/src/turso-driver.test.ts index 306d42e49a..a4fca76913 100644 --- a/packages/drivers/driver-turso/src/turso-driver.test.ts +++ b/packages/drivers/driver-turso/src/turso-driver.test.ts @@ -151,7 +151,7 @@ describe('TursoDriver (SQLite Integration)', () => { }); it('should count records', async () => { - const count = await driver.count('users', { where: { age: 17 } } as any); + const count = await driver.count('users', { object: 'users', where: { age: 17 } }); expect(count).toBe(2); }); @@ -752,7 +752,7 @@ describe('TursoDriver Remote Mode (via @libsql/client)', () => { }); it('should count records with filter', async () => { - const count = await driver.count('users', { where: { age: 17 } } as any); + const count = await driver.count('users', { object: 'users', where: { age: 17 } }); expect(count).toBe(2); }); diff --git a/packages/drivers/driver-turso/src/turso-temporal-conformance.test.ts b/packages/drivers/driver-turso/src/turso-temporal-conformance.test.ts index 5e970401d0..e6e5a9d904 100644 --- a/packages/drivers/driver-turso/src/turso-temporal-conformance.test.ts +++ b/packages/drivers/driver-turso/src/turso-temporal-conformance.test.ts @@ -122,14 +122,14 @@ describe('TursoDriver — temporal conformance (local mode)', () => { for (const c of TEMPORAL_CASES) { it(c.name, async () => { - const rows = await driver.find('conformance', { where: c.filter } as any); + 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', { where: resolveTokens(c.tokenFilter) } as any); + 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()); }); @@ -164,7 +164,7 @@ describe('TursoDriver — Field.time conformance (local mode)', () => { for (const c of TEMPORAL_TIME_CASES) { it(c.name, async () => { - const rows = await driver.find('time_conformance', { where: c.filter } as any); + 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()); }); @@ -209,7 +209,7 @@ describe('TursoDriver — temporal conformance on un-backfilled legacy storage', // 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', { where: c.filter } as any); + 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()); }); @@ -252,7 +252,7 @@ describe('TursoDriver — Field.time conformance on un-backfilled legacy storage for (const c of TEMPORAL_TIME_CASES) { it(c.name, async () => { - const rows = await driver.find('time_conformance', { where: c.filter } as any); + 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()); });