Skip to content

Commit 63eb057

Browse files
Copilothotlong
andcommitted
feat: add @objectstack/driver-turso extending SqlDriver with Turso/libSQL support
- Refactor SqlDriver: private → protected for extensibility - Fix count() bug: use ?? instead of || for zero-count handling - Implement TursoDriver extending SqlDriver (zero CRUD duplication) - Add multi-tenant router with TTL cache - 53 tests passing (39 driver + 14 multi-tenant) - 78 driver-sql regression tests passing Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> Agent-Logs-Url: https://github.com/objectstack-ai/spec/sessions/f3d58acb-4d15-42db-a9ef-f318b266842f
1 parent 67a1264 commit 63eb057

9 files changed

Lines changed: 1576 additions & 32 deletions

File tree

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

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}
@@ -364,7 +364,7 @@ export class SqlDriver implements IDataDriver {
364364
const result = await builder.count<{ count: number }[]>('* as count');
365365
if (result && result.length > 0) {
366366
const row: any = result[0];
367-
return Number(row.count || row['count(*)']);
367+
return Number(row.count ?? row['count(*)'] ?? 0);
368368
}
369369
return 0;
370370
}
@@ -702,7 +702,7 @@ export class SqlDriver implements IDataDriver {
702702
return this.knex;
703703
}
704704

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

713713
// ── Filter helpers ──────────────────────────────────────────────────────────
714714

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

718718
if (!Array.isArray(filters) && typeof filters === 'object') {
@@ -793,7 +793,7 @@ export class SqlDriver implements IDataDriver {
793793
}
794794
}
795795

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

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

865865
// ── Field mapping ───────────────────────────────────────────────────────────
866866

867-
private mapSortField(field: string): string {
867+
protected mapSortField(field: string): string {
868868
if (field === 'createdAt') return 'created_at';
869869
if (field === 'updatedAt') return 'updated_at';
870870
return field;
871871
}
872872

873-
private mapAggregateFunc(func: string): string {
873+
protected mapAggregateFunc(func: string): string {
874874
switch (func) {
875875
case 'count':
876876
return 'count';
@@ -889,7 +889,7 @@ export class SqlDriver implements IDataDriver {
889889

890890
// ── Window function builder ─────────────────────────────────────────────────
891891

892-
private buildWindowFunction(spec: any): string {
892+
protected buildWindowFunction(spec: any): string {
893893
const func = spec.function.toUpperCase();
894894
let sql = `${func}()`;
895895

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

918918
// ── Column creation helper ──────────────────────────────────────────────────
919919

920-
private createColumn(table: Knex.CreateTableBuilder, name: string, field: any) {
920+
protected createColumn(table: Knex.CreateTableBuilder, name: string, field: any) {
921921
if (field.multiple) {
922922
table.json(name);
923923
return;
@@ -996,7 +996,7 @@ export class SqlDriver implements IDataDriver {
996996

997997
// ── Database helpers ────────────────────────────────────────────────────────
998998

999-
private async ensureDatabaseExists() {
999+
protected async ensureDatabaseExists() {
10001000
if (!this.isPostgres) return;
10011001

10021002
try {
@@ -1010,7 +1010,7 @@ export class SqlDriver implements IDataDriver {
10101010
}
10111011
}
10121012

1013-
private async createDatabase() {
1013+
protected async createDatabase() {
10141014
const config = this.config as any;
10151015
const connection = config.connection;
10161016
let dbName = '';
@@ -1034,13 +1034,13 @@ export class SqlDriver implements IDataDriver {
10341034
}
10351035
}
10361036

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

10411041
// ── SQLite serialisation ────────────────────────────────────────────────────
10421042

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

10461046
const fields = this.jsonFields[object];
@@ -1055,7 +1055,7 @@ export class SqlDriver implements IDataDriver {
10551055
return copy;
10561056
}
10571057

1058-
private formatOutput(object: string, data: any): any {
1058+
protected formatOutput(object: string, data: any): any {
10591059
if (!data) return data;
10601060

10611061
if (this.isSqlite) {
@@ -1087,7 +1087,7 @@ export class SqlDriver implements IDataDriver {
10871087

10881088
// ── Introspection internals ─────────────────────────────────────────────────
10891089

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

@@ -1119,7 +1119,7 @@ export class SqlDriver implements IDataDriver {
11191119
return columns;
11201120
}
11211121

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

11251125
try {
@@ -1205,7 +1205,7 @@ export class SqlDriver implements IDataDriver {
12051205
return foreignKeys;
12061206
}
12071207

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

12111211
try {
@@ -1265,7 +1265,7 @@ export class SqlDriver implements IDataDriver {
12651265
return primaryKeys;
12661266
}
12671267

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

12711271
try {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"name": "@objectstack/driver-turso",
3+
"version": "3.2.9",
4+
"license": "Apache-2.0",
5+
"description": "Turso/libSQL Driver for ObjectStack — Edge-first SQLite with embedded replicas and multi-tenancy",
6+
"keywords": [
7+
"objectstack",
8+
"driver",
9+
"turso",
10+
"libsql",
11+
"sqlite",
12+
"edge",
13+
"serverless",
14+
"embedded-replica",
15+
"multi-tenant"
16+
],
17+
"main": "dist/index.js",
18+
"types": "dist/index.d.ts",
19+
"exports": {
20+
".": {
21+
"types": "./dist/index.d.ts",
22+
"import": "./dist/index.mjs",
23+
"require": "./dist/index.js"
24+
}
25+
},
26+
"scripts": {
27+
"build": "tsup --config ../../../tsup.config.ts",
28+
"dev": "tsc -w",
29+
"test": "vitest run"
30+
},
31+
"dependencies": {
32+
"@objectstack/core": "workspace:*",
33+
"@objectstack/driver-sql": "workspace:*",
34+
"@objectstack/spec": "workspace:*",
35+
"@libsql/client": "^0.17.2",
36+
"nanoid": "^3.3.11"
37+
},
38+
"devDependencies": {
39+
"@types/node": "^25.5.0",
40+
"better-sqlite3": "^11.9.1",
41+
"typescript": "^5.0.0",
42+
"vitest": "^4.1.0"
43+
}
44+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* @objectstack/driver-turso
5+
*
6+
* Turso/libSQL driver for ObjectStack — edge-first, globally distributed
7+
* SQLite with embedded replicas and database-per-tenant multi-tenancy.
8+
*
9+
* Extends `@objectstack/driver-sql` (SqlDriver) and inherits all CRUD,
10+
* schema management, filtering, aggregation, and introspection logic.
11+
* Only Turso-specific features (connection modes, sync, multi-tenant)
12+
* are implemented here — zero duplicated query/schema code.
13+
*
14+
* Supports three connection modes:
15+
* 1. Local (Embedded): `url: 'file:./data/local.db'`
16+
* 2. In-Memory (Testing): `url: ':memory:'`
17+
* 3. Embedded Replica (Hybrid): `url` + `syncUrl`
18+
*
19+
* @example
20+
* ```typescript
21+
* import { TursoDriver } from '@objectstack/driver-turso';
22+
*
23+
* const driver = new TursoDriver({
24+
* url: 'file:./data/app.db',
25+
* });
26+
* await driver.connect();
27+
* ```
28+
*/
29+
30+
import { TursoDriver } from './turso-driver.js';
31+
32+
export { TursoDriver, type TursoDriverConfig } from './turso-driver.js';
33+
34+
export {
35+
createMultiTenantRouter,
36+
type MultiTenantConfig,
37+
type MultiTenantRouter,
38+
} from './multi-tenant.js';
39+
40+
/**
41+
* Factory function to create a TursoDriver instance.
42+
*
43+
* @param config - Turso driver configuration
44+
* @returns A new TursoDriver instance (not yet connected)
45+
*
46+
* @example
47+
* ```typescript
48+
* import { createTursoDriver } from '@objectstack/driver-turso';
49+
*
50+
* // Local file
51+
* const driver = createTursoDriver({ url: 'file:./data/app.db' });
52+
*
53+
* // In-memory (testing)
54+
* const driver = createTursoDriver({ url: ':memory:' });
55+
*
56+
* // Embedded replica
57+
* const driver = createTursoDriver({
58+
* url: 'file:./data/replica.db',
59+
* syncUrl: 'libsql://my-db-orgname.turso.io',
60+
* authToken: process.env.TURSO_AUTH_TOKEN,
61+
* sync: { intervalSeconds: 60, onConnect: true },
62+
* });
63+
*
64+
* await driver.connect();
65+
* ```
66+
*/
67+
export function createTursoDriver(config: import('./turso-driver.js').TursoDriverConfig): TursoDriver {
68+
return new TursoDriver(config);
69+
}
70+
71+
export default {
72+
id: 'com.objectstack.driver.turso',
73+
version: '1.0.0',
74+
75+
onEnable: async (context: any) => {
76+
const { logger, config, drivers } = context;
77+
logger.info('[Turso Driver] Initializing...');
78+
79+
if (drivers) {
80+
const driver = new TursoDriver(config);
81+
drivers.register(driver);
82+
logger.info(`[Turso Driver] Registered driver: ${driver.name}`);
83+
} else {
84+
logger.warn('[Turso Driver] No driver registry found in context.');
85+
}
86+
},
87+
};

0 commit comments

Comments
 (0)