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
6 changes: 6 additions & 0 deletions packages/objectql/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# @objectstack/objectql

## 4.0.3

### Patch Changes

- fix: ObjectQL.init() now tracks and warns about failed driver connections instead of silently swallowing errors, improving debuggability for cold-start and serverless issues.

Comment on lines +3 to +8
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces a ## 4.0.3 changelog entry, but packages/objectql/package.json still reports version 4.0.2. Since this repo is configured for Changesets and other packages keep changelog/version in sync, consider either bumping the package version(s) as part of the release (fixed group) or switching to a Changeset file instead of manually editing the changelog.

Suggested change
## 4.0.3
### Patch Changes
- fix: ObjectQL.init() now tracks and warns about failed driver connections instead of silently swallowing errors, improving debuggability for cold-start and serverless issues.

Copilot uses AI. Check for mistakes.
## 4.0.2

### Patch Changes
Expand Down
10 changes: 10 additions & 0 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,14 +594,24 @@ export class ObjectQL implements IDataEngine {
drivers: Array.from(this.drivers.keys())
});

const failedDrivers: string[] = [];
for (const [name, driver] of this.drivers) {
try {
await driver.connect();
this.logger.info('Driver connected successfully', { driverName: name });
} catch (e) {
failedDrivers.push(name);
this.logger.error('Failed to connect driver', e as Error, { driverName: name });
}
}

if (failedDrivers.length > 0) {
this.logger.warn(
`${failedDrivers.length} of ${this.drivers.size} driver(s) failed initial connect. ` +
`Operations may recover via lazy reconnection or fail at query time.`,
Comment on lines +609 to +611
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ObjectQL.init() now warns when one or more drivers fail initial connect(). Since packages/objectql/src/engine.test.ts already covers init behavior, consider adding a test case where driver.connect() rejects to assert: (1) init still completes without throwing, and (2) a warning is emitted with the failed driver names. This will prevent regressions in the new debuggability behavior.

Suggested change
this.logger.warn(
`${failedDrivers.length} of ${this.drivers.size} driver(s) failed initial connect. ` +
`Operations may recover via lazy reconnection or fail at query time.`,
const failedDriverList = failedDrivers.join(', ');
this.logger.warn(
`${failedDrivers.length} of ${this.drivers.size} driver(s) failed initial connect ` +
`(${failedDriverList}). Operations may recover via lazy reconnection or fail at query time.`,

Copilot uses AI. Check for mistakes.
{ failedDrivers }
);
}

this.logger.info('ObjectQL engine initialization complete');
}
Expand Down
6 changes: 6 additions & 0 deletions packages/plugins/driver-turso/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# @objectstack/driver-turso

## 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.

Comment on lines +3 to +8
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds a ## 4.0.3 changelog entry, but package.json for this package is still 4.0.2. Given the repo uses Changesets (.changeset/config.json) and other packages keep changelog/package.json versions aligned, either bump the package version(s) to 4.0.3 as part of the fixed release group or replace this manual changelog edit with an appropriate .changeset/*.md entry.

Suggested change
## 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.

Copilot uses AI. Check for mistakes.
## 4.0.2

### Patch Changes
Expand Down
95 changes: 74 additions & 21 deletions packages/plugins/driver-turso/src/remote-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,40 @@ const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
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<Client>) | null = null;

/**
* Tracks whether a lazy-connect attempt is already in progress to prevent
* concurrent reconnection storms under high concurrency.
*/
private connectPromise: Promise<Client> | null = null;

/**
* Set the @libsql/client instance used for all queries.
*/
setClient(client: Client): void {
this.client = client;
}
Comment on lines 61 to 66
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RemoteTransport.client is explicitly Client | null, but setClient only accepts Client. This forces callers/tests to use null as any to simulate a lost client, and makes “client cleared” a first-class scenario without a typed API. Consider changing the signature to setClient(client: Client | null) (or adding a clearClient() method) so self-healing flows don’t require unsafe casts.

Copilot uses AI. Check for mistakes.

/**
* 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<Client>): void {
this.connectFactory = factory;
Comment on lines +73 to +76
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setConnectFactory() updates the factory but leaves any existing connectPromise intact. If the factory is overridden while a lazy-connect is in flight (or after a failed/stuck attempt), subsequent callers can still await the old promise and end up with a client created by the previous factory. Consider resetting connectPromise (and/or guarding against factory changes during an in-flight connect) when setting a new factory.

Suggested change
* becomes unavailable (e.g., serverless cold-start, transient error).
*/
setConnectFactory(factory: () => Promise<Client>): void {
this.connectFactory = factory;
* becomes unavailable (e.g., serverless cold-start, transient error).
*
* Replacing the factory invalidates any cached lazy-connect attempt so
* subsequent callers cannot continue awaiting a promise created by the
* previous factory.
*/
setConnectFactory(factory: () => Promise<Client>): void {
this.connectFactory = factory;
this.connectPromise = null;

Copilot uses AI. Check for mistakes.
}

/**
* Get the current @libsql/client instance.
*/
Expand All @@ -71,9 +98,9 @@ export class RemoteTransport {
// ===================================

async checkHealth(): Promise<boolean> {
if (!this.client) return false;
try {
await this.client.execute('SELECT 1');
const client = await this.ensureConnected();
await client.execute('SELECT 1');
return true;
} catch {
return false;
Expand All @@ -85,7 +112,7 @@ export class RemoteTransport {
// ===================================

async execute(command: unknown, params?: unknown[]): Promise<unknown> {
this.ensureClient();
await this.ensureConnected();
if (typeof command !== 'string') return command;

const stmt: InStatement = params && params.length > 0
Comment on lines 114 to 118
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In execute(), await this.ensureConnected() relies on the side-effect of setting this.client, but the method still uses this.client! later. This is vulnerable to races if another task calls close()/setClient() between awaits, and it also keeps the non-null assertion. Prefer const client = await this.ensureConnected() and use that local for the rest of the method (same applies to other CRUD methods).

Copilot uses AI. Check for mistakes.
Expand All @@ -101,7 +128,7 @@ export class RemoteTransport {
// ===================================

async find(object: string, query: any): Promise<Record<string, unknown>[]> {
this.ensureClient();
await this.ensureConnected();

const { sql, args } = this.buildSelectSQL(object, query);

Expand All @@ -123,7 +150,7 @@ export class RemoteTransport {
async findOne(object: string, query: any): Promise<Record<string, unknown> | null> {
// When called with a string/number id fall back gracefully
if (typeof query === 'string' || typeof query === 'number') {
this.ensureClient();
await this.ensureConnected();
const result = await this.client!.execute({
sql: `SELECT * FROM "${object}" WHERE "id" = ? LIMIT 1`,
args: [query],
Expand All @@ -148,7 +175,7 @@ export class RemoteTransport {
}

async create(object: string, data: Record<string, unknown>): Promise<Record<string, unknown>> {
this.ensureClient();
await this.ensureConnected();

const { _id, ...rest } = data as any;
const toInsert = { ...rest };
Expand Down Expand Up @@ -176,7 +203,7 @@ export class RemoteTransport {
}

async update(object: string, id: string | number, data: Record<string, unknown>): Promise<Record<string, unknown>> {
this.ensureClient();
await this.ensureConnected();

const columns = Object.keys(data);
const setClauses = columns.map((col) => `"${col}" = ?`).join(', ');
Expand All @@ -195,7 +222,7 @@ export class RemoteTransport {
}

async upsert(object: string, data: Record<string, unknown>, conflictKeys?: string[]): Promise<Record<string, unknown>> {
this.ensureClient();
await this.ensureConnected();

const { _id, ...rest } = data as any;
const toUpsert = { ...rest };
Expand Down Expand Up @@ -235,7 +262,7 @@ export class RemoteTransport {
}

async delete(object: string, id: string | number): Promise<boolean> {
this.ensureClient();
await this.ensureConnected();
const result = await this.client!.execute({
sql: `DELETE FROM "${object}" WHERE "id" = ?`,
args: [id],
Expand All @@ -244,7 +271,7 @@ export class RemoteTransport {
}

async count(object: string, query?: any): Promise<number> {
this.ensureClient();
await this.ensureConnected();

const { whereClauses, args } = this.buildWhereSQL(query?.where);
let sql = `SELECT COUNT(*) as count FROM "${object}"`;
Expand Down Expand Up @@ -283,7 +310,7 @@ export class RemoteTransport {
}

async bulkDelete(object: string, ids: Array<string | number>): Promise<void> {
this.ensureClient();
await this.ensureConnected();
if (ids.length === 0) return;

const placeholders = ids.map(() => '?').join(', ');
Expand All @@ -294,7 +321,7 @@ export class RemoteTransport {
}

async updateMany(object: string, query: any, data: Record<string, unknown>): Promise<number> {
this.ensureClient();
await this.ensureConnected();

const columns = Object.keys(data);
const setClauses = columns.map((col) => `"${col}" = ?`).join(', ');
Expand All @@ -309,7 +336,7 @@ export class RemoteTransport {
}

async deleteMany(object: string, query: any): Promise<number> {
this.ensureClient();
await this.ensureConnected();

const { whereClauses, args } = this.buildWhereSQL(query?.where);
let sql = `DELETE FROM "${object}"`;
Expand All @@ -324,7 +351,7 @@ export class RemoteTransport {
// ===================================

async beginTransaction(): Promise<any> {
this.ensureClient();
await this.ensureConnected();
return this.client!.transaction();
}

Expand All @@ -341,7 +368,7 @@ export class RemoteTransport {
// ===================================

async syncSchema(object: string, schema: any): Promise<void> {
this.ensureClient();
await this.ensureConnected();

const objectDef = schema as { name: string; fields?: Record<string, any> };
const tableName = object;
Expand Down Expand Up @@ -391,7 +418,7 @@ export class RemoteTransport {
* by the caller if a batch operation is not supported or fails.
*/
async syncSchemasBatch(schemas: Array<{ object: string; schema: any }>): Promise<void> {
this.ensureClient();
await this.ensureConnected();
if (schemas.length === 0) return;

// Validate all identifiers up-front
Expand Down Expand Up @@ -459,19 +486,45 @@ export class RemoteTransport {
}

async dropTable(object: string): Promise<void> {
this.ensureClient();
await this.ensureConnected();
await this.client!.execute(`DROP TABLE IF EXISTS "${object}"`);
}

// ===================================
// Internal Helpers
// ===================================

private ensureClient(): Client {
if (!this.client) {
throw new Error('RemoteTransport: @libsql/client is not initialized. Call connect() first.');
/**
* 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<Client> {
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;
}
return this.client;

throw new Error('RemoteTransport: @libsql/client is not initialized. Call connect() first.');
}

/**
Expand Down
Loading
Loading