Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **`@objectstack/driver-turso` plugin** — Migrated and standardized the Turso/libSQL driver from
`@objectql/driver-turso` into `packages/plugins/driver-turso/`. The driver **extends** `SqlDriver`
from `@objectstack/driver-sql` — all CRUD, schema, filter, aggregation, and introspection logic
is inherited with zero code duplication. Turso-specific features include: three connection modes
(local file, in-memory, embedded replica), `@libsql/client` sync mechanism for embedded replicas,
multi-tenant router with TTL-based driver caching, and enhanced capability flags (FTS5, JSON1,
CTE, savepoints, indexes). Includes 53 unit tests. Factory function `createTursoDriver()` and
plugin manifest for kernel integration.
- **Multi-tenant routing** (`createMultiTenantRouter`) — Database-per-tenant architecture with
automatic driver lifecycle management, tenant ID validation, configurable TTL cache, and
`onTenantCreate`/`onTenantEvict` lifecycle callbacks. Serverless-safe (no global intervals).

### Changed
- **`@objectstack/driver-sql` — Protected extensibility** — Changed `private` to `protected` for
all internal properties and methods (`knex`, `config`, `jsonFields`, `booleanFields`,
`tablesWithTimestamps`, `isSqlite`, `isPostgres`, `isMysql`, `getBuilder`, `applyFilters`,
`applyFilterCondition`, `mapSortField`, `mapAggregateFunc`, `buildWindowFunction`,
`createColumn`, `ensureDatabaseExists`, `createDatabase`, `isJsonField`, `formatInput`,
`formatOutput`, `introspectColumns`, `introspectForeignKeys`, `introspectPrimaryKeys`,
`introspectUniqueConstraints`). Enables clean subclassing for driver variants (Turso, D1, etc.)
without code duplication.

### Fixed
- **`@objectstack/driver-sql` — `count()` returns NaN for zero results** — Fixed `count()` method
using `||` (logical OR) instead of `??` (nullish coalescing) to read the count value. When the
actual count was `0`, `row.count || row['count(*)']` evaluated to `Number(undefined)` = `NaN`
because `0` is falsy. Now uses `row.count ?? row['count(*)'] ?? 0` for correct zero handling.

### Changed
- **Unified Data Driver Contract (`IDataDriver`)** — Resolved the split between `DriverInterface`
(core, minimal ~13 methods) and `IDataDriver` (spec, comprehensive 28 methods). `IDataDriver`
Expand Down
2 changes: 2 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ the ecosystem for enterprise workloads.
| Microkernel (ObjectKernel / LiteKernel) | ✅ | `@objectstack/core` |
| Data Engine (ObjectQL) | ✅ | `@objectstack/objectql` |
| In-Memory Driver | ✅ | `@objectstack/driver-memory` |
| SQL Driver (PostgreSQL, MySQL, SQLite) | ✅ | `@objectstack/driver-sql` |
| Turso/libSQL Driver (Edge SQLite) | ✅ | `@objectstack/driver-turso` |
| Metadata Service | ✅ | `@objectstack/metadata` |
| REST API Server | ✅ | `@objectstack/rest` |
| Client SDK (TypeScript) | ✅ | `@objectstack/client` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ describe('SqlDriver (QueryAST Format)', () => {
const results = await driver.find('products', {
fields: ['name'],
limit: 2,
orderBy: [['price', 'asc']],
orderBy: [{ field: 'price', order: 'asc' }],
} as any);

expect(results.length).toBe(2);
Expand Down
60 changes: 29 additions & 31 deletions packages/plugins/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ export type SqlDriverConfig = Knex.Config;
*/
export class SqlDriver implements IDataDriver {
// IDataDriver metadata
public readonly name = 'com.objectstack.driver.sql';
public readonly version = '1.0.0';
public readonly name: string = 'com.objectstack.driver.sql';
public readonly version: string = '1.0.0';
public readonly supports = {
// Basic CRUD Operations
create: true,
Expand Down Expand Up @@ -113,26 +113,26 @@ export class SqlDriver implements IDataDriver {
queryCache: false,
};

private knex: Knex;
private config: Knex.Config;
private jsonFields: Record<string, string[]> = {};
private booleanFields: Record<string, string[]> = {};
private tablesWithTimestamps: Set<string> = new Set();
protected knex: Knex;
protected config: Knex.Config;
protected jsonFields: Record<string, string[]> = {};
protected booleanFields: Record<string, string[]> = {};
protected tablesWithTimestamps: Set<string> = new Set();

/** Whether the underlying database is a SQLite variant (sqlite3 or better-sqlite3). */
private get isSqlite(): boolean {
protected get isSqlite(): boolean {
const c = (this.config as any).client;
return c === 'sqlite3' || c === 'better-sqlite3';
}

/** Whether the underlying database is PostgreSQL. */
private get isPostgres(): boolean {
protected get isPostgres(): boolean {
const c = (this.config as any).client;
return c === 'pg' || c === 'postgresql';
}

/** Whether the underlying database is MySQL. */
private get isMysql(): boolean {
protected get isMysql(): boolean {
const c = (this.config as any).client;
return c === 'mysql' || c === 'mysql2';
}
Expand Down Expand Up @@ -185,10 +185,8 @@ export class SqlDriver implements IDataDriver {
// ORDER BY
if (query.orderBy && Array.isArray(query.orderBy)) {
for (const item of query.orderBy) {
const field = item.field || item[0];
const dir = item.order || item[1] || 'asc';
if (field) {
builder.orderBy(this.mapSortField(field), dir);
if (item.field) {
builder.orderBy(this.mapSortField(item.field), item.order || 'asc');
}
}
}
Expand Down Expand Up @@ -364,7 +362,7 @@ export class SqlDriver implements IDataDriver {
const result = await builder.count<{ count: number }[]>('* as count');
if (result && result.length > 0) {
const row: any = result[0];
return Number(row.count || row['count(*)']);
return Number(row.count ?? row['count(*)'] ?? 0);
}
return 0;
}
Expand Down Expand Up @@ -702,7 +700,7 @@ export class SqlDriver implements IDataDriver {
return this.knex;
}

private getBuilder(object: string, options?: DriverOptions) {
protected getBuilder(object: string, options?: DriverOptions) {
let builder = this.knex(object);
if (options?.transaction) {
builder = builder.transacting(options.transaction as Knex.Transaction);
Expand All @@ -712,7 +710,7 @@ export class SqlDriver implements IDataDriver {

// ── Filter helpers ──────────────────────────────────────────────────────────

private applyFilters(builder: Knex.QueryBuilder, filters: any) {
protected applyFilters(builder: Knex.QueryBuilder, filters: any) {
if (!filters) return;

if (!Array.isArray(filters) && typeof filters === 'object') {
Expand Down Expand Up @@ -793,7 +791,7 @@ export class SqlDriver implements IDataDriver {
}
}

private applyFilterCondition(builder: Knex.QueryBuilder, condition: any, logicalOp: 'and' | 'or' = 'and') {
protected applyFilterCondition(builder: Knex.QueryBuilder, condition: any, logicalOp: 'and' | 'or' = 'and') {
if (!condition || typeof condition !== 'object') return;

for (const [key, value] of Object.entries(condition)) {
Expand Down Expand Up @@ -864,13 +862,13 @@ export class SqlDriver implements IDataDriver {

// ── Field mapping ───────────────────────────────────────────────────────────

private mapSortField(field: string): string {
protected mapSortField(field: string): string {
if (field === 'createdAt') return 'created_at';
if (field === 'updatedAt') return 'updated_at';
return field;
}

private mapAggregateFunc(func: string): string {
protected mapAggregateFunc(func: string): string {
switch (func) {
case 'count':
return 'count';
Expand All @@ -889,7 +887,7 @@ export class SqlDriver implements IDataDriver {

// ── Window function builder ─────────────────────────────────────────────────

private buildWindowFunction(spec: any): string {
protected buildWindowFunction(spec: any): string {
const func = spec.function.toUpperCase();
let sql = `${func}()`;

Expand Down Expand Up @@ -917,7 +915,7 @@ export class SqlDriver implements IDataDriver {

// ── Column creation helper ──────────────────────────────────────────────────

private createColumn(table: Knex.CreateTableBuilder, name: string, field: any) {
protected createColumn(table: Knex.CreateTableBuilder, name: string, field: any) {
if (field.multiple) {
table.json(name);
return;
Expand Down Expand Up @@ -996,7 +994,7 @@ export class SqlDriver implements IDataDriver {

// ── Database helpers ────────────────────────────────────────────────────────

private async ensureDatabaseExists() {
protected async ensureDatabaseExists() {
if (!this.isPostgres) return;

try {
Expand All @@ -1010,7 +1008,7 @@ export class SqlDriver implements IDataDriver {
}
}

private async createDatabase() {
protected async createDatabase() {
const config = this.config as any;
const connection = config.connection;
let dbName = '';
Expand All @@ -1034,13 +1032,13 @@ export class SqlDriver implements IDataDriver {
}
}

private isJsonField(type: string, field: any): boolean {
protected isJsonField(type: string, field: any): boolean {
return ['json', 'object', 'array', 'image', 'file', 'avatar', 'location'].includes(type) || field.multiple;
}

// ── SQLite serialisation ────────────────────────────────────────────────────

private formatInput(object: string, data: any): any {
protected formatInput(object: string, data: any): any {
if (!this.isSqlite) return data;

const fields = this.jsonFields[object];
Expand All @@ -1055,7 +1053,7 @@ export class SqlDriver implements IDataDriver {
return copy;
}

private formatOutput(object: string, data: any): any {
protected formatOutput(object: string, data: any): any {
if (!data) return data;

if (this.isSqlite) {
Expand Down Expand Up @@ -1087,7 +1085,7 @@ export class SqlDriver implements IDataDriver {

// ── Introspection internals ─────────────────────────────────────────────────

private async introspectColumns(tableName: string): Promise<IntrospectedColumn[]> {
protected async introspectColumns(tableName: string): Promise<IntrospectedColumn[]> {
const columnInfo = await this.knex(tableName).columnInfo();
const columns: IntrospectedColumn[] = [];

Expand Down Expand Up @@ -1119,7 +1117,7 @@ export class SqlDriver implements IDataDriver {
return columns;
}

private async introspectForeignKeys(tableName: string): Promise<IntrospectedForeignKey[]> {
protected async introspectForeignKeys(tableName: string): Promise<IntrospectedForeignKey[]> {
const foreignKeys: IntrospectedForeignKey[] = [];

try {
Expand Down Expand Up @@ -1205,7 +1203,7 @@ export class SqlDriver implements IDataDriver {
return foreignKeys;
}

private async introspectPrimaryKeys(tableName: string): Promise<string[]> {
protected async introspectPrimaryKeys(tableName: string): Promise<string[]> {
const primaryKeys: string[] = [];

try {
Expand Down Expand Up @@ -1265,7 +1263,7 @@ export class SqlDriver implements IDataDriver {
return primaryKeys;
}

private async introspectUniqueConstraints(tableName: string): Promise<string[]> {
protected async introspectUniqueConstraints(tableName: string): Promise<string[]> {
const uniqueColumns: string[] = [];

try {
Expand Down
Loading
Loading