@@ -33,6 +33,82 @@ interface DataEngineLike {
3333 registerDriver ?: ( driver : unknown , isDefault ?: boolean ) => void ;
3434 registerDatasourceDef ?: ( def : { name : string ; schemaMode ?: string ; external ?: { allowWrites ?: boolean } } ) => void ;
3535 getDriverByName ?: ( name : string ) => unknown ;
36+ // sys_metadata CRUD used to persist runtime datasource records durably (same
37+ // table runtime objects use). Optional — absent on lightweight kernels, in
38+ // which case persistence degrades to in-memory (pre-existing behavior).
39+ findOne ?: ( object : string , query : { where ?: Record < string , unknown > } ) => Promise < Record < string , unknown > | undefined | null > ;
40+ find ?: ( object : string , query : { where ?: Record < string , unknown > } ) => Promise < Record < string , unknown > [ ] > ;
41+ insert ?: ( object : string , row : Record < string , unknown > ) => Promise < unknown > ;
42+ update ?: ( object : string , row : Record < string , unknown > , opts : { where : Record < string , unknown > } ) => Promise < unknown > ;
43+ delete ?: ( object : string , opts : { where : Record < string , unknown > } ) => Promise < unknown > ;
44+ }
45+
46+ /**
47+ * Durable persistence for runtime datasource records via the `sys_metadata`
48+ * table — the same store runtime objects use (the protocol writes objects there
49+ * directly). `MetadataManager.register()` alone is in-memory unless a writable
50+ * `datasource:` loader is wired, which standalone `serve` does not do; so a
51+ * UI-created datasource vanished on restart. These helpers persist on write and
52+ * the plugin restores them into the registry on boot before rehydrating pools.
53+ * Credential cleartext is never stored — only the opaque `external.credentialsRef`.
54+ */
55+ const DS_META_TYPE = 'datasource' ;
56+ const SYS_METADATA = 'sys_metadata' ;
57+
58+ function newMetaId ( ) : string {
59+ return typeof crypto !== 'undefined' && typeof crypto . randomUUID === 'function'
60+ ? crypto . randomUUID ( )
61+ : `meta_${ Date . now ( ) } _${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } ` ;
62+ }
63+
64+ async function persistDatasourceRow ( engine : DataEngineLike | undefined , record : { name : string } ) : Promise < void > {
65+ if ( ! engine ?. insert || ! engine . findOne ) return ; // no durable store — in-memory only
66+ const now = new Date ( ) . toISOString ( ) ;
67+ const existing = await engine . findOne ( SYS_METADATA , {
68+ where : { type : DS_META_TYPE , name : record . name , state : 'active' } ,
69+ } ) ;
70+ if ( existing ) {
71+ await engine . update ?.(
72+ SYS_METADATA ,
73+ { metadata : JSON . stringify ( record ) , updated_at : now , version : ( ( existing . version as number ) || 0 ) + 1 , state : 'active' } ,
74+ { where : { id : existing . id } } ,
75+ ) ;
76+ } else {
77+ await engine . insert ( SYS_METADATA , {
78+ id : newMetaId ( ) ,
79+ name : record . name ,
80+ type : DS_META_TYPE ,
81+ scope : 'platform' ,
82+ metadata : JSON . stringify ( record ) ,
83+ state : 'active' ,
84+ version : 1 ,
85+ created_at : now ,
86+ updated_at : now ,
87+ } ) ;
88+ }
89+ }
90+
91+ async function deleteDatasourceRow ( engine : DataEngineLike | undefined , name : string ) : Promise < void > {
92+ if ( ! engine ?. findOne ) return ;
93+ const existing = await engine . findOne ( SYS_METADATA , { where : { type : DS_META_TYPE , name, state : 'active' } } ) ;
94+ if ( ! existing ) return ;
95+ if ( engine . delete ) await engine . delete ( SYS_METADATA , { where : { id : existing . id } } ) ;
96+ else await engine . update ?.( SYS_METADATA , { state : 'inactive' } , { where : { id : existing . id } } ) ;
97+ }
98+
99+ async function loadDatasourceRows ( engine : DataEngineLike | undefined ) : Promise < Array < Record < string , unknown > > > {
100+ if ( ! engine ?. find ) return [ ] ;
101+ const rows = await engine . find ( SYS_METADATA , { where : { type : DS_META_TYPE , state : 'active' } } ) ;
102+ const out : Array < Record < string , unknown > > = [ ] ;
103+ for ( const r of rows ?? [ ] ) {
104+ const raw = ( r as { metadata ?: unknown } ) . metadata ;
105+ try {
106+ out . push ( typeof raw === 'string' ? JSON . parse ( raw ) : ( raw as Record < string , unknown > ) ) ;
107+ } catch {
108+ /* skip corrupt row */
109+ }
110+ }
111+ return out ;
36112}
37113
38114/**
@@ -147,7 +223,10 @@ export class DatasourceAdminServicePlugin implements Plugin {
147223 if ( ! metadata ?. register ) {
148224 throw new Error ( 'Metadata service is unavailable; cannot persist datasource.' ) ;
149225 }
226+ // In-memory registry (immediate visibility) + durable sys_metadata row
227+ // (survives restart; restored on boot by restoreRuntimeDatasources).
150228 await metadata . register ( 'datasource' , record . name , record ) ;
229+ await persistDatasourceRow ( engineOf ( ) , record ) ;
151230 } ,
152231
153232 deleteDatasourceRecord : async ( name ) => {
@@ -156,6 +235,7 @@ export class DatasourceAdminServicePlugin implements Plugin {
156235 throw new Error ( 'Metadata service is unavailable; cannot remove datasource.' ) ;
157236 }
158237 await metadata . unregister ( 'datasource' , name ) ;
238+ await deleteDatasourceRow ( engineOf ( ) , name ) ;
159239 } ,
160240
161241 writeSecret : async ( input , hint ) => {
@@ -265,13 +345,43 @@ export class DatasourceAdminServicePlugin implements Plugin {
265345 }
266346
267347 async start ( ctx : PluginContext ) : Promise < void > {
268- // Rebuild live connection pools for persisted runtime datasources before
269- // announcing readiness — a node restart otherwise leaves UI-created
270- // datasources with a record but no open pool until the next write.
348+ // Restore UI-created (runtime) datasources from the durable sys_metadata
349+ // store back into the in-memory registry, THEN rebuild their live pools.
350+ // `register()` is in-memory only in standalone serve (no writable
351+ // `datasource:` loader), so without this a node restart drops every
352+ // UI-created datasource. Code-defined datasources come from the artifact and
353+ // are unaffected.
354+ await this . restoreRuntimeDatasources ( ctx ) ;
271355 await this . rehydratePools ( ) ;
272356 if ( this . service ) await ctx . trigger ( 'datasource-admin:ready' , this . service ) ;
273357 }
274358
359+ /** Reload persisted runtime datasource rows (sys_metadata) into the registry. */
360+ private async restoreRuntimeDatasources ( ctx : PluginContext ) : Promise < void > {
361+ const engine = safeGetService < DataEngineLike > ( ctx , 'data' ) ;
362+ const metadata = safeGetService < MetadataServiceLike > ( ctx , 'metadata' ) ;
363+ if ( ! engine ?. find || ! metadata ?. register ) return ;
364+ let rows : Array < Record < string , unknown > > ;
365+ try {
366+ rows = await loadDatasourceRows ( engine ) ;
367+ } catch ( err ) {
368+ this . options . logger ?. warn ?.( 'datasource restore: reading sys_metadata failed' , err ) ;
369+ return ;
370+ }
371+ let restored = 0 ;
372+ for ( const rec of rows ) {
373+ const name = ( rec as { name ?: string } ) . name ;
374+ if ( ! name ) continue ;
375+ try {
376+ await metadata . register ( 'datasource' , name , rec ) ;
377+ restored += 1 ;
378+ } catch ( err ) {
379+ this . options . logger ?. warn ?.( `datasource restore: register '${ name } ' failed` , err ) ;
380+ }
381+ }
382+ if ( restored > 0 ) this . options . logger ?. info ?.( `datasource: restored ${ restored } runtime record(s) from sys_metadata` ) ;
383+ }
384+
275385 /**
276386 * Boot-time rehydration: list persisted runtime datasources and re-register
277387 * each one's connection pool (driver build → connect → registerDriver),
0 commit comments