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
48 changes: 48 additions & 0 deletions .changeset/turso-host-loader-convergence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@objectstack/service-datasource": minor
"@objectstack/runtime": patch
---

fix(runtime,service-datasource): a `default` libSQL datasource keeps its whole config, and one missing-package class (#7314)

Two loaders build the libSQL/Turso driver, and which one runs is decided by
something the author cannot see — whether the datasource happens to be the
host's `default`. `@objectstack/runtime`'s host loader serves that one;
`createDefaultDatasourceDriverFactory`'s `turso` arm in
`@objectstack/service-datasource` serves every other door (a datasource created
in Setup, `testConnection`, a declared non-default). #6268 converged the two
HOST loaders onto one owner; it could not reach the third, one layer down, and
the two had drifted in two ways.

**Half the config was silently dropped for `default`.** The host loader built
`new TursoDriver({ url, authToken })` — two keys — while the open-core arm read
nine. `TursoConfigSchema` accepts all nine, so an encrypted or
embedded-replica `default` lost `encryptionKey` / `syncUrl` / `sync` /
`concurrency` / `timeout` / `mode` / `schemaMode` with no diagnostic anywhere,
and got them back the moment the datasource was renamed. Both loaders now build
through one exported `buildTursoDriverConfig`, whose key set is derived from a
reader table rather than hand-listed — a corrected second copy would only have
agreed until the next key. A `packages/cli` pin fails to compile if that builder
and the driver's own `TursoDriverConfig` stop covering the same keys.

The host loader also now trims the url before testing it, as the open-core arm
always has: a whitespace-only url is refused by name instead of being handed to
`@libsql/client`.

**One `MissingDriverPackageError`, reachable from both sides.** The class was
declared in `@objectstack/runtime`, which `@objectstack/service-datasource`
cannot import (the dependency runs the other way), so the open-core arm raised a
plain `Error` — matched by no `instanceof`, and pinnable only by message text.
The declaration moves DOWN to `@objectstack/service-datasource`, the lowest
package that raises it, and both loaders now throw the same class object.

**No import changes.** `MissingDriverPackageError`, `TURSO_DRIVER_PACKAGE` and
`TURSO_DRIVER_INSTALL_COMMAND` are still exported from `@objectstack/runtime`
(and from `@objectstack/cli`'s `utils/storage-driver.ts` through it) — they are
re-exports now rather than declarations. Code written against either spelling
keeps compiling, and against the same class: `serve.ts`'s
`e instanceof MissingDriverPackageError` fatal-boot branch depends on that
identity, so it is asserted by object identity rather than by name or message.
`@objectstack/service-datasource` additionally exports the class, the builder
(`buildTursoDriverConfig`, `resolveTursoUrl`, `TURSO_DRIVER_CONFIG_KEYS`) and
`resolveDatasourceSchemaMode` for hosts that build their own driver factory.
93 changes: 93 additions & 0 deletions packages/cli/src/utils/storage-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ import { describe, it, expect } from 'vitest';
// this file). `@objectstack/driver-turso` is an OPTIONAL peer of the CLI — this
// import is erased, so nothing here requires it at runtime.
import type { TursoDriverConfig } from '@objectstack/driver-turso';
// #7314: the shared libSQL config builder both loaders call, and the class both
// now raise. Imported from `@objectstack/service-datasource` — the lowest package
// of the three, and the only import direction the dependency graph allows.
import {
buildTursoDriverConfig,
resolveTursoUrl,
TURSO_DRIVER_CONFIG_KEYS,
MissingDriverPackageError as OpenCoreMissingDriverPackageError,
type TursoDriverConfigInput,
} from '@objectstack/service-datasource';
import {
inferDriverTypeFromUrl,
resolveDriverType,
Expand Down Expand Up @@ -411,6 +421,73 @@ describe('loadTursoDriverFactory: the optional driver package (#5602)', () => {
});
});

// #7314 — the CONFIG half of the convergence, pinned from the only package that
// can see both sides.
//
// Two loaders build the libSQL driver — `@objectstack/runtime`'s host loader for
// a host's `default` datasource, `@objectstack/service-datasource`'s open-core
// arm for every other door — and each used to hand-list the keys it read. The
// lists drifted: nine keys in the open-core arm, `url` and `authToken` in the
// host loader, so an encrypted or embedded-replica `default` silently lost
// `encryptionKey` / `syncUrl` / `sync` / `concurrency` / `timeout` / `mode` and
// got them back the moment it was renamed. Both now build through
// `buildTursoDriverConfig`, so they cannot disagree with EACH OTHER.
//
// This file pins the remaining gap: whether that one builder still agrees with
// the DRIVER. `@objectstack/driver-turso` is deliberately not resolvable from
// `@objectstack/runtime` or `@objectstack/service-datasource` (that is what
// "optional" means, and the open-core missing-package pin depends on it staying
// so), while the CLI carries it as a dev dependency for exactly this check. The
// assertions are compile-time — `packages/cli`'s `typecheck` compiles this file
// — because a key the driver added and the builder never read is a defect with
// no runtime symptom at all: the config constructs, the driver connects, and the
// setting is simply absent.
describe('#7314 — the shared libSQL config builder against the real TursoDriverConfig', () => {
/** Compile-time `T must be never`; the alias is only satisfiable when it is. */
type AssertNever<T extends never> = T;
/** A key the DRIVER accepts that the shared builder does not read. */
type UnreadByBuilder = Exclude<keyof Omit<TursoDriverConfig, 'client'>, keyof TursoDriverConfigInput>;
/**
* A key the builder emits that the driver does not declare. `schemaMode` is
* excluded on purpose: ADR-0015 ownership is honoured by the `SqlDriver` base
* `TursoDriver` extends, not declared on `TursoDriverConfig` itself.
*/
type UnknownToDriver = Exclude<keyof TursoDriverConfigInput, keyof TursoDriverConfig | 'schemaMode'>;

it('reads every authorable key of the driver config, and emits none it does not know', () => {
// `client` is excluded from the driver side: a pre-built `@libsql/client`
// instance is a host-composition escape hatch, never authorable config.
const unreadByBuilder: AssertNever<UnreadByBuilder>[] = [];
const unknownToDriver: AssertNever<UnknownToDriver>[] = [];
expect([...unreadByBuilder, ...unknownToDriver]).toEqual([]);
});

it('produces a config the real driver accepts, carrying every declared key', () => {
const spec = {
name: 'default',
driver: 'turso',
schemaMode: 'external',
config: {
url: 'libsql://my-db.turso.io',
authToken: 'jwt-token',
encryptionKey: 'aes-256-key',
concurrency: 7,
syncUrl: 'libsql://replica.turso.io',
sync: { intervalSeconds: 30, onConnect: false },
timeout: 9000,
mode: 'replica',
},
} as const;
const built = buildTursoDriverConfig(spec, resolveTursoUrl(spec));
const pinned: TursoDriverConfig = built;

expect(pinned.encryptionKey).toBe('aes-256-key');
expect(pinned.syncUrl).toBe('libsql://replica.turso.io');
expect(pinned.sync).toEqual({ intervalSeconds: 30, onConnect: false });
expect(Object.keys(built).sort()).toEqual([...TURSO_DRIVER_CONFIG_KEYS].sort());
});
});

// #6268 — the loader has ONE owner (`@objectstack/runtime`), and this file's
// exports are that owner's declarations rather than hand-aligned copies.
//
Expand Down Expand Up @@ -444,6 +521,15 @@ describe('#6268 — one loader, one class identity across cli and runtime', () =
// …and the demonstration that a message assertion could NOT have caught a
// broken identity: a twin declared right here carries the same message and
// the same fields, and passes every assertion except the one above.
//
// #7314 revisited this declaration and KEPT it. It is not a stand-in for a
// class that could not be reached — the real one is imported at the top of
// this file and asserted identical two cases up — it is the NEGATIVE CONTROL
// that gives the positive assertion its meaning: without something that
// matches on message and name and still fails `instanceof`, nothing here
// shows that the passing assertion is testing identity rather than wording.
// Deleting it "in favour of the real import" would have removed the only
// evidence that the pin has teeth.
class MissingDriverPackageErrorTwin extends Error {
constructor(readonly installCommand: string, message: string) {
super(message);
Expand All @@ -458,6 +544,13 @@ describe('#6268 — one loader, one class identity across cli and runtime', () =
expect(twin.name).toBe((err as Error).name);
expect(twin.installCommand).toBe(TURSO_DRIVER_INSTALL_COMMAND);
expect(twin instanceof MissingDriverPackageError).toBe(false);
// …and the twin fails the OPEN-CORE binding too (#7314). The class moved
// down into `@objectstack/service-datasource` so the open-core loader could
// raise it; the seam now has three bindings and one class object, and the
// twin must be outside all of them.
expect(twin instanceof OpenCoreMissingDriverPackageError).toBe(false);
expect(err).toBeInstanceOf(OpenCoreMissingDriverPackageError);
expect(MissingDriverPackageError).toBe(OpenCoreMissingDriverPackageError);
});

it('and the reverse: the CLI loader raises an error the runtime binding matches', async () => {
Expand Down
6 changes: 6 additions & 0 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export type { StandaloneStackConfig, StandaloneStackResult, ResolvedStandaloneDa
// `loadTursoDriverFactory` and RE-EXPORTS `MissingDriverPackageError`, so
// `serve.ts`'s `e instanceof MissingDriverPackageError` fatal branch tests one
// class identity rather than one of two same-named twins.
//
// Since #7314 that class — and the package / install-command pair — is DECLARED
// one layer down, in `@objectstack/service-datasource`, so the open-core loader
// can raise it too. This export surface is deliberately unchanged: that is what
// keeps every importer written against `@objectstack/runtime` compiling, and
// against the same class object.
export {
loadTursoDriverFactory,
isTursoDriverId,
Expand Down
192 changes: 192 additions & 0 deletions packages/runtime/src/turso-driver-factory.convergence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #7314 — the third libSQL loader, and the two ways it disagreed with this one.
//
// #6268 converged the two HOST-injected loaders (CLI + standalone stack) onto
// `turso-driver-factory.ts`. A third arm it could not reach —
// `createDefaultDatasourceDriverFactory`'s `turso` case in
// `@objectstack/service-datasource` — serves every door that is NOT a host's
// `default` datasource: one created in Setup, one probed by `testConnection`, a
// declared non-default. Two things differed across that seam, and an author
// could see neither:
//
// 1. THE CONFIG SURFACE (the half a user could actually hit). This loader
// built `new TursoDriver({ url, authToken })` — two keys — while the
// open-core arm read nine. `TursoConfigSchema` accepts all nine, so an
// encrypted or embedded-replica datasource silently lost `encryptionKey` /
// `syncUrl` / `sync` / `concurrency` / `timeout` / `mode` / `schemaMode`
// with no diagnostic, and got them back the moment it was renamed away from
// `default`.
// 2. THE ERROR CLASS. `MissingDriverPackageError` was declared in this package,
// which `service-datasource` cannot import (runtime depends on it, not the
// reverse), so that arm raised a plain `Error` — matched by no `instanceof`.
//
// The assertions below are deliberately of two kinds, because the two defects
// hide from different tests:
//
// - the config pin asserts THE CONSTRUCTOR ARGUMENT, not a successful boot. A
// dropped key produces a driver that constructs perfectly and connects to
// the wrong thing; every boot-level assertion stayed green through the years
// this loader read two keys.
// - the identity pin asserts CLASS IDENTITY (`===` / `instanceof`), never
// `name` or message text. Two same-named classes produce byte-identical
// messages — that is exactly what makes the defect invisible.
//
// No test here touches a real libSQL endpoint: the optional package is
// substituted through `importDriverPackage`.

import { describe, it, expect } from 'vitest';
import {
buildTursoDriverConfig,
createDefaultDatasourceDriverFactory,
MissingDriverPackageError as OpenCoreMissingDriverPackageError,
TURSO_DRIVER_CONFIG_KEYS,
type DatasourceConnectionSpec,
} from '@objectstack/service-datasource';
import { loadTursoDriverFactory, MissingDriverPackageError } from './turso-driver-factory.js';

/**
* A `default` libSQL datasource declaring EVERY key the config contract accepts
* — the case the narrow read silently degraded.
*
* `schemaMode` rides on the spec rather than in `config`, which is where a
* datasource actually declares it (#4410); the builder reads all three of its
* sources, and a loader that only looked inside `config` would drop it.
*/
const FULL_SPEC: DatasourceConnectionSpec = {
name: 'default',
driver: 'turso',
schemaMode: 'external',
config: {
url: 'libsql://my-db.turso.io',
authToken: 'jwt-token',
encryptionKey: 'aes-256-key',
concurrency: 7,
syncUrl: 'libsql://replica.turso.io',
sync: { intervalSeconds: 30, onConnect: false },
timeout: 9000,
mode: 'replica',
},
};

/** Substitute the optional package with a ctor that records what it was handed. */
function capturingDriverPackage() {
const seen: unknown[] = [];
class TursoDriver {
constructor(config: unknown) {
seen.push(config);
}
}
return { seen, importDriverPackage: async () => ({ TursoDriver }) };
}

describe('#7314 point 3 — the host loader reads the whole libSQL config, not two keys of it', () => {
it('reaches the driver with every declared key, asserted on the constructor argument', async () => {
const { seen, importDriverPackage } = capturingDriverPackage();
const factory = await loadTursoDriverFactory({ importDriverPackage });
factory.create(FULL_SPEC);

expect(seen).toHaveLength(1);
// Spelled out rather than compared to the builder alone: this is the list an
// author can point at, and it is what the open-core arm has always honoured.
expect(seen[0]).toEqual({
url: 'libsql://my-db.turso.io',
authToken: 'jwt-token',
encryptionKey: 'aes-256-key',
concurrency: 7,
syncUrl: 'libsql://replica.turso.io',
sync: { intervalSeconds: 30, onConnect: false },
timeout: 9000,
mode: 'replica',
schemaMode: 'external',
});
});

// The anti-drift half. The pin above would go green again on a SECOND
// hand-written list that happened to agree today — which is precisely how the
// first two lists came to disagree. This one fails unless the loader is
// actually building through the shared derivation.
it('builds through the shared builder, so a new key cannot reach one loader only', async () => {
const { seen, importDriverPackage } = capturingDriverPackage();
const factory = await loadTursoDriverFactory({ importDriverPackage });
factory.create(FULL_SPEC);

expect(seen[0]).toEqual(buildTursoDriverConfig(FULL_SPEC, 'libsql://my-db.turso.io'));
expect(Object.keys(seen[0] as object).sort()).toEqual([...TURSO_DRIVER_CONFIG_KEYS].sort());
});

// Absent ≠ present-and-undefined: `@libsql/client` reads some options by
// presence, and the open-core arm has always spread-omitted rather than
// passing `undefined` through.
it('omits keys the datasource did not declare rather than passing undefined', async () => {
const { seen, importDriverPackage } = capturingDriverPackage();
const factory = await loadTursoDriverFactory({ importDriverPackage });
factory.create({ name: 'default', driver: 'turso', config: { url: 'file:./data/objectstack.db' } });

expect(seen[0]).toEqual({ url: 'file:./data/objectstack.db' });
expect(Object.keys(seen[0] as object)).toEqual(['url']);
});

// Shared url resolution, which this loader did not have: it tested
// `typeof url === 'string'` without trimming, so a whitespace-only url reached
// `@libsql/client` instead of the named refusal the open-core arm gives.
it('refuses a whitespace-only url by name instead of handing it to the driver', async () => {
const { seen, importDriverPackage } = capturingDriverPackage();
const factory = await loadTursoDriverFactory({
importDriverPackage,
missingUrlError: (message) => new Error(message),
});

expect(() => factory.create({ name: 'default', driver: 'turso', config: { url: ' ' } }))
.toThrow(/needs a libSQL url/);
expect(seen).toHaveLength(0);
});
});

describe('#7314 point 2 — one MissingDriverPackageError class across the seam', () => {
// The declaration moved DOWN (runtime depends on service-datasource, so this
// is the only legal direction) and is re-exported from its old home. Asserted
// by object identity: a second same-named class would satisfy every other
// assertion in this file.
it('the runtime export IS the service-datasource class object', () => {
expect(MissingDriverPackageError).toBe(OpenCoreMissingDriverPackageError);
});

it('the host loader raises an error the open-core binding matches', async () => {
const err = await loadTursoDriverFactory({
importDriverPackage: async () => { throw new Error("Cannot find package '@objectstack/driver-turso'"); },
}).then(() => null, (e: unknown) => e);

expect(err).toBeInstanceOf(OpenCoreMissingDriverPackageError);
expect((err as InstanceType<typeof MissingDriverPackageError>).driverType).toBe('turso');
});

// THE pin, and the direction that was impossible before this change: the
// open-core arm's failure now satisfies the predicate `serve.ts` runs
// (`e instanceof MissingDriverPackageError`, against the runtime binding).
// Until #7314 that arm raised a plain `Error` and this assertion could not
// have been written.
it('the open-core arm raises an error the runtime binding matches', async () => {
// `@objectstack/driver-turso` is deliberately not a dependency of
// `@objectstack/service-datasource` — that is what "optional" means — so its
// missing-package arm is reachable here without a stub.
let err: unknown = null;
try {
await createDefaultDatasourceDriverFactory()
.create({ name: 'warehouse', driver: 'turso', config: { url: 'libsql://my-db.turso.io' } });
} catch (e) {
err = e;
}

if (err === null) {
throw new Error(
'@objectstack/driver-turso resolved from @objectstack/service-datasource, so this case no '
+ 'longer exercises the missing-package arm. If the package was made a dependency, this '
+ 'assertion is the notice that the pin needs a stubbed import instead.',
);
}
expect(err).toBeInstanceOf(MissingDriverPackageError);
expect((err as InstanceType<typeof MissingDriverPackageError>).installCommand)
.toBe('npm install @objectstack/driver-turso');
});
});
Loading
Loading