Skip to content

Commit e84befe

Browse files
authored
Merge pull request #963 from objectstack-ai/copilot/standardize-migration-architecture-upgrade
2 parents e001d61 + 670d565 commit e84befe

File tree

13 files changed

+1908
-39
lines changed

13 files changed

+1908
-39
lines changed

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **`@objectstack/driver-turso` plugin** — Migrated and standardized the Turso/libSQL driver from
12+
`@objectql/driver-turso` into `packages/plugins/driver-turso/`. The driver **extends** `SqlDriver`
13+
from `@objectstack/driver-sql` — all CRUD, schema, filter, aggregation, and introspection logic
14+
is inherited with zero code duplication. Turso-specific features include: three connection modes
15+
(local file, in-memory, embedded replica), `@libsql/client` sync mechanism for embedded replicas,
16+
multi-tenant router with TTL-based driver caching, and enhanced capability flags (FTS5, JSON1,
17+
CTE, savepoints, indexes). Includes 53 unit tests. Factory function `createTursoDriver()` and
18+
plugin manifest for kernel integration.
19+
- **Multi-tenant routing** (`createMultiTenantRouter`) — Database-per-tenant architecture with
20+
automatic driver lifecycle management, tenant ID validation, configurable TTL cache, and
21+
`onTenantCreate`/`onTenantEvict` lifecycle callbacks. Serverless-safe (no global intervals).
22+
23+
### Changed
24+
- **`@objectstack/driver-sql` — Protected extensibility** — Changed `private` to `protected` for
25+
all internal properties and methods (`knex`, `config`, `jsonFields`, `booleanFields`,
26+
`tablesWithTimestamps`, `isSqlite`, `isPostgres`, `isMysql`, `getBuilder`, `applyFilters`,
27+
`applyFilterCondition`, `mapSortField`, `mapAggregateFunc`, `buildWindowFunction`,
28+
`createColumn`, `ensureDatabaseExists`, `createDatabase`, `isJsonField`, `formatInput`,
29+
`formatOutput`, `introspectColumns`, `introspectForeignKeys`, `introspectPrimaryKeys`,
30+
`introspectUniqueConstraints`). Enables clean subclassing for driver variants (Turso, D1, etc.)
31+
without code duplication.
32+
33+
### Fixed
34+
- **`@objectstack/driver-sql``count()` returns NaN for zero results** — Fixed `count()` method
35+
using `||` (logical OR) instead of `??` (nullish coalescing) to read the count value. When the
36+
actual count was `0`, `row.count || row['count(*)']` evaluated to `Number(undefined)` = `NaN`
37+
because `0` is falsy. Now uses `row.count ?? row['count(*)'] ?? 0` for correct zero handling.
38+
1039
### Changed
1140
- **Unified Data Driver Contract (`IDataDriver`)** — Resolved the split between `DriverInterface`
1241
(core, minimal ~13 methods) and `IDataDriver` (spec, comprehensive 28 methods). `IDataDriver`

ROADMAP.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ the ecosystem for enterprise workloads.
4444
| Microkernel (ObjectKernel / LiteKernel) || `@objectstack/core` |
4545
| Data Engine (ObjectQL) || `@objectstack/objectql` |
4646
| In-Memory Driver || `@objectstack/driver-memory` |
47+
| SQL Driver (PostgreSQL, MySQL, SQLite) || `@objectstack/driver-sql` |
48+
| Turso/libSQL Driver (Edge SQLite) || `@objectstack/driver-turso` |
4749
| Metadata Service || `@objectstack/metadata` |
4850
| REST API Server || `@objectstack/rest` |
4951
| Client SDK (TypeScript) || `@objectstack/client` |

packages/plugins/driver-sql/src/sql-driver-queryast.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ describe('SqlDriver (QueryAST Format)', () => {
144144
const results = await driver.find('products', {
145145
fields: ['name'],
146146
limit: 2,
147-
orderBy: [['price', 'asc']],
147+
orderBy: [{ field: 'price', order: 'asc' }],
148148
} as any);
149149

150150
expect(results.length).toBe(2);

packages/plugins/driver-sql/src/sql-driver.ts

Lines changed: 29 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ export type SqlDriverConfig = Knex.Config;
6565
*/
6666
export class SqlDriver implements IDataDriver {
6767
// IDataDriver metadata
68-
public readonly name = 'com.objectstack.driver.sql';
69-
public readonly version = '1.0.0';
68+
public readonly name: string = 'com.objectstack.driver.sql';
69+
public readonly version: string = '1.0.0';
7070
public readonly supports = {
7171
// Basic CRUD Operations
7272
create: true,
@@ -113,26 +113,26 @@ export class SqlDriver implements IDataDriver {
113113
queryCache: false,
114114
};
115115

116-
private knex: Knex;
117-
private config: Knex.Config;
118-
private jsonFields: Record<string, string[]> = {};
119-
private booleanFields: Record<string, string[]> = {};
120-
private tablesWithTimestamps: Set<string> = new Set();
116+
protected knex: Knex;
117+
protected config: Knex.Config;
118+
protected jsonFields: Record<string, string[]> = {};
119+
protected booleanFields: Record<string, string[]> = {};
120+
protected tablesWithTimestamps: Set<string> = new Set();
121121

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

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

134134
/** Whether the underlying database is MySQL. */
135-
private get isMysql(): boolean {
135+
protected get isMysql(): boolean {
136136
const c = (this.config as any).client;
137137
return c === 'mysql' || c === 'mysql2';
138138
}
@@ -185,10 +185,8 @@ export class SqlDriver implements IDataDriver {
185185
// ORDER BY
186186
if (query.orderBy && Array.isArray(query.orderBy)) {
187187
for (const item of query.orderBy) {
188-
const field = item.field || item[0];
189-
const dir = item.order || item[1] || 'asc';
190-
if (field) {
191-
builder.orderBy(this.mapSortField(field), dir);
188+
if (item.field) {
189+
builder.orderBy(this.mapSortField(item.field), item.order || 'asc');
192190
}
193191
}
194192
}
@@ -364,7 +362,7 @@ export class SqlDriver implements IDataDriver {
364362
const result = await builder.count<{ count: number }[]>('* as count');
365363
if (result && result.length > 0) {
366364
const row: any = result[0];
367-
return Number(row.count || row['count(*)']);
365+
return Number(row.count ?? row['count(*)'] ?? 0);
368366
}
369367
return 0;
370368
}
@@ -702,7 +700,7 @@ export class SqlDriver implements IDataDriver {
702700
return this.knex;
703701
}
704702

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

713711
// ── Filter helpers ──────────────────────────────────────────────────────────
714712

715-
private applyFilters(builder: Knex.QueryBuilder, filters: any) {
713+
protected applyFilters(builder: Knex.QueryBuilder, filters: any) {
716714
if (!filters) return;
717715

718716
if (!Array.isArray(filters) && typeof filters === 'object') {
@@ -793,7 +791,7 @@ export class SqlDriver implements IDataDriver {
793791
}
794792
}
795793

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

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

865863
// ── Field mapping ───────────────────────────────────────────────────────────
866864

867-
private mapSortField(field: string): string {
865+
protected mapSortField(field: string): string {
868866
if (field === 'createdAt') return 'created_at';
869867
if (field === 'updatedAt') return 'updated_at';
870868
return field;
871869
}
872870

873-
private mapAggregateFunc(func: string): string {
871+
protected mapAggregateFunc(func: string): string {
874872
switch (func) {
875873
case 'count':
876874
return 'count';
@@ -889,7 +887,7 @@ export class SqlDriver implements IDataDriver {
889887

890888
// ── Window function builder ─────────────────────────────────────────────────
891889

892-
private buildWindowFunction(spec: any): string {
890+
protected buildWindowFunction(spec: any): string {
893891
const func = spec.function.toUpperCase();
894892
let sql = `${func}()`;
895893

@@ -917,7 +915,7 @@ export class SqlDriver implements IDataDriver {
917915

918916
// ── Column creation helper ──────────────────────────────────────────────────
919917

920-
private createColumn(table: Knex.CreateTableBuilder, name: string, field: any) {
918+
protected createColumn(table: Knex.CreateTableBuilder, name: string, field: any) {
921919
if (field.multiple) {
922920
table.json(name);
923921
return;
@@ -996,7 +994,7 @@ export class SqlDriver implements IDataDriver {
996994

997995
// ── Database helpers ────────────────────────────────────────────────────────
998996

999-
private async ensureDatabaseExists() {
997+
protected async ensureDatabaseExists() {
1000998
if (!this.isPostgres) return;
1001999

10021000
try {
@@ -1010,7 +1008,7 @@ export class SqlDriver implements IDataDriver {
10101008
}
10111009
}
10121010

1013-
private async createDatabase() {
1011+
protected async createDatabase() {
10141012
const config = this.config as any;
10151013
const connection = config.connection;
10161014
let dbName = '';
@@ -1034,13 +1032,13 @@ export class SqlDriver implements IDataDriver {
10341032
}
10351033
}
10361034

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

10411039
// ── SQLite serialisation ────────────────────────────────────────────────────
10421040

1043-
private formatInput(object: string, data: any): any {
1041+
protected formatInput(object: string, data: any): any {
10441042
if (!this.isSqlite) return data;
10451043

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

1058-
private formatOutput(object: string, data: any): any {
1056+
protected formatOutput(object: string, data: any): any {
10591057
if (!data) return data;
10601058

10611059
if (this.isSqlite) {
@@ -1087,7 +1085,7 @@ export class SqlDriver implements IDataDriver {
10871085

10881086
// ── Introspection internals ─────────────────────────────────────────────────
10891087

1090-
private async introspectColumns(tableName: string): Promise<IntrospectedColumn[]> {
1088+
protected async introspectColumns(tableName: string): Promise<IntrospectedColumn[]> {
10911089
const columnInfo = await this.knex(tableName).columnInfo();
10921090
const columns: IntrospectedColumn[] = [];
10931091

@@ -1119,7 +1117,7 @@ export class SqlDriver implements IDataDriver {
11191117
return columns;
11201118
}
11211119

1122-
private async introspectForeignKeys(tableName: string): Promise<IntrospectedForeignKey[]> {
1120+
protected async introspectForeignKeys(tableName: string): Promise<IntrospectedForeignKey[]> {
11231121
const foreignKeys: IntrospectedForeignKey[] = [];
11241122

11251123
try {
@@ -1205,7 +1203,7 @@ export class SqlDriver implements IDataDriver {
12051203
return foreignKeys;
12061204
}
12071205

1208-
private async introspectPrimaryKeys(tableName: string): Promise<string[]> {
1206+
protected async introspectPrimaryKeys(tableName: string): Promise<string[]> {
12091207
const primaryKeys: string[] = [];
12101208

12111209
try {
@@ -1265,7 +1263,7 @@ export class SqlDriver implements IDataDriver {
12651263
return primaryKeys;
12661264
}
12671265

1268-
private async introspectUniqueConstraints(tableName: string): Promise<string[]> {
1266+
protected async introspectUniqueConstraints(tableName: string): Promise<string[]> {
12691267
const uniqueColumns: string[] = [];
12701268

12711269
try {

0 commit comments

Comments
 (0)