Skip to content

feat(datasource): runtime-UI-creatable datasources (ADR-0015 Addendum)#1396

Merged
os-zhuang merged 8 commits into
mainfrom
feat/runtime-ui-datasource-admin
May 30, 2026
Merged

feat(datasource): runtime-UI-creatable datasources (ADR-0015 Addendum)#1396
os-zhuang merged 8 commits into
mainfrom
feat/runtime-ui-datasource-admin

Conversation

@xuyushun441-sys
Copy link
Copy Markdown
Contributor

What

Enable adding a datasource from the UI/API at runtime, alongside code-defined ones — the low-code persona's "connect to a remote business DB in Studio" goal (ADR-0015 Addendum). Two-tier model:

  • origin:'code' — declared in *.datasource.ts, GitOps-managed, read-only
  • origin:'runtime' — created via API, editable

Backend only. The Studio connection wizard (../objectui) is a follow-up (P-B).

Changes

Spec / contracts

  • DatasourceAdminService contract — list / test / create / update / remove
  • DatasourceDriverFactory contract — host-provided (no universal driver-by-id registry); driver escape-hatch + checkHealth on the handle

Service (decoupled core + kernel plugin)

  • DatasourceAdminService — origin gating (code read-only, code wins on name collision), removal refused while objects are bound, secret indirection. Pure + unit-tested.
  • DatasourceAdminServicePlugin — lazy per-call resolution of metadata/data (plugin init order safe), connection probe (build→connect→ping→close), hot pool (de)register, fail-closed SecretBinder (secret-bearing create/update throws when no secret store is wired)

Runtime host glue

  • default datasource driver factory (postgres / sqlite / mongodb / memory)
  • datasource secret binder (encrypt → sys_secret, persist only credentialsRef)
  • AppPlugin registers code datasources in-memory only (never persisted)

Metadata

  • registerInMemory() — in-memory-only registration so GitOps datasources are listable without leaking into the runtime DB store

REST

  • datasource routes register unconditionally (previously gated behind the package service, which threw when the marketplace was absent → 404)

Fix (hono-server adapter)

  • guard the response stream controller against double-close and return a null-body response on res.end(), fixing an ERR_INVALID_STATE process crash on 204 routes (also fixes 5 latent .status(204).end() DELETE routes in REST)

Security invariant

Credentials are never persisted in cleartext — only an opaque credentialsRef → sys_secret handle is stored. Cleartext transits create/update/test only; the secret is unbound on remove. Verified by DB inspection: sys_secret holds aes-256-gcm ciphertext, no cleartext leak.

Verification

  • Unit tests: service-external-datasource 38, hono-server 38, metadata 238 — all pass.
  • E2E against app-crm dev server:
    • GET /datasources → code (crm_primary/crm_analytics) + runtime entries
    • POST /datasources/test (sqlite) → ok:true, serverVersion:3.53.1
    • POST /datasources (+secret) → 201, origin:runtime
    • PATCH/DELETE on code → 400 read-only; DELETE runtime → 204 (server survives), secret unbound
    • sys_metadata datasource rows = 0 (code datasources never persisted)

Known dev limitation (not a defect)

In dev there is no writable datasource: loader, so runtime datasources live in-memory and don't survive restart; DB-backed deployments persist them. Boot-time pool rehydration with secret decryption is a later phase (P-D).

os-zhuang added 2 commits May 31, 2026 01:07
Enable adding a datasource from the UI/API at runtime alongside
code-defined ones, with a two-tier model:
  - origin:'code'   — declared in *.datasource.ts, GitOps-managed, read-only
  - origin:'runtime'— created via API, editable

Spec/contracts:
  - DatasourceAdminService contract: list/test/create/update/remove
  - DatasourceDriverFactory contract (host-provided; no universal registry),
    driver escape-hatch + checkHealth on the handle

Service (decoupled + kernel plugin):
  - DatasourceAdminService: origin gating (code read-only, code wins on
    name collision), removal refused while objects bound, secret indirection
  - DatasourceAdminServicePlugin: lazy per-call resolution of metadata/data,
    probe (build→connect→ping→close), hot pool (de)register, fail-closed
    SecretBinder (secret-bearing create/update throws when absent)

Runtime host glue:
  - default datasource driver factory (postgres/sqlite/mongodb/memory)
  - datasource secret binder (encrypt→sys_secret, persist only credentialsRef)
  - AppPlugin registers code datasources in-memory only (never persisted)

Metadata:
  - registerInMemory(): in-memory-only registration so GitOps datasources
    are listable without leaking into the runtime DB store

REST:
  - register datasource routes unconditionally (no longer gated behind the
    package service); 204 on remove

Security invariant: credentials never persisted in cleartext — only an
opaque credentialsRef→sys_secret handle; cleartext transits create/update/
test only; secret unbound on remove.

Fix(hono-server): guard the response stream controller against double-close
and return a null-body response on res.end(), fixing an ERR_INVALID_STATE
process crash on 204 routes (also affected 5 latent DELETE routes in REST).
Add External Datasource Studio and API

objectui@1c8f7753898b6283ffc528d2fb2ee85f68886ca5
@vercel
Copy link
Copy Markdown

vercel Bot commented May 30, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment May 30, 2026 10:34pm

Request Review

Runtime (UI-created) datasources persist only a credentialsRef handle, so
after a restart their live connection pools must be rebuilt from the cipher
store. Add a resolve() path that dereferences sys_secret -> cleartext and a
boot rehydration pass that rebuilds every active runtime pool.

- runtime binder: resolve(credentialsRef) reads the sys_secret row and
  decrypts under the same (namespace,key) AAD; degrades to undefined on a
  missing row / unreadable engine / decrypt failure so boot never crashes
- plugin: registerPool now resolves the credentialsRef before create() so
  password DBs get a working pool; start() runs rehydratePools() over all
  active origin:'runtime' records (skips code-defined and inactive)
- serve: lazyEngine gains find() so the binder can read sys_secret
- tests: binder round-trip/unbind/foreign-ref/no-find; plugin rehydration
  rebuilds only active runtime pools and hands decrypted secret to factory

Security invariant preserved: cleartext only transits create/update/test;
only sys_secret:<id> is persisted on the artefact.
The Gate-2 boot-validation plugin and the external_catalog metadata type
already existed but were not connected end to end.

- serve: register ExternalValidationPlugin into the boot sequence so Gate 2
  (kernel:ready -> validateAll -> onMismatch policy) actually runs; no-op
  when the external-datasource service is absent
- service: refreshCatalog now parses its snapshot through ExternalCatalogSchema
  and persists it as an external_catalog metadata record via an injected
  persistCatalog dep; best-effort so a persist failure still returns the live
  snapshot. Plugin wires persistCatalog -> metadata.register
- spec: tighten IExternalDatasourceService.refreshCatalog return to
  ExternalCatalog and refresh the stale 'persistence lands later' note
- tests: catalog persistence, read-only/throwing store, canonicalised shape
- docs: mark P3 done in the federation impl plan

Schema-mode -> driver injection (DDL gate for runtime external datasources)
was already wired in createDefaultDatasourceDriverFactory.
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label May 30, 2026
os-zhuang added 4 commits May 31, 2026 01:40
The Addendum flipped the 'datasource' metadata type to allowRuntimeCreate:true
(UI 'Add Datasource'), but overlay-precedence.test.ts still asserted it was
denied for any per-org write. Move datasource/datasources from the denied
cohort to the runtime-creatable cohort: brand-new runtime datasources now
succeed (code-defined collisions are still refused via artifact provenance,
covered in protocol-meta.test.ts). Fixes 2 stale failures on this branch.
The query_data tool already injected a LIMIT and SchemaRetriever already
badges federated objects, but a slow remote warehouse could hang the tool
loop indefinitely. Add a per-query timeout that applies only to federated
(external) objects, resolved from the datasource's external.queryTimeoutMs
(fallback externalQueryTimeoutMs, default 30s). Managed/local objects are
untouched. On expiry the model gets a clear 'exceeded the Nms timeout' error.

- query-data.tool: withTimeout() wrapper + resolveExternalTimeout() over
  metadata.get('datasource', ...); only the external branch is wrapped
- tests: datasource-declared timeout, fallback timeout, fast success, and
  managed objects never wrapped (datasource timeout never consulted)
- docs: mark P4 done; MVP (P1-P4 + P6) complete in the federation plan
ExternalValidationPlugin now arms a per-datasource setInterval for every
federated datasource declaring external.validation.checkIntervalMs
(ADR-0015 §5.2). Each tick re-runs validateAll() and emits an
external.schema.drift event per drifted object on the kernel bus
(ExternalSchemaDriftEvent), for audit/notification consumers.

Drift past boot is observational: the checker never throws or aborts the
process (unlike boot validation). Timers unref() and are cleared on stop();
re-arming clears prior timers so intervals can't accumulate.

Tests: event emission, validateAll-rejects no-op, selective scheduling,
firing interval, re-arm idempotence, no-metadata no-op (runtime 324 green).
Bridge the runtime persona: turn a browsed remote table into a live,
immediately-queryable federated object without a git commit (the GitOps
introspect->commit path stays separate).

- spec: IExternalDatasourceService.importObject(datasource, remoteName, opts?)
  -> ImportObjectResult; ImportObjectOpts adds name override + writable opt-in
  (still gated by datasource external.allowWrites, Gate 3).
- service: reuses the generateObjectDraft pipeline, applies overrides, persists
  via injected persistObject. Throws when no writable metadata store is wired
  (GitOps-only) and when the remote table is missing (before any write).
- plugin: persistObject -> metadata.register("object", ...) (runtime origin).
- rest: POST /datasources/:name/external/tables/:remote/import (201 / 503 / 400).

Tests: read-only default, name+writable overrides, draft-option forwarding,
no-store + missing-table guards (service suite 47 green).
@os-zhuang os-zhuang merged commit 812f451 into main May 30, 2026
11 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation protocol:data size/xl tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants