@objectstack/service-datasource 9.10.0 → 10.0.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.
@@ -1 +1 @@
1
- {"version":3,"sources":["/home/runner/work/framework/framework/packages/services/service-datasource/dist/index.cjs","../src/external-datasource-service.ts","../src/plugin.ts","../src/datasource-admin-service.ts","../src/datasource-admin-plugin.ts","../src/default-datasource-driver-factory.ts","../src/datasource-secret-binder.ts","../src/admin-routes.ts"],"names":[],"mappings":"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","file":"/home/runner/work/framework/framework/packages/services/service-datasource/dist/index.cjs","sourcesContent":[null,"// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * ExternalDatasourceService — implements {@link IExternalDatasourceService}\n * (ADR-0015 §6) on top of driver introspection.\n *\n * The service is intentionally decoupled from the kernel: all I/O\n * (introspection, metadata reads) is injected via\n * {@link ExternalDatasourceServiceConfig}, so the introspection/draft/validate\n * logic is pure and unit-testable. The kernel plugin wires the real\n * `IDataEngine` + `IMetadataService` callbacks in.\n */\n\nimport type {\n IExternalDatasourceService,\n RemoteTable,\n GenerateDraftOpts,\n ObjectDraft,\n ImportObjectOpts,\n ImportObjectResult,\n SchemaValidationResult,\n SchemaValidationReport,\n IntrospectedSchema,\n IntrospectedTable,\n} from '@objectstack/spec/contracts';\nimport type { SchemaDiffEntry } from '@objectstack/spec/shared';\nimport {\n suggestFieldType,\n isCompatible,\n ExternalCatalogSchema,\n type ExternalCatalog,\n type SqlDialect,\n type FieldType,\n} from '@objectstack/spec/data';\n\n/** Minimal datasource shape the service reads (subset of `Datasource`). */\nexport interface DatasourceLike {\n name: string;\n schemaMode?: 'managed' | 'external' | 'validate-only';\n external?: {\n allowedSchemas?: string[];\n validation?: { onMismatch?: 'fail' | 'warn' | 'ignore' };\n };\n}\n\n/** Minimal object shape the service reads (subset of `ServiceObject`). */\nexport interface ObjectLike {\n name: string;\n label?: string;\n datasource?: string;\n external?: {\n remoteName?: string;\n remoteSchema?: string;\n columnMap?: Record<string, string>;\n ignoreColumns?: string[];\n };\n fields?: Record<string, { type?: string; required?: boolean }>;\n}\n\nexport interface Logger {\n warn: (message: string, meta?: unknown) => void;\n info?: (message: string, meta?: unknown) => void;\n}\n\n/**\n * Injected dependencies. The plugin supplies real implementations backed by\n * the driver registry and `IMetadataService`; tests supply fakes.\n */\nexport interface ExternalDatasourceServiceConfig {\n /** Introspect a datasource's live schema via its driver. */\n introspect: (datasource: string) => Promise<IntrospectedSchema>;\n /** Resolve a datasource definition by name. */\n getDatasource: (name: string) => Promise<DatasourceLike | undefined>;\n /** Resolve one object definition by name. */\n getObject: (name: string) => Promise<ObjectLike | undefined>;\n /** List all object definitions (for `validateAll`). */\n listObjects: () => Promise<ObjectLike[]>;\n /**\n * Persist a refreshed catalog snapshot as an `external_catalog` metadata\n * record. Optional: when absent, `refreshCatalog` still returns the snapshot\n * but does not cache it (e.g. dev runs without a writable metadata store).\n */\n persistCatalog?: (catalog: ExternalCatalog) => Promise<void>;\n /**\n * Persist an imported object definition as a live (runtime-origin) `object`\n * metadata record. Optional: when absent, {@link ExternalDatasourceService.importObject}\n * throws (the deployment is GitOps-only / has no writable metadata store).\n */\n persistObject?: (name: string, definition: Record<string, unknown>) => Promise<void>;\n logger?: Logger;\n}\n\n/** Columns ObjectStack manages itself — never validated against the remote. */\nconst BUILTIN_COLUMNS = new Set(['id', 'created_at', 'updated_at']);\n\n/** Split a possibly schema-qualified name (`mart.fact_orders`). */\nfunction parseQualified(raw: string): { schema?: string; name: string } {\n const idx = raw.indexOf('.');\n if (idx === -1) return { name: raw };\n return { schema: raw.slice(0, idx), name: raw.slice(idx + 1) };\n}\n\n/** Normalise a remote table name into a snake_case object name. */\nfunction toObjectName(remoteName: string): string {\n const { name } = parseQualified(remoteName);\n return name\n .replace(/[^a-zA-Z0-9_]/g, '_')\n .replace(/^[^a-z_]/, (c) => `_${c.toLowerCase()}`)\n .toLowerCase();\n}\n\n/** snake_case → Title Case label. */\nfunction toLabel(name: string): string {\n return name\n .split('_')\n .filter(Boolean)\n .map((w) => w.charAt(0).toUpperCase() + w.slice(1))\n .join(' ');\n}\n\nexport class ExternalDatasourceService implements IExternalDatasourceService {\n constructor(private readonly config: ExternalDatasourceServiceConfig) {}\n\n private get logger(): Logger | undefined {\n return this.config.logger;\n }\n\n private findTable(schema: IntrospectedSchema, remoteName: string): IntrospectedTable | undefined {\n const want = parseQualified(remoteName).name;\n for (const table of Object.values(schema.tables)) {\n if (table.name === remoteName) return table;\n if (parseQualified(table.name).name === want) return table;\n }\n return undefined;\n }\n\n async listRemoteTables(\n datasource: string,\n opts?: { schema?: string },\n ): Promise<RemoteTable[]> {\n const [schema, ds] = await Promise.all([\n this.config.introspect(datasource),\n this.config.getDatasource(datasource),\n ]);\n const allowed = ds?.external?.allowedSchemas;\n\n const tables: RemoteTable[] = [];\n for (const table of Object.values(schema.tables)) {\n const { schema: tableSchema, name } = parseQualified(table.name);\n if (opts?.schema && tableSchema && tableSchema !== opts.schema) continue;\n // allowedSchemas only filters tables we can attribute to a schema.\n if (allowed && tableSchema && !allowed.includes(tableSchema)) continue;\n tables.push({ schema: tableSchema, name, columnCount: table.columns.length });\n }\n return tables;\n }\n\n async generateObjectDraft(\n datasource: string,\n remoteName: string,\n opts: GenerateDraftOpts = {},\n ): Promise<ObjectDraft> {\n const schema = await this.config.introspect(datasource);\n const table = this.findTable(schema, remoteName);\n if (!table) {\n throw new Error(\n `Remote table '${remoteName}' not found on datasource '${datasource}'.`,\n );\n }\n const dialect = schema.dialect as SqlDialect | undefined;\n // Derive the remote schema from the matched table's qualified name (the\n // caller may pass an unqualified `remoteName`).\n const matched = parseQualified(table.name);\n const remoteSchema = opts.remoteSchema ?? matched.schema;\n const resolvedRemoteName = matched.name;\n\n const include = opts.includeColumns ? new Set(opts.includeColumns) : undefined;\n const exclude = opts.excludeColumns ? new Set(opts.excludeColumns) : new Set<string>();\n const pkOverride = opts.primaryKey ? new Set(opts.primaryKey) : undefined;\n\n const fields: Record<string, { type: FieldType; primaryKey?: boolean }> = {};\n const review: ObjectDraft['review'] = [];\n\n for (const col of table.columns) {\n if (include && !include.has(col.name)) continue;\n if (exclude.has(col.name)) continue;\n\n const fieldName = opts.rename?.[col.name] ?? col.name;\n const suggested = suggestFieldType(col.type, dialect);\n const fieldType: FieldType = suggested ?? 'text';\n if (!suggested) {\n review.push({\n column: col.name,\n remoteType: col.type,\n note: `unrecognised remote type — defaulted to 'text', verify`,\n });\n } else if (isCompatible(col.type, fieldType, dialect) === 'lossy') {\n review.push({\n column: col.name,\n remoteType: col.type,\n note: `mapped lossy to '${fieldType}'`,\n });\n }\n\n const isPk = pkOverride ? pkOverride.has(col.name) : col.primaryKey;\n fields[fieldName] = isPk ? { type: fieldType, primaryKey: true } : { type: fieldType };\n }\n\n const name = toObjectName(resolvedRemoteName);\n const definition: Record<string, unknown> = {\n name,\n label: toLabel(name),\n datasource,\n external: {\n ...(remoteSchema ? { remoteSchema } : {}),\n remoteName: resolvedRemoteName,\n },\n fields,\n };\n\n return {\n name,\n datasource,\n definition,\n source: renderObjectSource(definition, fields, review),\n review,\n };\n }\n\n async importObject(\n datasource: string,\n remoteName: string,\n opts: ImportObjectOpts = {},\n ): Promise<ImportObjectResult> {\n if (!this.config.persistObject) {\n throw new Error(\n `importObject requires a writable metadata store, but none is wired ` +\n `(datasource '${datasource}'). This deployment may be GitOps-only — ` +\n `use 'os datasource introspect' and commit the generated *.object.ts instead.`,\n );\n }\n\n // Reuse the draft pipeline (type mapping, review notes, external binding).\n const draft = await this.generateObjectDraft(datasource, remoteName, opts);\n\n // Apply the runtime-persona overrides on top of the draft definition.\n const name = opts.name ?? draft.name;\n const external = {\n ...(draft.definition.external as Record<string, unknown>),\n ...(opts.writable ? { writable: true } : {}),\n };\n const definition: Record<string, unknown> = {\n ...draft.definition,\n name,\n label: toLabel(name),\n external,\n };\n\n await this.config.persistObject(name, definition);\n this.logger?.info?.(`importObject: persisted '${name}' from ${datasource}.${remoteName}`, {\n writable: opts.writable === true,\n review: draft.review.length,\n });\n\n return { name, definition, review: draft.review };\n }\n\n async refreshCatalog(datasource: string): Promise<ExternalCatalog> {\n const schema = await this.config.introspect(datasource);\n // Parse through the Zod schema so the persisted record is canonical\n // (defaults applied, shape validated) and matches the `external_catalog`\n // metadata type the boot gate + Studio read back.\n const catalog = ExternalCatalogSchema.parse({\n name: `${datasource}_catalog`,\n datasource,\n snapshotAt: new Date().toISOString(),\n dialect: schema.dialect,\n tables: Object.values(schema.tables).map((t) => {\n const { schema: s, name } = parseQualified(t.name);\n return {\n remoteSchema: s,\n remoteName: name,\n columns: t.columns.map((c) => ({\n name: c.name,\n sqlType: c.type,\n nullable: c.nullable,\n primaryKey: c.primaryKey,\n suggestedFieldType: suggestFieldType(c.type, schema.dialect as SqlDialect),\n })),\n };\n }),\n }) as ExternalCatalog;\n\n // Best-effort cache: a failure to persist must not fail the refresh — the\n // caller still gets the live snapshot back.\n if (this.config.persistCatalog) {\n try {\n await this.config.persistCatalog(catalog);\n } catch (err) {\n this.logger?.warn?.(`refreshCatalog: failed to persist '${catalog.name}'`, err);\n }\n }\n\n return catalog;\n }\n\n async validateObject(objectName: string): Promise<SchemaValidationResult> {\n const obj = await this.config.getObject(objectName);\n if (!obj) {\n throw new Error(`Object '${objectName}' not found.`);\n }\n const datasource = obj.datasource ?? 'default';\n const ds = await this.config.getDatasource(datasource);\n\n // Not a federated object → nothing to validate.\n if (!ds || !ds.schemaMode || ds.schemaMode === 'managed') {\n return { ok: true, datasource, object: objectName, diffs: [] };\n }\n\n const schema = await this.config.introspect(datasource);\n const dialect = schema.dialect as SqlDialect | undefined;\n const remoteName = obj.external?.remoteName ?? obj.name;\n const table = this.findTable(schema, remoteName);\n\n const diffs: SchemaDiffEntry[] = [];\n\n if (!table) {\n diffs.push({\n kind: 'missing_table',\n remoteSchema: obj.external?.remoteSchema,\n remoteName,\n severity: 'error',\n });\n return { ok: false, datasource, object: objectName, diffs };\n }\n\n const columnsByName = new Map(table.columns.map((c) => [c.name, c]));\n const ignore = new Set(obj.external?.ignoreColumns ?? []);\n // columnMap is remoteColumn → fieldName; invert for field → remoteColumn.\n const fieldToRemote = new Map<string, string>();\n for (const [remoteCol, fieldName] of Object.entries(obj.external?.columnMap ?? {})) {\n fieldToRemote.set(fieldName, remoteCol);\n }\n\n for (const [fieldName, field] of Object.entries(obj.fields ?? {})) {\n if (BUILTIN_COLUMNS.has(fieldName)) continue;\n const remoteCol = fieldToRemote.get(fieldName) ?? fieldName;\n if (ignore.has(remoteCol)) continue;\n\n const col = columnsByName.get(remoteCol);\n if (!col) {\n diffs.push({\n kind: 'missing_column',\n remoteName,\n column: remoteCol,\n severity: 'error',\n });\n continue;\n }\n const fieldType = (field.type ?? 'text') as FieldType;\n const compat = isCompatible(col.type, fieldType, dialect);\n if (compat === false) {\n diffs.push({\n kind: 'type_mismatch',\n remoteName,\n column: remoteCol,\n expected: fieldType,\n actual: col.type,\n severity: 'error',\n });\n } else if (compat === 'lossy') {\n diffs.push({\n kind: 'type_mismatch',\n remoteName,\n column: remoteCol,\n expected: fieldType,\n actual: col.type,\n severity: 'warning',\n });\n }\n }\n\n const ok = !diffs.some((d) => d.severity === 'error');\n return { ok, datasource, object: objectName, diffs };\n }\n\n async validateAll(): Promise<SchemaValidationReport> {\n const objects = await this.config.listObjects();\n const federated = objects.filter(\n (o) => o.external !== undefined || (o.datasource && o.datasource !== 'default'),\n );\n\n const results = await Promise.all(\n federated.map((o) =>\n this.validateObject(o.name).catch((err): SchemaValidationResult => {\n this.logger?.warn(`validateObject('${o.name}') failed`, err);\n return {\n ok: false,\n datasource: o.datasource ?? 'default',\n object: o.name,\n diffs: [\n {\n kind: 'missing_table',\n remoteName: o.external?.remoteName ?? o.name,\n actual: err instanceof Error ? err.message : String(err),\n severity: 'error',\n },\n ],\n };\n }),\n ),\n );\n\n const ok = results.every((r) => r.ok);\n return { ok, results };\n }\n}\n\n/** Render a reviewable `*.object.ts` source string for an object draft. */\nfunction renderObjectSource(\n definition: Record<string, unknown>,\n fields: Record<string, { type: FieldType; primaryKey?: boolean }>,\n review: ObjectDraft['review'],\n): string {\n const reviewByColumn = new Map(review.map((r) => [r.column, r.note]));\n const external = definition.external as { remoteSchema?: string; remoteName?: string };\n\n const fieldLines = Object.entries(fields).map(([fieldName, f]) => {\n const note = reviewByColumn.get(fieldName);\n const pk = f.primaryKey ? ', primaryKey: true' : '';\n const comment = note ? ` // REVIEW: ${note}` : '';\n return ` ${fieldName}: { type: '${f.type}'${pk} },${comment}`;\n });\n\n const externalLine = external.remoteSchema\n ? ` external: { remoteSchema: '${external.remoteSchema}', remoteName: '${external.remoteName}' },`\n : ` external: { remoteName: '${external.remoteName}' },`;\n\n return [\n `// Generated by \\`os datasource introspect\\` (ADR-0015). Review before committing.`,\n `import type { ServiceObjectInput } from '@objectstack/spec/data';`,\n ``,\n `const ${definition.name as string}: ServiceObjectInput = {`,\n ` name: '${definition.name as string}',`,\n ` label: '${definition.label as string}',`,\n ` datasource: '${definition.datasource as string}',`,\n externalLine,\n ` fields: {`,\n ...fieldLines,\n ` },`,\n `};`,\n ``,\n `export default ${definition.name as string};`,\n ``,\n ].join('\\n');\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport type { IntrospectedSchema } from '@objectstack/spec/contracts';\nimport {\n ExternalDatasourceService,\n type ExternalDatasourceServiceConfig,\n type DatasourceLike,\n type ObjectLike,\n type Logger,\n} from './external-datasource-service.js';\n\n/**\n * Minimal surfaces the plugin needs from the data engine + metadata service.\n * Kept structural so the plugin doesn't hard-depend on concrete classes.\n */\ninterface DataEngineLike {\n /** Resolve a driver by datasource name and introspect its live schema. */\n introspectDatasource?: (datasource: string) => Promise<IntrospectedSchema>;\n getDatasourceDriver?: (datasource: string) => { introspectSchema?: () => Promise<IntrospectedSchema> } | undefined;\n}\n\ninterface MetadataServiceLike {\n get: (type: string, name: string) => Promise<unknown>;\n getObject?: (name: string) => Promise<unknown>;\n listObjects?: () => Promise<unknown[]>;\n list?: (type: string) => Promise<unknown[]>;\n register?: (type: string, name: string, data: unknown) => Promise<void> | void;\n}\n\nexport interface ExternalDatasourceServicePluginOptions {\n /** Override the introspection function (mainly for tests). */\n introspect?: (datasource: string) => Promise<IntrospectedSchema>;\n logger?: Logger;\n}\n\n/**\n * ExternalDatasourceServicePlugin — registers `IExternalDatasourceService`\n * into the kernel as the `'external-datasource'` service (ADR-0015 §6.1).\n *\n * It bridges the decoupled {@link ExternalDatasourceService} to the live\n * `IDataEngine` (for driver introspection) and `IMetadataService` (for object\n * + datasource reads).\n */\nexport class ExternalDatasourceServicePlugin implements Plugin {\n name = 'com.objectstack.service-external-datasource';\n version = '1.0.0';\n type = 'standard' as const;\n dependencies: string[] = [];\n\n private service?: ExternalDatasourceService;\n private readonly options: ExternalDatasourceServicePluginOptions;\n\n constructor(options: ExternalDatasourceServicePluginOptions = {}) {\n this.options = options;\n }\n\n async init(ctx: PluginContext): Promise<void> {\n const engine = safeGetService<DataEngineLike>(ctx, 'data');\n const metadata = safeGetService<MetadataServiceLike>(ctx, 'metadata');\n\n const introspect: ExternalDatasourceServiceConfig['introspect'] =\n this.options.introspect ??\n (async (datasource: string) => {\n if (engine?.introspectDatasource) return engine.introspectDatasource(datasource);\n const driver = engine?.getDatasourceDriver?.(datasource);\n if (driver?.introspectSchema) return driver.introspectSchema();\n throw new Error(\n `Cannot introspect datasource '${datasource}': no driver introspection available.`,\n );\n });\n\n const config: ExternalDatasourceServiceConfig = {\n introspect,\n getDatasource: async (n) => (await metadata?.get('datasource', n)) as DatasourceLike | undefined,\n getObject: async (n) =>\n (metadata?.getObject ? await metadata.getObject(n) : await metadata?.get('object', n)) as ObjectLike | undefined,\n listObjects: async () =>\n ((metadata?.listObjects\n ? await metadata.listObjects()\n : await metadata?.list?.('object')) ?? []) as ObjectLike[],\n // Persist the refreshed snapshot as an `external_catalog` metadata record\n // so the boot gate + Studio's schema browser can read it without\n // re-introspecting. No-op when the metadata service can't write.\n ...(metadata?.register\n ? {\n persistCatalog: async (catalog) => {\n await metadata.register!('external_catalog', catalog.name, catalog);\n },\n // Runtime \"Import as Object\": persist a federated object so it's\n // immediately queryable, no git commit required (ADR-0015 Addendum).\n persistObject: async (name, definition) => {\n await metadata.register!('object', name, definition);\n },\n }\n : {}),\n logger: this.options.logger,\n };\n\n this.service = new ExternalDatasourceService(config);\n ctx.registerService('external-datasource', this.service);\n }\n\n async start(ctx: PluginContext): Promise<void> {\n if (this.service) await ctx.trigger('external-datasource:ready', this.service);\n }\n\n async destroy(): Promise<void> {\n this.service = undefined;\n }\n}\n\nfunction safeGetService<T>(ctx: PluginContext, name: string): T | undefined {\n try {\n return ctx.getService<T>(name);\n } catch {\n return undefined;\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * DatasourceAdminService — implements {@link IDatasourceAdminService}\n * (ADR-0015 Addendum) on top of injected persistence + secret + driver probe\n * callbacks.\n *\n * Like its federation sibling `ExternalDatasourceService`, this service is\n * intentionally decoupled from the kernel: every side effect (connection probe,\n * metadata read/write, secret write, bound-object count, hot pool (de)register)\n * is injected via {@link DatasourceAdminServiceConfig}, so the lifecycle rules\n * (origin gating, secret indirection, removal safety) are pure and unit-testable.\n *\n * Invariants enforced here, independent of the wiring:\n * - Code-defined datasources (`origin: 'code'`) are read-only — update/remove\n * reject them, and create refuses a name a code datasource already owns.\n * - A runtime datasource never shadows a code one (code wins on collision).\n * - Credentials never persist in cleartext: the cleartext {@link SecretInput}\n * transits create/update/test only; create/update write it to the secret\n * store and persist only the returned `credentialsRef`.\n * - Removal is refused while objects are still bound to the datasource.\n */\n\nimport type {\n IDatasourceAdminService,\n DatasourceDraft,\n SecretInput,\n TestConnectionResult,\n DatasourceSummary,\n} from './contracts/index.js';\nimport type { Logger } from './logger.js';\n\n/** Datasource name rule (mirrors `DatasourceSchema.name`). */\nconst NAME_RE = /^[a-z_][a-z0-9_]*$/;\n\n/**\n * A persisted datasource record (subset of `Datasource`). `origin` distinguishes\n * code-defined from runtime; `external.credentialsRef` is the opaque secret\n * handle — never a cleartext credential.\n */\nexport interface StoredDatasource {\n name: string;\n label?: string;\n driver: string;\n schemaMode?: 'managed' | 'external' | 'validate-only';\n config?: Record<string, unknown>;\n external?: (Record<string, unknown> & { credentialsRef?: string }) | undefined;\n pool?: Record<string, unknown>;\n active?: boolean;\n origin?: 'code' | 'runtime';\n /** Package that defines a code-origin datasource, when known. */\n definedIn?: string;\n}\n\n/** What a connection probe needs (cleartext secret is transient, never stored). */\nexport interface ProbeInput {\n driver: string;\n config: Record<string, unknown>;\n /** Cleartext secret used for this probe only (e.g. password / DSN). */\n secret?: string;\n external?: Record<string, unknown>;\n timeoutMs?: number;\n}\n\n/**\n * Injected dependencies. The plugin supplies real implementations backed by the\n * driver registry, `IMetadataService` (runtime store), and the secret store;\n * tests supply fakes.\n */\nexport interface DatasourceAdminServiceConfig {\n /** Probe a connection live (driver connect + cheap round-trip). */\n probe: (input: ProbeInput) => Promise<TestConnectionResult>;\n /** Read every datasource record (code + runtime). */\n listDatasourceRecords: () => Promise<StoredDatasource[]>;\n /** Read one datasource record by name. */\n getDatasourceRecord: (name: string) => Promise<StoredDatasource | undefined>;\n /** Persist a runtime datasource record into the runtime metadata store. */\n putDatasourceRecord: (record: StoredDatasource) => Promise<void>;\n /** Remove a runtime datasource record from the runtime metadata store. */\n deleteDatasourceRecord: (name: string) => Promise<void>;\n /** Encrypt + store a secret, returning an opaque `credentialsRef`. */\n writeSecret: (input: SecretInput, hint: { name: string }) => Promise<string>;\n /** Best-effort delete of a stored secret by ref (cleanup on remove/rewrap). */\n removeSecret?: (credentialsRef: string) => Promise<void>;\n /** Count objects bound to a datasource (removal blocked while > 0). */\n countBoundObjects: (datasource: string) => Promise<number>;\n /** Hot-(re)register a runtime datasource's connection pool after write. */\n registerPool?: (record: StoredDatasource) => Promise<void> | void;\n /** Tear down a runtime datasource's pool on remove. */\n unregisterPool?: (name: string) => Promise<void> | void;\n logger?: Logger;\n}\n\nexport class DatasourceAdminService implements IDatasourceAdminService {\n constructor(private readonly config: DatasourceAdminServiceConfig) {}\n\n private get logger(): Logger | undefined {\n return this.config.logger;\n }\n\n async listDatasources(): Promise<DatasourceSummary[]> {\n const records = await this.config.listDatasourceRecords();\n\n // Group by name; code wins on collision, and a shadowed runtime row marks\n // the effective (code) entry as conflicting.\n const byName = new Map<string, { code?: StoredDatasource; runtime?: StoredDatasource }>();\n for (const rec of records) {\n const slot = byName.get(rec.name) ?? {};\n if (rec.origin === 'runtime') slot.runtime = rec;\n else slot.code = rec;\n byName.set(rec.name, slot);\n }\n\n const summaries: DatasourceSummary[] = [];\n for (const [name, slot] of byName) {\n const effective = slot.code ?? slot.runtime;\n if (!effective) continue;\n summaries.push({\n name,\n label: effective.label,\n driver: effective.driver,\n schemaMode: effective.schemaMode ?? 'managed',\n origin: slot.code ? 'code' : 'runtime',\n active: effective.active ?? true,\n status: 'unvalidated',\n ...(slot.code?.definedIn ? { definedIn: slot.code.definedIn } : {}),\n ...(slot.code && slot.runtime ? { conflictsWithCode: true } : {}),\n });\n }\n return summaries;\n }\n\n async testConnection(input: DatasourceDraft, secret?: SecretInput): Promise<TestConnectionResult> {\n if (!input?.driver) {\n return { ok: false, error: 'A driver is required to test a connection.' };\n }\n const queryTimeoutMs = (input.external as { queryTimeoutMs?: number } | undefined)?.queryTimeoutMs;\n try {\n return await this.config.probe({\n driver: input.driver,\n config: input.config ?? {},\n secret: secret?.value,\n external: input.external,\n ...(typeof queryTimeoutMs === 'number' ? { timeoutMs: queryTimeoutMs } : {}),\n });\n } catch (err) {\n return { ok: false, error: err instanceof Error ? err.message : String(err) };\n }\n }\n\n async createDatasource(input: DatasourceDraft, secret?: SecretInput): Promise<DatasourceSummary> {\n this.assertValidName(input?.name);\n if (!input.driver) throw new Error('A driver is required to create a datasource.');\n\n const existing = await this.config.getDatasourceRecord(input.name);\n if (existing) {\n if (existing.origin === 'code' || existing.origin === undefined) {\n throw new Error(\n `Cannot create datasource '${input.name}': a code-defined datasource owns this name (read-only).`,\n );\n }\n throw new Error(`Datasource '${input.name}' already exists.`);\n }\n\n const record: StoredDatasource = {\n ...this.toRecord(input),\n origin: 'runtime',\n };\n\n if (secret) {\n const credentialsRef = await this.config.writeSecret(secret, { name: input.name });\n record.external = { ...(record.external ?? {}), credentialsRef };\n }\n\n await this.config.putDatasourceRecord(record);\n await this.tryRegisterPool(record);\n return this.toSummary(record);\n }\n\n async updateDatasource(\n name: string,\n patch: Partial<DatasourceDraft>,\n secret?: SecretInput,\n ): Promise<DatasourceSummary> {\n const existing = await this.config.getDatasourceRecord(name);\n if (!existing) throw new Error(`Datasource '${name}' not found.`);\n if (existing.origin !== 'runtime') {\n throw new Error(`Datasource '${name}' is code-defined and cannot be edited at runtime.`);\n }\n\n // Merge patch over the existing record; `name`/`origin` are never patched.\n const merged: StoredDatasource = {\n ...existing,\n ...(patch.label !== undefined ? { label: patch.label } : {}),\n ...(patch.driver !== undefined ? { driver: patch.driver } : {}),\n ...(patch.schemaMode !== undefined ? { schemaMode: patch.schemaMode } : {}),\n ...(patch.config !== undefined ? { config: patch.config } : {}),\n ...(patch.pool !== undefined ? { pool: patch.pool } : {}),\n ...(patch.active !== undefined ? { active: patch.active } : {}),\n name: existing.name,\n origin: 'runtime',\n };\n if (patch.external !== undefined) {\n // Preserve the existing credentialsRef unless a new secret rewraps it.\n merged.external = { ...patch.external, credentialsRef: existing.external?.credentialsRef };\n }\n\n if (secret) {\n const prevRef = existing.external?.credentialsRef;\n const credentialsRef = await this.config.writeSecret(secret, { name });\n merged.external = { ...(merged.external ?? {}), credentialsRef };\n if (prevRef && prevRef !== credentialsRef) await this.tryRemoveSecret(prevRef);\n }\n\n await this.config.putDatasourceRecord(merged);\n await this.tryRegisterPool(merged);\n return this.toSummary(merged);\n }\n\n async removeDatasource(name: string): Promise<void> {\n const existing = await this.config.getDatasourceRecord(name);\n if (!existing) throw new Error(`Datasource '${name}' not found.`);\n if (existing.origin !== 'runtime') {\n throw new Error(`Datasource '${name}' is code-defined and cannot be removed at runtime.`);\n }\n\n const bound = await this.config.countBoundObjects(name);\n if (bound > 0) {\n throw new Error(\n `Cannot remove datasource '${name}': ${bound} object(s) are still bound to it.`,\n );\n }\n\n await this.config.deleteDatasourceRecord(name);\n if (existing.external?.credentialsRef) await this.tryRemoveSecret(existing.external.credentialsRef);\n await this.tryUnregisterPool(name);\n }\n\n // --- internals -----------------------------------------------------------\n\n private assertValidName(name: string | undefined): void {\n if (!name || !NAME_RE.test(name)) {\n throw new Error(\n `Invalid datasource name '${name ?? ''}': must match /^[a-z_][a-z0-9_]*$/.`,\n );\n }\n }\n\n private toRecord(input: DatasourceDraft): StoredDatasource {\n return {\n name: input.name,\n ...(input.label !== undefined ? { label: input.label } : {}),\n driver: input.driver,\n ...(input.schemaMode !== undefined ? { schemaMode: input.schemaMode } : {}),\n ...(input.config !== undefined ? { config: input.config } : {}),\n ...(input.external !== undefined ? { external: input.external } : {}),\n ...(input.pool !== undefined ? { pool: input.pool } : {}),\n ...(input.active !== undefined ? { active: input.active } : {}),\n };\n }\n\n private toSummary(record: StoredDatasource): DatasourceSummary {\n return {\n name: record.name,\n label: record.label,\n driver: record.driver,\n schemaMode: record.schemaMode ?? 'managed',\n origin: record.origin ?? 'runtime',\n active: record.active ?? true,\n status: 'unvalidated',\n };\n }\n\n private async tryRegisterPool(record: StoredDatasource): Promise<void> {\n try {\n await this.config.registerPool?.(record);\n } catch (err) {\n this.logger?.warn(`registerPool('${record.name}') failed`, err);\n }\n }\n\n private async tryUnregisterPool(name: string): Promise<void> {\n try {\n await this.config.unregisterPool?.(name);\n } catch (err) {\n this.logger?.warn(`unregisterPool('${name}') failed`, err);\n }\n }\n\n private async tryRemoveSecret(credentialsRef: string): Promise<void> {\n try {\n await this.config.removeSecret?.(credentialsRef);\n } catch (err) {\n this.logger?.warn(`removeSecret('${credentialsRef}') failed`, err);\n }\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport { registerMetadataTypeActions } from '@objectstack/spec/kernel';\nimport type {\n IDatasourceDriverFactory,\n DatasourceConnectionSpec,\n TestConnectionResult,\n} from './contracts/index.js';\nimport {\n DatasourceAdminService,\n type DatasourceAdminServiceConfig,\n type StoredDatasource,\n type ProbeInput,\n} from './datasource-admin-service.js';\nimport type { Logger } from './logger.js';\n\n/**\n * Minimal metadata-service surface used for datasource persistence + the\n * bound-object count. Kept structural so the plugin doesn't hard-depend on the\n * concrete `MetadataManager`.\n */\ninterface MetadataServiceLike {\n get: (type: string, name: string) => Promise<unknown>;\n list: (type: string) => Promise<unknown[]>;\n register: (type: string, name: string, data: unknown) => Promise<void>;\n unregister: (type: string, name: string) => Promise<void>;\n listObjects?: () => Promise<unknown[]>;\n}\n\n/** Engine surface used for hot pool (de)registration. */\ninterface DataEngineLike {\n registerDriver?: (driver: unknown, isDefault?: boolean) => void;\n registerDatasourceDef?: (def: { name: string; schemaMode?: string; external?: { allowWrites?: boolean } }) => void;\n getDriverByName?: (name: string) => unknown;\n}\n\n/**\n * Host-provided secret binding. Encrypts a cleartext credential into the secret\n * store and returns an opaque `credentialsRef`; `unbind` deletes it. Wired by\n * the stack that owns the `ICryptoProvider` + `sys_secret` store. When absent,\n * the plugin fails *closed*: creating/updating a datasource *with* a secret\n * throws rather than risk persisting cleartext.\n */\nexport interface SecretBinder {\n bind: (input: { value: string; namespace?: string; key?: string }, hint: { name: string }) => Promise<string>;\n unbind?: (credentialsRef: string) => Promise<void>;\n /**\n * Dereference a `credentialsRef` back to cleartext for opening a live\n * connection (boot rehydration + hot pool registration). Optional: when\n * absent, pools for secret-bearing datasources are built without the\n * credential (fine for credential-less drivers like sqlite/memory).\n */\n resolve?: (credentialsRef: string) => Promise<string | undefined>;\n}\n\nexport interface DatasourceAdminServicePluginOptions {\n /** Secret binding backed by the host's crypto provider + `sys_secret`. */\n secrets?: SecretBinder;\n /** Override the driver factory (defaults to the `'datasource-driver-factory'` service). */\n driverFactory?: IDatasourceDriverFactory;\n logger?: Logger;\n}\n\n/**\n * DatasourceAdminServicePlugin — registers `IDatasourceAdminService` into the\n * kernel as the `'datasource-admin'` service (ADR-0015 Addendum).\n *\n * Bridges the decoupled {@link DatasourceAdminService} to live infrastructure:\n * - persistence + bound-object count via the `'metadata'` service\n * (`register`/`unregister` write through to the runtime DB loader),\n * - connection probe + hot pool (de)registration via the\n * `'datasource-driver-factory'` capability and the `'data'` engine,\n * - secret encryption via a host-provided {@link SecretBinder} (fail-closed).\n *\n * Every dependency degrades gracefully: a missing driver factory turns\n * `testConnection` into a clear `{ ok: false }` and skips hot pool registration\n * (the driver is picked up at next boot); a missing secret binder makes\n * secret-bearing create/update fail loudly instead of leaking cleartext.\n */\nexport class DatasourceAdminServicePlugin implements Plugin {\n name = 'com.objectstack.service-datasource-admin';\n version = '1.0.0';\n type = 'standard' as const;\n dependencies: string[] = [];\n\n private service?: DatasourceAdminService;\n private config?: DatasourceAdminServiceConfig;\n private readonly options: DatasourceAdminServicePluginOptions;\n\n constructor(options: DatasourceAdminServicePluginOptions = {}) {\n this.options = options;\n }\n\n async init(ctx: PluginContext): Promise<void> {\n const logger = this.options.logger;\n\n // Contribute the metadata-admin \"Test connection\" type-level action,\n // co-located with the route handler that serves it\n // (`POST /api/v1/datasources/:name/test`, see admin-routes.ts). The\n // open-source framework deliberately ships no declarative datasource\n // action, so the button is emitted by `/api/v1/meta` only when this\n // backend plugin is installed — never advertising a route the host\n // can't serve. `${ctx.recordId}` resolves to the datasource's name.\n registerMetadataTypeActions('datasource', [\n {\n name: 'test_connection',\n label: 'Test connection',\n icon: 'plug-zap',\n type: 'api',\n target: '/api/v1/datasources/${ctx.recordId}/test',\n method: 'POST',\n variant: 'secondary',\n refreshAfter: false,\n locations: ['record_header', 'list_item'],\n },\n ] as any);\n\n // Resolve infra services lazily, per call — `init()` may run before the\n // `data` / `metadata` plugins have registered their services (plugin start\n // order is dependency- not registration-driven), and admin requests only\n // arrive long after the full boot completes.\n const metadataOf = (): MetadataServiceLike | undefined =>\n safeGetService<MetadataServiceLike>(ctx, 'metadata');\n const engineOf = (): DataEngineLike | undefined =>\n safeGetService<DataEngineLike>(ctx, 'data');\n\n const factory = (): IDatasourceDriverFactory | undefined =>\n this.options.driverFactory ?? safeGetService<IDatasourceDriverFactory>(ctx, 'datasource-driver-factory');\n\n const config: DatasourceAdminServiceConfig = {\n probe: (input) => this.probe(factory(), input),\n\n listDatasourceRecords: async () => {\n const rows = ((await metadataOf()?.list('datasource')) ?? []) as StoredDatasource[];\n // Artefact-loaded rows may omit `origin`; treat them as code-defined.\n return rows.map((r) => ({ ...r, origin: r.origin ?? 'code' }));\n },\n\n getDatasourceRecord: async (name) => {\n const row = (await metadataOf()?.get('datasource', name)) as StoredDatasource | undefined;\n return row ? { ...row, origin: row.origin ?? 'code' } : undefined;\n },\n\n putDatasourceRecord: async (record) => {\n const metadata = metadataOf();\n if (!metadata?.register) {\n throw new Error('Metadata service is unavailable; cannot persist datasource.');\n }\n await metadata.register('datasource', record.name, record);\n },\n\n deleteDatasourceRecord: async (name) => {\n const metadata = metadataOf();\n if (!metadata?.unregister) {\n throw new Error('Metadata service is unavailable; cannot remove datasource.');\n }\n await metadata.unregister('datasource', name);\n },\n\n writeSecret: async (input, hint) => {\n const binder = this.options.secrets;\n if (!binder?.bind) {\n throw new Error(\n 'No secret store configured: refusing to persist a datasource credential in cleartext. ' +\n 'Wire a SecretBinder (CryptoProvider + sys_secret) into DatasourceAdminServicePlugin.',\n );\n }\n return binder.bind(input, hint);\n },\n\n removeSecret: async (ref) => {\n await this.options.secrets?.unbind?.(ref);\n },\n\n countBoundObjects: async (datasource) => {\n const metadata = metadataOf();\n const objects = ((await metadata?.listObjects?.()) ??\n (await metadata?.list('object')) ??\n []) as Array<{ datasource?: string }>;\n return objects.filter((o) => o?.datasource === datasource).length;\n },\n\n registerPool: async (record) => {\n const f = factory();\n const engine = engineOf();\n if (!f || !engine?.registerDriver || !f.supports(record.driver)) return;\n // Recover the cleartext credential from `sys_secret` so the pool opens\n // with the real password. The cleartext is never persisted on the\n // record (only `credentialsRef`), so it must be dereferenced here —\n // both on create/update and on boot rehydration. Credential-less\n // drivers (sqlite/memory) simply have no ref and skip this.\n const credentialsRef = record.external?.credentialsRef;\n const secret = credentialsRef ? await this.options.secrets?.resolve?.(credentialsRef) : undefined;\n const handle = await f.create({ ...this.toSpec(record), ...(secret ? { secret } : {}) });\n if (typeof handle?.connect === 'function') await handle.connect();\n // The engine routes a datasource to a driver by `driver.name === <datasource name>`\n // (see ObjectQL engine.getDriver). Prefer the factory's underlying engine\n // driver (the `driver` escape hatch); fall back to the handle itself. Stamp\n // the name so routing resolves to this pool.\n const engineDriver = (handle.driver ?? handle) as { name?: string };\n try {\n engineDriver.name = record.name;\n } catch {\n /* frozen driver — registration may still work if name already matches */\n }\n engine.registerDriver(engineDriver);\n engine.registerDatasourceDef?.({\n name: record.name,\n schemaMode: record.schemaMode,\n external: record.external as { allowWrites?: boolean } | undefined,\n });\n },\n\n unregisterPool: async (name) => {\n const driver = engineOf()?.getDriverByName?.(name) as { disconnect?: () => Promise<void> } | undefined;\n if (typeof driver?.disconnect === 'function') await driver.disconnect();\n },\n\n logger,\n };\n\n this.config = config;\n this.service = new DatasourceAdminService(config);\n ctx.registerService('datasource-admin', this.service);\n }\n\n async start(ctx: PluginContext): Promise<void> {\n // Rebuild live connection pools for persisted runtime datasources before\n // announcing readiness — a node restart otherwise leaves UI-created\n // datasources with a record but no open pool until the next write.\n await this.rehydratePools();\n if (this.service) await ctx.trigger('datasource-admin:ready', this.service);\n }\n\n /**\n * Boot-time rehydration: list persisted runtime datasources and re-register\n * each one's connection pool (driver build → connect → registerDriver),\n * decrypting its `sys_secret` credential on the way via the configured\n * `registerPool` (which resolves `credentialsRef`). Code-defined datasources\n * are owned by the host stack's own boot path and skipped here. Entirely\n * best-effort: a missing factory/engine, an unpersisted dev store (nothing\n * to rehydrate), or a single failing pool never blocks boot.\n */\n private async rehydratePools(): Promise<void> {\n const cfg = this.config;\n if (!cfg?.registerPool || !cfg.listDatasourceRecords) return;\n\n let records: StoredDatasource[];\n try {\n records = await cfg.listDatasourceRecords();\n } catch (err) {\n this.options.logger?.warn?.('datasource rehydrate: listing records failed', err);\n return;\n }\n\n const runtime = records.filter((r) => r.origin === 'runtime' && (r.active ?? true));\n if (runtime.length === 0) return;\n\n let registered = 0;\n for (const record of runtime) {\n try {\n await cfg.registerPool(record);\n registered++;\n } catch (err) {\n this.options.logger?.warn?.(`datasource rehydrate: pool '${record.name}' failed`, err);\n }\n }\n this.options.logger?.info?.(\n `Rehydrated ${registered}/${runtime.length} runtime datasource pool(s) on boot`,\n );\n }\n\n async destroy(): Promise<void> {\n this.service = undefined;\n }\n\n // --- internals -----------------------------------------------------------\n\n private toSpec(record: StoredDatasource): DatasourceConnectionSpec {\n return {\n name: record.name,\n driver: record.driver,\n config: record.config ?? {},\n external: record.external,\n pool: record.pool,\n };\n }\n\n /** Probe a connection via the driver factory: build → connect → ping → close. */\n private async probe(\n factory: IDatasourceDriverFactory | undefined,\n input: ProbeInput,\n ): Promise<TestConnectionResult> {\n if (!factory) {\n return { ok: false, error: 'No driver factory is registered to test connections.' };\n }\n if (!factory.supports(input.driver)) {\n return { ok: false, error: `No driver factory supports driver '${input.driver}'.` };\n }\n\n let driver: any;\n try {\n driver = await factory.create({\n driver: input.driver,\n config: input.config,\n secret: input.secret,\n external: input.external,\n });\n } catch (err) {\n return { ok: false, error: `Failed to build driver: ${errMsg(err)}` };\n }\n\n const startedAt = monotonicNow();\n try {\n if (typeof driver?.connect === 'function') await driver.connect();\n // Prefer a cheap ping; fall back to the engine driver's health check, then\n // a schema introspection round-trip — whichever the handle exposes.\n if (typeof driver?.ping === 'function') await driver.ping();\n else if (typeof driver?.checkHealth === 'function') await driver.checkHealth();\n else if (typeof driver?.introspectSchema === 'function') await driver.introspectSchema();\n const latencyMs = elapsedSince(startedAt);\n let serverVersion: string | undefined;\n try {\n serverVersion = typeof driver?.serverVersion === 'function' ? await driver.serverVersion() : undefined;\n } catch {\n /* version is best-effort */\n }\n return { ok: true, latencyMs, ...(serverVersion ? { serverVersion } : {}) };\n } catch (err) {\n return { ok: false, error: errMsg(err) };\n } finally {\n try {\n if (typeof driver?.disconnect === 'function') await driver.disconnect();\n } catch {\n /* best-effort teardown */\n }\n }\n }\n}\n\nfunction safeGetService<T>(ctx: PluginContext, name: string): T | undefined {\n try {\n return ctx.getService<T>(name);\n } catch {\n return undefined;\n }\n}\n\nfunction errMsg(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\n/** Monotonic clock when available (avoids wall-clock skew); falls back to 0. */\nfunction monotonicNow(): number {\n const perf = (globalThis as { performance?: { now?: () => number } }).performance;\n return typeof perf?.now === 'function' ? perf.now() : 0;\n}\n\nfunction elapsedSince(startedAt: number): number {\n return Math.max(0, Math.round(monotonicNow() - startedAt));\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Default (dev/self-host) implementation of {@link IDatasourceDriverFactory}.\n *\n * The framework ships no universal \"driver-by-id\" registry — concrete drivers\n * are constructed by the host stack (ADR-0015 Addendum §3.5). This factory is\n * the host-side glue that lets the runtime-datasource lifecycle\n * (`IDatasourceAdminService`) build a live driver from an *unsaved* draft so it\n * can probe a connection before \"Save\" and hot-register a pool afterwards.\n *\n * Supported driver ids map onto the same open-core drivers the standalone\n * stack auto-detects:\n * - `postgres` / `pg` / `postgresql` → `@objectstack/driver-sql` (client `pg`)\n * - `sqlite` / `sqlite3` → `@objectstack/driver-sql` (better-sqlite3)\n * - `mongodb` / `mongo` → `@objectstack/driver-mongodb` (peer dep)\n * - `memory` / `inmemory` → `@objectstack/driver-memory`\n *\n * Anything else returns `supports() === false`, so the admin service degrades\n * gracefully (testConnection → `{ ok: false }`, create skips hot pool reg).\n *\n * SECURITY: the cleartext `spec.secret` is used only to open the connection and\n * is never persisted or logged here.\n */\n\nimport type {\n IDatasourceDriverFactory,\n DatasourceConnectionSpec,\n DatasourceDriverHandle,\n} from './contracts/index.js';\n\ntype ResolvedKind = 'postgres' | 'sqlite' | 'mongodb' | 'memory';\n\nconst DRIVER_ID_ALIASES: Record<string, ResolvedKind> = {\n postgres: 'postgres',\n postgresql: 'postgres',\n pg: 'postgres',\n sqlite: 'sqlite',\n sqlite3: 'sqlite',\n 'better-sqlite3': 'sqlite',\n mongodb: 'mongodb',\n mongo: 'mongodb',\n memory: 'memory',\n inmemory: 'memory',\n 'in-memory': 'memory',\n};\n\nfunction resolveKind(driverId: string): ResolvedKind | undefined {\n return DRIVER_ID_ALIASES[String(driverId ?? '').toLowerCase()];\n}\n\n/**\n * Wrap a concrete engine driver in a probe handle. `ping`/`checkHealth` reuse\n * the driver's own health check; `driver` is the escape hatch the admin service\n * hands to `registerDriver()`.\n */\nfunction toHandle(driver: any, serverVersion?: () => Promise<string | undefined>): DatasourceDriverHandle {\n return {\n connect: typeof driver?.connect === 'function' ? () => driver.connect() : undefined,\n disconnect: typeof driver?.disconnect === 'function' ? () => driver.disconnect() : undefined,\n checkHealth: typeof driver?.checkHealth === 'function' ? () => driver.checkHealth() : undefined,\n ping: typeof driver?.checkHealth === 'function' ? () => driver.checkHealth() : undefined,\n ...(serverVersion ? { serverVersion } : {}),\n driver,\n };\n}\n\n/** Build the Knex `connection` for a SQL driver from a spec's config + secret. */\nfunction buildSqlConnection(spec: DatasourceConnectionSpec, client: 'pg' | 'better-sqlite3'): unknown {\n const cfg = (spec.config ?? {}) as Record<string, unknown>;\n\n if (client === 'better-sqlite3') {\n const filename =\n (cfg.filename as string | undefined) ??\n (cfg.file as string | undefined) ??\n (cfg.database as string | undefined) ??\n ':memory:';\n return { filename };\n }\n\n // pg — accept either a connection string (`url`/`connectionString`) or\n // discrete fields. The secret is the password and is never part of `config`.\n const url = (cfg.url as string | undefined) ?? (cfg.connectionString as string | undefined);\n if (url) {\n // For a DSN, a separately-supplied secret overrides the embedded password.\n return spec.secret ? { connectionString: url, password: spec.secret } : { connectionString: url };\n }\n return {\n host: cfg.host,\n port: cfg.port,\n database: cfg.database,\n user: cfg.user ?? cfg.username,\n ...(spec.secret ? { password: spec.secret } : cfg.password ? { password: cfg.password } : {}),\n ...(cfg.ssl != null ? { ssl: cfg.ssl } : {}),\n };\n}\n\n/** Build a mongodb connection URL from a spec's config + secret. */\nfunction buildMongoUrl(spec: DatasourceConnectionSpec): string {\n const cfg = (spec.config ?? {}) as Record<string, unknown>;\n const explicit = (cfg.url as string | undefined) ?? (cfg.uri as string | undefined);\n if (explicit) return explicit;\n const host = (cfg.host as string | undefined) ?? 'localhost';\n const port = (cfg.port as number | string | undefined) ?? 27017;\n const db = (cfg.database as string | undefined) ?? '';\n const user = (cfg.user as string | undefined) ?? (cfg.username as string | undefined);\n const auth = user ? `${encodeURIComponent(user)}:${encodeURIComponent(spec.secret ?? '')}@` : '';\n return `mongodb://${auth}${host}:${port}/${db}`;\n}\n\n/**\n * Create the default datasource driver factory. Driver packages are imported\n * lazily so a host that never builds (e.g.) a mongo connection doesn't pay for\n * the mongo SDK.\n */\nexport function createDefaultDatasourceDriverFactory(): IDatasourceDriverFactory {\n return {\n supports(driverId: string): boolean {\n return resolveKind(driverId) !== undefined;\n },\n\n async create(spec: DatasourceConnectionSpec): Promise<DatasourceDriverHandle> {\n const kind = resolveKind(spec.driver);\n if (!kind) {\n throw new Error(`Unsupported driver id '${spec.driver}'.`);\n }\n\n const schemaMode = (spec.external as { schemaMode?: string } | undefined)?.schemaMode\n ?? ((spec.config as Record<string, unknown> | undefined)?.schemaMode as string | undefined);\n\n if (kind === 'postgres') {\n const { SqlDriver } = await import('@objectstack/driver-sql');\n const driver = new SqlDriver({\n client: 'pg',\n connection: buildSqlConnection(spec, 'pg') as any,\n pool: { min: 0, max: 5 },\n ...(schemaMode ? { schemaMode: schemaMode as any } : {}),\n } as any);\n return toHandle(driver, () => sqlServerVersion(driver, 'pg'));\n }\n\n if (kind === 'sqlite') {\n const { SqlDriver } = await import('@objectstack/driver-sql');\n const driver = new SqlDriver({\n client: 'better-sqlite3',\n connection: buildSqlConnection(spec, 'better-sqlite3') as any,\n useNullAsDefault: true,\n ...(schemaMode ? { schemaMode: schemaMode as any } : {}),\n } as any);\n return toHandle(driver, () => sqlServerVersion(driver, 'sqlite'));\n }\n\n if (kind === 'mongodb') {\n let MongoDBDriver: any;\n try {\n ({ MongoDBDriver } = await import('@objectstack/driver-mongodb' as any));\n } catch (err: any) {\n throw new Error(\n `mongodb driver requested but @objectstack/driver-mongodb is not installed (${err?.message ?? err}).`,\n );\n }\n const driver = new MongoDBDriver({ url: buildMongoUrl(spec) });\n return toHandle(driver);\n }\n\n // memory\n const { InMemoryDriver } = await import('@objectstack/driver-memory');\n return toHandle(new InMemoryDriver());\n },\n };\n}\n\n/** Best-effort server version via a raw query; swallows everything. */\nasync function sqlServerVersion(driver: any, client: 'pg' | 'sqlite'): Promise<string | undefined> {\n if (typeof driver?.execute !== 'function') return undefined;\n try {\n const sql = client === 'pg' ? 'SELECT version() AS v' : 'SELECT sqlite_version() AS v';\n const rows: any = await driver.execute(sql);\n const first = Array.isArray(rows) ? rows[0] : Array.isArray(rows?.rows) ? rows.rows[0] : rows;\n const v = first?.v ?? first?.version ?? first?.['sqlite_version()'];\n return typeof v === 'string' ? v : undefined;\n } catch {\n return undefined;\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Default datasource SecretBinder — persists a runtime datasource's cleartext\n * credential into the `sys_secret` cipher store and returns an opaque\n * `credentialsRef` handle (ADR-0015 Addendum, security invariant).\n *\n * Mirrors the SettingsService Phase-3 split: the cleartext is wrapped by an\n * {@link ICryptoProvider} into a {@link CryptoHandle}, the ciphertext lands in a\n * `sys_secret` row keyed by `handle.id`, and only the handle id (wrapped as\n * `sys_secret:<id>`) is ever stored on the datasource artefact. Cleartext never\n * touches metadata.\n *\n * This is the dev/self-host wiring; production hosts swap the\n * `LocalCryptoProvider` for a KMS-backed `ICryptoProvider` and pass it here.\n */\n\nimport type { CryptoHandle, ICryptoProvider } from '@objectstack/spec/contracts';\n\n/** Prefix used to recognise a datasource credential handle. */\nconst REF_PREFIX = 'sys_secret:';\n\n/** A persisted `sys_secret` row (subset used to reconstruct a {@link CryptoHandle}). */\ninterface SecretRow {\n id: string;\n namespace: string;\n key: string;\n kms_key_id: string;\n alg: string;\n version: number;\n ciphertext: string;\n}\n\n/** Minimal data-engine surface used to read/write the `sys_secret` store. */\nexport interface SecretStoreEngineLike {\n insert(object: string, data: Record<string, unknown>, options?: unknown): Promise<unknown>;\n delete(object: string, options: { where: Record<string, unknown> }): Promise<unknown>;\n /**\n * Read `sys_secret` rows for the `resolve()` path. Optional so existing\n * callers that only bind/unbind keep working; `resolve()` no-ops when absent.\n * Mirrors `IDataEngine.find` — returns an array (or `{ data: [...] }`).\n */\n find?(object: string, query: Record<string, unknown>): Promise<unknown>;\n}\n\nexport interface DatasourceSecretBinderDeps {\n /** Data engine (ObjectQL) used to persist the `sys_secret` row. */\n engine: SecretStoreEngineLike;\n /** Crypto provider that wraps cleartext into a {@link CryptoHandle}. */\n cryptoProvider: ICryptoProvider;\n /** Settings namespace recorded on the secret row (default `'datasource'`). */\n namespace?: string;\n}\n\nexport interface DatasourceSecretBinder {\n bind(input: { value: string; namespace?: string; key?: string }, hint: { name: string }): Promise<string>;\n unbind(credentialsRef: string): Promise<void>;\n /**\n * Dereference a `credentialsRef` back to its cleartext credential by reading\n * the `sys_secret` row and decrypting it. Used at boot to rebuild a runtime\n * datasource's live connection pool (the cleartext is never persisted, so it\n * must be recovered from the cipher store). Returns `undefined` when the ref\n * isn't ours, the row is gone, the engine can't read, or decryption fails\n * (e.g. an ephemeral dev key changed across restarts) — callers degrade to\n * skipping that pool rather than crashing boot.\n */\n resolve(credentialsRef: string): Promise<string | undefined>;\n}\n\n/** Build a `credentialsRef` from a crypto handle id. */\nexport function toCredentialsRef(handleId: string): string {\n return `${REF_PREFIX}${handleId}`;\n}\n\n/** Extract the `sys_secret` handle id from a credentialsRef, if it is one. */\nexport function parseCredentialsRef(ref: string): string | undefined {\n return ref?.startsWith(REF_PREFIX) ? ref.slice(REF_PREFIX.length) : undefined;\n}\n\n/**\n * Create the default datasource secret binder. Persists into `sys_secret` via\n * the data engine and never returns or logs the cleartext.\n */\nexport function createDatasourceSecretBinder(deps: DatasourceSecretBinderDeps): DatasourceSecretBinder {\n const { engine, cryptoProvider } = deps;\n const defaultNamespace = deps.namespace ?? 'datasource';\n\n return {\n async bind(input, hint) {\n const namespace = input.namespace ?? defaultNamespace;\n const key = input.key ?? hint.name;\n const handle: CryptoHandle = await cryptoProvider.encrypt(input.value, { namespace, key });\n await engine.insert('sys_secret', {\n id: handle.id,\n namespace,\n key,\n kms_key_id: handle.kmsKeyId,\n alg: handle.alg,\n version: handle.version,\n ciphertext: handle.ciphertext,\n });\n return toCredentialsRef(handle.id);\n },\n\n async unbind(credentialsRef) {\n const id = parseCredentialsRef(credentialsRef);\n if (!id) return; // not ours (or already cleared) — nothing to do\n await engine.delete('sys_secret', { where: { id } });\n },\n\n async resolve(credentialsRef) {\n const id = parseCredentialsRef(credentialsRef);\n if (!id || typeof engine.find !== 'function') return undefined;\n try {\n const result = await engine.find('sys_secret', {\n where: { id },\n limit: 1,\n // Secrets are scoped through their owning datasource artefact, so\n // skip the tenant-audit warning (mirrors SettingsService's store).\n bypassTenantAudit: true,\n });\n const rows = (Array.isArray(result) ? result : (result as { data?: unknown[] })?.data) ?? [];\n const row = rows[0] as SecretRow | undefined;\n if (!row?.ciphertext) return undefined;\n // Reconstruct the handle and decrypt under the same (namespace,key)\n // AAD the row was sealed with — a mismatch fails authentication.\n return await cryptoProvider.decrypt(\n {\n id: row.id,\n kmsKeyId: row.kms_key_id,\n alg: row.alg,\n version: row.version,\n ciphertext: row.ciphertext,\n },\n { namespace: row.namespace, key: row.key },\n );\n } catch {\n // Missing row / unreadable engine / decrypt failure (e.g. rotated dev\n // key) — never block boot; the pool is simply not rehydrated.\n return undefined;\n }\n },\n };\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { PluginContext } from '@objectstack/core';\nimport type { IHttpServer } from '@objectstack/spec/contracts';\n\n/**\n * Datasource lifecycle REST routes (ADR-0015 Addendum §3.5).\n *\n * Mounted under `/api/v1/datasources` and served by the `datasource-admin`\n * service. Every route degrades gracefully\n * (`503 datasource_admin_unavailable`) when the service is not wired in, and\n * lifecycle/validation failures surface as `400` with the service's message.\n *\n * GET /datasources → listDatasources (provenance + health)\n * POST /datasources/test → testConnection (no persistence)\n * POST /datasources → createDatasource (origin: 'runtime')\n * PATCH /datasources/:name → updateDatasource (runtime only)\n * DELETE /datasources/:name → removeDatasource (runtime only)\n *\n * Request bodies carry the connection draft inline with an optional cleartext\n * `secret` field; the route splits `secret` out so it never reaches the draft\n * the service persists.\n */\nexport function registerDatasourceAdminRoutes(\n server: IHttpServer,\n ctx: PluginContext,\n basePath = '/api/v1',\n): void {\n const root = `${basePath}/datasources`;\n\n const adminService = (): any => {\n try {\n return ctx.getService<any>('datasource-admin');\n } catch {\n return undefined;\n }\n };\n\n const unavailable = (res: any) =>\n res.status(503).json({ error: 'datasource_admin_unavailable' });\n\n const badRequest = (res: any, err: unknown) =>\n res.status(400).json({ error: 'datasource_admin_error', message: err instanceof Error ? err.message : String(err) });\n\n /** Split an inline `{ secret, ...draft }` body into (draft, secret). */\n const splitSecret = (body: any): { draft: any; secret: any } => {\n const { secret, ...draft } = (body as Record<string, unknown>) ?? {};\n // Accept either a bare string or a `{ value, namespace?, key? }` object.\n const normalised =\n secret == null\n ? undefined\n : typeof secret === 'string'\n ? { value: secret }\n : secret;\n return { draft, secret: normalised };\n };\n\n // List all datasources with provenance + health.\n server.get(root, async (_req: any, res: any) => {\n const svc = adminService();\n if (!svc?.listDatasources) return unavailable(res);\n const datasources = await svc.listDatasources();\n res.json({ datasources });\n });\n\n // Probe a connection without persisting anything. Registered before the\n // `:name` routes so the literal `test` segment is never captured as a name.\n server.post(`${root}/test`, async (req: any, res: any) => {\n const svc = adminService();\n if (!svc?.testConnection) return unavailable(res);\n const { draft, secret } = splitSecret(req.body);\n try {\n const result = await svc.testConnection(draft, secret);\n res.json({ result });\n } catch (err) {\n badRequest(res, err);\n }\n });\n\n // Create a runtime datasource.\n server.post(root, async (req: any, res: any) => {\n const svc = adminService();\n if (!svc?.createDatasource) return unavailable(res);\n const { draft, secret } = splitSecret(req.body);\n try {\n const datasource = await svc.createDatasource(draft, secret);\n res.status(201).json({ datasource });\n } catch (err) {\n badRequest(res, err);\n }\n });\n\n // Patch a runtime datasource.\n server.patch(`${root}/:name`, async (req: any, res: any) => {\n const svc = adminService();\n if (!svc?.updateDatasource) return unavailable(res);\n const { draft, secret } = splitSecret(req.body);\n try {\n const datasource = await svc.updateDatasource(req.params.name, draft, secret);\n res.json({ datasource });\n } catch (err) {\n badRequest(res, err);\n }\n });\n\n // Remove a runtime datasource.\n server.delete(`${root}/:name`, async (req: any, res: any) => {\n const svc = adminService();\n if (!svc?.removeDatasource) return unavailable(res);\n try {\n await svc.removeDatasource(req.params.name);\n res.status(204).end();\n } catch (err) {\n badRequest(res, err);\n }\n });\n}\n"]}
1
+ {"version":3,"sources":["/home/runner/work/framework/framework/packages/services/service-datasource/dist/index.cjs","../src/external-datasource-service.ts","../src/plugin.ts","../src/datasource-admin-service.ts","../src/datasource-admin-plugin.ts","../src/default-datasource-driver-factory.ts","../src/datasource-secret-binder.ts","../src/driver-catalog.ts","../src/admin-routes.ts"],"names":[],"mappings":"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","file":"/home/runner/work/framework/framework/packages/services/service-datasource/dist/index.cjs","sourcesContent":[null,"// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * ExternalDatasourceService — implements {@link IExternalDatasourceService}\n * (ADR-0015 §6) on top of driver introspection.\n *\n * The service is intentionally decoupled from the kernel: all I/O\n * (introspection, metadata reads) is injected via\n * {@link ExternalDatasourceServiceConfig}, so the introspection/draft/validate\n * logic is pure and unit-testable. The kernel plugin wires the real\n * `IDataEngine` + `IMetadataService` callbacks in.\n */\n\nimport type {\n IExternalDatasourceService,\n RemoteTable,\n GenerateDraftOpts,\n ObjectDraft,\n ImportObjectOpts,\n ImportObjectResult,\n SchemaValidationResult,\n SchemaValidationReport,\n IntrospectedSchema,\n IntrospectedTable,\n} from '@objectstack/spec/contracts';\nimport type { SchemaDiffEntry } from '@objectstack/spec/shared';\nimport {\n suggestFieldType,\n isCompatible,\n ExternalCatalogSchema,\n type ExternalCatalog,\n type SqlDialect,\n type FieldType,\n} from '@objectstack/spec/data';\n\n/** Minimal datasource shape the service reads (subset of `Datasource`). */\nexport interface DatasourceLike {\n name: string;\n schemaMode?: 'managed' | 'external' | 'validate-only';\n external?: {\n allowedSchemas?: string[];\n validation?: { onMismatch?: 'fail' | 'warn' | 'ignore' };\n };\n}\n\n/** Minimal object shape the service reads (subset of `ServiceObject`). */\nexport interface ObjectLike {\n name: string;\n label?: string;\n datasource?: string;\n external?: {\n remoteName?: string;\n remoteSchema?: string;\n columnMap?: Record<string, string>;\n ignoreColumns?: string[];\n };\n fields?: Record<string, { type?: string; required?: boolean }>;\n}\n\nexport interface Logger {\n warn: (message: string, meta?: unknown) => void;\n info?: (message: string, meta?: unknown) => void;\n}\n\n/**\n * Injected dependencies. The plugin supplies real implementations backed by\n * the driver registry and `IMetadataService`; tests supply fakes.\n */\nexport interface ExternalDatasourceServiceConfig {\n /** Introspect a datasource's live schema via its driver. */\n introspect: (datasource: string) => Promise<IntrospectedSchema>;\n /** Resolve a datasource definition by name. */\n getDatasource: (name: string) => Promise<DatasourceLike | undefined>;\n /** Resolve one object definition by name. */\n getObject: (name: string) => Promise<ObjectLike | undefined>;\n /** List all object definitions (for `validateAll`). */\n listObjects: () => Promise<ObjectLike[]>;\n /**\n * Persist a refreshed catalog snapshot as an `external_catalog` metadata\n * record. Optional: when absent, `refreshCatalog` still returns the snapshot\n * but does not cache it (e.g. dev runs without a writable metadata store).\n */\n persistCatalog?: (catalog: ExternalCatalog) => Promise<void>;\n /**\n * Persist an imported object definition as a live (runtime-origin) `object`\n * metadata record. Optional: when absent, {@link ExternalDatasourceService.importObject}\n * throws (the deployment is GitOps-only / has no writable metadata store).\n */\n persistObject?: (name: string, definition: Record<string, unknown>) => Promise<void>;\n logger?: Logger;\n}\n\n/** Columns ObjectStack manages itself — never validated against the remote. */\nconst BUILTIN_COLUMNS = new Set(['id', 'created_at', 'updated_at']);\n\n/** Split a possibly schema-qualified name (`mart.fact_orders`). */\nfunction parseQualified(raw: string): { schema?: string; name: string } {\n const idx = raw.indexOf('.');\n if (idx === -1) return { name: raw };\n return { schema: raw.slice(0, idx), name: raw.slice(idx + 1) };\n}\n\n/** Normalise a remote table name into a snake_case object name. */\nfunction toObjectName(remoteName: string): string {\n const { name } = parseQualified(remoteName);\n return name\n .replace(/[^a-zA-Z0-9_]/g, '_')\n .replace(/^[^a-z_]/, (c) => `_${c.toLowerCase()}`)\n .toLowerCase();\n}\n\n/** snake_case → Title Case label. */\nfunction toLabel(name: string): string {\n return name\n .split('_')\n .filter(Boolean)\n .map((w) => w.charAt(0).toUpperCase() + w.slice(1))\n .join(' ');\n}\n\nexport class ExternalDatasourceService implements IExternalDatasourceService {\n constructor(private readonly config: ExternalDatasourceServiceConfig) {}\n\n private get logger(): Logger | undefined {\n return this.config.logger;\n }\n\n private findTable(schema: IntrospectedSchema, remoteName: string): IntrospectedTable | undefined {\n const want = parseQualified(remoteName).name;\n for (const table of Object.values(schema.tables)) {\n if (table.name === remoteName) return table;\n if (parseQualified(table.name).name === want) return table;\n }\n return undefined;\n }\n\n async listRemoteTables(\n datasource: string,\n opts?: { schema?: string },\n ): Promise<RemoteTable[]> {\n const [schema, ds] = await Promise.all([\n this.config.introspect(datasource),\n this.config.getDatasource(datasource),\n ]);\n const allowed = ds?.external?.allowedSchemas;\n\n const tables: RemoteTable[] = [];\n for (const table of Object.values(schema.tables)) {\n const { schema: tableSchema, name } = parseQualified(table.name);\n if (opts?.schema && tableSchema && tableSchema !== opts.schema) continue;\n // allowedSchemas only filters tables we can attribute to a schema.\n if (allowed && tableSchema && !allowed.includes(tableSchema)) continue;\n tables.push({ schema: tableSchema, name, columnCount: table.columns.length });\n }\n return tables;\n }\n\n /**\n * Probe a *saved* datasource by name with a live round-trip. Reuses the\n * introspection path (driver connect + schema read) as a cheap connectivity\n * check, so the secret is resolved through the same wired pool as the rest of\n * the introspection surface — the caller never handles cleartext. Returns a\n * structured result rather than throwing so the route can render ok/error\n * uniformly. This backs the `datasource` `test_connection` action\n * (`POST /datasources/:name/test`).\n */\n async testConnection(\n datasource: string,\n ): Promise<{ ok: boolean; latencyMs?: number; tableCount?: number; error?: string }> {\n const started = Date.now();\n try {\n const schema = await this.config.introspect(datasource);\n return {\n ok: true,\n latencyMs: Date.now() - started,\n tableCount: Object.keys(schema.tables).length,\n };\n } catch (err) {\n return { ok: false, error: err instanceof Error ? err.message : String(err) };\n }\n }\n\n async generateObjectDraft(\n datasource: string,\n remoteName: string,\n opts: GenerateDraftOpts = {},\n ): Promise<ObjectDraft> {\n const schema = await this.config.introspect(datasource);\n const table = this.findTable(schema, remoteName);\n if (!table) {\n throw new Error(\n `Remote table '${remoteName}' not found on datasource '${datasource}'.`,\n );\n }\n const dialect = schema.dialect as SqlDialect | undefined;\n // Derive the remote schema from the matched table's qualified name (the\n // caller may pass an unqualified `remoteName`).\n const matched = parseQualified(table.name);\n const remoteSchema = opts.remoteSchema ?? matched.schema;\n const resolvedRemoteName = matched.name;\n\n const include = opts.includeColumns ? new Set(opts.includeColumns) : undefined;\n const exclude = opts.excludeColumns ? new Set(opts.excludeColumns) : new Set<string>();\n const pkOverride = opts.primaryKey ? new Set(opts.primaryKey) : undefined;\n\n const fields: Record<string, { type: FieldType; primaryKey?: boolean }> = {};\n const review: ObjectDraft['review'] = [];\n\n for (const col of table.columns) {\n if (include && !include.has(col.name)) continue;\n if (exclude.has(col.name)) continue;\n\n const fieldName = opts.rename?.[col.name] ?? col.name;\n const suggested = suggestFieldType(col.type, dialect);\n const fieldType: FieldType = suggested ?? 'text';\n if (!suggested) {\n review.push({\n column: col.name,\n remoteType: col.type,\n note: `unrecognised remote type — defaulted to 'text', verify`,\n });\n } else if (isCompatible(col.type, fieldType, dialect) === 'lossy') {\n review.push({\n column: col.name,\n remoteType: col.type,\n note: `mapped lossy to '${fieldType}'`,\n });\n }\n\n const isPk = pkOverride ? pkOverride.has(col.name) : col.primaryKey;\n fields[fieldName] = isPk ? { type: fieldType, primaryKey: true } : { type: fieldType };\n }\n\n const name = toObjectName(resolvedRemoteName);\n const definition: Record<string, unknown> = {\n name,\n label: toLabel(name),\n datasource,\n external: {\n ...(remoteSchema ? { remoteSchema } : {}),\n remoteName: resolvedRemoteName,\n },\n fields,\n };\n\n return {\n name,\n datasource,\n definition,\n source: renderObjectSource(definition, fields, review),\n review,\n };\n }\n\n async importObject(\n datasource: string,\n remoteName: string,\n opts: ImportObjectOpts = {},\n ): Promise<ImportObjectResult> {\n if (!this.config.persistObject) {\n throw new Error(\n `importObject requires a writable metadata store, but none is wired ` +\n `(datasource '${datasource}'). This deployment may be GitOps-only — ` +\n `use 'os datasource introspect' and commit the generated *.object.ts instead.`,\n );\n }\n\n // Reuse the draft pipeline (type mapping, review notes, external binding).\n const draft = await this.generateObjectDraft(datasource, remoteName, opts);\n\n // Apply the runtime-persona overrides on top of the draft definition.\n const name = opts.name ?? draft.name;\n const external = {\n ...(draft.definition.external as Record<string, unknown>),\n ...(opts.writable ? { writable: true } : {}),\n };\n const definition: Record<string, unknown> = {\n ...draft.definition,\n name,\n label: toLabel(name),\n external,\n };\n\n await this.config.persistObject(name, definition);\n this.logger?.info?.(`importObject: persisted '${name}' from ${datasource}.${remoteName}`, {\n writable: opts.writable === true,\n review: draft.review.length,\n });\n\n return { name, definition, review: draft.review };\n }\n\n async refreshCatalog(datasource: string): Promise<ExternalCatalog> {\n const schema = await this.config.introspect(datasource);\n // Parse through the Zod schema so the persisted record is canonical\n // (defaults applied, shape validated) and matches the `external_catalog`\n // metadata type the boot gate + Studio read back.\n const catalog = ExternalCatalogSchema.parse({\n name: `${datasource}_catalog`,\n datasource,\n snapshotAt: new Date().toISOString(),\n dialect: schema.dialect,\n tables: Object.values(schema.tables).map((t) => {\n const { schema: s, name } = parseQualified(t.name);\n return {\n remoteSchema: s,\n remoteName: name,\n columns: t.columns.map((c) => ({\n name: c.name,\n sqlType: c.type,\n nullable: c.nullable,\n primaryKey: c.primaryKey,\n suggestedFieldType: suggestFieldType(c.type, schema.dialect as SqlDialect),\n })),\n };\n }),\n }) as ExternalCatalog;\n\n // Best-effort cache: a failure to persist must not fail the refresh — the\n // caller still gets the live snapshot back.\n if (this.config.persistCatalog) {\n try {\n await this.config.persistCatalog(catalog);\n } catch (err) {\n this.logger?.warn?.(`refreshCatalog: failed to persist '${catalog.name}'`, err);\n }\n }\n\n return catalog;\n }\n\n async validateObject(objectName: string): Promise<SchemaValidationResult> {\n const obj = await this.config.getObject(objectName);\n if (!obj) {\n throw new Error(`Object '${objectName}' not found.`);\n }\n const datasource = obj.datasource ?? 'default';\n const ds = await this.config.getDatasource(datasource);\n\n // Not a federated object → nothing to validate.\n if (!ds || !ds.schemaMode || ds.schemaMode === 'managed') {\n return { ok: true, datasource, object: objectName, diffs: [] };\n }\n\n const schema = await this.config.introspect(datasource);\n const dialect = schema.dialect as SqlDialect | undefined;\n const remoteName = obj.external?.remoteName ?? obj.name;\n const table = this.findTable(schema, remoteName);\n\n const diffs: SchemaDiffEntry[] = [];\n\n if (!table) {\n diffs.push({\n kind: 'missing_table',\n remoteSchema: obj.external?.remoteSchema,\n remoteName,\n severity: 'error',\n });\n return { ok: false, datasource, object: objectName, diffs };\n }\n\n const columnsByName = new Map(table.columns.map((c) => [c.name, c]));\n const ignore = new Set(obj.external?.ignoreColumns ?? []);\n // columnMap is remoteColumn → fieldName; invert for field → remoteColumn.\n const fieldToRemote = new Map<string, string>();\n for (const [remoteCol, fieldName] of Object.entries(obj.external?.columnMap ?? {})) {\n fieldToRemote.set(fieldName, remoteCol);\n }\n\n for (const [fieldName, field] of Object.entries(obj.fields ?? {})) {\n if (BUILTIN_COLUMNS.has(fieldName)) continue;\n const remoteCol = fieldToRemote.get(fieldName) ?? fieldName;\n if (ignore.has(remoteCol)) continue;\n\n const col = columnsByName.get(remoteCol);\n if (!col) {\n diffs.push({\n kind: 'missing_column',\n remoteName,\n column: remoteCol,\n severity: 'error',\n });\n continue;\n }\n const fieldType = (field.type ?? 'text') as FieldType;\n const compat = isCompatible(col.type, fieldType, dialect);\n if (compat === false) {\n diffs.push({\n kind: 'type_mismatch',\n remoteName,\n column: remoteCol,\n expected: fieldType,\n actual: col.type,\n severity: 'error',\n });\n } else if (compat === 'lossy') {\n diffs.push({\n kind: 'type_mismatch',\n remoteName,\n column: remoteCol,\n expected: fieldType,\n actual: col.type,\n severity: 'warning',\n });\n }\n }\n\n const ok = !diffs.some((d) => d.severity === 'error');\n return { ok, datasource, object: objectName, diffs };\n }\n\n async validateAll(): Promise<SchemaValidationReport> {\n const objects = await this.config.listObjects();\n const federated = objects.filter(\n (o) => o.external !== undefined || (o.datasource && o.datasource !== 'default'),\n );\n\n const results = await Promise.all(\n federated.map((o) =>\n this.validateObject(o.name).catch((err): SchemaValidationResult => {\n this.logger?.warn(`validateObject('${o.name}') failed`, err);\n return {\n ok: false,\n datasource: o.datasource ?? 'default',\n object: o.name,\n diffs: [\n {\n kind: 'missing_table',\n remoteName: o.external?.remoteName ?? o.name,\n actual: err instanceof Error ? err.message : String(err),\n severity: 'error',\n },\n ],\n };\n }),\n ),\n );\n\n const ok = results.every((r) => r.ok);\n return { ok, results };\n }\n}\n\n/** Render a reviewable `*.object.ts` source string for an object draft. */\nfunction renderObjectSource(\n definition: Record<string, unknown>,\n fields: Record<string, { type: FieldType; primaryKey?: boolean }>,\n review: ObjectDraft['review'],\n): string {\n const reviewByColumn = new Map(review.map((r) => [r.column, r.note]));\n const external = definition.external as { remoteSchema?: string; remoteName?: string };\n\n const fieldLines = Object.entries(fields).map(([fieldName, f]) => {\n const note = reviewByColumn.get(fieldName);\n const pk = f.primaryKey ? ', primaryKey: true' : '';\n const comment = note ? ` // REVIEW: ${note}` : '';\n return ` ${fieldName}: { type: '${f.type}'${pk} },${comment}`;\n });\n\n const externalLine = external.remoteSchema\n ? ` external: { remoteSchema: '${external.remoteSchema}', remoteName: '${external.remoteName}' },`\n : ` external: { remoteName: '${external.remoteName}' },`;\n\n return [\n `// Generated by \\`os datasource introspect\\` (ADR-0015). Review before committing.`,\n `import type { ServiceObjectInput } from '@objectstack/spec/data';`,\n ``,\n `const ${definition.name as string}: ServiceObjectInput = {`,\n ` name: '${definition.name as string}',`,\n ` label: '${definition.label as string}',`,\n ` datasource: '${definition.datasource as string}',`,\n externalLine,\n ` fields: {`,\n ...fieldLines,\n ` },`,\n `};`,\n ``,\n `export default ${definition.name as string};`,\n ``,\n ].join('\\n');\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport type { IntrospectedSchema } from '@objectstack/spec/contracts';\nimport {\n ExternalDatasourceService,\n type ExternalDatasourceServiceConfig,\n type DatasourceLike,\n type ObjectLike,\n type Logger,\n} from './external-datasource-service.js';\n\n/**\n * Minimal surfaces the plugin needs from the data engine + metadata service.\n * Kept structural so the plugin doesn't hard-depend on concrete classes.\n */\ninterface DataEngineLike {\n /** Resolve a driver by datasource name and introspect its live schema. */\n introspectDatasource?: (datasource: string) => Promise<IntrospectedSchema>;\n getDatasourceDriver?: (datasource: string) => { introspectSchema?: () => Promise<IntrospectedSchema> } | undefined;\n}\n\ninterface MetadataServiceLike {\n get: (type: string, name: string) => Promise<unknown>;\n getObject?: (name: string) => Promise<unknown>;\n listObjects?: () => Promise<unknown[]>;\n list?: (type: string) => Promise<unknown[]>;\n register?: (type: string, name: string, data: unknown) => Promise<void> | void;\n}\n\nexport interface ExternalDatasourceServicePluginOptions {\n /** Override the introspection function (mainly for tests). */\n introspect?: (datasource: string) => Promise<IntrospectedSchema>;\n logger?: Logger;\n}\n\n/**\n * ExternalDatasourceServicePlugin — registers `IExternalDatasourceService`\n * into the kernel as the `'external-datasource'` service (ADR-0015 §6.1).\n *\n * It bridges the decoupled {@link ExternalDatasourceService} to the live\n * `IDataEngine` (for driver introspection) and `IMetadataService` (for object\n * + datasource reads).\n */\nexport class ExternalDatasourceServicePlugin implements Plugin {\n name = 'com.objectstack.service-external-datasource';\n version = '1.0.0';\n type = 'standard' as const;\n dependencies: string[] = [];\n\n private service?: ExternalDatasourceService;\n private readonly options: ExternalDatasourceServicePluginOptions;\n\n constructor(options: ExternalDatasourceServicePluginOptions = {}) {\n this.options = options;\n }\n\n async init(ctx: PluginContext): Promise<void> {\n const engine = safeGetService<DataEngineLike>(ctx, 'data');\n const metadata = safeGetService<MetadataServiceLike>(ctx, 'metadata');\n\n const introspect: ExternalDatasourceServiceConfig['introspect'] =\n this.options.introspect ??\n (async (datasource: string) => {\n if (engine?.introspectDatasource) return engine.introspectDatasource(datasource);\n const driver = engine?.getDatasourceDriver?.(datasource);\n if (driver?.introspectSchema) return driver.introspectSchema();\n throw new Error(\n `Cannot introspect datasource '${datasource}': no driver introspection available.`,\n );\n });\n\n const config: ExternalDatasourceServiceConfig = {\n introspect,\n getDatasource: async (n) => (await metadata?.get('datasource', n)) as DatasourceLike | undefined,\n getObject: async (n) =>\n (metadata?.getObject ? await metadata.getObject(n) : await metadata?.get('object', n)) as ObjectLike | undefined,\n listObjects: async () =>\n ((metadata?.listObjects\n ? await metadata.listObjects()\n : await metadata?.list?.('object')) ?? []) as ObjectLike[],\n // Persist the refreshed snapshot as an `external_catalog` metadata record\n // so the boot gate + Studio's schema browser can read it without\n // re-introspecting. No-op when the metadata service can't write.\n ...(metadata?.register\n ? {\n persistCatalog: async (catalog) => {\n await metadata.register!('external_catalog', catalog.name, catalog);\n },\n // Runtime \"Import as Object\": persist a federated object so it's\n // immediately queryable, no git commit required (ADR-0015 Addendum).\n persistObject: async (name, definition) => {\n await metadata.register!('object', name, definition);\n },\n }\n : {}),\n logger: this.options.logger,\n };\n\n this.service = new ExternalDatasourceService(config);\n ctx.registerService('external-datasource', this.service);\n }\n\n async start(ctx: PluginContext): Promise<void> {\n if (this.service) await ctx.trigger('external-datasource:ready', this.service);\n }\n\n async destroy(): Promise<void> {\n this.service = undefined;\n }\n}\n\nfunction safeGetService<T>(ctx: PluginContext, name: string): T | undefined {\n try {\n return ctx.getService<T>(name);\n } catch {\n return undefined;\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * DatasourceAdminService — implements {@link IDatasourceAdminService}\n * (ADR-0015 Addendum) on top of injected persistence + secret + driver probe\n * callbacks.\n *\n * Like its federation sibling `ExternalDatasourceService`, this service is\n * intentionally decoupled from the kernel: every side effect (connection probe,\n * metadata read/write, secret write, bound-object count, hot pool (de)register)\n * is injected via {@link DatasourceAdminServiceConfig}, so the lifecycle rules\n * (origin gating, secret indirection, removal safety) are pure and unit-testable.\n *\n * Invariants enforced here, independent of the wiring:\n * - Code-defined datasources (`origin: 'code'`) are read-only — update/remove\n * reject them, and create refuses a name a code datasource already owns.\n * - A runtime datasource never shadows a code one (code wins on collision).\n * - Credentials never persist in cleartext: the cleartext {@link SecretInput}\n * transits create/update/test only; create/update write it to the secret\n * store and persist only the returned `credentialsRef`.\n * - Removal is refused while objects are still bound to the datasource.\n */\n\nimport type {\n IDatasourceAdminService,\n DatasourceDraft,\n SecretInput,\n TestConnectionResult,\n DatasourceSummary,\n} from './contracts/index.js';\nimport type { Logger } from './logger.js';\n\n/** Datasource name rule (mirrors `DatasourceSchema.name`). */\nconst NAME_RE = /^[a-z_][a-z0-9_]*$/;\n\n/**\n * A persisted datasource record (subset of `Datasource`). `origin` distinguishes\n * code-defined from runtime; `external.credentialsRef` is the opaque secret\n * handle — never a cleartext credential.\n */\nexport interface StoredDatasource {\n name: string;\n label?: string;\n driver: string;\n schemaMode?: 'managed' | 'external' | 'validate-only';\n config?: Record<string, unknown>;\n external?: (Record<string, unknown> & { credentialsRef?: string }) | undefined;\n pool?: Record<string, unknown>;\n active?: boolean;\n origin?: 'code' | 'runtime';\n /** Package that defines a code-origin datasource, when known. */\n definedIn?: string;\n}\n\n/** What a connection probe needs (cleartext secret is transient, never stored). */\nexport interface ProbeInput {\n driver: string;\n config: Record<string, unknown>;\n /** Cleartext secret used for this probe only (e.g. password / DSN). */\n secret?: string;\n external?: Record<string, unknown>;\n timeoutMs?: number;\n}\n\n/**\n * Injected dependencies. The plugin supplies real implementations backed by the\n * driver registry, `IMetadataService` (runtime store), and the secret store;\n * tests supply fakes.\n */\nexport interface DatasourceAdminServiceConfig {\n /** Probe a connection live (driver connect + cheap round-trip). */\n probe: (input: ProbeInput) => Promise<TestConnectionResult>;\n /** Read every datasource record (code + runtime). */\n listDatasourceRecords: () => Promise<StoredDatasource[]>;\n /** Read one datasource record by name. */\n getDatasourceRecord: (name: string) => Promise<StoredDatasource | undefined>;\n /** Persist a runtime datasource record into the runtime metadata store. */\n putDatasourceRecord: (record: StoredDatasource) => Promise<void>;\n /** Remove a runtime datasource record from the runtime metadata store. */\n deleteDatasourceRecord: (name: string) => Promise<void>;\n /** Encrypt + store a secret, returning an opaque `credentialsRef`. */\n writeSecret: (input: SecretInput, hint: { name: string }) => Promise<string>;\n /** Best-effort delete of a stored secret by ref (cleanup on remove/rewrap). */\n removeSecret?: (credentialsRef: string) => Promise<void>;\n /** Count objects bound to a datasource (removal blocked while > 0). */\n countBoundObjects: (datasource: string) => Promise<number>;\n /** Hot-(re)register a runtime datasource's connection pool after write. */\n registerPool?: (record: StoredDatasource) => Promise<void> | void;\n /** Tear down a runtime datasource's pool on remove. */\n unregisterPool?: (name: string) => Promise<void> | void;\n logger?: Logger;\n}\n\nexport class DatasourceAdminService implements IDatasourceAdminService {\n constructor(private readonly config: DatasourceAdminServiceConfig) {}\n\n private get logger(): Logger | undefined {\n return this.config.logger;\n }\n\n async listDatasources(): Promise<DatasourceSummary[]> {\n const records = await this.config.listDatasourceRecords();\n\n // Group by name; code wins on collision, and a shadowed runtime row marks\n // the effective (code) entry as conflicting.\n const byName = new Map<string, { code?: StoredDatasource; runtime?: StoredDatasource }>();\n for (const rec of records) {\n const slot = byName.get(rec.name) ?? {};\n if (rec.origin === 'runtime') slot.runtime = rec;\n else slot.code = rec;\n byName.set(rec.name, slot);\n }\n\n const summaries: DatasourceSummary[] = [];\n for (const [name, slot] of byName) {\n const effective = slot.code ?? slot.runtime;\n if (!effective) continue;\n summaries.push({\n name,\n label: effective.label,\n driver: effective.driver,\n schemaMode: effective.schemaMode ?? 'managed',\n origin: slot.code ? 'code' : 'runtime',\n active: effective.active ?? true,\n status: 'unvalidated',\n ...(slot.code?.definedIn ? { definedIn: slot.code.definedIn } : {}),\n ...(slot.code && slot.runtime ? { conflictsWithCode: true } : {}),\n });\n }\n return summaries;\n }\n\n /**\n * Read one datasource's full detail for editing, with the credential stripped.\n * Returns `config` (non-sensitive — credentials live in `sys_secret`, never in\n * config), `origin`, and a `hasSecret` flag so the UI can show \"leave blank to\n * keep\" without ever receiving the `credentialsRef` or any cleartext. Returns\n * `undefined` when the name is unknown.\n */\n async getDatasource(name: string): Promise<\n | (Pick<StoredDatasource, 'name' | 'label' | 'driver' | 'schemaMode' | 'config' | 'active' | 'definedIn'> & {\n origin: 'code' | 'runtime';\n hasSecret: boolean;\n })\n | undefined\n > {\n const rec = await this.config.getDatasourceRecord(name);\n if (!rec) return undefined;\n const hasSecret = Boolean(rec.external?.credentialsRef);\n return {\n name: rec.name,\n label: rec.label,\n driver: rec.driver,\n schemaMode: rec.schemaMode ?? 'managed',\n config: rec.config ?? {},\n active: rec.active ?? true,\n origin: rec.origin === 'runtime' ? 'runtime' : 'code',\n hasSecret,\n ...(rec.definedIn ? { definedIn: rec.definedIn } : {}),\n };\n }\n\n async testConnection(input: DatasourceDraft, secret?: SecretInput): Promise<TestConnectionResult> {\n if (!input?.driver) {\n return { ok: false, error: 'A driver is required to test a connection.' };\n }\n const queryTimeoutMs = (input.external as { queryTimeoutMs?: number } | undefined)?.queryTimeoutMs;\n try {\n return await this.config.probe({\n driver: input.driver,\n config: input.config ?? {},\n secret: secret?.value,\n external: input.external,\n ...(typeof queryTimeoutMs === 'number' ? { timeoutMs: queryTimeoutMs } : {}),\n });\n } catch (err) {\n return { ok: false, error: err instanceof Error ? err.message : String(err) };\n }\n }\n\n async createDatasource(input: DatasourceDraft, secret?: SecretInput): Promise<DatasourceSummary> {\n this.assertValidName(input?.name);\n if (!input.driver) throw new Error('A driver is required to create a datasource.');\n\n const existing = await this.config.getDatasourceRecord(input.name);\n if (existing) {\n if (existing.origin === 'code' || existing.origin === undefined) {\n throw new Error(\n `Cannot create datasource '${input.name}': a code-defined datasource owns this name (read-only).`,\n );\n }\n throw new Error(`Datasource '${input.name}' already exists.`);\n }\n\n const record: StoredDatasource = {\n ...this.toRecord(input),\n origin: 'runtime',\n };\n\n if (secret) {\n const credentialsRef = await this.config.writeSecret(secret, { name: input.name });\n record.external = { ...(record.external ?? {}), credentialsRef };\n }\n\n await this.config.putDatasourceRecord(record);\n await this.tryRegisterPool(record);\n return this.toSummary(record);\n }\n\n async updateDatasource(\n name: string,\n patch: Partial<DatasourceDraft>,\n secret?: SecretInput,\n ): Promise<DatasourceSummary> {\n const existing = await this.config.getDatasourceRecord(name);\n if (!existing) throw new Error(`Datasource '${name}' not found.`);\n if (existing.origin !== 'runtime') {\n throw new Error(`Datasource '${name}' is code-defined and cannot be edited at runtime.`);\n }\n\n // Merge patch over the existing record; `name`/`origin` are never patched.\n const merged: StoredDatasource = {\n ...existing,\n ...(patch.label !== undefined ? { label: patch.label } : {}),\n ...(patch.driver !== undefined ? { driver: patch.driver } : {}),\n ...(patch.schemaMode !== undefined ? { schemaMode: patch.schemaMode } : {}),\n ...(patch.config !== undefined ? { config: patch.config } : {}),\n ...(patch.pool !== undefined ? { pool: patch.pool } : {}),\n ...(patch.active !== undefined ? { active: patch.active } : {}),\n name: existing.name,\n origin: 'runtime',\n };\n if (patch.external !== undefined) {\n // Preserve the existing credentialsRef unless a new secret rewraps it.\n merged.external = { ...patch.external, credentialsRef: existing.external?.credentialsRef };\n }\n\n if (secret) {\n const prevRef = existing.external?.credentialsRef;\n const credentialsRef = await this.config.writeSecret(secret, { name });\n merged.external = { ...(merged.external ?? {}), credentialsRef };\n if (prevRef && prevRef !== credentialsRef) await this.tryRemoveSecret(prevRef);\n }\n\n await this.config.putDatasourceRecord(merged);\n await this.tryRegisterPool(merged);\n return this.toSummary(merged);\n }\n\n async removeDatasource(name: string): Promise<void> {\n const existing = await this.config.getDatasourceRecord(name);\n if (!existing) throw new Error(`Datasource '${name}' not found.`);\n if (existing.origin !== 'runtime') {\n throw new Error(`Datasource '${name}' is code-defined and cannot be removed at runtime.`);\n }\n\n const bound = await this.config.countBoundObjects(name);\n if (bound > 0) {\n throw new Error(\n `Cannot remove datasource '${name}': ${bound} object(s) are still bound to it.`,\n );\n }\n\n await this.config.deleteDatasourceRecord(name);\n if (existing.external?.credentialsRef) await this.tryRemoveSecret(existing.external.credentialsRef);\n await this.tryUnregisterPool(name);\n }\n\n // --- internals -----------------------------------------------------------\n\n private assertValidName(name: string | undefined): void {\n if (!name || !NAME_RE.test(name)) {\n throw new Error(\n `Invalid datasource name '${name ?? ''}': must match /^[a-z_][a-z0-9_]*$/.`,\n );\n }\n }\n\n private toRecord(input: DatasourceDraft): StoredDatasource {\n return {\n name: input.name,\n ...(input.label !== undefined ? { label: input.label } : {}),\n driver: input.driver,\n ...(input.schemaMode !== undefined ? { schemaMode: input.schemaMode } : {}),\n ...(input.config !== undefined ? { config: input.config } : {}),\n ...(input.external !== undefined ? { external: input.external } : {}),\n ...(input.pool !== undefined ? { pool: input.pool } : {}),\n ...(input.active !== undefined ? { active: input.active } : {}),\n };\n }\n\n private toSummary(record: StoredDatasource): DatasourceSummary {\n return {\n name: record.name,\n label: record.label,\n driver: record.driver,\n schemaMode: record.schemaMode ?? 'managed',\n origin: record.origin ?? 'runtime',\n active: record.active ?? true,\n status: 'unvalidated',\n };\n }\n\n private async tryRegisterPool(record: StoredDatasource): Promise<void> {\n try {\n await this.config.registerPool?.(record);\n } catch (err) {\n this.logger?.warn(`registerPool('${record.name}') failed`, err);\n }\n }\n\n private async tryUnregisterPool(name: string): Promise<void> {\n try {\n await this.config.unregisterPool?.(name);\n } catch (err) {\n this.logger?.warn(`unregisterPool('${name}') failed`, err);\n }\n }\n\n private async tryRemoveSecret(credentialsRef: string): Promise<void> {\n try {\n await this.config.removeSecret?.(credentialsRef);\n } catch (err) {\n this.logger?.warn(`removeSecret('${credentialsRef}') failed`, err);\n }\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport { registerMetadataTypeActions } from '@objectstack/spec/kernel';\nimport type {\n IDatasourceDriverFactory,\n DatasourceConnectionSpec,\n TestConnectionResult,\n} from './contracts/index.js';\nimport {\n DatasourceAdminService,\n type DatasourceAdminServiceConfig,\n type StoredDatasource,\n type ProbeInput,\n} from './datasource-admin-service.js';\nimport type { Logger } from './logger.js';\n\n/**\n * Minimal metadata-service surface used for datasource persistence + the\n * bound-object count. Kept structural so the plugin doesn't hard-depend on the\n * concrete `MetadataManager`.\n */\ninterface MetadataServiceLike {\n get: (type: string, name: string) => Promise<unknown>;\n list: (type: string) => Promise<unknown[]>;\n register: (type: string, name: string, data: unknown) => Promise<void>;\n unregister: (type: string, name: string) => Promise<void>;\n listObjects?: () => Promise<unknown[]>;\n}\n\n/** Engine surface used for hot pool (de)registration. */\ninterface DataEngineLike {\n registerDriver?: (driver: unknown, isDefault?: boolean) => void;\n registerDatasourceDef?: (def: { name: string; schemaMode?: string; external?: { allowWrites?: boolean } }) => void;\n getDriverByName?: (name: string) => unknown;\n // sys_metadata CRUD used to persist runtime datasource records durably (same\n // table runtime objects use). Optional — absent on lightweight kernels, in\n // which case persistence degrades to in-memory (pre-existing behavior).\n findOne?: (object: string, query: { where?: Record<string, unknown> }) => Promise<Record<string, unknown> | undefined | null>;\n find?: (object: string, query: { where?: Record<string, unknown> }) => Promise<Record<string, unknown>[]>;\n insert?: (object: string, row: Record<string, unknown>) => Promise<unknown>;\n update?: (object: string, row: Record<string, unknown>, opts: { where: Record<string, unknown> }) => Promise<unknown>;\n delete?: (object: string, opts: { where: Record<string, unknown> }) => Promise<unknown>;\n}\n\n/**\n * Durable persistence for runtime datasource records via the `sys_metadata`\n * table — the same store runtime objects use (the protocol writes objects there\n * directly). `MetadataManager.register()` alone is in-memory unless a writable\n * `datasource:` loader is wired, which standalone `serve` does not do; so a\n * UI-created datasource vanished on restart. These helpers persist on write and\n * the plugin restores them into the registry on boot before rehydrating pools.\n * Credential cleartext is never stored — only the opaque `external.credentialsRef`.\n */\nconst DS_META_TYPE = 'datasource';\nconst SYS_METADATA = 'sys_metadata';\n\nfunction newMetaId(): string {\n return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'\n ? crypto.randomUUID()\n : `meta_${Date.now()}_${Math.random().toString(36).slice(2)}`;\n}\n\nasync function persistDatasourceRow(engine: DataEngineLike | undefined, record: { name: string }): Promise<void> {\n if (!engine?.insert || !engine.findOne) return; // no durable store — in-memory only\n const now = new Date().toISOString();\n const existing = await engine.findOne(SYS_METADATA, {\n where: { type: DS_META_TYPE, name: record.name, state: 'active' },\n });\n if (existing) {\n await engine.update?.(\n SYS_METADATA,\n { metadata: JSON.stringify(record), updated_at: now, version: ((existing.version as number) || 0) + 1, state: 'active' },\n { where: { id: existing.id } },\n );\n } else {\n await engine.insert(SYS_METADATA, {\n id: newMetaId(),\n name: record.name,\n type: DS_META_TYPE,\n scope: 'platform',\n metadata: JSON.stringify(record),\n state: 'active',\n version: 1,\n created_at: now,\n updated_at: now,\n });\n }\n}\n\nasync function deleteDatasourceRow(engine: DataEngineLike | undefined, name: string): Promise<void> {\n if (!engine?.findOne) return;\n const existing = await engine.findOne(SYS_METADATA, { where: { type: DS_META_TYPE, name, state: 'active' } });\n if (!existing) return;\n if (engine.delete) await engine.delete(SYS_METADATA, { where: { id: existing.id } });\n else await engine.update?.(SYS_METADATA, { state: 'inactive' }, { where: { id: existing.id } });\n}\n\nasync function loadDatasourceRows(engine: DataEngineLike | undefined): Promise<Array<Record<string, unknown>>> {\n if (!engine?.find) return [];\n const rows = await engine.find(SYS_METADATA, { where: { type: DS_META_TYPE, state: 'active' } });\n const out: Array<Record<string, unknown>> = [];\n for (const r of rows ?? []) {\n const raw = (r as { metadata?: unknown }).metadata;\n try {\n out.push(typeof raw === 'string' ? JSON.parse(raw) : (raw as Record<string, unknown>));\n } catch {\n /* skip corrupt row */\n }\n }\n return out;\n}\n\n/**\n * Host-provided secret binding. Encrypts a cleartext credential into the secret\n * store and returns an opaque `credentialsRef`; `unbind` deletes it. Wired by\n * the stack that owns the `ICryptoProvider` + `sys_secret` store. When absent,\n * the plugin fails *closed*: creating/updating a datasource *with* a secret\n * throws rather than risk persisting cleartext.\n */\nexport interface SecretBinder {\n bind: (input: { value: string; namespace?: string; key?: string }, hint: { name: string }) => Promise<string>;\n unbind?: (credentialsRef: string) => Promise<void>;\n /**\n * Dereference a `credentialsRef` back to cleartext for opening a live\n * connection (boot rehydration + hot pool registration). Optional: when\n * absent, pools for secret-bearing datasources are built without the\n * credential (fine for credential-less drivers like sqlite/memory).\n */\n resolve?: (credentialsRef: string) => Promise<string | undefined>;\n}\n\nexport interface DatasourceAdminServicePluginOptions {\n /** Secret binding backed by the host's crypto provider + `sys_secret`. */\n secrets?: SecretBinder;\n /** Override the driver factory (defaults to the `'datasource-driver-factory'` service). */\n driverFactory?: IDatasourceDriverFactory;\n logger?: Logger;\n}\n\n/**\n * DatasourceAdminServicePlugin — registers `IDatasourceAdminService` into the\n * kernel as the `'datasource-admin'` service (ADR-0015 Addendum).\n *\n * Bridges the decoupled {@link DatasourceAdminService} to live infrastructure:\n * - persistence + bound-object count via the `'metadata'` service\n * (`register`/`unregister` write through to the runtime DB loader),\n * - connection probe + hot pool (de)registration via the\n * `'datasource-driver-factory'` capability and the `'data'` engine,\n * - secret encryption via a host-provided {@link SecretBinder} (fail-closed).\n *\n * Every dependency degrades gracefully: a missing driver factory turns\n * `testConnection` into a clear `{ ok: false }` and skips hot pool registration\n * (the driver is picked up at next boot); a missing secret binder makes\n * secret-bearing create/update fail loudly instead of leaking cleartext.\n */\nexport class DatasourceAdminServicePlugin implements Plugin {\n name = 'com.objectstack.service-datasource-admin';\n version = '1.0.0';\n type = 'standard' as const;\n dependencies: string[] = [];\n\n private service?: DatasourceAdminService;\n private config?: DatasourceAdminServiceConfig;\n private readonly options: DatasourceAdminServicePluginOptions;\n\n constructor(options: DatasourceAdminServicePluginOptions = {}) {\n this.options = options;\n }\n\n async init(ctx: PluginContext): Promise<void> {\n const logger = this.options.logger;\n\n // Contribute the metadata-admin \"Test connection\" type-level action,\n // co-located with the route handler that serves it\n // (`POST /api/v1/datasources/:name/test`, see admin-routes.ts). The\n // open-source framework deliberately ships no declarative datasource\n // action, so the button is emitted by `/api/v1/meta` only when this\n // backend plugin is installed — never advertising a route the host\n // can't serve. `${ctx.recordId}` resolves to the datasource's name.\n registerMetadataTypeActions('datasource', [\n {\n name: 'test_connection',\n label: 'Test connection',\n icon: 'plug-zap',\n type: 'api',\n target: '/api/v1/datasources/${ctx.recordId}/test',\n method: 'POST',\n variant: 'secondary',\n refreshAfter: false,\n locations: ['record_header', 'list_item'],\n },\n ] as any);\n\n // Resolve infra services lazily, per call — `init()` may run before the\n // `data` / `metadata` plugins have registered their services (plugin start\n // order is dependency- not registration-driven), and admin requests only\n // arrive long after the full boot completes.\n const metadataOf = (): MetadataServiceLike | undefined =>\n safeGetService<MetadataServiceLike>(ctx, 'metadata');\n const engineOf = (): DataEngineLike | undefined =>\n safeGetService<DataEngineLike>(ctx, 'data');\n\n const factory = (): IDatasourceDriverFactory | undefined =>\n this.options.driverFactory ?? safeGetService<IDatasourceDriverFactory>(ctx, 'datasource-driver-factory');\n\n const config: DatasourceAdminServiceConfig = {\n probe: (input) => this.probe(factory(), input),\n\n listDatasourceRecords: async () => {\n const rows = ((await metadataOf()?.list('datasource')) ?? []) as StoredDatasource[];\n // Artefact-loaded rows may omit `origin`; treat them as code-defined.\n return rows.map((r) => ({ ...r, origin: r.origin ?? 'code' }));\n },\n\n getDatasourceRecord: async (name) => {\n const row = (await metadataOf()?.get('datasource', name)) as StoredDatasource | undefined;\n return row ? { ...row, origin: row.origin ?? 'code' } : undefined;\n },\n\n putDatasourceRecord: async (record) => {\n const metadata = metadataOf();\n if (!metadata?.register) {\n throw new Error('Metadata service is unavailable; cannot persist datasource.');\n }\n // In-memory registry (immediate visibility) + durable sys_metadata row\n // (survives restart; restored on boot by restoreRuntimeDatasources).\n await metadata.register('datasource', record.name, record);\n await persistDatasourceRow(engineOf(), record);\n },\n\n deleteDatasourceRecord: async (name) => {\n const metadata = metadataOf();\n if (!metadata?.unregister) {\n throw new Error('Metadata service is unavailable; cannot remove datasource.');\n }\n await metadata.unregister('datasource', name);\n await deleteDatasourceRow(engineOf(), name);\n },\n\n writeSecret: async (input, hint) => {\n const binder = this.options.secrets;\n if (!binder?.bind) {\n throw new Error(\n 'No secret store configured: refusing to persist a datasource credential in cleartext. ' +\n 'Wire a SecretBinder (CryptoProvider + sys_secret) into DatasourceAdminServicePlugin.',\n );\n }\n return binder.bind(input, hint);\n },\n\n removeSecret: async (ref) => {\n await this.options.secrets?.unbind?.(ref);\n },\n\n countBoundObjects: async (datasource) => {\n const metadata = metadataOf();\n const objects = ((await metadata?.listObjects?.()) ??\n (await metadata?.list('object')) ??\n []) as Array<{ datasource?: string }>;\n return objects.filter((o) => o?.datasource === datasource).length;\n },\n\n registerPool: async (record) => {\n const f = factory();\n const engine = engineOf();\n if (!f || !engine?.registerDriver || !f.supports(record.driver)) return;\n // Recover the cleartext credential from `sys_secret` so the pool opens\n // with the real password. The cleartext is never persisted on the\n // record (only `credentialsRef`), so it must be dereferenced here —\n // both on create/update and on boot rehydration. Credential-less\n // drivers (sqlite/memory) simply have no ref and skip this.\n const credentialsRef = record.external?.credentialsRef;\n const secret = credentialsRef ? await this.options.secrets?.resolve?.(credentialsRef) : undefined;\n const handle = await f.create({ ...this.toSpec(record), ...(secret ? { secret } : {}) });\n if (typeof handle?.connect === 'function') await handle.connect();\n // The engine routes a datasource to a driver by `driver.name === <datasource name>`\n // (see ObjectQL engine.getDriver). Prefer the factory's underlying engine\n // driver (the `driver` escape hatch); fall back to the handle itself. Stamp\n // the name so routing resolves to this pool.\n const engineDriver = (handle.driver ?? handle) as { name?: string };\n try {\n engineDriver.name = record.name;\n } catch {\n /* frozen driver — registration may still work if name already matches */\n }\n engine.registerDriver(engineDriver);\n engine.registerDatasourceDef?.({\n name: record.name,\n schemaMode: record.schemaMode,\n external: record.external as { allowWrites?: boolean } | undefined,\n });\n },\n\n unregisterPool: async (name) => {\n const driver = engineOf()?.getDriverByName?.(name) as { disconnect?: () => Promise<void> } | undefined;\n if (typeof driver?.disconnect === 'function') await driver.disconnect();\n },\n\n logger,\n };\n\n this.config = config;\n this.service = new DatasourceAdminService(config);\n ctx.registerService('datasource-admin', this.service);\n\n // Setup-app nav (ADR-0029 D7): datasources are a *capability* this plugin\n // owns, so it contributes its own entry into the `group_integrations` slot\n // (core setup-nav must not fill capability-owned slots). datasource is a\n // metadata type, so the entry opens the generic metadata-admin engine route\n // rather than a bespoke page or an object view.\n try {\n const manifest = ctx.getService<{ register(m: any): void }>('manifest');\n if (manifest && typeof manifest.register === 'function') {\n manifest.register({\n id: 'com.objectstack.service-datasource.nav',\n namespace: 'sys',\n version: this.version,\n type: 'plugin',\n scope: 'system',\n name: 'Datasource Navigation',\n description: 'Contributes the Datasources entry to the Setup app Integrations group.',\n navigationContributions: [\n {\n app: 'setup',\n group: 'group_integrations',\n priority: 100,\n items: [\n {\n id: 'nav_datasources',\n type: 'url',\n label: 'Datasources',\n url: '/apps/setup/component/metadata/resource?type=datasource',\n icon: 'database',\n requiredPermissions: ['manage_platform_settings'],\n },\n ],\n },\n ],\n });\n }\n } catch (err) {\n this.options.logger?.warn?.('datasource nav contribution skipped', err);\n }\n }\n\n async start(ctx: PluginContext): Promise<void> {\n // Restore UI-created (runtime) datasources from the durable sys_metadata\n // store back into the in-memory registry, THEN rebuild their live pools.\n // `register()` is in-memory only in standalone serve (no writable\n // `datasource:` loader), so without this a node restart drops every\n // UI-created datasource. Code-defined datasources come from the artifact and\n // are unaffected.\n await this.restoreRuntimeDatasources(ctx);\n await this.rehydratePools();\n if (this.service) await ctx.trigger('datasource-admin:ready', this.service);\n }\n\n /** Reload persisted runtime datasource rows (sys_metadata) into the registry. */\n private async restoreRuntimeDatasources(ctx: PluginContext): Promise<void> {\n const engine = safeGetService<DataEngineLike>(ctx, 'data');\n const metadata = safeGetService<MetadataServiceLike>(ctx, 'metadata');\n if (!engine?.find || !metadata?.register) return;\n let rows: Array<Record<string, unknown>>;\n try {\n rows = await loadDatasourceRows(engine);\n } catch (err) {\n this.options.logger?.warn?.('datasource restore: reading sys_metadata failed', err);\n return;\n }\n let restored = 0;\n for (const rec of rows) {\n const name = (rec as { name?: string }).name;\n if (!name) continue;\n try {\n await metadata.register('datasource', name, rec);\n restored += 1;\n } catch (err) {\n this.options.logger?.warn?.(`datasource restore: register '${name}' failed`, err);\n }\n }\n if (restored > 0) this.options.logger?.info?.(`datasource: restored ${restored} runtime record(s) from sys_metadata`);\n }\n\n /**\n * Boot-time rehydration: list persisted runtime datasources and re-register\n * each one's connection pool (driver build → connect → registerDriver),\n * decrypting its `sys_secret` credential on the way via the configured\n * `registerPool` (which resolves `credentialsRef`). Code-defined datasources\n * are owned by the host stack's own boot path and skipped here. Entirely\n * best-effort: a missing factory/engine, an unpersisted dev store (nothing\n * to rehydrate), or a single failing pool never blocks boot.\n */\n private async rehydratePools(): Promise<void> {\n const cfg = this.config;\n if (!cfg?.registerPool || !cfg.listDatasourceRecords) return;\n\n let records: StoredDatasource[];\n try {\n records = await cfg.listDatasourceRecords();\n } catch (err) {\n this.options.logger?.warn?.('datasource rehydrate: listing records failed', err);\n return;\n }\n\n const runtime = records.filter((r) => r.origin === 'runtime' && (r.active ?? true));\n if (runtime.length === 0) return;\n\n let registered = 0;\n for (const record of runtime) {\n try {\n await cfg.registerPool(record);\n registered++;\n } catch (err) {\n this.options.logger?.warn?.(`datasource rehydrate: pool '${record.name}' failed`, err);\n }\n }\n this.options.logger?.info?.(\n `Rehydrated ${registered}/${runtime.length} runtime datasource pool(s) on boot`,\n );\n }\n\n async destroy(): Promise<void> {\n this.service = undefined;\n }\n\n // --- internals -----------------------------------------------------------\n\n private toSpec(record: StoredDatasource): DatasourceConnectionSpec {\n return {\n name: record.name,\n driver: record.driver,\n config: record.config ?? {},\n external: record.external,\n pool: record.pool,\n };\n }\n\n /** Probe a connection via the driver factory: build → connect → ping → close. */\n private async probe(\n factory: IDatasourceDriverFactory | undefined,\n input: ProbeInput,\n ): Promise<TestConnectionResult> {\n if (!factory) {\n return { ok: false, error: 'No driver factory is registered to test connections.' };\n }\n if (!factory.supports(input.driver)) {\n return { ok: false, error: `No driver factory supports driver '${input.driver}'.` };\n }\n\n let driver: any;\n try {\n driver = await factory.create({\n driver: input.driver,\n config: input.config,\n secret: input.secret,\n external: input.external,\n });\n } catch (err) {\n return { ok: false, error: `Failed to build driver: ${errMsg(err)}` };\n }\n\n const startedAt = monotonicNow();\n try {\n if (typeof driver?.connect === 'function') await driver.connect();\n // Prefer a cheap ping; fall back to the engine driver's health check, then\n // a schema introspection round-trip — whichever the handle exposes.\n if (typeof driver?.ping === 'function') await driver.ping();\n else if (typeof driver?.checkHealth === 'function') await driver.checkHealth();\n else if (typeof driver?.introspectSchema === 'function') await driver.introspectSchema();\n const latencyMs = elapsedSince(startedAt);\n let serverVersion: string | undefined;\n try {\n serverVersion = typeof driver?.serverVersion === 'function' ? await driver.serverVersion() : undefined;\n } catch {\n /* version is best-effort */\n }\n return { ok: true, latencyMs, ...(serverVersion ? { serverVersion } : {}) };\n } catch (err) {\n return { ok: false, error: errMsg(err) };\n } finally {\n try {\n if (typeof driver?.disconnect === 'function') await driver.disconnect();\n } catch {\n /* best-effort teardown */\n }\n }\n }\n}\n\nfunction safeGetService<T>(ctx: PluginContext, name: string): T | undefined {\n try {\n return ctx.getService<T>(name);\n } catch {\n return undefined;\n }\n}\n\nfunction errMsg(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\n/** Monotonic clock when available (avoids wall-clock skew); falls back to 0. */\nfunction monotonicNow(): number {\n const perf = (globalThis as { performance?: { now?: () => number } }).performance;\n return typeof perf?.now === 'function' ? perf.now() : 0;\n}\n\nfunction elapsedSince(startedAt: number): number {\n return Math.max(0, Math.round(monotonicNow() - startedAt));\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Default (dev/self-host) implementation of {@link IDatasourceDriverFactory}.\n *\n * The framework ships no universal \"driver-by-id\" registry — concrete drivers\n * are constructed by the host stack (ADR-0015 Addendum §3.5). This factory is\n * the host-side glue that lets the runtime-datasource lifecycle\n * (`IDatasourceAdminService`) build a live driver from an *unsaved* draft so it\n * can probe a connection before \"Save\" and hot-register a pool afterwards.\n *\n * Supported driver ids map onto the same open-core drivers the standalone\n * stack auto-detects:\n * - `postgres` / `pg` / `postgresql` → `@objectstack/driver-sql` (client `pg`)\n * - `sqlite` / `sqlite3` → `@objectstack/driver-sql` (better-sqlite3)\n * - `mongodb` / `mongo` → `@objectstack/driver-mongodb` (peer dep)\n * - `memory` / `inmemory` → `@objectstack/driver-memory`\n *\n * Anything else returns `supports() === false`, so the admin service degrades\n * gracefully (testConnection → `{ ok: false }`, create skips hot pool reg).\n *\n * SECURITY: the cleartext `spec.secret` is used only to open the connection and\n * is never persisted or logged here.\n */\n\nimport type {\n IDatasourceDriverFactory,\n DatasourceConnectionSpec,\n DatasourceDriverHandle,\n} from './contracts/index.js';\n\ntype ResolvedKind = 'postgres' | 'sqlite' | 'mongodb' | 'memory';\n\nconst DRIVER_ID_ALIASES: Record<string, ResolvedKind> = {\n postgres: 'postgres',\n postgresql: 'postgres',\n pg: 'postgres',\n sqlite: 'sqlite',\n sqlite3: 'sqlite',\n 'better-sqlite3': 'sqlite',\n mongodb: 'mongodb',\n mongo: 'mongodb',\n memory: 'memory',\n inmemory: 'memory',\n 'in-memory': 'memory',\n};\n\nfunction resolveKind(driverId: string): ResolvedKind | undefined {\n return DRIVER_ID_ALIASES[String(driverId ?? '').toLowerCase()];\n}\n\n/**\n * Wrap a concrete engine driver in a probe handle. `ping`/`checkHealth` reuse\n * the driver's own health check; `driver` is the escape hatch the admin service\n * hands to `registerDriver()`.\n */\nfunction toHandle(driver: any, serverVersion?: () => Promise<string | undefined>): DatasourceDriverHandle {\n return {\n connect: typeof driver?.connect === 'function' ? () => driver.connect() : undefined,\n disconnect: typeof driver?.disconnect === 'function' ? () => driver.disconnect() : undefined,\n checkHealth: typeof driver?.checkHealth === 'function' ? () => driver.checkHealth() : undefined,\n ping: typeof driver?.checkHealth === 'function' ? () => driver.checkHealth() : undefined,\n ...(serverVersion ? { serverVersion } : {}),\n driver,\n };\n}\n\n/** Build the Knex `connection` for a SQL driver from a spec's config + secret. */\nfunction buildSqlConnection(spec: DatasourceConnectionSpec, client: 'pg' | 'better-sqlite3'): unknown {\n const cfg = (spec.config ?? {}) as Record<string, unknown>;\n\n if (client === 'better-sqlite3') {\n const filename =\n (cfg.filename as string | undefined) ??\n (cfg.file as string | undefined) ??\n (cfg.database as string | undefined) ??\n ':memory:';\n return { filename };\n }\n\n // pg — accept either a connection string (`url`/`connectionString`) or\n // discrete fields. The secret is the password and is never part of `config`.\n const url = (cfg.url as string | undefined) ?? (cfg.connectionString as string | undefined);\n if (url) {\n // For a DSN, a separately-supplied secret overrides the embedded password.\n return spec.secret ? { connectionString: url, password: spec.secret } : { connectionString: url };\n }\n return {\n host: cfg.host,\n port: cfg.port,\n database: cfg.database,\n user: cfg.user ?? cfg.username,\n ...(spec.secret ? { password: spec.secret } : cfg.password ? { password: cfg.password } : {}),\n ...(cfg.ssl != null ? { ssl: cfg.ssl } : {}),\n };\n}\n\n/** Build a mongodb connection URL from a spec's config + secret. */\nfunction buildMongoUrl(spec: DatasourceConnectionSpec): string {\n const cfg = (spec.config ?? {}) as Record<string, unknown>;\n const explicit = (cfg.url as string | undefined) ?? (cfg.uri as string | undefined);\n if (explicit) return explicit;\n const host = (cfg.host as string | undefined) ?? 'localhost';\n const port = (cfg.port as number | string | undefined) ?? 27017;\n const db = (cfg.database as string | undefined) ?? '';\n const user = (cfg.user as string | undefined) ?? (cfg.username as string | undefined);\n const auth = user ? `${encodeURIComponent(user)}:${encodeURIComponent(spec.secret ?? '')}@` : '';\n return `mongodb://${auth}${host}:${port}/${db}`;\n}\n\n/**\n * Create the default datasource driver factory. Driver packages are imported\n * lazily so a host that never builds (e.g.) a mongo connection doesn't pay for\n * the mongo SDK.\n */\nexport function createDefaultDatasourceDriverFactory(): IDatasourceDriverFactory {\n return {\n supports(driverId: string): boolean {\n return resolveKind(driverId) !== undefined;\n },\n\n async create(spec: DatasourceConnectionSpec): Promise<DatasourceDriverHandle> {\n const kind = resolveKind(spec.driver);\n if (!kind) {\n throw new Error(`Unsupported driver id '${spec.driver}'.`);\n }\n\n const schemaMode = (spec.external as { schemaMode?: string } | undefined)?.schemaMode\n ?? ((spec.config as Record<string, unknown> | undefined)?.schemaMode as string | undefined);\n\n if (kind === 'postgres') {\n const { SqlDriver } = await import('@objectstack/driver-sql');\n const driver = new SqlDriver({\n client: 'pg',\n connection: buildSqlConnection(spec, 'pg') as any,\n pool: { min: 0, max: 5 },\n ...(schemaMode ? { schemaMode: schemaMode as any } : {}),\n } as any);\n return toHandle(driver, () => sqlServerVersion(driver, 'pg'));\n }\n\n if (kind === 'sqlite') {\n const { SqlDriver } = await import('@objectstack/driver-sql');\n const driver = new SqlDriver({\n client: 'better-sqlite3',\n connection: buildSqlConnection(spec, 'better-sqlite3') as any,\n useNullAsDefault: true,\n ...(schemaMode ? { schemaMode: schemaMode as any } : {}),\n } as any);\n return toHandle(driver, () => sqlServerVersion(driver, 'sqlite'));\n }\n\n if (kind === 'mongodb') {\n let MongoDBDriver: any;\n try {\n ({ MongoDBDriver } = await import('@objectstack/driver-mongodb' as any));\n } catch (err: any) {\n throw new Error(\n `mongodb driver requested but @objectstack/driver-mongodb is not installed (${err?.message ?? err}).`,\n );\n }\n const driver = new MongoDBDriver({ url: buildMongoUrl(spec) });\n return toHandle(driver);\n }\n\n // memory\n const { InMemoryDriver } = await import('@objectstack/driver-memory');\n return toHandle(new InMemoryDriver());\n },\n };\n}\n\n/** Best-effort server version via a raw query; swallows everything. */\nasync function sqlServerVersion(driver: any, client: 'pg' | 'sqlite'): Promise<string | undefined> {\n if (typeof driver?.execute !== 'function') return undefined;\n try {\n const sql = client === 'pg' ? 'SELECT version() AS v' : 'SELECT sqlite_version() AS v';\n const rows: any = await driver.execute(sql);\n const first = Array.isArray(rows) ? rows[0] : Array.isArray(rows?.rows) ? rows.rows[0] : rows;\n const v = first?.v ?? first?.version ?? first?.['sqlite_version()'];\n return typeof v === 'string' ? v : undefined;\n } catch {\n return undefined;\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Default datasource SecretBinder — persists a runtime datasource's cleartext\n * credential into the `sys_secret` cipher store and returns an opaque\n * `credentialsRef` handle (ADR-0015 Addendum, security invariant).\n *\n * Mirrors the SettingsService Phase-3 split: the cleartext is wrapped by an\n * {@link ICryptoProvider} into a {@link CryptoHandle}, the ciphertext lands in a\n * `sys_secret` row keyed by `handle.id`, and only the handle id (wrapped as\n * `sys_secret:<id>`) is ever stored on the datasource artefact. Cleartext never\n * touches metadata.\n *\n * This is the dev/self-host wiring; production hosts swap the\n * `LocalCryptoProvider` for a KMS-backed `ICryptoProvider` and pass it here.\n */\n\nimport type { CryptoHandle, ICryptoProvider } from '@objectstack/spec/contracts';\n\n/** Prefix used to recognise a datasource credential handle. */\nconst REF_PREFIX = 'sys_secret:';\n\n/** A persisted `sys_secret` row (subset used to reconstruct a {@link CryptoHandle}). */\ninterface SecretRow {\n id: string;\n namespace: string;\n key: string;\n kms_key_id: string;\n alg: string;\n version: number;\n ciphertext: string;\n}\n\n/** Minimal data-engine surface used to read/write the `sys_secret` store. */\nexport interface SecretStoreEngineLike {\n insert(object: string, data: Record<string, unknown>, options?: unknown): Promise<unknown>;\n delete(object: string, options: { where: Record<string, unknown> }): Promise<unknown>;\n /**\n * Read `sys_secret` rows for the `resolve()` path. Optional so existing\n * callers that only bind/unbind keep working; `resolve()` no-ops when absent.\n * Mirrors `IDataEngine.find` — returns an array (or `{ data: [...] }`).\n */\n find?(object: string, query: Record<string, unknown>): Promise<unknown>;\n}\n\nexport interface DatasourceSecretBinderDeps {\n /** Data engine (ObjectQL) used to persist the `sys_secret` row. */\n engine: SecretStoreEngineLike;\n /** Crypto provider that wraps cleartext into a {@link CryptoHandle}. */\n cryptoProvider: ICryptoProvider;\n /** Settings namespace recorded on the secret row (default `'datasource'`). */\n namespace?: string;\n}\n\nexport interface DatasourceSecretBinder {\n bind(input: { value: string; namespace?: string; key?: string }, hint: { name: string }): Promise<string>;\n unbind(credentialsRef: string): Promise<void>;\n /**\n * Dereference a `credentialsRef` back to its cleartext credential by reading\n * the `sys_secret` row and decrypting it. Used at boot to rebuild a runtime\n * datasource's live connection pool (the cleartext is never persisted, so it\n * must be recovered from the cipher store). Returns `undefined` when the ref\n * isn't ours, the row is gone, the engine can't read, or decryption fails\n * (e.g. an ephemeral dev key changed across restarts) — callers degrade to\n * skipping that pool rather than crashing boot.\n */\n resolve(credentialsRef: string): Promise<string | undefined>;\n}\n\n/** Build a `credentialsRef` from a crypto handle id. */\nexport function toCredentialsRef(handleId: string): string {\n return `${REF_PREFIX}${handleId}`;\n}\n\n/** Extract the `sys_secret` handle id from a credentialsRef, if it is one. */\nexport function parseCredentialsRef(ref: string): string | undefined {\n return ref?.startsWith(REF_PREFIX) ? ref.slice(REF_PREFIX.length) : undefined;\n}\n\n/**\n * Create the default datasource secret binder. Persists into `sys_secret` via\n * the data engine and never returns or logs the cleartext.\n */\nexport function createDatasourceSecretBinder(deps: DatasourceSecretBinderDeps): DatasourceSecretBinder {\n const { engine, cryptoProvider } = deps;\n const defaultNamespace = deps.namespace ?? 'datasource';\n\n return {\n async bind(input, hint) {\n const namespace = input.namespace ?? defaultNamespace;\n const key = input.key ?? hint.name;\n const handle: CryptoHandle = await cryptoProvider.encrypt(input.value, { namespace, key });\n await engine.insert('sys_secret', {\n id: handle.id,\n namespace,\n key,\n kms_key_id: handle.kmsKeyId,\n alg: handle.alg,\n version: handle.version,\n ciphertext: handle.ciphertext,\n });\n return toCredentialsRef(handle.id);\n },\n\n async unbind(credentialsRef) {\n const id = parseCredentialsRef(credentialsRef);\n if (!id) return; // not ours (or already cleared) — nothing to do\n await engine.delete('sys_secret', { where: { id } });\n },\n\n async resolve(credentialsRef) {\n const id = parseCredentialsRef(credentialsRef);\n if (!id || typeof engine.find !== 'function') return undefined;\n try {\n const result = await engine.find('sys_secret', {\n where: { id },\n limit: 1,\n // Secrets are scoped through their owning datasource artefact, so\n // skip the tenant-audit warning (mirrors SettingsService's store).\n bypassTenantAudit: true,\n });\n const rows = (Array.isArray(result) ? result : (result as { data?: unknown[] })?.data) ?? [];\n const row = rows[0] as SecretRow | undefined;\n if (!row?.ciphertext) return undefined;\n // Reconstruct the handle and decrypt under the same (namespace,key)\n // AAD the row was sealed with — a mismatch fails authentication.\n return await cryptoProvider.decrypt(\n {\n id: row.id,\n kmsKeyId: row.kms_key_id,\n alg: row.alg,\n version: row.version,\n ciphertext: row.ciphertext,\n },\n { namespace: row.namespace, key: row.key },\n );\n } catch {\n // Missing row / unreadable engine / decrypt failure (e.g. rotated dev\n // key) — never block boot; the pool is simply not rehydrated.\n return undefined;\n }\n },\n };\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Built-in datasource driver catalog.\n *\n * Each entry carries a JSON-Schema `configSchema` describing the driver's\n * connection options, so the Studio UI can render a typed connection form\n * instead of a raw-JSON editor (the `DriverDefinitionSchema.configSchema`\n * contract — \"Used by the UI to generate the connection form\").\n *\n * Served by `GET /api/v1/datasources/drivers`. This is the curated set of\n * connection drivers the connection form offers; a future runtime driver\n * registry can supersede this list without changing the route contract.\n */\n\nexport interface DriverCatalogEntry {\n /** Unique driver identifier used as `datasource.driver`. */\n id: string;\n /** Display label. */\n label: string;\n /** Optional one-line description. */\n description?: string;\n /** Optional Lucide icon name. */\n icon?: string;\n /** JSON Schema (draft-2020-12) for the driver's `config` object. */\n configSchema: Record<string, unknown>;\n}\n\nconst SSL_PROP = {\n ssl: { type: 'boolean', title: 'Use SSL/TLS', default: false },\n} as const;\n\nexport const DRIVER_CATALOG: DriverCatalogEntry[] = [\n {\n id: 'memory',\n label: 'In-Memory',\n description: 'Ephemeral in-memory driver for dev, tests, and prototyping. No connection settings.',\n icon: 'memory-stick',\n configSchema: { type: 'object', properties: {}, additionalProperties: false },\n },\n {\n id: 'sqlite',\n label: 'SQLite',\n description: 'File-backed (or in-memory) SQL database. Great for local dev and small deployments.',\n icon: 'database',\n configSchema: {\n type: 'object',\n properties: {\n filename: {\n type: 'string',\n title: 'Filename',\n description: 'Database file path, or \":memory:\" for an ephemeral in-memory database.',\n default: ':memory:',\n },\n },\n required: ['filename'],\n additionalProperties: false,\n },\n },\n {\n id: 'postgres',\n label: 'PostgreSQL',\n description: 'PostgreSQL connection. Supply host/port/database or a connection URL.',\n icon: 'database',\n configSchema: {\n type: 'object',\n properties: {\n url: { type: 'string', title: 'Connection URL', description: 'postgres://user:pass@host:5432/db (overrides the fields below when set).' },\n host: { type: 'string', title: 'Host', default: 'localhost' },\n port: { type: 'number', title: 'Port', default: 5432 },\n database: { type: 'string', title: 'Database' },\n username: { type: 'string', title: 'User' },\n password: { type: 'string', title: 'Password', format: 'password' },\n schema: { type: 'string', title: 'Schema', default: 'public' },\n ...SSL_PROP,\n },\n additionalProperties: true,\n },\n },\n {\n id: 'mysql',\n label: 'MySQL / MariaDB',\n description: 'MySQL or MariaDB connection.',\n icon: 'database',\n configSchema: {\n type: 'object',\n properties: {\n host: { type: 'string', title: 'Host', default: 'localhost' },\n port: { type: 'number', title: 'Port', default: 3306 },\n database: { type: 'string', title: 'Database' },\n username: { type: 'string', title: 'User' },\n password: { type: 'string', title: 'Password', format: 'password' },\n ...SSL_PROP,\n },\n additionalProperties: true,\n },\n },\n {\n id: 'mongo',\n label: 'MongoDB',\n description: 'MongoDB connection via a connection URI.',\n icon: 'database',\n configSchema: {\n type: 'object',\n properties: {\n url: { type: 'string', title: 'Connection URI', description: 'mongodb://host:27017' },\n database: { type: 'string', title: 'Database' },\n },\n required: ['url'],\n additionalProperties: true,\n },\n },\n];\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { PluginContext } from '@objectstack/core';\nimport type { IHttpServer } from '@objectstack/spec/contracts';\nimport { DRIVER_CATALOG } from './driver-catalog.js';\n\n/**\n * Datasource lifecycle REST routes (ADR-0015 Addendum §3.5).\n *\n * Mounted under `/api/v1/datasources` and served by the `datasource-admin`\n * service. Every route degrades gracefully\n * (`503 datasource_admin_unavailable`) when the service is not wired in, and\n * lifecycle/validation failures surface as `400` with the service's message.\n *\n * GET /datasources → listDatasources (provenance + health)\n * POST /datasources/test → testConnection (no persistence)\n * POST /datasources → createDatasource (origin: 'runtime')\n * PATCH /datasources/:name → updateDatasource (runtime only)\n * DELETE /datasources/:name → removeDatasource (runtime only)\n *\n * Request bodies carry the connection draft inline with an optional cleartext\n * `secret` field; the route splits `secret` out so it never reaches the draft\n * the service persists.\n */\nexport function registerDatasourceAdminRoutes(\n server: IHttpServer,\n ctx: PluginContext,\n basePath = '/api/v1',\n): void {\n const root = `${basePath}/datasources`;\n\n const adminService = (): any => {\n try {\n return ctx.getService<any>('datasource-admin');\n } catch {\n return undefined;\n }\n };\n\n const externalService = (): any => {\n try {\n return ctx.getService<any>('external-datasource');\n } catch {\n return undefined;\n }\n };\n\n const unavailable = (res: any) =>\n res.status(503).json({ error: 'datasource_admin_unavailable' });\n\n const badRequest = (res: any, err: unknown) =>\n res.status(400).json({ error: 'datasource_admin_error', message: err instanceof Error ? err.message : String(err) });\n\n /** Split an inline `{ secret, ...draft }` body into (draft, secret). */\n const splitSecret = (body: any): { draft: any; secret: any } => {\n const { secret, ...draft } = (body as Record<string, unknown>) ?? {};\n // Accept either a bare string or a `{ value, namespace?, key? }` object.\n const normalised =\n secret == null\n ? undefined\n : typeof secret === 'string'\n ? { value: secret }\n : secret;\n return { draft, secret: normalised };\n };\n\n // List all datasources with provenance + health.\n server.get(root, async (_req: any, res: any) => {\n const svc = adminService();\n if (!svc?.listDatasources) return unavailable(res);\n const datasources = await svc.listDatasources();\n res.json({ datasources });\n });\n\n // Catalog of connection drivers + their JSON-Schema config (drives the\n // Studio connection form). Static metadata — no service dependency, so it\n // is always available even before any datasource-admin service is wired.\n server.get(`${root}/drivers`, async (_req: any, res: any) => {\n res.json({ drivers: DRIVER_CATALOG });\n });\n\n // Read-only schema introspection for the Studio \"sync objects\" flow.\n // `GET /datasources/:name/remote-tables` lists the datasource's remote tables;\n // `POST /datasources/:name/object-draft` generates an ObjectStack object\n // definition draft for one table (introspect + type-map, no persistence —\n // the caller creates the object through the normal metadata channel).\n server.get(`${root}/:name/remote-tables`, async (req: any, res: any) => {\n const svc = externalService();\n if (!svc?.listRemoteTables) return unavailable(res);\n try {\n const tables = await svc.listRemoteTables(req.params.name);\n res.json({ tables });\n } catch (err) {\n badRequest(res, err);\n }\n });\n\n // Test a *saved* datasource by name with a live round-trip (backs the\n // `datasource` `test_connection` action). Distinct from `POST /datasources/test`\n // which probes an unsaved draft carried inline. Registered before the generic\n // `:name` mutation routes.\n // Read one datasource's full detail for the edit form (credential stripped;\n // `config` is non-sensitive, plus a `hasSecret` flag). Registered after the\n // static `/drivers` route so that literal segment is never captured as a name.\n server.get(`${root}/:name`, async (req: any, res: any) => {\n const svc = adminService();\n if (!svc?.getDatasource) return unavailable(res);\n try {\n const datasource = await svc.getDatasource(req.params.name);\n if (!datasource) return res.status(404).json({ error: 'not_found' });\n res.json({ datasource });\n } catch (err) {\n badRequest(res, err);\n }\n });\n\n server.post(`${root}/:name/test`, async (req: any, res: any) => {\n const svc = externalService();\n if (!svc?.testConnection) return unavailable(res);\n try {\n const result = await svc.testConnection(req.params.name);\n res.json(result);\n } catch (err) {\n badRequest(res, err);\n }\n });\n\n server.post(`${root}/:name/object-draft`, async (req: any, res: any) => {\n const svc = externalService();\n if (!svc?.generateObjectDraft) return unavailable(res);\n const { table, ...opts } = (req.body as Record<string, unknown>) ?? {};\n if (!table) return badRequest(res, new Error('Body field \"table\" is required.'));\n try {\n const draft = await svc.generateObjectDraft(req.params.name, String(table), opts);\n res.json({ draft });\n } catch (err) {\n badRequest(res, err);\n }\n });\n\n // Probe a connection without persisting anything. Registered before the\n // `:name` routes so the literal `test` segment is never captured as a name.\n server.post(`${root}/test`, async (req: any, res: any) => {\n const svc = adminService();\n if (!svc?.testConnection) return unavailable(res);\n const { draft, secret } = splitSecret(req.body);\n try {\n const result = await svc.testConnection(draft, secret);\n res.json({ result });\n } catch (err) {\n badRequest(res, err);\n }\n });\n\n // Create a runtime datasource.\n server.post(root, async (req: any, res: any) => {\n const svc = adminService();\n if (!svc?.createDatasource) return unavailable(res);\n const { draft, secret } = splitSecret(req.body);\n try {\n const datasource = await svc.createDatasource(draft, secret);\n res.status(201).json({ datasource });\n } catch (err) {\n badRequest(res, err);\n }\n });\n\n // Patch a runtime datasource.\n server.patch(`${root}/:name`, async (req: any, res: any) => {\n const svc = adminService();\n if (!svc?.updateDatasource) return unavailable(res);\n const { draft, secret } = splitSecret(req.body);\n try {\n const datasource = await svc.updateDatasource(req.params.name, draft, secret);\n res.json({ datasource });\n } catch (err) {\n badRequest(res, err);\n }\n });\n\n // Remove a runtime datasource.\n server.delete(`${root}/:name`, async (req: any, res: any) => {\n const svc = adminService();\n if (!svc?.removeDatasource) return unavailable(res);\n try {\n await svc.removeDatasource(req.params.name);\n res.status(204).end();\n } catch (err) {\n badRequest(res, err);\n }\n });\n}\n"]}
package/dist/index.d.cts CHANGED
@@ -81,6 +81,21 @@ declare class ExternalDatasourceService implements IExternalDatasourceService {
81
81
  listRemoteTables(datasource: string, opts?: {
82
82
  schema?: string;
83
83
  }): Promise<RemoteTable[]>;
84
+ /**
85
+ * Probe a *saved* datasource by name with a live round-trip. Reuses the
86
+ * introspection path (driver connect + schema read) as a cheap connectivity
87
+ * check, so the secret is resolved through the same wired pool as the rest of
88
+ * the introspection surface — the caller never handles cleartext. Returns a
89
+ * structured result rather than throwing so the route can render ok/error
90
+ * uniformly. This backs the `datasource` `test_connection` action
91
+ * (`POST /datasources/:name/test`).
92
+ */
93
+ testConnection(datasource: string): Promise<{
94
+ ok: boolean;
95
+ latencyMs?: number;
96
+ tableCount?: number;
97
+ error?: string;
98
+ }>;
84
99
  generateObjectDraft(datasource: string, remoteName: string, opts?: GenerateDraftOpts): Promise<ObjectDraft>;
85
100
  importObject(datasource: string, remoteName: string, opts?: ImportObjectOpts): Promise<ImportObjectResult>;
86
101
  refreshCatalog(datasource: string): Promise<ExternalCatalog>;
@@ -209,6 +224,17 @@ declare class DatasourceAdminService implements IDatasourceAdminService {
209
224
  constructor(config: DatasourceAdminServiceConfig);
210
225
  private get logger();
211
226
  listDatasources(): Promise<DatasourceSummary[]>;
227
+ /**
228
+ * Read one datasource's full detail for editing, with the credential stripped.
229
+ * Returns `config` (non-sensitive — credentials live in `sys_secret`, never in
230
+ * config), `origin`, and a `hasSecret` flag so the UI can show "leave blank to
231
+ * keep" without ever receiving the `credentialsRef` or any cleartext. Returns
232
+ * `undefined` when the name is unknown.
233
+ */
234
+ getDatasource(name: string): Promise<(Pick<StoredDatasource, 'name' | 'label' | 'driver' | 'schemaMode' | 'config' | 'active' | 'definedIn'> & {
235
+ origin: 'code' | 'runtime';
236
+ hasSecret: boolean;
237
+ }) | undefined>;
212
238
  testConnection(input: DatasourceDraft, secret?: SecretInput): Promise<TestConnectionResult>;
213
239
  createDatasource(input: DatasourceDraft, secret?: SecretInput): Promise<DatasourceSummary>;
214
240
  updateDatasource(name: string, patch: Partial<DatasourceDraft>, secret?: SecretInput): Promise<DatasourceSummary>;
@@ -279,6 +305,8 @@ declare class DatasourceAdminServicePlugin implements Plugin {
279
305
  constructor(options?: DatasourceAdminServicePluginOptions);
280
306
  init(ctx: PluginContext): Promise<void>;
281
307
  start(ctx: PluginContext): Promise<void>;
308
+ /** Reload persisted runtime datasource rows (sys_metadata) into the registry. */
309
+ private restoreRuntimeDatasources;
282
310
  /**
283
311
  * Boot-time rehydration: list persisted runtime datasources and re-register
284
312
  * each one's connection pool (driver build → connect → registerDriver),
package/dist/index.d.ts CHANGED
@@ -81,6 +81,21 @@ declare class ExternalDatasourceService implements IExternalDatasourceService {
81
81
  listRemoteTables(datasource: string, opts?: {
82
82
  schema?: string;
83
83
  }): Promise<RemoteTable[]>;
84
+ /**
85
+ * Probe a *saved* datasource by name with a live round-trip. Reuses the
86
+ * introspection path (driver connect + schema read) as a cheap connectivity
87
+ * check, so the secret is resolved through the same wired pool as the rest of
88
+ * the introspection surface — the caller never handles cleartext. Returns a
89
+ * structured result rather than throwing so the route can render ok/error
90
+ * uniformly. This backs the `datasource` `test_connection` action
91
+ * (`POST /datasources/:name/test`).
92
+ */
93
+ testConnection(datasource: string): Promise<{
94
+ ok: boolean;
95
+ latencyMs?: number;
96
+ tableCount?: number;
97
+ error?: string;
98
+ }>;
84
99
  generateObjectDraft(datasource: string, remoteName: string, opts?: GenerateDraftOpts): Promise<ObjectDraft>;
85
100
  importObject(datasource: string, remoteName: string, opts?: ImportObjectOpts): Promise<ImportObjectResult>;
86
101
  refreshCatalog(datasource: string): Promise<ExternalCatalog>;
@@ -209,6 +224,17 @@ declare class DatasourceAdminService implements IDatasourceAdminService {
209
224
  constructor(config: DatasourceAdminServiceConfig);
210
225
  private get logger();
211
226
  listDatasources(): Promise<DatasourceSummary[]>;
227
+ /**
228
+ * Read one datasource's full detail for editing, with the credential stripped.
229
+ * Returns `config` (non-sensitive — credentials live in `sys_secret`, never in
230
+ * config), `origin`, and a `hasSecret` flag so the UI can show "leave blank to
231
+ * keep" without ever receiving the `credentialsRef` or any cleartext. Returns
232
+ * `undefined` when the name is unknown.
233
+ */
234
+ getDatasource(name: string): Promise<(Pick<StoredDatasource, 'name' | 'label' | 'driver' | 'schemaMode' | 'config' | 'active' | 'definedIn'> & {
235
+ origin: 'code' | 'runtime';
236
+ hasSecret: boolean;
237
+ }) | undefined>;
212
238
  testConnection(input: DatasourceDraft, secret?: SecretInput): Promise<TestConnectionResult>;
213
239
  createDatasource(input: DatasourceDraft, secret?: SecretInput): Promise<DatasourceSummary>;
214
240
  updateDatasource(name: string, patch: Partial<DatasourceDraft>, secret?: SecretInput): Promise<DatasourceSummary>;
@@ -279,6 +305,8 @@ declare class DatasourceAdminServicePlugin implements Plugin {
279
305
  constructor(options?: DatasourceAdminServicePluginOptions);
280
306
  init(ctx: PluginContext): Promise<void>;
281
307
  start(ctx: PluginContext): Promise<void>;
308
+ /** Reload persisted runtime datasource rows (sys_metadata) into the registry. */
309
+ private restoreRuntimeDatasources;
282
310
  /**
283
311
  * Boot-time rehydration: list persisted runtime datasources and re-register
284
312
  * each one's connection pool (driver build → connect → registerDriver),