@objectstack/service-datasource 10.0.0 → 10.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +28 -16
- package/CHANGELOG.md +100 -0
- package/dist/chunk-76HQ74MX.cjs +82 -0
- package/dist/chunk-76HQ74MX.cjs.map +1 -0
- package/dist/chunk-BI2SYWLC.cjs +9 -0
- package/dist/chunk-BI2SYWLC.cjs.map +1 -0
- package/dist/chunk-JRBGOCRJ.js +82 -0
- package/dist/chunk-JRBGOCRJ.js.map +1 -0
- package/dist/chunk-XLS4RP7B.js +9 -0
- package/dist/chunk-XLS4RP7B.js.map +1 -0
- package/dist/contracts/index.cjs +7 -1
- package/dist/contracts/index.cjs.map +1 -1
- package/dist/contracts/index.d.cts +59 -1
- package/dist/contracts/index.d.ts +59 -1
- package/dist/contracts/index.js +6 -0
- package/dist/index.cjs +284 -106
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +270 -5
- package/dist/index.d.ts +270 -5
- package/dist/index.js +216 -38
- package/dist/index.js.map +1 -1
- package/dist/sqlite-driver-fallback-BPFQYLX7.js +11 -0
- package/dist/sqlite-driver-fallback-BPFQYLX7.js.map +1 -0
- package/dist/sqlite-driver-fallback-JX4XOICD.cjs +11 -0
- package/dist/sqlite-driver-fallback-JX4XOICD.cjs.map +1 -0
- package/package.json +8 -7
- package/src/__tests__/datasource-connection-service.test.ts +294 -0
- package/src/contracts/connect-policy.ts +69 -0
- package/src/contracts/index.ts +11 -0
- package/src/datasource-admin-plugin.ts +37 -40
- package/src/datasource-admin-service.ts +2 -0
- package/src/datasource-connection-service.ts +364 -0
- package/src/default-datasource-driver-factory.ts +26 -9
- package/src/index.ts +29 -0
- package/src/logger.ts +2 -0
- package/src/sqlite-driver-fallback.test.ts +184 -0
- package/src/sqlite-driver-fallback.ts +195 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DatasourceConnectionService — the single "definition → live driver" path
|
|
5
|
+
* (ADR-0062 D1).
|
|
6
|
+
*
|
|
7
|
+
* Given a datasource definition, it: consults the injectable connect policy
|
|
8
|
+
* (D5/epic seam), builds a driver via the host-provided driver factory,
|
|
9
|
+
* resolves any `external.credentialsRef` to a cleartext secret via the
|
|
10
|
+
* `SecretBinder` (D3, wired in Phase 2), opens the connection, and registers
|
|
11
|
+
* the live driver + the datasource *definition* into the ObjectQL engine under
|
|
12
|
+
* the datasource name (the engine routes by `driver.name === <datasource>`).
|
|
13
|
+
*
|
|
14
|
+
* Both origins converge here (D1):
|
|
15
|
+
* - **code-defined** datasources auto-connect at boot via
|
|
16
|
+
* {@link connectDeclared} (gated per D2 — see {@link isDatasourceAddressed}),
|
|
17
|
+
* called from `AppPlugin.start()`.
|
|
18
|
+
* - **runtime** (UI-created) datasources connect via {@link connect}, called
|
|
19
|
+
* from `DatasourceAdminServicePlugin`'s `registerPool` (create/update + boot
|
|
20
|
+
* rehydration).
|
|
21
|
+
*
|
|
22
|
+
* Idempotent: a datasource already registered as a live driver is skipped, so
|
|
23
|
+
* an app's legacy `onEnable` driver registration (the escape hatch, ADR-0062
|
|
24
|
+
* D8) and auto-connect never double-register.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type {
|
|
28
|
+
IDatasourceDriverFactory,
|
|
29
|
+
DatasourceConnectionSpec,
|
|
30
|
+
} from './contracts/datasource-driver-factory.js';
|
|
31
|
+
import {
|
|
32
|
+
allowAllConnectPolicy,
|
|
33
|
+
type DatasourceConnectPolicy,
|
|
34
|
+
type DatasourceConnectContext,
|
|
35
|
+
} from './contracts/connect-policy.js';
|
|
36
|
+
import type { Logger } from './logger.js';
|
|
37
|
+
|
|
38
|
+
/** A datasource definition this service can connect (code- or runtime-origin). */
|
|
39
|
+
export interface ConnectableDatasource {
|
|
40
|
+
name: string;
|
|
41
|
+
label?: string;
|
|
42
|
+
driver: string;
|
|
43
|
+
schemaMode?: 'managed' | 'external' | 'validate-only';
|
|
44
|
+
config?: Record<string, unknown>;
|
|
45
|
+
external?: (Record<string, unknown> & {
|
|
46
|
+
credentialsRef?: string;
|
|
47
|
+
validation?: { onMismatch?: 'fail' | 'warn' | 'ignore' };
|
|
48
|
+
}) | undefined;
|
|
49
|
+
pool?: Record<string, unknown>;
|
|
50
|
+
active?: boolean;
|
|
51
|
+
origin?: 'code' | 'runtime';
|
|
52
|
+
/**
|
|
53
|
+
* ADR-0062 D2(c): explicit opt-in to auto-connect even for a managed,
|
|
54
|
+
* unrouted datasource. Defaults to false.
|
|
55
|
+
*/
|
|
56
|
+
autoConnect?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Minimal object shape used for the D2 routing gate + post-connect schema sync. */
|
|
60
|
+
export interface DatasourceBoundObject {
|
|
61
|
+
name?: string;
|
|
62
|
+
/** The object's explicit `datasource` binding (ADR-0015 federation). */
|
|
63
|
+
datasource?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Engine surface this service drives (the ObjectQL `'data'` engine). */
|
|
67
|
+
export interface ConnectionEngineLike {
|
|
68
|
+
registerDriver?: (driver: unknown, isDefault?: boolean) => void;
|
|
69
|
+
registerDatasourceDef?: (def: {
|
|
70
|
+
name: string;
|
|
71
|
+
schemaMode?: string;
|
|
72
|
+
external?: { allowWrites?: boolean };
|
|
73
|
+
}) => void;
|
|
74
|
+
getDriverByName?: (name: string) => unknown;
|
|
75
|
+
/**
|
|
76
|
+
* Register read metadata (DDL-free) for a federated object so its physical
|
|
77
|
+
* remote table/columns resolve for queries. Idempotent; called per bound
|
|
78
|
+
* external object after the driver is registered, because boot schema-sync
|
|
79
|
+
* ran before this driver existed (ADR-0015 §18; matches what the legacy
|
|
80
|
+
* `onEnable` bridge does manually).
|
|
81
|
+
*/
|
|
82
|
+
syncObjectSchema?: (objectName: string) => Promise<void>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Secret dereference surface (the `SecretBinder.resolve`, Phase 2 / D3). */
|
|
86
|
+
export interface ConnectionSecretResolver {
|
|
87
|
+
resolve?: (credentialsRef: string) => Promise<string | undefined>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface DatasourceConnectionServiceConfig {
|
|
91
|
+
/** Resolve the host driver factory (lazy — may be registered after init). */
|
|
92
|
+
factory: () => IDatasourceDriverFactory | undefined;
|
|
93
|
+
/** Resolve the ObjectQL engine (lazy). */
|
|
94
|
+
engine: () => ConnectionEngineLike | undefined;
|
|
95
|
+
/** Dereference `credentialsRef` → cleartext (Phase 2). Optional in Phase 1. */
|
|
96
|
+
secrets?: ConnectionSecretResolver;
|
|
97
|
+
/** Injectable connect policy. Defaults to {@link allowAllConnectPolicy}. */
|
|
98
|
+
policy?: DatasourceConnectPolicy;
|
|
99
|
+
logger?: Logger;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Outcome of a single {@link DatasourceConnectionService.connect} attempt. */
|
|
103
|
+
export type ConnectStatus =
|
|
104
|
+
| 'connected'
|
|
105
|
+
| 'already-registered'
|
|
106
|
+
| 'skipped-policy'
|
|
107
|
+
| 'skipped-no-infra'
|
|
108
|
+
| 'skipped-unsupported'
|
|
109
|
+
| 'failed-credentials'
|
|
110
|
+
| 'failed-degraded';
|
|
111
|
+
|
|
112
|
+
export interface ConnectResult {
|
|
113
|
+
name: string;
|
|
114
|
+
status: ConnectStatus;
|
|
115
|
+
reason?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* ADR-0062 D2 — is this declared datasource "meaningfully addressed", such that
|
|
120
|
+
* auto-connecting it is safe and intended?
|
|
121
|
+
*
|
|
122
|
+
* Returns true when:
|
|
123
|
+
* - (a) it is external (`schemaMode !== 'managed'`), OR
|
|
124
|
+
* - (b) some object **explicitly** binds to it (`object.datasource === name`), OR
|
|
125
|
+
* - (c) it sets `autoConnect: true`.
|
|
126
|
+
*
|
|
127
|
+
* Deliberately NOT triggered by a `datasourceMapping` rule alone. A managed
|
|
128
|
+
* datasource that is only *mapped* (namespace/package/default) but has no live
|
|
129
|
+
* driver historically falls through to the `default` driver at query time
|
|
130
|
+
* (`engine.getDriver` step 4) — e.g. `examples/app-crm`'s `crm_primary`
|
|
131
|
+
* (`:memory:`, mapped + default-fallback, no `onEnable`). Connecting it would
|
|
132
|
+
* divert those objects to a fresh, empty connection and silently change app
|
|
133
|
+
* behavior. So mapping-only routing to a *managed* datasource is treated as
|
|
134
|
+
* decorative, keeping existing apps byte-for-byte unchanged (D2's load-bearing
|
|
135
|
+
* backward-compat guarantee). External datasources and explicit
|
|
136
|
+
* `object.datasource` bindings never resolved to `default` (they throw when
|
|
137
|
+
* unregistered), so auto-connecting them is a strict improvement, not a change.
|
|
138
|
+
*/
|
|
139
|
+
export function isDatasourceAddressed(
|
|
140
|
+
ds: Pick<ConnectableDatasource, 'name' | 'schemaMode' | 'autoConnect'>,
|
|
141
|
+
ctx: { objects?: readonly DatasourceBoundObject[] },
|
|
142
|
+
): boolean {
|
|
143
|
+
if (ds.schemaMode && ds.schemaMode !== 'managed') return true; // (a)
|
|
144
|
+
if (ds.autoConnect === true) return true; // (c)
|
|
145
|
+
if (ctx.objects?.some((o) => o?.datasource === ds.name)) return true; // (b)
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export class DatasourceConnectionService {
|
|
150
|
+
private readonly cfg: DatasourceConnectionServiceConfig;
|
|
151
|
+
private readonly policy: DatasourceConnectPolicy;
|
|
152
|
+
private readonly logger?: Logger;
|
|
153
|
+
|
|
154
|
+
constructor(cfg: DatasourceConnectionServiceConfig) {
|
|
155
|
+
this.cfg = cfg;
|
|
156
|
+
this.policy = cfg.policy ?? allowAllConnectPolicy;
|
|
157
|
+
this.logger = cfg.logger;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Auto-connect the declared (code-defined) datasources that pass the D2 gate.
|
|
162
|
+
* Called from `AppPlugin.start()` with the app bundle's datasources + objects.
|
|
163
|
+
* Each connected external datasource also has its bound objects' read metadata
|
|
164
|
+
* synced so they are immediately queryable with zero app code.
|
|
165
|
+
*/
|
|
166
|
+
async connectDeclared(input: {
|
|
167
|
+
datasources: readonly ConnectableDatasource[];
|
|
168
|
+
objects?: readonly DatasourceBoundObject[];
|
|
169
|
+
}): Promise<ConnectResult[]> {
|
|
170
|
+
const objects = input.objects ?? [];
|
|
171
|
+
const results: ConnectResult[] = [];
|
|
172
|
+
for (const ds of input.datasources) {
|
|
173
|
+
if (!ds?.name) continue;
|
|
174
|
+
if (ds.active === false) continue;
|
|
175
|
+
if (!isDatasourceAddressed(ds, { objects })) continue; // D2 gate
|
|
176
|
+
const bound = objects
|
|
177
|
+
.filter((o) => o?.datasource === ds.name && typeof o?.name === 'string')
|
|
178
|
+
.map((o) => o.name as string);
|
|
179
|
+
results.push(
|
|
180
|
+
await this.connect(ds, { objects: bound, context: { origin: ds.origin ?? 'code', trigger: 'declared-auto' } }),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return results;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Build + connect + register a single datasource's live driver. The shared
|
|
188
|
+
* core used by both auto-connect and the runtime-admin pool registration.
|
|
189
|
+
*
|
|
190
|
+
* Failure policy (ADR-0062 D5): an `external` datasource with
|
|
191
|
+
* `validation.onMismatch: 'fail'` fails fast (re-throws, bricking boot as
|
|
192
|
+
* intended); everything else degrades with a warning so an optional replica's
|
|
193
|
+
* connectivity blip never bricks boot.
|
|
194
|
+
*/
|
|
195
|
+
async connect(
|
|
196
|
+
record: ConnectableDatasource,
|
|
197
|
+
opts: { objects?: readonly string[]; context?: DatasourceConnectContext } = {},
|
|
198
|
+
): Promise<ConnectResult> {
|
|
199
|
+
const name = record.name;
|
|
200
|
+
const engine = this.cfg.engine();
|
|
201
|
+
const factory = this.cfg.factory();
|
|
202
|
+
|
|
203
|
+
// Idempotent: never double-register (e.g. a legacy `onEnable` bridge already
|
|
204
|
+
// registered this driver — the D8 escape hatch).
|
|
205
|
+
if (engine?.getDriverByName?.(name)) {
|
|
206
|
+
return { name, status: 'already-registered' };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Policy gate (fail-closed on throw).
|
|
210
|
+
let decision;
|
|
211
|
+
try {
|
|
212
|
+
decision = await this.policy.canConnect(
|
|
213
|
+
{ name, driver: record.driver, schemaMode: record.schemaMode, external: record.external },
|
|
214
|
+
opts.context,
|
|
215
|
+
);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
decision = { allow: false, reason: `connect policy threw: ${errMsg(err)}` };
|
|
218
|
+
}
|
|
219
|
+
if (!decision.allow) {
|
|
220
|
+
this.logger?.info?.(`datasource '${name}': connect denied by policy${decision.reason ? ` (${decision.reason})` : ''}`);
|
|
221
|
+
return { name, status: 'skipped-policy', reason: decision.reason };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!factory || !engine?.registerDriver) {
|
|
225
|
+
this.logger?.debug?.(`datasource '${name}': no driver factory / engine — left metadata-only`);
|
|
226
|
+
return { name, status: 'skipped-no-infra' };
|
|
227
|
+
}
|
|
228
|
+
if (!factory.supports(record.driver)) {
|
|
229
|
+
return this.handleFailure(
|
|
230
|
+
record,
|
|
231
|
+
'skipped-unsupported',
|
|
232
|
+
`no driver factory supports driver '${record.driver}'`,
|
|
233
|
+
opts.context,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Credential resolution (ADR-0062 D3) — FAIL-CLOSED, and done *before* the
|
|
238
|
+
// build try-block so a fail-fast verdict propagates (rather than being
|
|
239
|
+
// swallowed and re-classified by the catch below). A declared
|
|
240
|
+
// `external.credentialsRef` MUST resolve to a cleartext secret before we
|
|
241
|
+
// open a connection: building a driver without it would silently connect
|
|
242
|
+
// with no/wrong auth (or fail later with a confusing driver error). So an
|
|
243
|
+
// absent secret store, or an unresolvable/undecryptable ref, leaves the
|
|
244
|
+
// datasource unconnected with a clear message — never a silent skip.
|
|
245
|
+
let secret: string | undefined;
|
|
246
|
+
const credentialsRef = record.external?.credentialsRef;
|
|
247
|
+
if (credentialsRef) {
|
|
248
|
+
const resolver = this.cfg.secrets?.resolve;
|
|
249
|
+
if (!resolver) {
|
|
250
|
+
return this.handleFailure(
|
|
251
|
+
record,
|
|
252
|
+
'failed-credentials',
|
|
253
|
+
`requires credential '${credentialsRef}' but no secret store (SecretBinder/ICryptoProvider) is configured`,
|
|
254
|
+
opts.context,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
secret = await resolver(credentialsRef);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
return this.handleFailure(record, 'failed-credentials', `resolving credential '${credentialsRef}' threw: ${errMsg(err)}`, opts.context);
|
|
261
|
+
}
|
|
262
|
+
if (secret == null || secret === '') {
|
|
263
|
+
return this.handleFailure(
|
|
264
|
+
record,
|
|
265
|
+
'failed-credentials',
|
|
266
|
+
`credential '${credentialsRef}' could not be resolved or decrypted (missing sys_secret row, or the encryption key changed)`,
|
|
267
|
+
opts.context,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
const handle = await factory.create({ ...toSpec(record), ...(secret ? { secret } : {}) });
|
|
274
|
+
if (typeof handle?.connect === 'function') await handle.connect();
|
|
275
|
+
|
|
276
|
+
// The engine routes a datasource to a driver by `driver.name === <datasource>`.
|
|
277
|
+
// Prefer the factory's underlying engine driver (the `driver` escape hatch);
|
|
278
|
+
// fall back to the handle. Stamp the name so routing resolves to this pool.
|
|
279
|
+
const engineDriver = (handle.driver ?? handle) as { name?: string };
|
|
280
|
+
try {
|
|
281
|
+
engineDriver.name = name;
|
|
282
|
+
} catch {
|
|
283
|
+
/* frozen driver — registration may still work if name already matches */
|
|
284
|
+
}
|
|
285
|
+
engine.registerDriver(engineDriver);
|
|
286
|
+
engine.registerDatasourceDef?.({
|
|
287
|
+
name,
|
|
288
|
+
schemaMode: record.schemaMode,
|
|
289
|
+
external: record.external as { allowWrites?: boolean } | undefined,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Register read metadata for bound federated objects (DDL-free). Boot
|
|
293
|
+
// schema-sync ran before this driver existed, so do it on-demand now.
|
|
294
|
+
for (const objectName of opts.objects ?? []) {
|
|
295
|
+
try {
|
|
296
|
+
await engine.syncObjectSchema?.(objectName);
|
|
297
|
+
} catch (err) {
|
|
298
|
+
this.logger?.warn?.(`datasource '${name}': syncObjectSchema('${objectName}') failed: ${errMsg(err)}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
this.logger?.info?.(`datasource '${name}': connected (driver=${record.driver}, schemaMode=${record.schemaMode ?? 'managed'})`);
|
|
303
|
+
return { name, status: 'connected' };
|
|
304
|
+
} catch (err) {
|
|
305
|
+
return this.handleFailure(record, 'failed-degraded', errMsg(err), opts.context);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Gracefully disconnect a previously-registered datasource pool. */
|
|
310
|
+
async disconnect(name: string): Promise<void> {
|
|
311
|
+
const driver = this.cfg.engine()?.getDriverByName?.(name) as { disconnect?: () => Promise<void> } | undefined;
|
|
312
|
+
if (typeof driver?.disconnect === 'function') {
|
|
313
|
+
try {
|
|
314
|
+
await driver.disconnect();
|
|
315
|
+
} catch (err) {
|
|
316
|
+
this.logger?.warn?.(`datasource '${name}': disconnect failed: ${errMsg(err)}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Apply the D5 connect-failure policy (also covers D3 credential failures). A
|
|
323
|
+
* code-defined `external` datasource with `onMismatch:'fail'` auto-connected at
|
|
324
|
+
* boot re-throws (fail-fast, bricking boot as intended). Runtime-admin
|
|
325
|
+
* create/update + boot rehydration always degrade-with-warning — a UI action
|
|
326
|
+
* or a replica blip must never brick the running server (preserves the
|
|
327
|
+
* pre-ADR-0062 admin behavior). Either way the datasource is left unconnected
|
|
328
|
+
* with a clear message — never a silent skip.
|
|
329
|
+
*/
|
|
330
|
+
private handleFailure(
|
|
331
|
+
record: ConnectableDatasource,
|
|
332
|
+
status: ConnectStatus,
|
|
333
|
+
reason: string,
|
|
334
|
+
context?: DatasourceConnectContext,
|
|
335
|
+
): ConnectResult {
|
|
336
|
+
const isExternal = record.schemaMode && record.schemaMode !== 'managed';
|
|
337
|
+
const failFast =
|
|
338
|
+
context?.trigger === 'declared-auto' &&
|
|
339
|
+
isExternal &&
|
|
340
|
+
record.external?.validation?.onMismatch === 'fail';
|
|
341
|
+
const msg = `datasource '${record.name}': connect failed — ${reason}`;
|
|
342
|
+
if (failFast) {
|
|
343
|
+
throw new Error(
|
|
344
|
+
`${msg}. (schemaMode=${record.schemaMode}, validation.onMismatch='fail' ⇒ fail-fast per ADR-0062 D5)`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
this.logger?.warn?.(`${msg} — degrading (datasource left unconnected)`);
|
|
348
|
+
return { name: record.name, status, reason };
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function toSpec(record: ConnectableDatasource): DatasourceConnectionSpec {
|
|
353
|
+
return {
|
|
354
|
+
name: record.name,
|
|
355
|
+
driver: record.driver,
|
|
356
|
+
config: record.config ?? {},
|
|
357
|
+
external: record.external,
|
|
358
|
+
pool: record.pool,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function errMsg(err: unknown): string {
|
|
363
|
+
return err instanceof Error ? err.message : String(err);
|
|
364
|
+
}
|
|
@@ -113,7 +113,19 @@ function buildMongoUrl(spec: DatasourceConnectionSpec): string {
|
|
|
113
113
|
* lazily so a host that never builds (e.g.) a mongo connection doesn't pay for
|
|
114
114
|
* the mongo SDK.
|
|
115
115
|
*/
|
|
116
|
-
export
|
|
116
|
+
export interface DefaultDatasourceDriverFactoryOptions {
|
|
117
|
+
/**
|
|
118
|
+
* Enables the dev-only native-`better-sqlite3` → wasm → in-memory step-down
|
|
119
|
+
* for sqlite construction (#2229). When omitted, defaults per call to
|
|
120
|
+
* `process.env.NODE_ENV === 'development'`. In production a native load
|
|
121
|
+
* failure is NOT silently swapped for a different engine (fail-closed).
|
|
122
|
+
*/
|
|
123
|
+
dev?: boolean;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function createDefaultDatasourceDriverFactory(
|
|
127
|
+
options: DefaultDatasourceDriverFactoryOptions = {},
|
|
128
|
+
): IDatasourceDriverFactory {
|
|
117
129
|
return {
|
|
118
130
|
supports(driverId: string): boolean {
|
|
119
131
|
return resolveKind(driverId) !== undefined;
|
|
@@ -140,14 +152,19 @@ export function createDefaultDatasourceDriverFactory(): IDatasourceDriverFactory
|
|
|
140
152
|
}
|
|
141
153
|
|
|
142
154
|
if (kind === 'sqlite') {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
|
|
155
|
+
// better-sqlite3 loads its native addon lazily (first query), so an ABI
|
|
156
|
+
// mismatch is invisible at construction and crashes later. resolveSqliteDriver
|
|
157
|
+
// probes up-front and, IN DEV ONLY, steps down to wasm SQLite (real SQL +
|
|
158
|
+
// on-disk persistence) then in-memory; in production it returns the native
|
|
159
|
+
// driver unprobed so a failure surfaces loudly (fail-closed). (#2229)
|
|
160
|
+
const conn = buildSqlConnection(spec, 'better-sqlite3') as { filename?: string };
|
|
161
|
+
const { resolveSqliteDriver } = await import('./sqlite-driver-fallback.js');
|
|
162
|
+
const resolved = await resolveSqliteDriver({
|
|
163
|
+
filename: conn.filename ?? ':memory:',
|
|
164
|
+
dev: options.dev,
|
|
165
|
+
...(schemaMode ? { schemaMode } : {}),
|
|
166
|
+
});
|
|
167
|
+
return toHandle(resolved.driver, () => sqlServerVersion(resolved.driver, 'sqlite'));
|
|
151
168
|
}
|
|
152
169
|
|
|
153
170
|
if (kind === 'mongodb') {
|
package/src/index.ts
CHANGED
|
@@ -39,7 +39,25 @@ export type {
|
|
|
39
39
|
DatasourceConnectionSpec,
|
|
40
40
|
DatasourceDriverHandle,
|
|
41
41
|
IDatasourceDriverFactory,
|
|
42
|
+
// Connect policy (ADR-0062 D5 / epic #2163 seam).
|
|
43
|
+
DatasourceConnectPolicy,
|
|
44
|
+
DatasourceConnectDecision,
|
|
45
|
+
DatasourceConnectContext,
|
|
46
|
+
DatasourceConnectSubject,
|
|
42
47
|
} from './contracts/index.js';
|
|
48
|
+
export { allowAllConnectPolicy } from './contracts/index.js';
|
|
49
|
+
|
|
50
|
+
// Shared "definition → live driver" path (ADR-0062 D1).
|
|
51
|
+
export { DatasourceConnectionService, isDatasourceAddressed } from './datasource-connection-service.js';
|
|
52
|
+
export type {
|
|
53
|
+
DatasourceConnectionServiceConfig,
|
|
54
|
+
ConnectableDatasource,
|
|
55
|
+
DatasourceBoundObject,
|
|
56
|
+
ConnectionEngineLike,
|
|
57
|
+
ConnectionSecretResolver,
|
|
58
|
+
ConnectResult,
|
|
59
|
+
ConnectStatus,
|
|
60
|
+
} from './datasource-connection-service.js';
|
|
43
61
|
|
|
44
62
|
// Decoupled lifecycle service + injected-config shape.
|
|
45
63
|
export { DatasourceAdminService } from './datasource-admin-service.js';
|
|
@@ -58,6 +76,17 @@ export type {
|
|
|
58
76
|
|
|
59
77
|
// Host glue: dev driver factory + fail-closed secret binder.
|
|
60
78
|
export { createDefaultDatasourceDriverFactory } from './default-datasource-driver-factory.js';
|
|
79
|
+
// Shared native-better-sqlite3 → wasm → in-memory step-down (#2229).
|
|
80
|
+
export {
|
|
81
|
+
resolveSqliteDriver,
|
|
82
|
+
NATIVE_SQLITE_WASM_FALLBACK_WARNING,
|
|
83
|
+
NATIVE_SQLITE_MEMORY_FALLBACK_WARNING,
|
|
84
|
+
} from './sqlite-driver-fallback.js';
|
|
85
|
+
export type {
|
|
86
|
+
ResolveSqliteDriverOptions,
|
|
87
|
+
ResolvedSqliteDriver,
|
|
88
|
+
SqliteFallbackEngine,
|
|
89
|
+
} from './sqlite-driver-fallback.js';
|
|
61
90
|
export {
|
|
62
91
|
createDatasourceSecretBinder,
|
|
63
92
|
toCredentialsRef,
|
package/src/logger.ts
CHANGED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
resolveSqliteDriver,
|
|
6
|
+
NATIVE_SQLITE_WASM_FALLBACK_WARNING,
|
|
7
|
+
NATIVE_SQLITE_MEMORY_FALLBACK_WARNING,
|
|
8
|
+
} from './sqlite-driver-fallback.js';
|
|
9
|
+
|
|
10
|
+
// Shared, mutable test state read by the mocked driver constructors. `vi.hoisted`
|
|
11
|
+
// makes it available inside the hoisted `vi.mock` factories below.
|
|
12
|
+
const state = vi.hoisted(() => ({
|
|
13
|
+
/** Make the native better-sqlite3 driver throw a NODE_MODULE_VERSION-style error. */
|
|
14
|
+
nativeFails: false,
|
|
15
|
+
/** Make the wasm SQLite driver fail to connect (forces the in-memory last resort). */
|
|
16
|
+
wasmFails: false,
|
|
17
|
+
nativeConfigs: [] as any[],
|
|
18
|
+
wasmConfigs: [] as any[],
|
|
19
|
+
memoryCount: 0,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const ABI_ERROR_MESSAGE =
|
|
23
|
+
"The module '/x/better_sqlite3.node' was compiled against a different Node.js version " +
|
|
24
|
+
'using NODE_MODULE_VERSION 141. This version of Node.js requires NODE_MODULE_VERSION 127. ' +
|
|
25
|
+
'Please try re-compiling or re-installing the module.';
|
|
26
|
+
|
|
27
|
+
vi.mock('@objectstack/driver-sql', () => {
|
|
28
|
+
class SqlDriver {
|
|
29
|
+
public readonly name = 'com.objectstack.driver.sql';
|
|
30
|
+
constructor(public readonly config: any) {
|
|
31
|
+
state.nativeConfigs.push(config);
|
|
32
|
+
}
|
|
33
|
+
async connect(): Promise<void> {
|
|
34
|
+
// Mirrors the real driver: connect() runs mkdir + a PRAGMA whose error it
|
|
35
|
+
// swallows — so it is NOT where the ABI failure surfaces.
|
|
36
|
+
}
|
|
37
|
+
async execute(_sql: string): Promise<unknown> {
|
|
38
|
+
// better-sqlite3 loads its native addon lazily at the first query, so the
|
|
39
|
+
// ABI mismatch surfaces here (the SELECT 1 probe), not at construction.
|
|
40
|
+
if (state.nativeFails) throw new Error(ABI_ERROR_MESSAGE);
|
|
41
|
+
return [{ ok: 1 }];
|
|
42
|
+
}
|
|
43
|
+
async disconnect(): Promise<void> {}
|
|
44
|
+
}
|
|
45
|
+
return { SqlDriver };
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
vi.mock('@objectstack/driver-sqlite-wasm', () => {
|
|
49
|
+
class SqliteWasmDriver {
|
|
50
|
+
public readonly name = 'com.objectstack.driver.sqlite-wasm';
|
|
51
|
+
constructor(public readonly config: any) {
|
|
52
|
+
state.wasmConfigs.push(config);
|
|
53
|
+
}
|
|
54
|
+
async connect(): Promise<void> {
|
|
55
|
+
if (state.wasmFails) throw new Error('wasm sqlite failed to initialise');
|
|
56
|
+
}
|
|
57
|
+
async execute(): Promise<unknown> {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
async disconnect(): Promise<void> {}
|
|
61
|
+
}
|
|
62
|
+
return { SqliteWasmDriver };
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
vi.mock('@objectstack/driver-memory', () => {
|
|
66
|
+
class InMemoryDriver {
|
|
67
|
+
public readonly name = 'com.objectstack.driver.memory';
|
|
68
|
+
constructor() {
|
|
69
|
+
state.memoryCount += 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { InMemoryDriver };
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('resolveSqliteDriver — native better-sqlite3 → wasm → in-memory step-down (#2229)', () => {
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
state.nativeFails = false;
|
|
78
|
+
state.wasmFails = false;
|
|
79
|
+
state.nativeConfigs = [];
|
|
80
|
+
state.wasmConfigs = [];
|
|
81
|
+
state.memoryCount = 0;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('uses native better-sqlite3 on the happy path (no fallback, no warning)', async () => {
|
|
85
|
+
const warn = vi.fn();
|
|
86
|
+
const resolved = await resolveSqliteDriver({ filename: ':memory:', dev: true, warn });
|
|
87
|
+
|
|
88
|
+
expect(resolved.engine).toBe('better-sqlite3');
|
|
89
|
+
expect(resolved.label).toBe('SqlDriver(sqlite)');
|
|
90
|
+
expect(resolved.driver.name).toBe('com.objectstack.driver.sql');
|
|
91
|
+
expect(warn).not.toHaveBeenCalled();
|
|
92
|
+
expect(state.wasmConfigs).toHaveLength(0);
|
|
93
|
+
expect(state.memoryCount).toBe(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('falls back to wasm SQLite when the native addon fails to load, emitting the warning', async () => {
|
|
97
|
+
state.nativeFails = true;
|
|
98
|
+
const warn = vi.fn();
|
|
99
|
+
|
|
100
|
+
const resolved = await resolveSqliteDriver({
|
|
101
|
+
filename: '/tmp/proj/.objectstack/data/dev.db',
|
|
102
|
+
dev: true,
|
|
103
|
+
warn,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(resolved.engine).toBe('sqlite-wasm');
|
|
107
|
+
expect(resolved.label).toBe('SqliteWasmDriver');
|
|
108
|
+
expect(resolved.driver.name).toBe('com.objectstack.driver.sqlite-wasm');
|
|
109
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
110
|
+
expect(warn).toHaveBeenCalledWith(NATIVE_SQLITE_WASM_FALLBACK_WARNING);
|
|
111
|
+
// The persistent file path is preserved (real on-disk persistence via wasm).
|
|
112
|
+
expect(state.wasmConfigs[0].filename).toBe('/tmp/proj/.objectstack/data/dev.db');
|
|
113
|
+
expect(state.wasmConfigs[0].persist).toBe('on-write');
|
|
114
|
+
expect(state.memoryCount).toBe(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('uses on-disconnect persistence for an ephemeral :memory: wasm fallback', async () => {
|
|
118
|
+
state.nativeFails = true;
|
|
119
|
+
const resolved = await resolveSqliteDriver({ filename: ':memory:', dev: true, warn: vi.fn() });
|
|
120
|
+
|
|
121
|
+
expect(resolved.engine).toBe('sqlite-wasm');
|
|
122
|
+
expect(state.wasmConfigs[0].filename).toBe(':memory:');
|
|
123
|
+
expect(state.wasmConfigs[0].persist).toBe('on-disconnect');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('drops to InMemoryDriver as a dev-only last resort when neither native nor wasm load', async () => {
|
|
127
|
+
state.nativeFails = true;
|
|
128
|
+
state.wasmFails = true;
|
|
129
|
+
const warn = vi.fn();
|
|
130
|
+
|
|
131
|
+
const resolved = await resolveSqliteDriver({ filename: ':memory:', dev: true, warn });
|
|
132
|
+
|
|
133
|
+
expect(resolved.engine).toBe('memory');
|
|
134
|
+
expect(resolved.label).toBe('InMemoryDriver');
|
|
135
|
+
expect(state.memoryCount).toBe(1);
|
|
136
|
+
expect(warn).toHaveBeenCalledWith(NATIVE_SQLITE_MEMORY_FALLBACK_WARNING);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('forwards autoMigrate / schemaMode to the native driver', async () => {
|
|
140
|
+
await resolveSqliteDriver({ filename: ':memory:', dev: true, autoMigrate: 'safe', schemaMode: 'managed' });
|
|
141
|
+
expect(state.nativeConfigs[0]).toMatchObject({
|
|
142
|
+
client: 'better-sqlite3',
|
|
143
|
+
useNullAsDefault: true,
|
|
144
|
+
autoMigrate: 'safe',
|
|
145
|
+
schemaMode: 'managed',
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('production (dev=false) is fail-closed — returns native unprobed, never degrades', async () => {
|
|
150
|
+
state.nativeFails = true;
|
|
151
|
+
const warn = vi.fn();
|
|
152
|
+
|
|
153
|
+
const resolved = await resolveSqliteDriver({ filename: '/tmp/prod.db', dev: false, warn });
|
|
154
|
+
|
|
155
|
+
// The native driver is handed back as-is so the ABI failure surfaces loudly
|
|
156
|
+
// at first use — we must NOT swap in wasm/mingo behind the operator's back.
|
|
157
|
+
expect(resolved.engine).toBe('better-sqlite3');
|
|
158
|
+
expect(resolved.label).toBe('SqlDriver(sqlite)');
|
|
159
|
+
expect(warn).not.toHaveBeenCalled();
|
|
160
|
+
expect(state.wasmConfigs).toHaveLength(0);
|
|
161
|
+
expect(state.memoryCount).toBe(0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('dev gate defaults to NODE_ENV when not passed explicitly', () => {
|
|
165
|
+
const originalNodeEnv = process.env.NODE_ENV;
|
|
166
|
+
afterAll(() => {
|
|
167
|
+
process.env.NODE_ENV = originalNodeEnv;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('falls back to wasm when dev is omitted and NODE_ENV=development', async () => {
|
|
171
|
+
state.nativeFails = true;
|
|
172
|
+
process.env.NODE_ENV = 'development';
|
|
173
|
+
const resolved = await resolveSqliteDriver({ filename: ':memory:', warn: vi.fn() });
|
|
174
|
+
expect(resolved.engine).toBe('sqlite-wasm');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('is fail-closed when dev is omitted and NODE_ENV=production', async () => {
|
|
178
|
+
state.nativeFails = true;
|
|
179
|
+
process.env.NODE_ENV = 'production';
|
|
180
|
+
const resolved = await resolveSqliteDriver({ filename: ':memory:', warn: vi.fn() });
|
|
181
|
+
expect(resolved.engine).toBe('better-sqlite3');
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|