Skip to content

Commit ecf0bef

Browse files
hotlongclaude
andauthored
fix(runtime,service-datasource): converge the two libSQL loaders on one config read and one error class (#7314) (#7999)
Two loaders build the libSQL/Turso driver and which one runs is decided by whether the datasource happens to be the host's `default`. #6268 converged the two HOST-injected loaders; it could not reach the third — the open-core `turso` arm in `@objectstack/service-datasource` — and the two had drifted. Point 3: the host loader read `url` and `authToken`; the open-core arm read nine keys. So a `default` libSQL datasource silently lost `encryptionKey` / `syncUrl` / `sync` / `concurrency` / `timeout` / `mode` / `schemaMode`, all accepted by `TursoConfigSchema` and all honoured the moment the datasource was renamed. Both loaders now build through one `buildTursoDriverConfig`, whose key set is DERIVED from a reader table rather than hand-listed; a `packages/cli` compile-time pin fails when that builder and the driver's own `TursoDriverConfig` stop covering the same keys. The host loader also trims the url, as the open-core arm always has. Point 2: `MissingDriverPackageError` was declared in `@objectstack/runtime`, which `service-datasource` cannot import, so the open-core arm raised a plain `Error`. The class moves DOWN to the lowest package that raises it and is re-exported from its old home, so every existing importer keeps compiling — against the same class object, which is what `serve.ts`'s `e instanceof MissingDriverPackageError` fatal branch depends on. Pinned on the constructor argument (not a successful boot) and by object identity (not by name or message), because a dropped key and a twin class both produce a driver that constructs cleanly and a message that reads correctly. Claude-Session: https://claude.ai/code/session_01RjUepKTxiGcJX6WQtwFmcf Co-authored-by: Claude <noreply@anthropic.com>
1 parent e98c9d3 commit ecf0bef

12 files changed

Lines changed: 890 additions & 86 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/service-datasource": minor
3+
"@objectstack/runtime": patch
4+
---
5+
6+
fix(runtime,service-datasource): a `default` libSQL datasource keeps its whole config, and one missing-package class (#7314)
7+
8+
Two loaders build the libSQL/Turso driver, and which one runs is decided by
9+
something the author cannot see — whether the datasource happens to be the
10+
host's `default`. `@objectstack/runtime`'s host loader serves that one;
11+
`createDefaultDatasourceDriverFactory`'s `turso` arm in
12+
`@objectstack/service-datasource` serves every other door (a datasource created
13+
in Setup, `testConnection`, a declared non-default). #6268 converged the two
14+
HOST loaders onto one owner; it could not reach the third, one layer down, and
15+
the two had drifted in two ways.
16+
17+
**Half the config was silently dropped for `default`.** The host loader built
18+
`new TursoDriver({ url, authToken })` — two keys — while the open-core arm read
19+
nine. `TursoConfigSchema` accepts all nine, so an encrypted or
20+
embedded-replica `default` lost `encryptionKey` / `syncUrl` / `sync` /
21+
`concurrency` / `timeout` / `mode` / `schemaMode` with no diagnostic anywhere,
22+
and got them back the moment the datasource was renamed. Both loaders now build
23+
through one exported `buildTursoDriverConfig`, whose key set is derived from a
24+
reader table rather than hand-listed — a corrected second copy would only have
25+
agreed until the next key. A `packages/cli` pin fails to compile if that builder
26+
and the driver's own `TursoDriverConfig` stop covering the same keys.
27+
28+
The host loader also now trims the url before testing it, as the open-core arm
29+
always has: a whitespace-only url is refused by name instead of being handed to
30+
`@libsql/client`.
31+
32+
**One `MissingDriverPackageError`, reachable from both sides.** The class was
33+
declared in `@objectstack/runtime`, which `@objectstack/service-datasource`
34+
cannot import (the dependency runs the other way), so the open-core arm raised a
35+
plain `Error` — matched by no `instanceof`, and pinnable only by message text.
36+
The declaration moves DOWN to `@objectstack/service-datasource`, the lowest
37+
package that raises it, and both loaders now throw the same class object.
38+
39+
**No import changes.** `MissingDriverPackageError`, `TURSO_DRIVER_PACKAGE` and
40+
`TURSO_DRIVER_INSTALL_COMMAND` are still exported from `@objectstack/runtime`
41+
(and from `@objectstack/cli`'s `utils/storage-driver.ts` through it) — they are
42+
re-exports now rather than declarations. Code written against either spelling
43+
keeps compiling, and against the same class: `serve.ts`'s
44+
`e instanceof MissingDriverPackageError` fatal-boot branch depends on that
45+
identity, so it is asserted by object identity rather than by name or message.
46+
`@objectstack/service-datasource` additionally exports the class, the builder
47+
(`buildTursoDriverConfig`, `resolveTursoUrl`, `TURSO_DRIVER_CONFIG_KEYS`) and
48+
`resolveDatasourceSchemaMode` for hosts that build their own driver factory.

packages/cli/src/utils/storage-driver.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ import { describe, it, expect } from 'vitest';
55
// this file). `@objectstack/driver-turso` is an OPTIONAL peer of the CLI — this
66
// import is erased, so nothing here requires it at runtime.
77
import type { TursoDriverConfig } from '@objectstack/driver-turso';
8+
// #7314: the shared libSQL config builder both loaders call, and the class both
9+
// now raise. Imported from `@objectstack/service-datasource` — the lowest package
10+
// of the three, and the only import direction the dependency graph allows.
11+
import {
12+
buildTursoDriverConfig,
13+
resolveTursoUrl,
14+
TURSO_DRIVER_CONFIG_KEYS,
15+
MissingDriverPackageError as OpenCoreMissingDriverPackageError,
16+
type TursoDriverConfigInput,
17+
} from '@objectstack/service-datasource';
818
import {
919
inferDriverTypeFromUrl,
1020
resolveDriverType,
@@ -411,6 +421,73 @@ describe('loadTursoDriverFactory: the optional driver package (#5602)', () => {
411421
});
412422
});
413423

424+
// #7314 — the CONFIG half of the convergence, pinned from the only package that
425+
// can see both sides.
426+
//
427+
// Two loaders build the libSQL driver — `@objectstack/runtime`'s host loader for
428+
// a host's `default` datasource, `@objectstack/service-datasource`'s open-core
429+
// arm for every other door — and each used to hand-list the keys it read. The
430+
// lists drifted: nine keys in the open-core arm, `url` and `authToken` in the
431+
// host loader, so an encrypted or embedded-replica `default` silently lost
432+
// `encryptionKey` / `syncUrl` / `sync` / `concurrency` / `timeout` / `mode` and
433+
// got them back the moment it was renamed. Both now build through
434+
// `buildTursoDriverConfig`, so they cannot disagree with EACH OTHER.
435+
//
436+
// This file pins the remaining gap: whether that one builder still agrees with
437+
// the DRIVER. `@objectstack/driver-turso` is deliberately not resolvable from
438+
// `@objectstack/runtime` or `@objectstack/service-datasource` (that is what
439+
// "optional" means, and the open-core missing-package pin depends on it staying
440+
// so), while the CLI carries it as a dev dependency for exactly this check. The
441+
// assertions are compile-time — `packages/cli`'s `typecheck` compiles this file
442+
// — because a key the driver added and the builder never read is a defect with
443+
// no runtime symptom at all: the config constructs, the driver connects, and the
444+
// setting is simply absent.
445+
describe('#7314 — the shared libSQL config builder against the real TursoDriverConfig', () => {
446+
/** Compile-time `T must be never`; the alias is only satisfiable when it is. */
447+
type AssertNever<T extends never> = T;
448+
/** A key the DRIVER accepts that the shared builder does not read. */
449+
type UnreadByBuilder = Exclude<keyof Omit<TursoDriverConfig, 'client'>, keyof TursoDriverConfigInput>;
450+
/**
451+
* A key the builder emits that the driver does not declare. `schemaMode` is
452+
* excluded on purpose: ADR-0015 ownership is honoured by the `SqlDriver` base
453+
* `TursoDriver` extends, not declared on `TursoDriverConfig` itself.
454+
*/
455+
type UnknownToDriver = Exclude<keyof TursoDriverConfigInput, keyof TursoDriverConfig | 'schemaMode'>;
456+
457+
it('reads every authorable key of the driver config, and emits none it does not know', () => {
458+
// `client` is excluded from the driver side: a pre-built `@libsql/client`
459+
// instance is a host-composition escape hatch, never authorable config.
460+
const unreadByBuilder: AssertNever<UnreadByBuilder>[] = [];
461+
const unknownToDriver: AssertNever<UnknownToDriver>[] = [];
462+
expect([...unreadByBuilder, ...unknownToDriver]).toEqual([]);
463+
});
464+
465+
it('produces a config the real driver accepts, carrying every declared key', () => {
466+
const spec = {
467+
name: 'default',
468+
driver: 'turso',
469+
schemaMode: 'external',
470+
config: {
471+
url: 'libsql://my-db.turso.io',
472+
authToken: 'jwt-token',
473+
encryptionKey: 'aes-256-key',
474+
concurrency: 7,
475+
syncUrl: 'libsql://replica.turso.io',
476+
sync: { intervalSeconds: 30, onConnect: false },
477+
timeout: 9000,
478+
mode: 'replica',
479+
},
480+
} as const;
481+
const built = buildTursoDriverConfig(spec, resolveTursoUrl(spec));
482+
const pinned: TursoDriverConfig = built;
483+
484+
expect(pinned.encryptionKey).toBe('aes-256-key');
485+
expect(pinned.syncUrl).toBe('libsql://replica.turso.io');
486+
expect(pinned.sync).toEqual({ intervalSeconds: 30, onConnect: false });
487+
expect(Object.keys(built).sort()).toEqual([...TURSO_DRIVER_CONFIG_KEYS].sort());
488+
});
489+
});
490+
414491
// #6268 — the loader has ONE owner (`@objectstack/runtime`), and this file's
415492
// exports are that owner's declarations rather than hand-aligned copies.
416493
//
@@ -444,6 +521,15 @@ describe('#6268 — one loader, one class identity across cli and runtime', () =
444521
// …and the demonstration that a message assertion could NOT have caught a
445522
// broken identity: a twin declared right here carries the same message and
446523
// the same fields, and passes every assertion except the one above.
524+
//
525+
// #7314 revisited this declaration and KEPT it. It is not a stand-in for a
526+
// class that could not be reached — the real one is imported at the top of
527+
// this file and asserted identical two cases up — it is the NEGATIVE CONTROL
528+
// that gives the positive assertion its meaning: without something that
529+
// matches on message and name and still fails `instanceof`, nothing here
530+
// shows that the passing assertion is testing identity rather than wording.
531+
// Deleting it "in favour of the real import" would have removed the only
532+
// evidence that the pin has teeth.
447533
class MissingDriverPackageErrorTwin extends Error {
448534
constructor(readonly installCommand: string, message: string) {
449535
super(message);
@@ -458,6 +544,13 @@ describe('#6268 — one loader, one class identity across cli and runtime', () =
458544
expect(twin.name).toBe((err as Error).name);
459545
expect(twin.installCommand).toBe(TURSO_DRIVER_INSTALL_COMMAND);
460546
expect(twin instanceof MissingDriverPackageError).toBe(false);
547+
// …and the twin fails the OPEN-CORE binding too (#7314). The class moved
548+
// down into `@objectstack/service-datasource` so the open-core loader could
549+
// raise it; the seam now has three bindings and one class object, and the
550+
// twin must be outside all of them.
551+
expect(twin instanceof OpenCoreMissingDriverPackageError).toBe(false);
552+
expect(err).toBeInstanceOf(OpenCoreMissingDriverPackageError);
553+
expect(MissingDriverPackageError).toBe(OpenCoreMissingDriverPackageError);
461554
});
462555

463556
it('and the reverse: the CLI loader raises an error the runtime binding matches', async () => {

packages/runtime/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ export type { StandaloneStackConfig, StandaloneStackResult, ResolvedStandaloneDa
1616
// `loadTursoDriverFactory` and RE-EXPORTS `MissingDriverPackageError`, so
1717
// `serve.ts`'s `e instanceof MissingDriverPackageError` fatal branch tests one
1818
// class identity rather than one of two same-named twins.
19+
//
20+
// Since #7314 that class — and the package / install-command pair — is DECLARED
21+
// one layer down, in `@objectstack/service-datasource`, so the open-core loader
22+
// can raise it too. This export surface is deliberately unchanged: that is what
23+
// keeps every importer written against `@objectstack/runtime` compiling, and
24+
// against the same class object.
1925
export {
2026
loadTursoDriverFactory,
2127
isTursoDriverId,
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #7314 — the third libSQL loader, and the two ways it disagreed with this one.
4+
//
5+
// #6268 converged the two HOST-injected loaders (CLI + standalone stack) onto
6+
// `turso-driver-factory.ts`. A third arm it could not reach —
7+
// `createDefaultDatasourceDriverFactory`'s `turso` case in
8+
// `@objectstack/service-datasource` — serves every door that is NOT a host's
9+
// `default` datasource: one created in Setup, one probed by `testConnection`, a
10+
// declared non-default. Two things differed across that seam, and an author
11+
// could see neither:
12+
//
13+
// 1. THE CONFIG SURFACE (the half a user could actually hit). This loader
14+
// built `new TursoDriver({ url, authToken })` — two keys — while the
15+
// open-core arm read nine. `TursoConfigSchema` accepts all nine, so an
16+
// encrypted or embedded-replica datasource silently lost `encryptionKey` /
17+
// `syncUrl` / `sync` / `concurrency` / `timeout` / `mode` / `schemaMode`
18+
// with no diagnostic, and got them back the moment it was renamed away from
19+
// `default`.
20+
// 2. THE ERROR CLASS. `MissingDriverPackageError` was declared in this package,
21+
// which `service-datasource` cannot import (runtime depends on it, not the
22+
// reverse), so that arm raised a plain `Error` — matched by no `instanceof`.
23+
//
24+
// The assertions below are deliberately of two kinds, because the two defects
25+
// hide from different tests:
26+
//
27+
// - the config pin asserts THE CONSTRUCTOR ARGUMENT, not a successful boot. A
28+
// dropped key produces a driver that constructs perfectly and connects to
29+
// the wrong thing; every boot-level assertion stayed green through the years
30+
// this loader read two keys.
31+
// - the identity pin asserts CLASS IDENTITY (`===` / `instanceof`), never
32+
// `name` or message text. Two same-named classes produce byte-identical
33+
// messages — that is exactly what makes the defect invisible.
34+
//
35+
// No test here touches a real libSQL endpoint: the optional package is
36+
// substituted through `importDriverPackage`.
37+
38+
import { describe, it, expect } from 'vitest';
39+
import {
40+
buildTursoDriverConfig,
41+
createDefaultDatasourceDriverFactory,
42+
MissingDriverPackageError as OpenCoreMissingDriverPackageError,
43+
TURSO_DRIVER_CONFIG_KEYS,
44+
type DatasourceConnectionSpec,
45+
} from '@objectstack/service-datasource';
46+
import { loadTursoDriverFactory, MissingDriverPackageError } from './turso-driver-factory.js';
47+
48+
/**
49+
* A `default` libSQL datasource declaring EVERY key the config contract accepts
50+
* — the case the narrow read silently degraded.
51+
*
52+
* `schemaMode` rides on the spec rather than in `config`, which is where a
53+
* datasource actually declares it (#4410); the builder reads all three of its
54+
* sources, and a loader that only looked inside `config` would drop it.
55+
*/
56+
const FULL_SPEC: DatasourceConnectionSpec = {
57+
name: 'default',
58+
driver: 'turso',
59+
schemaMode: 'external',
60+
config: {
61+
url: 'libsql://my-db.turso.io',
62+
authToken: 'jwt-token',
63+
encryptionKey: 'aes-256-key',
64+
concurrency: 7,
65+
syncUrl: 'libsql://replica.turso.io',
66+
sync: { intervalSeconds: 30, onConnect: false },
67+
timeout: 9000,
68+
mode: 'replica',
69+
},
70+
};
71+
72+
/** Substitute the optional package with a ctor that records what it was handed. */
73+
function capturingDriverPackage() {
74+
const seen: unknown[] = [];
75+
class TursoDriver {
76+
constructor(config: unknown) {
77+
seen.push(config);
78+
}
79+
}
80+
return { seen, importDriverPackage: async () => ({ TursoDriver }) };
81+
}
82+
83+
describe('#7314 point 3 — the host loader reads the whole libSQL config, not two keys of it', () => {
84+
it('reaches the driver with every declared key, asserted on the constructor argument', async () => {
85+
const { seen, importDriverPackage } = capturingDriverPackage();
86+
const factory = await loadTursoDriverFactory({ importDriverPackage });
87+
factory.create(FULL_SPEC);
88+
89+
expect(seen).toHaveLength(1);
90+
// Spelled out rather than compared to the builder alone: this is the list an
91+
// author can point at, and it is what the open-core arm has always honoured.
92+
expect(seen[0]).toEqual({
93+
url: 'libsql://my-db.turso.io',
94+
authToken: 'jwt-token',
95+
encryptionKey: 'aes-256-key',
96+
concurrency: 7,
97+
syncUrl: 'libsql://replica.turso.io',
98+
sync: { intervalSeconds: 30, onConnect: false },
99+
timeout: 9000,
100+
mode: 'replica',
101+
schemaMode: 'external',
102+
});
103+
});
104+
105+
// The anti-drift half. The pin above would go green again on a SECOND
106+
// hand-written list that happened to agree today — which is precisely how the
107+
// first two lists came to disagree. This one fails unless the loader is
108+
// actually building through the shared derivation.
109+
it('builds through the shared builder, so a new key cannot reach one loader only', async () => {
110+
const { seen, importDriverPackage } = capturingDriverPackage();
111+
const factory = await loadTursoDriverFactory({ importDriverPackage });
112+
factory.create(FULL_SPEC);
113+
114+
expect(seen[0]).toEqual(buildTursoDriverConfig(FULL_SPEC, 'libsql://my-db.turso.io'));
115+
expect(Object.keys(seen[0] as object).sort()).toEqual([...TURSO_DRIVER_CONFIG_KEYS].sort());
116+
});
117+
118+
// Absent ≠ present-and-undefined: `@libsql/client` reads some options by
119+
// presence, and the open-core arm has always spread-omitted rather than
120+
// passing `undefined` through.
121+
it('omits keys the datasource did not declare rather than passing undefined', async () => {
122+
const { seen, importDriverPackage } = capturingDriverPackage();
123+
const factory = await loadTursoDriverFactory({ importDriverPackage });
124+
factory.create({ name: 'default', driver: 'turso', config: { url: 'file:./data/objectstack.db' } });
125+
126+
expect(seen[0]).toEqual({ url: 'file:./data/objectstack.db' });
127+
expect(Object.keys(seen[0] as object)).toEqual(['url']);
128+
});
129+
130+
// Shared url resolution, which this loader did not have: it tested
131+
// `typeof url === 'string'` without trimming, so a whitespace-only url reached
132+
// `@libsql/client` instead of the named refusal the open-core arm gives.
133+
it('refuses a whitespace-only url by name instead of handing it to the driver', async () => {
134+
const { seen, importDriverPackage } = capturingDriverPackage();
135+
const factory = await loadTursoDriverFactory({
136+
importDriverPackage,
137+
missingUrlError: (message) => new Error(message),
138+
});
139+
140+
expect(() => factory.create({ name: 'default', driver: 'turso', config: { url: ' ' } }))
141+
.toThrow(/needs a libSQL url/);
142+
expect(seen).toHaveLength(0);
143+
});
144+
});
145+
146+
describe('#7314 point 2 — one MissingDriverPackageError class across the seam', () => {
147+
// The declaration moved DOWN (runtime depends on service-datasource, so this
148+
// is the only legal direction) and is re-exported from its old home. Asserted
149+
// by object identity: a second same-named class would satisfy every other
150+
// assertion in this file.
151+
it('the runtime export IS the service-datasource class object', () => {
152+
expect(MissingDriverPackageError).toBe(OpenCoreMissingDriverPackageError);
153+
});
154+
155+
it('the host loader raises an error the open-core binding matches', async () => {
156+
const err = await loadTursoDriverFactory({
157+
importDriverPackage: async () => { throw new Error("Cannot find package '@objectstack/driver-turso'"); },
158+
}).then(() => null, (e: unknown) => e);
159+
160+
expect(err).toBeInstanceOf(OpenCoreMissingDriverPackageError);
161+
expect((err as InstanceType<typeof MissingDriverPackageError>).driverType).toBe('turso');
162+
});
163+
164+
// THE pin, and the direction that was impossible before this change: the
165+
// open-core arm's failure now satisfies the predicate `serve.ts` runs
166+
// (`e instanceof MissingDriverPackageError`, against the runtime binding).
167+
// Until #7314 that arm raised a plain `Error` and this assertion could not
168+
// have been written.
169+
it('the open-core arm raises an error the runtime binding matches', async () => {
170+
// `@objectstack/driver-turso` is deliberately not a dependency of
171+
// `@objectstack/service-datasource` — that is what "optional" means — so its
172+
// missing-package arm is reachable here without a stub.
173+
let err: unknown = null;
174+
try {
175+
await createDefaultDatasourceDriverFactory()
176+
.create({ name: 'warehouse', driver: 'turso', config: { url: 'libsql://my-db.turso.io' } });
177+
} catch (e) {
178+
err = e;
179+
}
180+
181+
if (err === null) {
182+
throw new Error(
183+
'@objectstack/driver-turso resolved from @objectstack/service-datasource, so this case no '
184+
+ 'longer exercises the missing-package arm. If the package was made a dependency, this '
185+
+ 'assertion is the notice that the pin needs a stubbed import instead.',
186+
);
187+
}
188+
expect(err).toBeInstanceOf(MissingDriverPackageError);
189+
expect((err as InstanceType<typeof MissingDriverPackageError>).installCommand)
190+
.toBe('npm install @objectstack/driver-turso');
191+
});
192+
});

0 commit comments

Comments
 (0)