Skip to content

Commit a68aec1

Browse files
committed
refactor(runtime,cli): give the optional Turso/libSQL loader one owner (#6268)
The libSQL/Turso host driver factory existed twice — runtime's turso-driver-factory.ts (#5820) and cli's utils/storage-driver.ts (#5602) — kept equal by hand. The drift had already started: #6345 moved the CLI half onto @objectstack/spec's shared driver vocabulary and left the runtime half on a private Set(['turso', 'libsql']). The runtime now owns the loader; the CLI delegates and re-exports. Critically MissingDriverPackageError is ONE class across both packages, because serve.ts decides fatality with `e instanceof MissingDriverPackageError` and two same-named classes would make that predicate silently stop matching. Two things stay host-owned because moving them would change behaviour: the dynamic import() specifier (the optional package is a peer of @objectstack/cli and is not linked under @objectstack/runtime at all) and the error TYPE for a url-less turso config (the CLI's UnsupportedDriverError, which the ruling keeps in the CLI). The message for the latter is shared. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0158ZQo7LiHSxGWpYKuPq1wu
1 parent 5777b1a commit a68aec1

5 files changed

Lines changed: 369 additions & 189 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/runtime": patch
3+
"@objectstack/cli": patch
4+
---
5+
6+
refactor(runtime,cli): give the optional Turso/libSQL loader ONE owner (#6268)
7+
8+
`@objectstack/driver-turso` is an optional install, so neither host can let the
9+
open-core datasource factory build the `default` datasource for a `libsql://`
10+
selection — both inject a host driver factory instead. That loader was written
11+
out **twice**: `packages/runtime/src/turso-driver-factory.ts` (`os migrate` /
12+
`createStandaloneStack` / embedded hosts, #5820) and
13+
`packages/cli/src/utils/storage-driver.ts` (`os serve` / `os start`, #5602). The
14+
two were kept equal **by hand**, which is the #3741#3758 shape: one decision,
15+
two implementations, one of them fixed and the other missed for three months.
16+
17+
It had already begun. #6345 moved the CLI's `isTursoDriverId` onto
18+
`@objectstack/spec`'s shared driver vocabulary and left the runtime half on a
19+
private `Set(['turso', 'libsql'])` — equal only because the spec table's `turso`
20+
row happens to list exactly those two aliases today.
21+
22+
**The runtime now owns it and the CLI consumes it.** `@objectstack/runtime`
23+
exports `loadTursoDriverFactory`, `isTursoDriverId`, `MissingDriverPackageError`,
24+
`TURSO_DRIVER_PACKAGE` and `TURSO_DRIVER_INSTALL_COMMAND`;
25+
`packages/cli/src/utils/storage-driver.ts` re-exports them, so every existing CLI
26+
import site is unchanged. `UnsupportedDriverError` stays in the CLI — it is
27+
CLI-only semantics (a `turso` selection with no URL), not a copy.
28+
29+
**One class identity, deliberately.** `serve.ts` decides whether a boot failure
30+
is fatal with `e instanceof MissingDriverPackageError`. A convergence that left
31+
two same-named classes would make that predicate silently stop matching and
32+
degrade a fatal branch to a non-fatal one with no diagnostic anywhere, so the
33+
CLI re-exports the runtime's class rather than declaring its own, and a test pins
34+
that an error raised by the runtime loader still satisfies the CLI-side
35+
`instanceof`.
36+
37+
**Behaviour, for operators:** unchanged, with one exception. Missing package
38+
still fails loudly with the same `npm install @objectstack/driver-turso`, the
39+
same error fields and no SQLite fallback; a present package still yields the same
40+
factory handle shape. The exception is the missing-package **message**, which is
41+
now one wording for both hosts and therefore names both consequences (a server
42+
booted against an empty local database, and an `os migrate` DDL run against that
43+
same one) instead of only the one its host used to mention.
44+
45+
Two things stay host-owned because moving them would change behaviour: the
46+
dynamic `import()` specifier (it resolves from the node_modules tree of whichever
47+
module evaluates it, and the package is an optional **peer** of
48+
`@objectstack/cli` that `@objectstack/runtime` does not declare at all), and the
49+
error TYPE for a url-less turso config. Only the message for the latter is
50+
shared.

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

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,22 @@ import {
1010
resolveDriverType,
1111
resolveStorageDefinition,
1212
loadTursoDriverFactory,
13+
isTursoDriverId,
1314
MissingDriverPackageError,
15+
TURSO_DRIVER_INSTALL_COMMAND,
1416
UnsupportedDriverError,
1517
} from './storage-driver.js';
18+
// #6268: the OTHER host's bindings, imported under their own names so the
19+
// identity assertions below compare two real module paths rather than one alias
20+
// of the same import. `@objectstack/runtime` resolves to the BUILT package here,
21+
// exactly as it does for `storage-driver.ts` itself — which is what makes the
22+
// identity pinned below the identity that ships.
23+
import {
24+
loadTursoDriverFactory as loadRuntimeTursoDriverFactory,
25+
isTursoDriverId as runtimeIsTursoDriverId,
26+
MissingDriverPackageError as RuntimeMissingDriverPackageError,
27+
TURSO_DRIVER_INSTALL_COMMAND as RUNTIME_TURSO_DRIVER_INSTALL_COMMAND,
28+
} from '@objectstack/runtime';
1629

1730
describe('inferDriverTypeFromUrl', () => {
1831
it('maps each recognized URL scheme to its canonical driver kind', () => {
@@ -397,3 +410,97 @@ describe('loadTursoDriverFactory: the optional driver package (#5602)', () => {
397410
expect(pinned.authToken).toBe('jwt-token');
398411
});
399412
});
413+
414+
// #6268 — the loader has ONE owner (`@objectstack/runtime`), and this file's
415+
// exports are that owner's declarations rather than hand-aligned copies.
416+
//
417+
// The property under test is CLASS IDENTITY, not wording. `serve.ts:1136` decides
418+
// whether a boot failure is fatal with
419+
//
420+
// if (e instanceof MissingDriverPackageError) throw e;
421+
//
422+
// so a convergence that left two same-named classes — one per package — would
423+
// make that predicate stop matching and degrade a fatal branch to a non-fatal one
424+
// with no diagnostic anywhere. Nothing in the message would change, which is why
425+
// the first case below demonstrates, in-suite, that a message assertion is blind
426+
// to exactly this defect.
427+
describe('#6268 — one loader, one class identity across cli and runtime', () => {
428+
it('the CLI export IS the runtime class object, not a same-named twin', () => {
429+
expect(MissingDriverPackageError).toBe(RuntimeMissingDriverPackageError);
430+
expect(isTursoDriverId).toBe(runtimeIsTursoDriverId);
431+
expect(TURSO_DRIVER_INSTALL_COMMAND).toBe(RUNTIME_TURSO_DRIVER_INSTALL_COMMAND);
432+
});
433+
434+
// THE pin. An error raised by the RUNTIME loader must satisfy the instanceof
435+
// that `serve.ts` performs against the CLI-side binding.
436+
it("an error raised by the runtime loader satisfies serve.ts's CLI-side instanceof", async () => {
437+
const err = await loadRuntimeTursoDriverFactory({
438+
importDriverPackage: async () => { throw new Error("Cannot find module '@objectstack/driver-turso'"); },
439+
}).then(() => null, (e: unknown) => e);
440+
441+
// The predicate serve.ts runs, spelled the way serve.ts spells it.
442+
expect(err instanceof MissingDriverPackageError).toBe(true);
443+
444+
// …and the demonstration that a message assertion could NOT have caught a
445+
// broken identity: a twin declared right here carries the same message and
446+
// the same fields, and passes every assertion except the one above.
447+
class MissingDriverPackageErrorTwin extends Error {
448+
constructor(readonly installCommand: string, message: string) {
449+
super(message);
450+
this.name = 'MissingDriverPackageError';
451+
}
452+
}
453+
const twin = new MissingDriverPackageErrorTwin(
454+
(err as MissingDriverPackageError).installCommand,
455+
(err as Error).message,
456+
);
457+
expect(twin.message).toBe((err as Error).message);
458+
expect(twin.name).toBe((err as Error).name);
459+
expect(twin.installCommand).toBe(TURSO_DRIVER_INSTALL_COMMAND);
460+
expect(twin instanceof MissingDriverPackageError).toBe(false);
461+
});
462+
463+
it('and the reverse: the CLI loader raises an error the runtime binding matches', async () => {
464+
const err = await loadTursoDriverFactory({
465+
importDriverPackage: async () => { throw new Error('nope'); },
466+
}).then(() => null, (e: unknown) => e);
467+
expect(err instanceof RuntimeMissingDriverPackageError).toBe(true);
468+
expect((err as MissingDriverPackageError).installCommand).toBe('npm install @objectstack/driver-turso');
469+
});
470+
471+
// The one thing the convergence deliberately did NOT move: the dynamic
472+
// import's specifier, whose RESOLUTION ROOT is the module that evaluates it.
473+
// `@objectstack/driver-turso` is an optional PEER of `@objectstack/cli` and is
474+
// not declared by `@objectstack/runtime` at all, so under pnpm's strict layout
475+
// it is linked into the CLI's node_modules and not the runtime's. Had the CLI
476+
// taken the runtime's default thunk, an operator who ran the exact install
477+
// command this error prints would still be told the package was missing.
478+
//
479+
// This case pins the CLI half — the default thunk finds the package that is
480+
// installed next to the CLI. The runtime half is pinned from the other side by
481+
// `standalone-stack.libsql.test.ts`, where a `libsql://` boot with no injected
482+
// thunk takes the missing-package arm precisely because the runtime does not
483+
// declare it. Together they assert that the two roots are still distinct.
484+
it('resolves the optional package from the CLI’s own node_modules by default', async () => {
485+
const factory = await loadTursoDriverFactory();
486+
expect(factory.supports('turso')).toBe(true);
487+
expect(factory.supports('libsql')).toBe(true);
488+
expect(factory.supports('sqlite')).toBe(false);
489+
});
490+
491+
// The CLI-only semantics the ruling keeps on this side: a url-less turso config
492+
// is refused as `UnsupportedDriverError`, which serve.ts re-throws as fatal —
493+
// while the runtime's own default keeps raising its `[StandaloneStack]` error
494+
// for the same message. Only the TYPE is host-chosen; the wording is shared.
495+
it('keeps UnsupportedDriverError as the CLI’s url-less refusal, with the shared wording', async () => {
496+
const factory = await loadTursoDriverFactory({
497+
importDriverPackage: async () => ({ TursoDriver: class { constructor(_c: unknown) {} } }),
498+
});
499+
let err: unknown;
500+
try { factory.create({ name: 'default', driver: 'turso', config: {} }); } catch (e) { err = e; }
501+
expect(err).toBeInstanceOf(UnsupportedDriverError);
502+
expect((err as Error).message).toMatch(/needs a libSQL url/);
503+
// Not the standalone stack's prefix — that host keeps its own error type.
504+
expect((err as Error).message).not.toMatch(/\[StandaloneStack\]/);
505+
});
506+
});

0 commit comments

Comments
 (0)