@ankhorage/data-sources 0.1.0 → 0.3.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,486 @@
1
+ import type {
2
+ CredentialRef,
3
+ DataContractValue,
4
+ DataEndpointConfig,
5
+ DataOperationConfig,
6
+ DataOperationIntent,
7
+ DataOperationResponse,
8
+ DataSchema,
9
+ DataSchemaRegistry,
10
+ DataSourceDiagnostic,
11
+ DataSourceDiagnosticResult,
12
+ GraphQlDataSourceConfig,
13
+ OperationId,
14
+ } from '@ankhorage/contracts/data';
15
+
16
+ export const GRAPHQL_INTROSPECTION_QUERY = `query AnkhorageGraphQlIntrospection {
17
+ __schema {
18
+ queryType { name }
19
+ mutationType { name }
20
+ subscriptionType { name }
21
+ types {
22
+ kind
23
+ name
24
+ description
25
+ fields {
26
+ name
27
+ description
28
+ args {
29
+ name
30
+ description
31
+ type { kind name ofType { kind name ofType { kind name ofType { kind name } } } }
32
+ defaultValue
33
+ }
34
+ type { kind name ofType { kind name ofType { kind name ofType { kind name } } } }
35
+ }
36
+ inputFields {
37
+ name
38
+ description
39
+ type { kind name ofType { kind name ofType { kind name ofType { kind name } } } }
40
+ defaultValue
41
+ }
42
+ enumValues { name description }
43
+ possibleTypes { kind name }
44
+ }
45
+ }
46
+ }`;
47
+
48
+ export type GraphQlOperationKind = 'mutation' | 'query' | 'subscription';
49
+
50
+ type DataContractRecord = Record<string, DataContractValue>;
51
+
52
+ export interface GraphQlIntrospectionTypeRef {
53
+ readonly kind: string;
54
+ readonly name?: string | null;
55
+ readonly ofType?: GraphQlIntrospectionTypeRef | null;
56
+ }
57
+
58
+ export interface GraphQlIntrospectionInputValue {
59
+ readonly name: string;
60
+ readonly description?: string | null;
61
+ readonly type: GraphQlIntrospectionTypeRef;
62
+ readonly defaultValue?: string | null;
63
+ }
64
+
65
+ export interface GraphQlIntrospectionField {
66
+ readonly name: string;
67
+ readonly description?: string | null;
68
+ readonly args?: readonly GraphQlIntrospectionInputValue[] | null;
69
+ readonly type: GraphQlIntrospectionTypeRef;
70
+ }
71
+
72
+ export interface GraphQlIntrospectionEnumValue {
73
+ readonly name: string;
74
+ readonly description?: string | null;
75
+ }
76
+
77
+ export interface GraphQlIntrospectionType {
78
+ readonly kind: string;
79
+ readonly name?: string | null;
80
+ readonly description?: string | null;
81
+ readonly fields?: readonly GraphQlIntrospectionField[] | null;
82
+ readonly inputFields?: readonly GraphQlIntrospectionInputValue[] | null;
83
+ readonly enumValues?: readonly GraphQlIntrospectionEnumValue[] | null;
84
+ readonly possibleTypes?: readonly GraphQlIntrospectionTypeRef[] | null;
85
+ }
86
+
87
+ export interface GraphQlIntrospectionSchema {
88
+ readonly queryType?: { readonly name?: string | null } | null;
89
+ readonly mutationType?: { readonly name?: string | null } | null;
90
+ readonly subscriptionType?: { readonly name?: string | null } | null;
91
+ readonly types?: readonly GraphQlIntrospectionType[] | null;
92
+ }
93
+
94
+ export interface GraphQlIntrospectionResult {
95
+ readonly __schema?: GraphQlIntrospectionSchema;
96
+ }
97
+
98
+ export interface GraphQlOperationDefinition {
99
+ readonly id: OperationId;
100
+ readonly kind: GraphQlOperationKind;
101
+ readonly name?: string;
102
+ readonly description?: string;
103
+ readonly document?: string;
104
+ readonly variables?: DataSchema;
105
+ readonly response?: DataSchema;
106
+ readonly selectionPath?: string;
107
+ readonly metadata?: DataContractValue;
108
+ }
109
+
110
+ export interface GraphQlDataSourceDefinition {
111
+ readonly id: string;
112
+ readonly endpointUrl: string;
113
+ readonly name?: string;
114
+ readonly description?: string;
115
+ readonly credential?: CredentialRef;
116
+ readonly introspection?: GraphQlIntrospectionResult;
117
+ readonly introspectionEnabled?: boolean;
118
+ readonly schemaVersion?: string;
119
+ readonly operations?: readonly GraphQlOperationDefinition[];
120
+ readonly metadata?: DataContractValue;
121
+ }
122
+
123
+ export interface GraphQlIntrospectionRequest {
124
+ readonly query: typeof GRAPHQL_INTROSPECTION_QUERY;
125
+ readonly operationName: 'AnkhorageGraphQlIntrospection';
126
+ }
127
+
128
+ export function createGraphQlIntrospectionRequest(): GraphQlIntrospectionRequest {
129
+ return {
130
+ query: GRAPHQL_INTROSPECTION_QUERY,
131
+ operationName: 'AnkhorageGraphQlIntrospection',
132
+ };
133
+ }
134
+
135
+ export function normalizeGraphQlOperationId(kind: GraphQlOperationKind, name: string): OperationId {
136
+ const normalizedName = name
137
+ .replace(/[^A-Za-z0-9]+/g, '-')
138
+ .replace(/^-+|-+$/g, '')
139
+ .toLowerCase();
140
+
141
+ return normalizedName.length > 0 ? `${kind}.${normalizedName}` : `${kind}.operation`;
142
+ }
143
+
144
+ export function createGraphQlDataSource(
145
+ definition: GraphQlDataSourceDefinition,
146
+ ): DataSourceDiagnosticResult<GraphQlDataSourceConfig> {
147
+ const diagnostics = validateGraphQlDataSource(definition);
148
+ const hasErrors = diagnostics.some((diagnostic) => diagnostic.severity === 'error');
149
+
150
+ if (hasErrors) return { ok: false, diagnostics };
151
+
152
+ return {
153
+ ok: true,
154
+ data: normalizeGraphQlDataSource(definition),
155
+ diagnostics,
156
+ };
157
+ }
158
+
159
+ export function validateGraphQlDataSource(
160
+ definition: GraphQlDataSourceDefinition,
161
+ ): readonly DataSourceDiagnostic[] {
162
+ const diagnostics: DataSourceDiagnostic[] = [];
163
+
164
+ if (definition.endpointUrl.trim().length === 0) {
165
+ diagnostics.push({
166
+ code: 'invalid-config',
167
+ dataSourceId: definition.id,
168
+ message: 'GraphQL data source requires a non-empty endpointUrl.',
169
+ path: 'endpointUrl',
170
+ severity: 'error',
171
+ });
172
+ }
173
+
174
+ if (definition.introspectionEnabled === false && definition.introspection !== undefined) {
175
+ diagnostics.push({
176
+ code: 'invalid-config',
177
+ dataSourceId: definition.id,
178
+ message: 'GraphQL introspection data was provided while introspection is disabled.',
179
+ path: 'introspection',
180
+ severity: 'warning',
181
+ });
182
+ }
183
+
184
+ if (definition.introspectionEnabled !== false && definition.introspection === undefined) {
185
+ diagnostics.push({
186
+ code: 'missing-schema',
187
+ dataSourceId: definition.id,
188
+ message:
189
+ 'GraphQL introspection result was not provided. Manual operations can still be used.',
190
+ path: 'introspection',
191
+ severity: 'info',
192
+ });
193
+ }
194
+
195
+ return diagnostics;
196
+ }
197
+
198
+ export function normalizeGraphQlDataSource(
199
+ definition: GraphQlDataSourceDefinition,
200
+ ): GraphQlDataSourceConfig {
201
+ const schemas = normalizeGraphQlIntrospectionSchemas(definition.introspection);
202
+ const introspectionOperations = normalizeGraphQlIntrospectionOperations(definition.introspection);
203
+ const manualOperations = definition.operations ?? [];
204
+ const operations: Record<OperationId, DataOperationConfig> = {};
205
+
206
+ for (const operation of [...introspectionOperations, ...manualOperations]) {
207
+ operations[operation.id] = normalizeGraphQlOperation(operation);
208
+ }
209
+
210
+ const endpoint: DataEndpointConfig = {
211
+ id: 'graphql',
212
+ kind: 'graphql',
213
+ baseUrl: definition.endpointUrl,
214
+ credential: definition.credential,
215
+ operations,
216
+ metadata: {
217
+ source: 'graphql',
218
+ },
219
+ };
220
+
221
+ return {
222
+ id: definition.id,
223
+ kind: 'graphql',
224
+ name: definition.name,
225
+ description: definition.description,
226
+ endpointUrl: definition.endpointUrl,
227
+ credential: definition.credential,
228
+ endpoints: {
229
+ graphql: endpoint,
230
+ },
231
+ schemas,
232
+ introspection: {
233
+ enabled: definition.introspectionEnabled ?? definition.introspection !== undefined,
234
+ schemaVersion: definition.schemaVersion,
235
+ },
236
+ metadata: definition.metadata,
237
+ };
238
+ }
239
+
240
+ export function normalizeGraphQlIntrospectionSchemas(
241
+ introspection: GraphQlIntrospectionResult | undefined,
242
+ ): DataSchemaRegistry | undefined {
243
+ const types = introspection?.__schema?.types?.filter(isNamedGraphQlType) ?? [];
244
+ if (types.length === 0) return undefined;
245
+
246
+ const schemas: Record<string, DataSchema> = {};
247
+
248
+ for (const type of types) {
249
+ if (type.name === undefined || type.name === null || type.name.startsWith('__')) continue;
250
+ schemas[type.name] = normalizeGraphQlType(type);
251
+ }
252
+
253
+ return schemas;
254
+ }
255
+
256
+ export function normalizeGraphQlIntrospectionOperations(
257
+ introspection: GraphQlIntrospectionResult | undefined,
258
+ ): readonly GraphQlOperationDefinition[] {
259
+ const schema = introspection?.__schema;
260
+ if (schema === undefined) return [];
261
+
262
+ const types = schema.types?.filter(isNamedGraphQlType) ?? [];
263
+ const operations: GraphQlOperationDefinition[] = [];
264
+
265
+ appendRootOperations(operations, 'query', schema.queryType?.name, types);
266
+ appendRootOperations(operations, 'mutation', schema.mutationType?.name, types);
267
+ appendRootOperations(operations, 'subscription', schema.subscriptionType?.name, types);
268
+
269
+ return operations;
270
+ }
271
+
272
+ function appendRootOperations(
273
+ operations: GraphQlOperationDefinition[],
274
+ kind: GraphQlOperationKind,
275
+ rootTypeName: string | null | undefined,
276
+ types: readonly GraphQlIntrospectionType[],
277
+ ): void {
278
+ if (rootTypeName === undefined || rootTypeName === null) return;
279
+
280
+ const rootType = types.find((type) => type.name === rootTypeName);
281
+ for (const field of rootType?.fields ?? []) {
282
+ operations.push({
283
+ id: normalizeGraphQlOperationId(kind, field.name),
284
+ kind,
285
+ name: field.name,
286
+ description: field.description ?? undefined,
287
+ variables: normalizeGraphQlVariablesSchema(field.args ?? []),
288
+ response: normalizeGraphQlTypeRef(field.type),
289
+ selectionPath: `$.data.${field.name}`,
290
+ metadata: {
291
+ rootType: rootTypeName,
292
+ source: 'introspection',
293
+ },
294
+ });
295
+ }
296
+ }
297
+
298
+ function normalizeGraphQlOperation(operation: GraphQlOperationDefinition): DataOperationConfig {
299
+ const response: DataOperationResponse | undefined =
300
+ operation.response === undefined
301
+ ? undefined
302
+ : {
303
+ schema: operation.response,
304
+ };
305
+
306
+ return {
307
+ id: operation.id,
308
+ endpointId: 'graphql',
309
+ name: operation.name,
310
+ description: operation.description,
311
+ protocol: 'graphql',
312
+ intent: mapGraphQlOperationKindToIntent(operation.kind),
313
+ request: {
314
+ schema: operation.variables,
315
+ },
316
+ response,
317
+ metadata: createGraphQlOperationMetadata(operation),
318
+ };
319
+ }
320
+
321
+ function mapGraphQlOperationKindToIntent(kind: GraphQlOperationKind): DataOperationIntent {
322
+ if (kind === 'query' || kind === 'subscription') return 'read';
323
+ return 'action';
324
+ }
325
+
326
+ function normalizeGraphQlVariablesSchema(
327
+ args: readonly GraphQlIntrospectionInputValue[],
328
+ ): DataSchema {
329
+ const properties: Record<string, DataSchema> = {};
330
+ const required: string[] = [];
331
+
332
+ for (const arg of args) {
333
+ properties[arg.name] = {
334
+ ...normalizeGraphQlTypeRef(arg.type),
335
+ description: arg.description ?? undefined,
336
+ default: arg.defaultValue ?? undefined,
337
+ };
338
+ if (isGraphQlNonNull(arg.type)) required.push(arg.name);
339
+ }
340
+
341
+ return {
342
+ type: 'object',
343
+ required: required.length > 0 ? required : undefined,
344
+ properties,
345
+ };
346
+ }
347
+
348
+ function normalizeGraphQlType(type: GraphQlIntrospectionType): DataSchema {
349
+ if (type.kind === 'OBJECT' || type.kind === 'INTERFACE' || type.kind === 'INPUT_OBJECT') {
350
+ return normalizeGraphQlObjectType(type);
351
+ }
352
+
353
+ if (type.kind === 'ENUM') {
354
+ return {
355
+ type: 'string',
356
+ title: type.name ?? undefined,
357
+ description: type.description ?? undefined,
358
+ enum: type.enumValues?.map((value) => value.name),
359
+ };
360
+ }
361
+
362
+ if (type.kind === 'SCALAR') {
363
+ return {
364
+ ...normalizeGraphQlNamedScalar(type.name),
365
+ title: type.name ?? undefined,
366
+ description: type.description ?? undefined,
367
+ };
368
+ }
369
+
370
+ if (type.kind === 'UNION') {
371
+ return {
372
+ title: type.name ?? undefined,
373
+ description: type.description ?? undefined,
374
+ anyOf: type.possibleTypes?.map((possibleType) => normalizeGraphQlTypeRef(possibleType)),
375
+ };
376
+ }
377
+
378
+ return {
379
+ title: type.name ?? undefined,
380
+ description: type.description ?? undefined,
381
+ };
382
+ }
383
+
384
+ function normalizeGraphQlObjectType(type: GraphQlIntrospectionType): DataSchema {
385
+ const fields = type.kind === 'INPUT_OBJECT' ? type.inputFields : type.fields;
386
+ const properties: Record<string, DataSchema> = {};
387
+ const required: string[] = [];
388
+
389
+ for (const field of fields ?? []) {
390
+ const fieldType = 'type' in field ? field.type : undefined;
391
+ if (fieldType === undefined) continue;
392
+
393
+ properties[field.name] = {
394
+ ...normalizeGraphQlTypeRef(fieldType),
395
+ description: field.description ?? undefined,
396
+ };
397
+
398
+ if (isGraphQlNonNull(fieldType)) required.push(field.name);
399
+ }
400
+
401
+ return {
402
+ type: 'object',
403
+ title: type.name ?? undefined,
404
+ description: type.description ?? undefined,
405
+ required: required.length > 0 ? required : undefined,
406
+ properties,
407
+ };
408
+ }
409
+
410
+ function normalizeGraphQlTypeRef(type: GraphQlIntrospectionTypeRef): DataSchema {
411
+ if (type.kind === 'NON_NULL' && type.ofType !== undefined && type.ofType !== null) {
412
+ return {
413
+ ...normalizeGraphQlTypeRef(type.ofType),
414
+ nullable: false,
415
+ };
416
+ }
417
+
418
+ if (type.kind === 'LIST' && type.ofType !== undefined && type.ofType !== null) {
419
+ return {
420
+ type: 'array',
421
+ items: normalizeGraphQlTypeRef(type.ofType),
422
+ };
423
+ }
424
+
425
+ if (type.kind === 'SCALAR') {
426
+ return normalizeGraphQlNamedScalar(type.name);
427
+ }
428
+
429
+ if (type.name !== undefined && type.name !== null) {
430
+ return { ref: { id: type.name } };
431
+ }
432
+
433
+ return {};
434
+ }
435
+
436
+ function normalizeGraphQlNamedScalar(name: string | null | undefined): DataSchema {
437
+ if (name === 'Boolean') return { type: 'boolean' };
438
+ if (name === 'Float') return { type: 'number' };
439
+ if (name === 'ID' || name === 'String') return { type: 'string' };
440
+ if (name === 'Int') return { type: 'integer' };
441
+
442
+ return {
443
+ type: 'string',
444
+ format: name ?? undefined,
445
+ };
446
+ }
447
+
448
+ function isGraphQlNonNull(type: GraphQlIntrospectionTypeRef): boolean {
449
+ return type.kind === 'NON_NULL';
450
+ }
451
+
452
+ function isNamedGraphQlType(type: GraphQlIntrospectionType): boolean {
453
+ return type.name !== undefined && type.name !== null;
454
+ }
455
+
456
+ function createGraphQlOperationMetadata(operation: GraphQlOperationDefinition): DataContractRecord {
457
+ const metadata = toMetadataRecord(operation.metadata);
458
+ metadata.kind = operation.kind;
459
+
460
+ if (operation.document !== undefined) {
461
+ metadata.document = operation.document;
462
+ }
463
+
464
+ if (operation.selectionPath !== undefined) {
465
+ metadata.selectionPath = operation.selectionPath;
466
+ }
467
+
468
+ return metadata;
469
+ }
470
+
471
+ function toMetadataRecord(value: DataContractValue | undefined): DataContractRecord {
472
+ if (!isDataContractRecord(value)) return {};
473
+
474
+ const metadata: DataContractRecord = {};
475
+ for (const [key, item] of Object.entries(value)) {
476
+ metadata[key] = item;
477
+ }
478
+
479
+ return metadata;
480
+ }
481
+
482
+ function isDataContractRecord(value: DataContractValue | undefined): value is DataContractRecord {
483
+ return (
484
+ value !== undefined && typeof value === 'object' && value !== null && !Array.isArray(value)
485
+ );
486
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { DataSourceConfig, DataSourceKind } from '@ankhorage/contracts/data';
2
2
 
3
+ export * from './graphql';
4
+ export * from './managed-api';
3
5
  export * from './openapi';
4
6
  export * from './rest';
5
7
 
@@ -0,0 +1,181 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import {
4
+ createManagedApiDataSource,
5
+ createManagedApiOperationId,
6
+ createManagedApiResourceSchema,
7
+ getManagedApiOperations,
8
+ type ManagedApiGenerationDefinition,
9
+ normalizeManagedApiDataSource,
10
+ } from './index';
11
+
12
+ function assertSerializable<TValue>(value: TValue): void {
13
+ expect(JSON.parse(JSON.stringify(value))).toEqual(value);
14
+ }
15
+
16
+ function createPostsApiDefinition(): ManagedApiGenerationDefinition {
17
+ return {
18
+ id: 'posts-api',
19
+ name: 'Posts API',
20
+ adapter: {
21
+ id: 'primary-db',
22
+ kind: 'database',
23
+ packageName: '@ankhorage/supabase-db',
24
+ },
25
+ resources: [
26
+ {
27
+ name: 'posts',
28
+ collection: {
29
+ name: 'posts',
30
+ schema: 'public',
31
+ primaryKey: 'id',
32
+ fields: [
33
+ { name: 'id', type: 'uuid', required: true, unique: true },
34
+ { name: 'title', type: 'text', required: true },
35
+ { name: 'published', type: 'boolean', defaultValue: false },
36
+ { name: 'createdAt', type: 'datetime' },
37
+ { name: 'metadata', type: 'json' },
38
+ ],
39
+ },
40
+ policies: [{ id: 'posts-policy' }],
41
+ },
42
+ ],
43
+ };
44
+ }
45
+
46
+ describe('managed API generation helpers', () => {
47
+ it('generates deterministic operation IDs', () => {
48
+ expect(createManagedApiOperationId('posts', 'list')).toBe('posts.list');
49
+ expect(createManagedApiOperationId('posts', 'delete')).toBe('posts.delete');
50
+ });
51
+
52
+ it('uses all CRUD operations by default', () => {
53
+ const [resource] = createPostsApiDefinition().resources;
54
+
55
+ expect(resource).toBeDefined();
56
+ if (resource !== undefined) {
57
+ expect(getManagedApiOperations(resource)).toEqual([
58
+ 'list',
59
+ 'read',
60
+ 'create',
61
+ 'update',
62
+ 'delete',
63
+ ]);
64
+ }
65
+ });
66
+
67
+ it('generates a serializable resource schema from a DB collection', () => {
68
+ const [resource] = createPostsApiDefinition().resources;
69
+
70
+ expect(resource).toBeDefined();
71
+ if (resource !== undefined) {
72
+ const schema = createManagedApiResourceSchema(resource.collection);
73
+
74
+ assertSerializable(schema);
75
+ expect(schema.type).toBe('object');
76
+ expect(schema.properties?.id?.format).toBe('uuid');
77
+ expect(schema.properties?.createdAt?.format).toBe('date-time');
78
+ expect(schema.properties?.metadata?.type).toBe('object');
79
+ expect(schema.required).toEqual(['id', 'title']);
80
+ }
81
+ });
82
+
83
+ it('generates list/read/create/update/delete endpoint configs', () => {
84
+ const source = normalizeManagedApiDataSource(createPostsApiDefinition());
85
+
86
+ assertSerializable(source);
87
+ expect(source.kind).toBe('managed-api');
88
+ expect(source.adapter.id).toBe('primary-db');
89
+ expect(source.endpoints.posts?.kind).toBe('database');
90
+ expect(source.endpoints.posts?.operations['posts.list']?.intent).toBe('read');
91
+ expect(source.endpoints.posts?.operations['posts.read']?.intent).toBe('read');
92
+ expect(source.endpoints.posts?.operations['posts.create']?.intent).toBe('create');
93
+ expect(source.endpoints.posts?.operations['posts.update']?.intent).toBe('update');
94
+ expect(source.endpoints.posts?.operations['posts.delete']?.intent).toBe('delete');
95
+ expect(source.endpoints.posts?.operations['posts.read']?.request?.parameters?.[0]?.name).toBe(
96
+ 'id',
97
+ );
98
+ expect(
99
+ source.endpoints.posts?.operations['posts.list']?.request?.parameters?.map(
100
+ (parameter) => parameter.name,
101
+ ),
102
+ ).toEqual(['limit', 'offset']);
103
+ });
104
+
105
+ it('creates diagnostic result for valid managed APIs', () => {
106
+ const result = createManagedApiDataSource(createPostsApiDefinition());
107
+
108
+ expect(result.ok).toBe(true);
109
+ if (result.ok) {
110
+ expect(result.data.resources[0]?.name).toBe('posts');
111
+ expect(result.diagnostics).toEqual([]);
112
+ }
113
+ });
114
+
115
+ it('supports multiple managed APIs backed by different adapter refs', () => {
116
+ const postsApi = normalizeManagedApiDataSource(createPostsApiDefinition());
117
+ const analyticsApi = normalizeManagedApiDataSource({
118
+ id: 'analytics-api',
119
+ adapter: {
120
+ id: 'analytics-db',
121
+ kind: 'database',
122
+ packageName: '@ankhorage/postgres-db',
123
+ },
124
+ resources: [
125
+ {
126
+ name: 'events',
127
+ operations: ['list'],
128
+ collection: {
129
+ name: 'events',
130
+ primaryKey: 'id',
131
+ fields: [
132
+ { name: 'id', type: 'uuid', required: true, unique: true },
133
+ { name: 'name', type: 'text', required: true },
134
+ ],
135
+ },
136
+ },
137
+ ],
138
+ });
139
+
140
+ assertSerializable([postsApi, analyticsApi]);
141
+ expect(postsApi.id).toBe('posts-api');
142
+ expect(analyticsApi.id).toBe('analytics-api');
143
+ expect(postsApi.adapter.id).toBe('primary-db');
144
+ expect(analyticsApi.adapter.id).toBe('analytics-db');
145
+ expect(analyticsApi.endpoints.events?.operations['events.list']?.intent).toBe('read');
146
+ expect(analyticsApi.endpoints.events?.operations['events.read']).toBeUndefined();
147
+ });
148
+
149
+ it('reports missing resources and missing primary key diagnostics', () => {
150
+ const emptyResult = createManagedApiDataSource({
151
+ id: 'empty-api',
152
+ adapter: { id: 'db', kind: 'database' },
153
+ resources: [],
154
+ });
155
+ const missingPrimaryKeyResult = createManagedApiDataSource({
156
+ id: 'broken-api',
157
+ adapter: { id: 'db', kind: 'database' },
158
+ resources: [
159
+ {
160
+ name: 'posts',
161
+ collection: {
162
+ name: 'posts',
163
+ fields: [{ name: 'title', type: 'text' }],
164
+ },
165
+ },
166
+ ],
167
+ });
168
+
169
+ expect(emptyResult.ok).toBe(false);
170
+ if (!emptyResult.ok) {
171
+ expect(emptyResult.diagnostics.map((diagnostic) => diagnostic.path)).toContain('resources');
172
+ }
173
+
174
+ expect(missingPrimaryKeyResult.ok).toBe(false);
175
+ if (!missingPrimaryKeyResult.ok) {
176
+ expect(missingPrimaryKeyResult.diagnostics.map((diagnostic) => diagnostic.path)).toContain(
177
+ 'resources.posts.collection.primaryKey',
178
+ );
179
+ }
180
+ });
181
+ });