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