@objectstack/service-datasource 7.6.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.
Files changed (37) hide show
  1. package/.turbo/turbo-build.log +28 -0
  2. package/CHANGELOG.md +94 -0
  3. package/LICENSE +202 -0
  4. package/LICENSE.apache +202 -0
  5. package/README.md +50 -0
  6. package/dist/contracts/index.cjs +1 -0
  7. package/dist/contracts/index.cjs.map +1 -0
  8. package/dist/contracts/index.d.cts +178 -0
  9. package/dist/contracts/index.d.ts +178 -0
  10. package/dist/contracts/index.js +1 -0
  11. package/dist/contracts/index.js.map +1 -0
  12. package/dist/index.cjs +995 -0
  13. package/dist/index.cjs.map +1 -0
  14. package/dist/index.d.cts +414 -0
  15. package/dist/index.d.ts +414 -0
  16. package/dist/index.js +995 -0
  17. package/dist/index.js.map +1 -0
  18. package/package.json +61 -0
  19. package/src/__tests__/admin-routes.test.ts +106 -0
  20. package/src/__tests__/datasource-admin-plugin.test.ts +231 -0
  21. package/src/__tests__/datasource-admin-service.test.ts +288 -0
  22. package/src/__tests__/datasource-secret-binder.test.ts +101 -0
  23. package/src/__tests__/external-datasource-service.test.ts +360 -0
  24. package/src/admin-routes.ts +117 -0
  25. package/src/contracts/datasource-admin-service.ts +119 -0
  26. package/src/contracts/datasource-driver-factory.ts +77 -0
  27. package/src/contracts/index.ts +18 -0
  28. package/src/datasource-admin-plugin.ts +362 -0
  29. package/src/datasource-admin-service.ts +297 -0
  30. package/src/datasource-secret-binder.ts +144 -0
  31. package/src/default-datasource-driver-factory.ts +185 -0
  32. package/src/external-datasource-service.ts +456 -0
  33. package/src/index.ts +73 -0
  34. package/src/logger.ts +11 -0
  35. package/src/plugin.ts +119 -0
  36. package/tsconfig.json +17 -0
  37. package/tsup.config.ts +19 -0
@@ -0,0 +1,414 @@
1
+ import { IExternalDatasourceService, IntrospectedSchema, RemoteTable, GenerateDraftOpts, ObjectDraft, ImportObjectOpts, ImportObjectResult, SchemaValidationResult, SchemaValidationReport, ICryptoProvider, IHttpServer } from '@objectstack/spec/contracts';
2
+ import { ExternalCatalog } from '@objectstack/spec/data';
3
+ import { Plugin, PluginContext } from '@objectstack/core';
4
+ import { IDatasourceAdminService, TestConnectionResult, SecretInput, DatasourceSummary, DatasourceDraft, IDatasourceDriverFactory } from './contracts/index.js';
5
+ export { DatasourceConnectionSpec, DatasourceDriverHandle, DatasourceOrigin } from './contracts/index.js';
6
+
7
+ /**
8
+ * ExternalDatasourceService — implements {@link IExternalDatasourceService}
9
+ * (ADR-0015 §6) on top of driver introspection.
10
+ *
11
+ * The service is intentionally decoupled from the kernel: all I/O
12
+ * (introspection, metadata reads) is injected via
13
+ * {@link ExternalDatasourceServiceConfig}, so the introspection/draft/validate
14
+ * logic is pure and unit-testable. The kernel plugin wires the real
15
+ * `IDataEngine` + `IMetadataService` callbacks in.
16
+ */
17
+
18
+ /** Minimal datasource shape the service reads (subset of `Datasource`). */
19
+ interface DatasourceLike {
20
+ name: string;
21
+ schemaMode?: 'managed' | 'external' | 'validate-only';
22
+ external?: {
23
+ allowedSchemas?: string[];
24
+ validation?: {
25
+ onMismatch?: 'fail' | 'warn' | 'ignore';
26
+ };
27
+ };
28
+ }
29
+ /** Minimal object shape the service reads (subset of `ServiceObject`). */
30
+ interface ObjectLike {
31
+ name: string;
32
+ label?: string;
33
+ datasource?: string;
34
+ external?: {
35
+ remoteName?: string;
36
+ remoteSchema?: string;
37
+ columnMap?: Record<string, string>;
38
+ ignoreColumns?: string[];
39
+ };
40
+ fields?: Record<string, {
41
+ type?: string;
42
+ required?: boolean;
43
+ }>;
44
+ }
45
+ interface Logger$1 {
46
+ warn: (message: string, meta?: unknown) => void;
47
+ info?: (message: string, meta?: unknown) => void;
48
+ }
49
+ /**
50
+ * Injected dependencies. The plugin supplies real implementations backed by
51
+ * the driver registry and `IMetadataService`; tests supply fakes.
52
+ */
53
+ interface ExternalDatasourceServiceConfig {
54
+ /** Introspect a datasource's live schema via its driver. */
55
+ introspect: (datasource: string) => Promise<IntrospectedSchema>;
56
+ /** Resolve a datasource definition by name. */
57
+ getDatasource: (name: string) => Promise<DatasourceLike | undefined>;
58
+ /** Resolve one object definition by name. */
59
+ getObject: (name: string) => Promise<ObjectLike | undefined>;
60
+ /** List all object definitions (for `validateAll`). */
61
+ listObjects: () => Promise<ObjectLike[]>;
62
+ /**
63
+ * Persist a refreshed catalog snapshot as an `external_catalog` metadata
64
+ * record. Optional: when absent, `refreshCatalog` still returns the snapshot
65
+ * but does not cache it (e.g. dev runs without a writable metadata store).
66
+ */
67
+ persistCatalog?: (catalog: ExternalCatalog) => Promise<void>;
68
+ /**
69
+ * Persist an imported object definition as a live (runtime-origin) `object`
70
+ * metadata record. Optional: when absent, {@link ExternalDatasourceService.importObject}
71
+ * throws (the deployment is GitOps-only / has no writable metadata store).
72
+ */
73
+ persistObject?: (name: string, definition: Record<string, unknown>) => Promise<void>;
74
+ logger?: Logger$1;
75
+ }
76
+ declare class ExternalDatasourceService implements IExternalDatasourceService {
77
+ private readonly config;
78
+ constructor(config: ExternalDatasourceServiceConfig);
79
+ private get logger();
80
+ private findTable;
81
+ listRemoteTables(datasource: string, opts?: {
82
+ schema?: string;
83
+ }): Promise<RemoteTable[]>;
84
+ generateObjectDraft(datasource: string, remoteName: string, opts?: GenerateDraftOpts): Promise<ObjectDraft>;
85
+ importObject(datasource: string, remoteName: string, opts?: ImportObjectOpts): Promise<ImportObjectResult>;
86
+ refreshCatalog(datasource: string): Promise<ExternalCatalog>;
87
+ validateObject(objectName: string): Promise<SchemaValidationResult>;
88
+ validateAll(): Promise<SchemaValidationReport>;
89
+ }
90
+
91
+ interface ExternalDatasourceServicePluginOptions {
92
+ /** Override the introspection function (mainly for tests). */
93
+ introspect?: (datasource: string) => Promise<IntrospectedSchema>;
94
+ logger?: Logger$1;
95
+ }
96
+ /**
97
+ * ExternalDatasourceServicePlugin — registers `IExternalDatasourceService`
98
+ * into the kernel as the `'external-datasource'` service (ADR-0015 §6.1).
99
+ *
100
+ * It bridges the decoupled {@link ExternalDatasourceService} to the live
101
+ * `IDataEngine` (for driver introspection) and `IMetadataService` (for object
102
+ * + datasource reads).
103
+ */
104
+ declare class ExternalDatasourceServicePlugin implements Plugin {
105
+ name: string;
106
+ version: string;
107
+ type: "standard";
108
+ dependencies: string[];
109
+ private service?;
110
+ private readonly options;
111
+ constructor(options?: ExternalDatasourceServicePluginOptions);
112
+ init(ctx: PluginContext): Promise<void>;
113
+ start(ctx: PluginContext): Promise<void>;
114
+ destroy(): Promise<void>;
115
+ }
116
+
117
+ /**
118
+ * Minimal logger surface used by the runtime-admin half of this package.
119
+ * (Structurally identical to the federation service's own `Logger`; kept
120
+ * separate so the admin modules carry no internal import coupling.)
121
+ */
122
+ interface Logger {
123
+ warn: (message: string, meta?: unknown) => void;
124
+ info?: (message: string, meta?: unknown) => void;
125
+ }
126
+
127
+ /**
128
+ * DatasourceAdminService — implements {@link IDatasourceAdminService}
129
+ * (ADR-0015 Addendum) on top of injected persistence + secret + driver probe
130
+ * callbacks.
131
+ *
132
+ * Like its federation sibling `ExternalDatasourceService`, this service is
133
+ * intentionally decoupled from the kernel: every side effect (connection probe,
134
+ * metadata read/write, secret write, bound-object count, hot pool (de)register)
135
+ * is injected via {@link DatasourceAdminServiceConfig}, so the lifecycle rules
136
+ * (origin gating, secret indirection, removal safety) are pure and unit-testable.
137
+ *
138
+ * Invariants enforced here, independent of the wiring:
139
+ * - Code-defined datasources (`origin: 'code'`) are read-only — update/remove
140
+ * reject them, and create refuses a name a code datasource already owns.
141
+ * - A runtime datasource never shadows a code one (code wins on collision).
142
+ * - Credentials never persist in cleartext: the cleartext {@link SecretInput}
143
+ * transits create/update/test only; create/update write it to the secret
144
+ * store and persist only the returned `credentialsRef`.
145
+ * - Removal is refused while objects are still bound to the datasource.
146
+ */
147
+
148
+ /**
149
+ * A persisted datasource record (subset of `Datasource`). `origin` distinguishes
150
+ * code-defined from runtime; `external.credentialsRef` is the opaque secret
151
+ * handle — never a cleartext credential.
152
+ */
153
+ interface StoredDatasource {
154
+ name: string;
155
+ label?: string;
156
+ driver: string;
157
+ schemaMode?: 'managed' | 'external' | 'validate-only';
158
+ config?: Record<string, unknown>;
159
+ external?: (Record<string, unknown> & {
160
+ credentialsRef?: string;
161
+ }) | undefined;
162
+ pool?: Record<string, unknown>;
163
+ active?: boolean;
164
+ origin?: 'code' | 'runtime';
165
+ /** Package that defines a code-origin datasource, when known. */
166
+ definedIn?: string;
167
+ }
168
+ /** What a connection probe needs (cleartext secret is transient, never stored). */
169
+ interface ProbeInput {
170
+ driver: string;
171
+ config: Record<string, unknown>;
172
+ /** Cleartext secret used for this probe only (e.g. password / DSN). */
173
+ secret?: string;
174
+ external?: Record<string, unknown>;
175
+ timeoutMs?: number;
176
+ }
177
+ /**
178
+ * Injected dependencies. The plugin supplies real implementations backed by the
179
+ * driver registry, `IMetadataService` (runtime store), and the secret store;
180
+ * tests supply fakes.
181
+ */
182
+ interface DatasourceAdminServiceConfig {
183
+ /** Probe a connection live (driver connect + cheap round-trip). */
184
+ probe: (input: ProbeInput) => Promise<TestConnectionResult>;
185
+ /** Read every datasource record (code + runtime). */
186
+ listDatasourceRecords: () => Promise<StoredDatasource[]>;
187
+ /** Read one datasource record by name. */
188
+ getDatasourceRecord: (name: string) => Promise<StoredDatasource | undefined>;
189
+ /** Persist a runtime datasource record into the runtime metadata store. */
190
+ putDatasourceRecord: (record: StoredDatasource) => Promise<void>;
191
+ /** Remove a runtime datasource record from the runtime metadata store. */
192
+ deleteDatasourceRecord: (name: string) => Promise<void>;
193
+ /** Encrypt + store a secret, returning an opaque `credentialsRef`. */
194
+ writeSecret: (input: SecretInput, hint: {
195
+ name: string;
196
+ }) => Promise<string>;
197
+ /** Best-effort delete of a stored secret by ref (cleanup on remove/rewrap). */
198
+ removeSecret?: (credentialsRef: string) => Promise<void>;
199
+ /** Count objects bound to a datasource (removal blocked while > 0). */
200
+ countBoundObjects: (datasource: string) => Promise<number>;
201
+ /** Hot-(re)register a runtime datasource's connection pool after write. */
202
+ registerPool?: (record: StoredDatasource) => Promise<void> | void;
203
+ /** Tear down a runtime datasource's pool on remove. */
204
+ unregisterPool?: (name: string) => Promise<void> | void;
205
+ logger?: Logger;
206
+ }
207
+ declare class DatasourceAdminService implements IDatasourceAdminService {
208
+ private readonly config;
209
+ constructor(config: DatasourceAdminServiceConfig);
210
+ private get logger();
211
+ listDatasources(): Promise<DatasourceSummary[]>;
212
+ testConnection(input: DatasourceDraft, secret?: SecretInput): Promise<TestConnectionResult>;
213
+ createDatasource(input: DatasourceDraft, secret?: SecretInput): Promise<DatasourceSummary>;
214
+ updateDatasource(name: string, patch: Partial<DatasourceDraft>, secret?: SecretInput): Promise<DatasourceSummary>;
215
+ removeDatasource(name: string): Promise<void>;
216
+ private assertValidName;
217
+ private toRecord;
218
+ private toSummary;
219
+ private tryRegisterPool;
220
+ private tryUnregisterPool;
221
+ private tryRemoveSecret;
222
+ }
223
+
224
+ /**
225
+ * Host-provided secret binding. Encrypts a cleartext credential into the secret
226
+ * store and returns an opaque `credentialsRef`; `unbind` deletes it. Wired by
227
+ * the stack that owns the `ICryptoProvider` + `sys_secret` store. When absent,
228
+ * the plugin fails *closed*: creating/updating a datasource *with* a secret
229
+ * throws rather than risk persisting cleartext.
230
+ */
231
+ interface SecretBinder {
232
+ bind: (input: {
233
+ value: string;
234
+ namespace?: string;
235
+ key?: string;
236
+ }, hint: {
237
+ name: string;
238
+ }) => Promise<string>;
239
+ unbind?: (credentialsRef: string) => Promise<void>;
240
+ /**
241
+ * Dereference a `credentialsRef` back to cleartext for opening a live
242
+ * connection (boot rehydration + hot pool registration). Optional: when
243
+ * absent, pools for secret-bearing datasources are built without the
244
+ * credential (fine for credential-less drivers like sqlite/memory).
245
+ */
246
+ resolve?: (credentialsRef: string) => Promise<string | undefined>;
247
+ }
248
+ interface DatasourceAdminServicePluginOptions {
249
+ /** Secret binding backed by the host's crypto provider + `sys_secret`. */
250
+ secrets?: SecretBinder;
251
+ /** Override the driver factory (defaults to the `'datasource-driver-factory'` service). */
252
+ driverFactory?: IDatasourceDriverFactory;
253
+ logger?: Logger;
254
+ }
255
+ /**
256
+ * DatasourceAdminServicePlugin — registers `IDatasourceAdminService` into the
257
+ * kernel as the `'datasource-admin'` service (ADR-0015 Addendum).
258
+ *
259
+ * Bridges the decoupled {@link DatasourceAdminService} to live infrastructure:
260
+ * - persistence + bound-object count via the `'metadata'` service
261
+ * (`register`/`unregister` write through to the runtime DB loader),
262
+ * - connection probe + hot pool (de)registration via the
263
+ * `'datasource-driver-factory'` capability and the `'data'` engine,
264
+ * - secret encryption via a host-provided {@link SecretBinder} (fail-closed).
265
+ *
266
+ * Every dependency degrades gracefully: a missing driver factory turns
267
+ * `testConnection` into a clear `{ ok: false }` and skips hot pool registration
268
+ * (the driver is picked up at next boot); a missing secret binder makes
269
+ * secret-bearing create/update fail loudly instead of leaking cleartext.
270
+ */
271
+ declare class DatasourceAdminServicePlugin implements Plugin {
272
+ name: string;
273
+ version: string;
274
+ type: "standard";
275
+ dependencies: string[];
276
+ private service?;
277
+ private config?;
278
+ private readonly options;
279
+ constructor(options?: DatasourceAdminServicePluginOptions);
280
+ init(ctx: PluginContext): Promise<void>;
281
+ start(ctx: PluginContext): Promise<void>;
282
+ /**
283
+ * Boot-time rehydration: list persisted runtime datasources and re-register
284
+ * each one's connection pool (driver build → connect → registerDriver),
285
+ * decrypting its `sys_secret` credential on the way via the configured
286
+ * `registerPool` (which resolves `credentialsRef`). Code-defined datasources
287
+ * are owned by the host stack's own boot path and skipped here. Entirely
288
+ * best-effort: a missing factory/engine, an unpersisted dev store (nothing
289
+ * to rehydrate), or a single failing pool never blocks boot.
290
+ */
291
+ private rehydratePools;
292
+ destroy(): Promise<void>;
293
+ private toSpec;
294
+ /** Probe a connection via the driver factory: build → connect → ping → close. */
295
+ private probe;
296
+ }
297
+
298
+ /**
299
+ * Default (dev/self-host) implementation of {@link IDatasourceDriverFactory}.
300
+ *
301
+ * The framework ships no universal "driver-by-id" registry — concrete drivers
302
+ * are constructed by the host stack (ADR-0015 Addendum §3.5). This factory is
303
+ * the host-side glue that lets the runtime-datasource lifecycle
304
+ * (`IDatasourceAdminService`) build a live driver from an *unsaved* draft so it
305
+ * can probe a connection before "Save" and hot-register a pool afterwards.
306
+ *
307
+ * Supported driver ids map onto the same open-core drivers the standalone
308
+ * stack auto-detects:
309
+ * - `postgres` / `pg` / `postgresql` → `@objectstack/driver-sql` (client `pg`)
310
+ * - `sqlite` / `sqlite3` → `@objectstack/driver-sql` (better-sqlite3)
311
+ * - `mongodb` / `mongo` → `@objectstack/driver-mongodb` (peer dep)
312
+ * - `memory` / `inmemory` → `@objectstack/driver-memory`
313
+ *
314
+ * Anything else returns `supports() === false`, so the admin service degrades
315
+ * gracefully (testConnection → `{ ok: false }`, create skips hot pool reg).
316
+ *
317
+ * SECURITY: the cleartext `spec.secret` is used only to open the connection and
318
+ * is never persisted or logged here.
319
+ */
320
+
321
+ /**
322
+ * Create the default datasource driver factory. Driver packages are imported
323
+ * lazily so a host that never builds (e.g.) a mongo connection doesn't pay for
324
+ * the mongo SDK.
325
+ */
326
+ declare function createDefaultDatasourceDriverFactory(): IDatasourceDriverFactory;
327
+
328
+ /**
329
+ * Default datasource SecretBinder — persists a runtime datasource's cleartext
330
+ * credential into the `sys_secret` cipher store and returns an opaque
331
+ * `credentialsRef` handle (ADR-0015 Addendum, security invariant).
332
+ *
333
+ * Mirrors the SettingsService Phase-3 split: the cleartext is wrapped by an
334
+ * {@link ICryptoProvider} into a {@link CryptoHandle}, the ciphertext lands in a
335
+ * `sys_secret` row keyed by `handle.id`, and only the handle id (wrapped as
336
+ * `sys_secret:<id>`) is ever stored on the datasource artefact. Cleartext never
337
+ * touches metadata.
338
+ *
339
+ * This is the dev/self-host wiring; production hosts swap the
340
+ * `LocalCryptoProvider` for a KMS-backed `ICryptoProvider` and pass it here.
341
+ */
342
+
343
+ /** Minimal data-engine surface used to read/write the `sys_secret` store. */
344
+ interface SecretStoreEngineLike {
345
+ insert(object: string, data: Record<string, unknown>, options?: unknown): Promise<unknown>;
346
+ delete(object: string, options: {
347
+ where: Record<string, unknown>;
348
+ }): Promise<unknown>;
349
+ /**
350
+ * Read `sys_secret` rows for the `resolve()` path. Optional so existing
351
+ * callers that only bind/unbind keep working; `resolve()` no-ops when absent.
352
+ * Mirrors `IDataEngine.find` — returns an array (or `{ data: [...] }`).
353
+ */
354
+ find?(object: string, query: Record<string, unknown>): Promise<unknown>;
355
+ }
356
+ interface DatasourceSecretBinderDeps {
357
+ /** Data engine (ObjectQL) used to persist the `sys_secret` row. */
358
+ engine: SecretStoreEngineLike;
359
+ /** Crypto provider that wraps cleartext into a {@link CryptoHandle}. */
360
+ cryptoProvider: ICryptoProvider;
361
+ /** Settings namespace recorded on the secret row (default `'datasource'`). */
362
+ namespace?: string;
363
+ }
364
+ interface DatasourceSecretBinder {
365
+ bind(input: {
366
+ value: string;
367
+ namespace?: string;
368
+ key?: string;
369
+ }, hint: {
370
+ name: string;
371
+ }): Promise<string>;
372
+ unbind(credentialsRef: string): Promise<void>;
373
+ /**
374
+ * Dereference a `credentialsRef` back to its cleartext credential by reading
375
+ * the `sys_secret` row and decrypting it. Used at boot to rebuild a runtime
376
+ * datasource's live connection pool (the cleartext is never persisted, so it
377
+ * must be recovered from the cipher store). Returns `undefined` when the ref
378
+ * isn't ours, the row is gone, the engine can't read, or decryption fails
379
+ * (e.g. an ephemeral dev key changed across restarts) — callers degrade to
380
+ * skipping that pool rather than crashing boot.
381
+ */
382
+ resolve(credentialsRef: string): Promise<string | undefined>;
383
+ }
384
+ /** Build a `credentialsRef` from a crypto handle id. */
385
+ declare function toCredentialsRef(handleId: string): string;
386
+ /** Extract the `sys_secret` handle id from a credentialsRef, if it is one. */
387
+ declare function parseCredentialsRef(ref: string): string | undefined;
388
+ /**
389
+ * Create the default datasource secret binder. Persists into `sys_secret` via
390
+ * the data engine and never returns or logs the cleartext.
391
+ */
392
+ declare function createDatasourceSecretBinder(deps: DatasourceSecretBinderDeps): DatasourceSecretBinder;
393
+
394
+ /**
395
+ * Datasource lifecycle REST routes (ADR-0015 Addendum §3.5).
396
+ *
397
+ * Mounted under `/api/v1/datasources` and served by the `datasource-admin`
398
+ * service. Every route degrades gracefully
399
+ * (`503 datasource_admin_unavailable`) when the service is not wired in, and
400
+ * lifecycle/validation failures surface as `400` with the service's message.
401
+ *
402
+ * GET /datasources → listDatasources (provenance + health)
403
+ * POST /datasources/test → testConnection (no persistence)
404
+ * POST /datasources → createDatasource (origin: 'runtime')
405
+ * PATCH /datasources/:name → updateDatasource (runtime only)
406
+ * DELETE /datasources/:name → removeDatasource (runtime only)
407
+ *
408
+ * Request bodies carry the connection draft inline with an optional cleartext
409
+ * `secret` field; the route splits `secret` out so it never reaches the draft
410
+ * the service persists.
411
+ */
412
+ declare function registerDatasourceAdminRoutes(server: IHttpServer, ctx: PluginContext, basePath?: string): void;
413
+
414
+ export { DatasourceAdminService, type DatasourceAdminServiceConfig, DatasourceAdminServicePlugin, type DatasourceAdminServicePluginOptions, DatasourceDraft, type DatasourceLike, type DatasourceSecretBinder, type DatasourceSecretBinderDeps, DatasourceSummary, ExternalDatasourceService, type ExternalDatasourceServiceConfig, ExternalDatasourceServicePlugin, type ExternalDatasourceServicePluginOptions, IDatasourceAdminService, IDatasourceDriverFactory, type Logger$1 as Logger, type ObjectLike, type ProbeInput, type SecretBinder, SecretInput, type SecretStoreEngineLike, type StoredDatasource, TestConnectionResult, createDatasourceSecretBinder, createDefaultDatasourceDriverFactory, parseCredentialsRef, registerDatasourceAdminRoutes, toCredentialsRef };