@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.
- package/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +44 -0
- package/LICENSE +202 -0
- package/LICENSE.apache +202 -0
- package/dist/index.cjs +376 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +115 -0
- package/dist/index.d.ts +115 -0
- package/dist/index.js +352 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
- package/src/__tests__/external-datasource-service.test.ts +360 -0
- package/src/external-datasource-service.ts +456 -0
- package/src/index.ts +19 -0
- package/src/plugin.ts +119 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import type { IntrospectedSchema } from '@objectstack/spec/contracts';
|
|
5
|
+
import {
|
|
6
|
+
ExternalDatasourceService,
|
|
7
|
+
type DatasourceLike,
|
|
8
|
+
type ObjectLike,
|
|
9
|
+
} from '../external-datasource-service.js';
|
|
10
|
+
|
|
11
|
+
/** Build a fake introspected schema for the `warehouse` datasource. */
|
|
12
|
+
function warehouseSchema(): IntrospectedSchema {
|
|
13
|
+
return {
|
|
14
|
+
dialect: 'postgres',
|
|
15
|
+
introspectedAt: '2026-05-30T00:00:00.000Z',
|
|
16
|
+
tables: {
|
|
17
|
+
'mart.fact_orders': {
|
|
18
|
+
name: 'mart.fact_orders',
|
|
19
|
+
indexes: [],
|
|
20
|
+
columns: [
|
|
21
|
+
{ name: 'order_id', type: 'text', nullable: false, primaryKey: true },
|
|
22
|
+
{ name: 'customer_id', type: 'text', nullable: false, primaryKey: false },
|
|
23
|
+
{ name: 'amount', type: 'numeric(10,2)', nullable: true, primaryKey: false },
|
|
24
|
+
{ name: 'ordered_at', type: 'timestamptz', nullable: true, primaryKey: false },
|
|
25
|
+
{ name: 'metadata', type: 'jsonb', nullable: true, primaryKey: false },
|
|
26
|
+
{ name: 'geo', type: 'geography', nullable: true, primaryKey: false },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
'public.dim_customer': {
|
|
30
|
+
name: 'public.dim_customer',
|
|
31
|
+
indexes: [],
|
|
32
|
+
columns: [
|
|
33
|
+
{ name: 'id', type: 'text', nullable: false, primaryKey: true },
|
|
34
|
+
{ name: 'name', type: 'varchar(255)', nullable: true, primaryKey: false },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeService(overrides?: {
|
|
42
|
+
datasource?: DatasourceLike;
|
|
43
|
+
objects?: ObjectLike[];
|
|
44
|
+
}) {
|
|
45
|
+
const ds: DatasourceLike = overrides?.datasource ?? {
|
|
46
|
+
name: 'warehouse',
|
|
47
|
+
schemaMode: 'external',
|
|
48
|
+
external: { allowedSchemas: ['mart', 'public'] },
|
|
49
|
+
};
|
|
50
|
+
const objects = overrides?.objects ?? [];
|
|
51
|
+
return new ExternalDatasourceService({
|
|
52
|
+
introspect: async () => warehouseSchema(),
|
|
53
|
+
getDatasource: async (n) => (n === ds.name ? ds : undefined),
|
|
54
|
+
getObject: async (n) => objects.find((o) => o.name === n),
|
|
55
|
+
listObjects: async () => objects,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('listRemoteTables', () => {
|
|
60
|
+
it('lists tables with parsed schema + column counts', async () => {
|
|
61
|
+
const svc = makeService();
|
|
62
|
+
const tables = await svc.listRemoteTables('warehouse');
|
|
63
|
+
expect(tables).toHaveLength(2);
|
|
64
|
+
const orders = tables.find((t) => t.name === 'fact_orders')!;
|
|
65
|
+
expect(orders.schema).toBe('mart');
|
|
66
|
+
expect(orders.columnCount).toBe(6);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('filters by requested schema', async () => {
|
|
70
|
+
const svc = makeService();
|
|
71
|
+
const tables = await svc.listRemoteTables('warehouse', { schema: 'public' });
|
|
72
|
+
expect(tables.map((t) => t.name)).toEqual(['dim_customer']);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('respects allowedSchemas', async () => {
|
|
76
|
+
const svc = makeService({
|
|
77
|
+
datasource: { name: 'warehouse', schemaMode: 'external', external: { allowedSchemas: ['mart'] } },
|
|
78
|
+
});
|
|
79
|
+
const tables = await svc.listRemoteTables('warehouse');
|
|
80
|
+
expect(tables.map((t) => t.name)).toEqual(['fact_orders']);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('generateObjectDraft', () => {
|
|
85
|
+
it('maps columns to field types and flags lossy/unknown for review', async () => {
|
|
86
|
+
const svc = makeService();
|
|
87
|
+
const draft = await svc.generateObjectDraft('warehouse', 'fact_orders');
|
|
88
|
+
|
|
89
|
+
expect(draft.name).toBe('fact_orders');
|
|
90
|
+
expect(draft.datasource).toBe('warehouse');
|
|
91
|
+
const fields = draft.definition.fields as Record<string, { type: string; primaryKey?: boolean }>;
|
|
92
|
+
expect(fields.order_id).toEqual({ type: 'text', primaryKey: true });
|
|
93
|
+
expect(fields.amount.type).toBe('number');
|
|
94
|
+
expect(fields.ordered_at.type).toBe('datetime');
|
|
95
|
+
expect(fields.metadata.type).toBe('json');
|
|
96
|
+
// geography is unknown → defaulted to text + review note.
|
|
97
|
+
expect(fields.geo.type).toBe('text');
|
|
98
|
+
expect(draft.review.some((r) => r.column === 'geo')).toBe(true);
|
|
99
|
+
|
|
100
|
+
// Source carries the external binding + a REVIEW marker.
|
|
101
|
+
expect(draft.source).toContain("remoteName: 'fact_orders'");
|
|
102
|
+
expect(draft.source).toContain("remoteSchema: 'mart'");
|
|
103
|
+
expect(draft.source).toContain('REVIEW:');
|
|
104
|
+
expect(draft.source).toContain("order_id: { type: 'text', primaryKey: true }");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('honours include/exclude/rename/primaryKey options', async () => {
|
|
108
|
+
const svc = makeService();
|
|
109
|
+
const draft = await svc.generateObjectDraft('warehouse', 'fact_orders', {
|
|
110
|
+
includeColumns: ['order_id', 'amount'],
|
|
111
|
+
rename: { amount: 'total' },
|
|
112
|
+
primaryKey: ['order_id'],
|
|
113
|
+
});
|
|
114
|
+
const fields = draft.definition.fields as Record<string, unknown>;
|
|
115
|
+
expect(Object.keys(fields)).toEqual(['order_id', 'total']);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('throws when the remote table is missing', async () => {
|
|
119
|
+
const svc = makeService();
|
|
120
|
+
await expect(svc.generateObjectDraft('warehouse', 'nope')).rejects.toThrow(/not found/);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('importObject', () => {
|
|
125
|
+
/** Build a service with a recording persistObject (runtime metadata store). */
|
|
126
|
+
function makeImporter(persistObject?: (name: string, def: Record<string, unknown>) => Promise<void>) {
|
|
127
|
+
const persisted: Array<{ name: string; def: Record<string, unknown> }> = [];
|
|
128
|
+
const svc = new ExternalDatasourceService({
|
|
129
|
+
introspect: async () => warehouseSchema(),
|
|
130
|
+
getDatasource: async () => ({ name: 'warehouse', schemaMode: 'external' }),
|
|
131
|
+
getObject: async () => undefined,
|
|
132
|
+
listObjects: async () => [],
|
|
133
|
+
persistObject:
|
|
134
|
+
persistObject ?? (async (name, def) => { persisted.push({ name, def }); }),
|
|
135
|
+
});
|
|
136
|
+
return { svc, persisted };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
it('persists a runtime federated object and returns name/definition/review', async () => {
|
|
140
|
+
const { svc, persisted } = makeImporter();
|
|
141
|
+
const result = await svc.importObject('warehouse', 'fact_orders');
|
|
142
|
+
|
|
143
|
+
expect(result.name).toBe('fact_orders');
|
|
144
|
+
expect(persisted).toHaveLength(1);
|
|
145
|
+
expect(persisted[0].name).toBe('fact_orders');
|
|
146
|
+
const def = persisted[0].def as { datasource: string; external: { remoteName: string; writable?: boolean } };
|
|
147
|
+
expect(def.datasource).toBe('warehouse');
|
|
148
|
+
expect(def.external.remoteName).toBe('fact_orders');
|
|
149
|
+
// Read-only by default — no writable flag leaks in.
|
|
150
|
+
expect(def.external.writable).toBeUndefined();
|
|
151
|
+
// The geography column surfaced a review note (carried over from the draft).
|
|
152
|
+
expect(result.review.some((r) => r.column === 'geo')).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('applies the name override and writable opt-in', async () => {
|
|
156
|
+
const { svc, persisted } = makeImporter();
|
|
157
|
+
const result = await svc.importObject('warehouse', 'fact_orders', {
|
|
158
|
+
name: 'wh_orders',
|
|
159
|
+
writable: true,
|
|
160
|
+
});
|
|
161
|
+
expect(result.name).toBe('wh_orders');
|
|
162
|
+
const def = persisted[0].def as { name: string; label: string; external: { writable?: boolean } };
|
|
163
|
+
expect(def.name).toBe('wh_orders');
|
|
164
|
+
expect(def.label).toBe('Wh Orders');
|
|
165
|
+
expect(def.external.writable).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('forwards draft options (include/rename) through to the persisted fields', async () => {
|
|
169
|
+
const { svc, persisted } = makeImporter();
|
|
170
|
+
await svc.importObject('warehouse', 'fact_orders', {
|
|
171
|
+
includeColumns: ['order_id', 'amount'],
|
|
172
|
+
rename: { amount: 'total' },
|
|
173
|
+
});
|
|
174
|
+
const def = persisted[0].def as { fields: Record<string, unknown> };
|
|
175
|
+
expect(Object.keys(def.fields)).toEqual(['order_id', 'total']);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('throws when no writable metadata store is wired', async () => {
|
|
179
|
+
const svc = new ExternalDatasourceService({
|
|
180
|
+
introspect: async () => warehouseSchema(),
|
|
181
|
+
getDatasource: async () => ({ name: 'warehouse', schemaMode: 'external' }),
|
|
182
|
+
getObject: async () => undefined,
|
|
183
|
+
listObjects: async () => [],
|
|
184
|
+
// no persistObject
|
|
185
|
+
});
|
|
186
|
+
await expect(svc.importObject('warehouse', 'fact_orders')).rejects.toThrow(
|
|
187
|
+
/writable metadata store/,
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('throws when the remote table is missing (no persistence)', async () => {
|
|
192
|
+
const { svc, persisted } = makeImporter();
|
|
193
|
+
await expect(svc.importObject('warehouse', 'ghost')).rejects.toThrow(/not found/);
|
|
194
|
+
expect(persisted).toHaveLength(0);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('validateObject', () => {
|
|
199
|
+
const baseObject: ObjectLike = {
|
|
200
|
+
name: 'wh_order',
|
|
201
|
+
datasource: 'warehouse',
|
|
202
|
+
external: { remoteName: 'fact_orders' },
|
|
203
|
+
fields: {
|
|
204
|
+
order_id: { type: 'text' },
|
|
205
|
+
customer_id: { type: 'text' },
|
|
206
|
+
amount: { type: 'number' },
|
|
207
|
+
ordered_at: { type: 'datetime' },
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
it('returns ok for a matching federated object', async () => {
|
|
212
|
+
const svc = makeService({ objects: [baseObject] });
|
|
213
|
+
const result = await svc.validateObject('wh_order');
|
|
214
|
+
expect(result.ok).toBe(true);
|
|
215
|
+
expect(result.diffs).toHaveLength(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('reports a type_mismatch error for an incompatible field', async () => {
|
|
219
|
+
const svc = makeService({
|
|
220
|
+
objects: [{ ...baseObject, fields: { ...baseObject.fields, amount: { type: 'datetime' } } }],
|
|
221
|
+
});
|
|
222
|
+
const result = await svc.validateObject('wh_order');
|
|
223
|
+
expect(result.ok).toBe(false);
|
|
224
|
+
expect(result.diffs).toContainEqual(
|
|
225
|
+
expect.objectContaining({ kind: 'type_mismatch', column: 'amount', severity: 'error' }),
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('reports a missing_column error', async () => {
|
|
230
|
+
const svc = makeService({
|
|
231
|
+
objects: [{ ...baseObject, fields: { ...baseObject.fields, nonexistent: { type: 'text' } } }],
|
|
232
|
+
});
|
|
233
|
+
const result = await svc.validateObject('wh_order');
|
|
234
|
+
expect(result.ok).toBe(false);
|
|
235
|
+
expect(result.diffs).toContainEqual(
|
|
236
|
+
expect.objectContaining({ kind: 'missing_column', column: 'nonexistent' }),
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('reports missing_table when the remote table is absent', async () => {
|
|
241
|
+
const svc = makeService({
|
|
242
|
+
objects: [{ ...baseObject, external: { remoteName: 'ghost' } }],
|
|
243
|
+
});
|
|
244
|
+
const result = await svc.validateObject('wh_order');
|
|
245
|
+
expect(result.ok).toBe(false);
|
|
246
|
+
expect(result.diffs[0].kind).toBe('missing_table');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('treats a managed datasource object as nothing-to-validate', async () => {
|
|
250
|
+
const svc = makeService({
|
|
251
|
+
datasource: { name: 'warehouse', schemaMode: 'managed' },
|
|
252
|
+
objects: [baseObject],
|
|
253
|
+
});
|
|
254
|
+
const result = await svc.validateObject('wh_order');
|
|
255
|
+
expect(result.ok).toBe(true);
|
|
256
|
+
expect(result.diffs).toHaveLength(0);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('honours columnMap and ignoreColumns', async () => {
|
|
260
|
+
const svc = makeService({
|
|
261
|
+
objects: [
|
|
262
|
+
{
|
|
263
|
+
name: 'wh_order',
|
|
264
|
+
datasource: 'warehouse',
|
|
265
|
+
external: {
|
|
266
|
+
remoteName: 'fact_orders',
|
|
267
|
+
columnMap: { customer_id: 'cust' },
|
|
268
|
+
ignoreColumns: ['metadata'],
|
|
269
|
+
},
|
|
270
|
+
fields: { order_id: { type: 'text' }, cust: { type: 'text' } },
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
});
|
|
274
|
+
const result = await svc.validateObject('wh_order');
|
|
275
|
+
expect(result.ok).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('flags a lossy mapping as a warning without failing', async () => {
|
|
279
|
+
const svc = makeService({
|
|
280
|
+
objects: [
|
|
281
|
+
{
|
|
282
|
+
name: 'wh_order',
|
|
283
|
+
datasource: 'warehouse',
|
|
284
|
+
external: { remoteName: 'fact_orders' },
|
|
285
|
+
fields: { order_id: { type: 'text' }, metadata: { type: 'text' } },
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
});
|
|
289
|
+
const result = await svc.validateObject('wh_order');
|
|
290
|
+
expect(result.ok).toBe(true);
|
|
291
|
+
expect(result.diffs).toContainEqual(
|
|
292
|
+
expect.objectContaining({ kind: 'type_mismatch', column: 'metadata', severity: 'warning' }),
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe('validateAll', () => {
|
|
298
|
+
it('aggregates results across federated objects only', async () => {
|
|
299
|
+
const svc = makeService({
|
|
300
|
+
objects: [
|
|
301
|
+
{ name: 'local_thing', datasource: 'default', fields: { id: { type: 'text' } } },
|
|
302
|
+
{
|
|
303
|
+
name: 'wh_order',
|
|
304
|
+
datasource: 'warehouse',
|
|
305
|
+
external: { remoteName: 'fact_orders' },
|
|
306
|
+
fields: { order_id: { type: 'text' }, amount: { type: 'number' } },
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
});
|
|
310
|
+
const report = await svc.validateAll();
|
|
311
|
+
expect(report.ok).toBe(true);
|
|
312
|
+
expect(report.results.map((r) => r.object)).toEqual(['wh_order']);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe('refreshCatalog', () => {
|
|
317
|
+
it('produces a snapshot with suggested field types', async () => {
|
|
318
|
+
const svc = makeService();
|
|
319
|
+
const catalog = await svc.refreshCatalog('warehouse');
|
|
320
|
+
expect(catalog.datasource).toBe('warehouse');
|
|
321
|
+
expect(catalog.name).toBe('warehouse_catalog');
|
|
322
|
+
const orders = catalog.tables.find((t) => t.remoteName === 'fact_orders')!;
|
|
323
|
+
expect(orders.columns.find((c) => c.name === 'amount')?.suggestedFieldType).toBe('number');
|
|
324
|
+
// Canonicalised through the Zod schema: primaryKey default applied.
|
|
325
|
+
expect(orders.columns.find((c) => c.name === 'order_id')?.primaryKey).toBe(true);
|
|
326
|
+
expect(orders.columns.find((c) => c.name === 'amount')?.primaryKey).toBe(false);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('persists the snapshot as an external_catalog record when a store is wired', async () => {
|
|
330
|
+
const persisted: unknown[] = [];
|
|
331
|
+
const svc = new ExternalDatasourceService({
|
|
332
|
+
introspect: async () => warehouseSchema(),
|
|
333
|
+
getDatasource: async () => ({ name: 'warehouse', schemaMode: 'external' }),
|
|
334
|
+
getObject: async () => undefined,
|
|
335
|
+
listObjects: async () => [],
|
|
336
|
+
persistCatalog: async (c) => {
|
|
337
|
+
persisted.push(c);
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
const catalog = await svc.refreshCatalog('warehouse');
|
|
341
|
+
expect(persisted).toHaveLength(1);
|
|
342
|
+
expect(persisted[0]).toBe(catalog);
|
|
343
|
+
expect((persisted[0] as { name: string }).name).toBe('warehouse_catalog');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('still returns the snapshot when persistence throws (best-effort cache)', async () => {
|
|
347
|
+
const svc = new ExternalDatasourceService({
|
|
348
|
+
introspect: async () => warehouseSchema(),
|
|
349
|
+
getDatasource: async () => ({ name: 'warehouse', schemaMode: 'external' }),
|
|
350
|
+
getObject: async () => undefined,
|
|
351
|
+
listObjects: async () => [],
|
|
352
|
+
persistCatalog: async () => {
|
|
353
|
+
throw new Error('metadata store is read-only');
|
|
354
|
+
},
|
|
355
|
+
logger: { warn: () => {} },
|
|
356
|
+
});
|
|
357
|
+
const catalog = await svc.refreshCatalog('warehouse');
|
|
358
|
+
expect(catalog.name).toBe('warehouse_catalog');
|
|
359
|
+
});
|
|
360
|
+
});
|