@objectstack/service-external-datasource 7.4.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.
@@ -0,0 +1,456 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * ExternalDatasourceService — implements {@link IExternalDatasourceService}
5
+ * (ADR-0015 §6) on top of driver introspection.
6
+ *
7
+ * The service is intentionally decoupled from the kernel: all I/O
8
+ * (introspection, metadata reads) is injected via
9
+ * {@link ExternalDatasourceServiceConfig}, so the introspection/draft/validate
10
+ * logic is pure and unit-testable. The kernel plugin wires the real
11
+ * `IDataEngine` + `IMetadataService` callbacks in.
12
+ */
13
+
14
+ import type {
15
+ IExternalDatasourceService,
16
+ RemoteTable,
17
+ GenerateDraftOpts,
18
+ ObjectDraft,
19
+ ImportObjectOpts,
20
+ ImportObjectResult,
21
+ SchemaValidationResult,
22
+ SchemaValidationReport,
23
+ IntrospectedSchema,
24
+ IntrospectedTable,
25
+ } from '@objectstack/spec/contracts';
26
+ import type { SchemaDiffEntry } from '@objectstack/spec/shared';
27
+ import {
28
+ suggestFieldType,
29
+ isCompatible,
30
+ ExternalCatalogSchema,
31
+ type ExternalCatalog,
32
+ type SqlDialect,
33
+ type FieldType,
34
+ } from '@objectstack/spec/data';
35
+
36
+ /** Minimal datasource shape the service reads (subset of `Datasource`). */
37
+ export interface DatasourceLike {
38
+ name: string;
39
+ schemaMode?: 'managed' | 'external' | 'validate-only';
40
+ external?: {
41
+ allowedSchemas?: string[];
42
+ validation?: { onMismatch?: 'fail' | 'warn' | 'ignore' };
43
+ };
44
+ }
45
+
46
+ /** Minimal object shape the service reads (subset of `ServiceObject`). */
47
+ export interface ObjectLike {
48
+ name: string;
49
+ label?: string;
50
+ datasource?: string;
51
+ external?: {
52
+ remoteName?: string;
53
+ remoteSchema?: string;
54
+ columnMap?: Record<string, string>;
55
+ ignoreColumns?: string[];
56
+ };
57
+ fields?: Record<string, { type?: string; required?: boolean }>;
58
+ }
59
+
60
+ export interface Logger {
61
+ warn: (message: string, meta?: unknown) => void;
62
+ info?: (message: string, meta?: unknown) => void;
63
+ }
64
+
65
+ /**
66
+ * Injected dependencies. The plugin supplies real implementations backed by
67
+ * the driver registry and `IMetadataService`; tests supply fakes.
68
+ */
69
+ export interface ExternalDatasourceServiceConfig {
70
+ /** Introspect a datasource's live schema via its driver. */
71
+ introspect: (datasource: string) => Promise<IntrospectedSchema>;
72
+ /** Resolve a datasource definition by name. */
73
+ getDatasource: (name: string) => Promise<DatasourceLike | undefined>;
74
+ /** Resolve one object definition by name. */
75
+ getObject: (name: string) => Promise<ObjectLike | undefined>;
76
+ /** List all object definitions (for `validateAll`). */
77
+ listObjects: () => Promise<ObjectLike[]>;
78
+ /**
79
+ * Persist a refreshed catalog snapshot as an `external_catalog` metadata
80
+ * record. Optional: when absent, `refreshCatalog` still returns the snapshot
81
+ * but does not cache it (e.g. dev runs without a writable metadata store).
82
+ */
83
+ persistCatalog?: (catalog: ExternalCatalog) => Promise<void>;
84
+ /**
85
+ * Persist an imported object definition as a live (runtime-origin) `object`
86
+ * metadata record. Optional: when absent, {@link ExternalDatasourceService.importObject}
87
+ * throws (the deployment is GitOps-only / has no writable metadata store).
88
+ */
89
+ persistObject?: (name: string, definition: Record<string, unknown>) => Promise<void>;
90
+ logger?: Logger;
91
+ }
92
+
93
+ /** Columns ObjectStack manages itself — never validated against the remote. */
94
+ const BUILTIN_COLUMNS = new Set(['id', 'created_at', 'updated_at']);
95
+
96
+ /** Split a possibly schema-qualified name (`mart.fact_orders`). */
97
+ function parseQualified(raw: string): { schema?: string; name: string } {
98
+ const idx = raw.indexOf('.');
99
+ if (idx === -1) return { name: raw };
100
+ return { schema: raw.slice(0, idx), name: raw.slice(idx + 1) };
101
+ }
102
+
103
+ /** Normalise a remote table name into a snake_case object name. */
104
+ function toObjectName(remoteName: string): string {
105
+ const { name } = parseQualified(remoteName);
106
+ return name
107
+ .replace(/[^a-zA-Z0-9_]/g, '_')
108
+ .replace(/^[^a-z_]/, (c) => `_${c.toLowerCase()}`)
109
+ .toLowerCase();
110
+ }
111
+
112
+ /** snake_case → Title Case label. */
113
+ function toLabel(name: string): string {
114
+ return name
115
+ .split('_')
116
+ .filter(Boolean)
117
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
118
+ .join(' ');
119
+ }
120
+
121
+ export class ExternalDatasourceService implements IExternalDatasourceService {
122
+ constructor(private readonly config: ExternalDatasourceServiceConfig) {}
123
+
124
+ private get logger(): Logger | undefined {
125
+ return this.config.logger;
126
+ }
127
+
128
+ private findTable(schema: IntrospectedSchema, remoteName: string): IntrospectedTable | undefined {
129
+ const want = parseQualified(remoteName).name;
130
+ for (const table of Object.values(schema.tables)) {
131
+ if (table.name === remoteName) return table;
132
+ if (parseQualified(table.name).name === want) return table;
133
+ }
134
+ return undefined;
135
+ }
136
+
137
+ async listRemoteTables(
138
+ datasource: string,
139
+ opts?: { schema?: string },
140
+ ): Promise<RemoteTable[]> {
141
+ const [schema, ds] = await Promise.all([
142
+ this.config.introspect(datasource),
143
+ this.config.getDatasource(datasource),
144
+ ]);
145
+ const allowed = ds?.external?.allowedSchemas;
146
+
147
+ const tables: RemoteTable[] = [];
148
+ for (const table of Object.values(schema.tables)) {
149
+ const { schema: tableSchema, name } = parseQualified(table.name);
150
+ if (opts?.schema && tableSchema && tableSchema !== opts.schema) continue;
151
+ // allowedSchemas only filters tables we can attribute to a schema.
152
+ if (allowed && tableSchema && !allowed.includes(tableSchema)) continue;
153
+ tables.push({ schema: tableSchema, name, columnCount: table.columns.length });
154
+ }
155
+ return tables;
156
+ }
157
+
158
+ async generateObjectDraft(
159
+ datasource: string,
160
+ remoteName: string,
161
+ opts: GenerateDraftOpts = {},
162
+ ): Promise<ObjectDraft> {
163
+ const schema = await this.config.introspect(datasource);
164
+ const table = this.findTable(schema, remoteName);
165
+ if (!table) {
166
+ throw new Error(
167
+ `Remote table '${remoteName}' not found on datasource '${datasource}'.`,
168
+ );
169
+ }
170
+ const dialect = schema.dialect as SqlDialect | undefined;
171
+ // Derive the remote schema from the matched table's qualified name (the
172
+ // caller may pass an unqualified `remoteName`).
173
+ const matched = parseQualified(table.name);
174
+ const remoteSchema = opts.remoteSchema ?? matched.schema;
175
+ const resolvedRemoteName = matched.name;
176
+
177
+ const include = opts.includeColumns ? new Set(opts.includeColumns) : undefined;
178
+ const exclude = opts.excludeColumns ? new Set(opts.excludeColumns) : new Set<string>();
179
+ const pkOverride = opts.primaryKey ? new Set(opts.primaryKey) : undefined;
180
+
181
+ const fields: Record<string, { type: FieldType; primaryKey?: boolean }> = {};
182
+ const review: ObjectDraft['review'] = [];
183
+
184
+ for (const col of table.columns) {
185
+ if (include && !include.has(col.name)) continue;
186
+ if (exclude.has(col.name)) continue;
187
+
188
+ const fieldName = opts.rename?.[col.name] ?? col.name;
189
+ const suggested = suggestFieldType(col.type, dialect);
190
+ const fieldType: FieldType = suggested ?? 'text';
191
+ if (!suggested) {
192
+ review.push({
193
+ column: col.name,
194
+ remoteType: col.type,
195
+ note: `unrecognised remote type — defaulted to 'text', verify`,
196
+ });
197
+ } else if (isCompatible(col.type, fieldType, dialect) === 'lossy') {
198
+ review.push({
199
+ column: col.name,
200
+ remoteType: col.type,
201
+ note: `mapped lossy to '${fieldType}'`,
202
+ });
203
+ }
204
+
205
+ const isPk = pkOverride ? pkOverride.has(col.name) : col.primaryKey;
206
+ fields[fieldName] = isPk ? { type: fieldType, primaryKey: true } : { type: fieldType };
207
+ }
208
+
209
+ const name = toObjectName(resolvedRemoteName);
210
+ const definition: Record<string, unknown> = {
211
+ name,
212
+ label: toLabel(name),
213
+ datasource,
214
+ external: {
215
+ ...(remoteSchema ? { remoteSchema } : {}),
216
+ remoteName: resolvedRemoteName,
217
+ },
218
+ fields,
219
+ };
220
+
221
+ return {
222
+ name,
223
+ datasource,
224
+ definition,
225
+ source: renderObjectSource(definition, fields, review),
226
+ review,
227
+ };
228
+ }
229
+
230
+ async importObject(
231
+ datasource: string,
232
+ remoteName: string,
233
+ opts: ImportObjectOpts = {},
234
+ ): Promise<ImportObjectResult> {
235
+ if (!this.config.persistObject) {
236
+ throw new Error(
237
+ `importObject requires a writable metadata store, but none is wired ` +
238
+ `(datasource '${datasource}'). This deployment may be GitOps-only — ` +
239
+ `use 'os datasource introspect' and commit the generated *.object.ts instead.`,
240
+ );
241
+ }
242
+
243
+ // Reuse the draft pipeline (type mapping, review notes, external binding).
244
+ const draft = await this.generateObjectDraft(datasource, remoteName, opts);
245
+
246
+ // Apply the runtime-persona overrides on top of the draft definition.
247
+ const name = opts.name ?? draft.name;
248
+ const external = {
249
+ ...(draft.definition.external as Record<string, unknown>),
250
+ ...(opts.writable ? { writable: true } : {}),
251
+ };
252
+ const definition: Record<string, unknown> = {
253
+ ...draft.definition,
254
+ name,
255
+ label: toLabel(name),
256
+ external,
257
+ };
258
+
259
+ await this.config.persistObject(name, definition);
260
+ this.logger?.info?.(`importObject: persisted '${name}' from ${datasource}.${remoteName}`, {
261
+ writable: opts.writable === true,
262
+ review: draft.review.length,
263
+ });
264
+
265
+ return { name, definition, review: draft.review };
266
+ }
267
+
268
+ async refreshCatalog(datasource: string): Promise<ExternalCatalog> {
269
+ const schema = await this.config.introspect(datasource);
270
+ // Parse through the Zod schema so the persisted record is canonical
271
+ // (defaults applied, shape validated) and matches the `external_catalog`
272
+ // metadata type the boot gate + Studio read back.
273
+ const catalog = ExternalCatalogSchema.parse({
274
+ name: `${datasource}_catalog`,
275
+ datasource,
276
+ snapshotAt: new Date().toISOString(),
277
+ dialect: schema.dialect,
278
+ tables: Object.values(schema.tables).map((t) => {
279
+ const { schema: s, name } = parseQualified(t.name);
280
+ return {
281
+ remoteSchema: s,
282
+ remoteName: name,
283
+ columns: t.columns.map((c) => ({
284
+ name: c.name,
285
+ sqlType: c.type,
286
+ nullable: c.nullable,
287
+ primaryKey: c.primaryKey,
288
+ suggestedFieldType: suggestFieldType(c.type, schema.dialect as SqlDialect),
289
+ })),
290
+ };
291
+ }),
292
+ }) as ExternalCatalog;
293
+
294
+ // Best-effort cache: a failure to persist must not fail the refresh — the
295
+ // caller still gets the live snapshot back.
296
+ if (this.config.persistCatalog) {
297
+ try {
298
+ await this.config.persistCatalog(catalog);
299
+ } catch (err) {
300
+ this.logger?.warn?.(`refreshCatalog: failed to persist '${catalog.name}'`, err);
301
+ }
302
+ }
303
+
304
+ return catalog;
305
+ }
306
+
307
+ async validateObject(objectName: string): Promise<SchemaValidationResult> {
308
+ const obj = await this.config.getObject(objectName);
309
+ if (!obj) {
310
+ throw new Error(`Object '${objectName}' not found.`);
311
+ }
312
+ const datasource = obj.datasource ?? 'default';
313
+ const ds = await this.config.getDatasource(datasource);
314
+
315
+ // Not a federated object → nothing to validate.
316
+ if (!ds || !ds.schemaMode || ds.schemaMode === 'managed') {
317
+ return { ok: true, datasource, object: objectName, diffs: [] };
318
+ }
319
+
320
+ const schema = await this.config.introspect(datasource);
321
+ const dialect = schema.dialect as SqlDialect | undefined;
322
+ const remoteName = obj.external?.remoteName ?? obj.name;
323
+ const table = this.findTable(schema, remoteName);
324
+
325
+ const diffs: SchemaDiffEntry[] = [];
326
+
327
+ if (!table) {
328
+ diffs.push({
329
+ kind: 'missing_table',
330
+ remoteSchema: obj.external?.remoteSchema,
331
+ remoteName,
332
+ severity: 'error',
333
+ });
334
+ return { ok: false, datasource, object: objectName, diffs };
335
+ }
336
+
337
+ const columnsByName = new Map(table.columns.map((c) => [c.name, c]));
338
+ const ignore = new Set(obj.external?.ignoreColumns ?? []);
339
+ // columnMap is remoteColumn → fieldName; invert for field → remoteColumn.
340
+ const fieldToRemote = new Map<string, string>();
341
+ for (const [remoteCol, fieldName] of Object.entries(obj.external?.columnMap ?? {})) {
342
+ fieldToRemote.set(fieldName, remoteCol);
343
+ }
344
+
345
+ for (const [fieldName, field] of Object.entries(obj.fields ?? {})) {
346
+ if (BUILTIN_COLUMNS.has(fieldName)) continue;
347
+ const remoteCol = fieldToRemote.get(fieldName) ?? fieldName;
348
+ if (ignore.has(remoteCol)) continue;
349
+
350
+ const col = columnsByName.get(remoteCol);
351
+ if (!col) {
352
+ diffs.push({
353
+ kind: 'missing_column',
354
+ remoteName,
355
+ column: remoteCol,
356
+ severity: 'error',
357
+ });
358
+ continue;
359
+ }
360
+ const fieldType = (field.type ?? 'text') as FieldType;
361
+ const compat = isCompatible(col.type, fieldType, dialect);
362
+ if (compat === false) {
363
+ diffs.push({
364
+ kind: 'type_mismatch',
365
+ remoteName,
366
+ column: remoteCol,
367
+ expected: fieldType,
368
+ actual: col.type,
369
+ severity: 'error',
370
+ });
371
+ } else if (compat === 'lossy') {
372
+ diffs.push({
373
+ kind: 'type_mismatch',
374
+ remoteName,
375
+ column: remoteCol,
376
+ expected: fieldType,
377
+ actual: col.type,
378
+ severity: 'warning',
379
+ });
380
+ }
381
+ }
382
+
383
+ const ok = !diffs.some((d) => d.severity === 'error');
384
+ return { ok, datasource, object: objectName, diffs };
385
+ }
386
+
387
+ async validateAll(): Promise<SchemaValidationReport> {
388
+ const objects = await this.config.listObjects();
389
+ const federated = objects.filter(
390
+ (o) => o.external !== undefined || (o.datasource && o.datasource !== 'default'),
391
+ );
392
+
393
+ const results = await Promise.all(
394
+ federated.map((o) =>
395
+ this.validateObject(o.name).catch((err): SchemaValidationResult => {
396
+ this.logger?.warn(`validateObject('${o.name}') failed`, err);
397
+ return {
398
+ ok: false,
399
+ datasource: o.datasource ?? 'default',
400
+ object: o.name,
401
+ diffs: [
402
+ {
403
+ kind: 'missing_table',
404
+ remoteName: o.external?.remoteName ?? o.name,
405
+ actual: err instanceof Error ? err.message : String(err),
406
+ severity: 'error',
407
+ },
408
+ ],
409
+ };
410
+ }),
411
+ ),
412
+ );
413
+
414
+ const ok = results.every((r) => r.ok);
415
+ return { ok, results };
416
+ }
417
+ }
418
+
419
+ /** Render a reviewable `*.object.ts` source string for an object draft. */
420
+ function renderObjectSource(
421
+ definition: Record<string, unknown>,
422
+ fields: Record<string, { type: FieldType; primaryKey?: boolean }>,
423
+ review: ObjectDraft['review'],
424
+ ): string {
425
+ const reviewByColumn = new Map(review.map((r) => [r.column, r.note]));
426
+ const external = definition.external as { remoteSchema?: string; remoteName?: string };
427
+
428
+ const fieldLines = Object.entries(fields).map(([fieldName, f]) => {
429
+ const note = reviewByColumn.get(fieldName);
430
+ const pk = f.primaryKey ? ', primaryKey: true' : '';
431
+ const comment = note ? ` // REVIEW: ${note}` : '';
432
+ return ` ${fieldName}: { type: '${f.type}'${pk} },${comment}`;
433
+ });
434
+
435
+ const externalLine = external.remoteSchema
436
+ ? ` external: { remoteSchema: '${external.remoteSchema}', remoteName: '${external.remoteName}' },`
437
+ : ` external: { remoteName: '${external.remoteName}' },`;
438
+
439
+ return [
440
+ `// Generated by \`os datasource introspect\` (ADR-0015). Review before committing.`,
441
+ `import type { ServiceObjectInput } from '@objectstack/spec/data';`,
442
+ ``,
443
+ `const ${definition.name as string}: ServiceObjectInput = {`,
444
+ ` name: '${definition.name as string}',`,
445
+ ` label: '${definition.label as string}',`,
446
+ ` datasource: '${definition.datasource as string}',`,
447
+ externalLine,
448
+ ` fields: {`,
449
+ ...fieldLines,
450
+ ` },`,
451
+ `};`,
452
+ ``,
453
+ `export default ${definition.name as string};`,
454
+ ``,
455
+ ].join('\n');
456
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ // Core service
4
+ export { ExternalDatasourceService } from './external-datasource-service.js';
5
+ export type {
6
+ ExternalDatasourceServiceConfig,
7
+ DatasourceLike,
8
+ ObjectLike,
9
+ Logger,
10
+ } from './external-datasource-service.js';
11
+
12
+ // NOTE: the runtime datasource *lifecycle* (DatasourceAdminService /
13
+ // DatasourceAdminServicePlugin, ADR-0015 Addendum) was extracted into the
14
+ // private `@objectstack/datasource-admin` package. This package keeps only
15
+ // *federation* (introspect / draft / import / validate) — ADR-0015 main body.
16
+
17
+ // Kernel plugin
18
+ export { ExternalDatasourceServicePlugin } from './plugin.js';
19
+ export type { ExternalDatasourceServicePluginOptions } from './plugin.js';
package/src/plugin.ts ADDED
@@ -0,0 +1,119 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { Plugin, PluginContext } from '@objectstack/core';
4
+ import type { IntrospectedSchema } from '@objectstack/spec/contracts';
5
+ import {
6
+ ExternalDatasourceService,
7
+ type ExternalDatasourceServiceConfig,
8
+ type DatasourceLike,
9
+ type ObjectLike,
10
+ type Logger,
11
+ } from './external-datasource-service.js';
12
+
13
+ /**
14
+ * Minimal surfaces the plugin needs from the data engine + metadata service.
15
+ * Kept structural so the plugin doesn't hard-depend on concrete classes.
16
+ */
17
+ interface DataEngineLike {
18
+ /** Resolve a driver by datasource name and introspect its live schema. */
19
+ introspectDatasource?: (datasource: string) => Promise<IntrospectedSchema>;
20
+ getDatasourceDriver?: (datasource: string) => { introspectSchema?: () => Promise<IntrospectedSchema> } | undefined;
21
+ }
22
+
23
+ interface MetadataServiceLike {
24
+ get: (type: string, name: string) => Promise<unknown>;
25
+ getObject?: (name: string) => Promise<unknown>;
26
+ listObjects?: () => Promise<unknown[]>;
27
+ list?: (type: string) => Promise<unknown[]>;
28
+ register?: (type: string, name: string, data: unknown) => Promise<void> | void;
29
+ }
30
+
31
+ export interface ExternalDatasourceServicePluginOptions {
32
+ /** Override the introspection function (mainly for tests). */
33
+ introspect?: (datasource: string) => Promise<IntrospectedSchema>;
34
+ logger?: Logger;
35
+ }
36
+
37
+ /**
38
+ * ExternalDatasourceServicePlugin — registers `IExternalDatasourceService`
39
+ * into the kernel as the `'external-datasource'` service (ADR-0015 §6.1).
40
+ *
41
+ * It bridges the decoupled {@link ExternalDatasourceService} to the live
42
+ * `IDataEngine` (for driver introspection) and `IMetadataService` (for object
43
+ * + datasource reads).
44
+ */
45
+ export class ExternalDatasourceServicePlugin implements Plugin {
46
+ name = 'com.objectstack.service-external-datasource';
47
+ version = '1.0.0';
48
+ type = 'standard' as const;
49
+ dependencies: string[] = [];
50
+
51
+ private service?: ExternalDatasourceService;
52
+ private readonly options: ExternalDatasourceServicePluginOptions;
53
+
54
+ constructor(options: ExternalDatasourceServicePluginOptions = {}) {
55
+ this.options = options;
56
+ }
57
+
58
+ async init(ctx: PluginContext): Promise<void> {
59
+ const engine = safeGetService<DataEngineLike>(ctx, 'data');
60
+ const metadata = safeGetService<MetadataServiceLike>(ctx, 'metadata');
61
+
62
+ const introspect: ExternalDatasourceServiceConfig['introspect'] =
63
+ this.options.introspect ??
64
+ (async (datasource: string) => {
65
+ if (engine?.introspectDatasource) return engine.introspectDatasource(datasource);
66
+ const driver = engine?.getDatasourceDriver?.(datasource);
67
+ if (driver?.introspectSchema) return driver.introspectSchema();
68
+ throw new Error(
69
+ `Cannot introspect datasource '${datasource}': no driver introspection available.`,
70
+ );
71
+ });
72
+
73
+ const config: ExternalDatasourceServiceConfig = {
74
+ introspect,
75
+ getDatasource: async (n) => (await metadata?.get('datasource', n)) as DatasourceLike | undefined,
76
+ getObject: async (n) =>
77
+ (metadata?.getObject ? await metadata.getObject(n) : await metadata?.get('object', n)) as ObjectLike | undefined,
78
+ listObjects: async () =>
79
+ ((metadata?.listObjects
80
+ ? await metadata.listObjects()
81
+ : await metadata?.list?.('object')) ?? []) as ObjectLike[],
82
+ // Persist the refreshed snapshot as an `external_catalog` metadata record
83
+ // so the boot gate + Studio's schema browser can read it without
84
+ // re-introspecting. No-op when the metadata service can't write.
85
+ ...(metadata?.register
86
+ ? {
87
+ persistCatalog: async (catalog) => {
88
+ await metadata.register!('external_catalog', catalog.name, catalog);
89
+ },
90
+ // Runtime "Import as Object": persist a federated object so it's
91
+ // immediately queryable, no git commit required (ADR-0015 Addendum).
92
+ persistObject: async (name, definition) => {
93
+ await metadata.register!('object', name, definition);
94
+ },
95
+ }
96
+ : {}),
97
+ logger: this.options.logger,
98
+ };
99
+
100
+ this.service = new ExternalDatasourceService(config);
101
+ ctx.registerService('external-datasource', this.service);
102
+ }
103
+
104
+ async start(ctx: PluginContext): Promise<void> {
105
+ if (this.service) await ctx.trigger('external-datasource:ready', this.service);
106
+ }
107
+
108
+ async destroy(): Promise<void> {
109
+ this.service = undefined;
110
+ }
111
+ }
112
+
113
+ function safeGetService<T>(ctx: PluginContext, name: string): T | undefined {
114
+ try {
115
+ return ctx.getService<T>(name);
116
+ } catch {
117
+ return undefined;
118
+ }
119
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src",
6
+ "types": [
7
+ "node"
8
+ ]
9
+ },
10
+ "include": [
11
+ "src"
12
+ ],
13
+ "exclude": [
14
+ "node_modules",
15
+ "dist"
16
+ ]
17
+ }