@ankhorage/infra 1.1.0 → 2.0.1
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/CHANGELOG.md +12 -0
- package/README.md +4 -1
- package/dist/adapters/minikube/auth/oauthRuntime.js +6 -12
- package/dist/adapters/minikube/auth/oauthRuntime.js.map +1 -1
- package/dist/adapters/minikube/index.d.ts.map +1 -1
- package/dist/adapters/minikube/index.js +13 -5
- package/dist/adapters/minikube/index.js.map +1 -1
- package/dist/generatedApiDatabase.d.ts +20 -0
- package/dist/generatedApiDatabase.d.ts.map +1 -0
- package/dist/generatedApiDatabase.js +343 -0
- package/dist/generatedApiDatabase.js.map +1 -0
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/project.d.ts +1 -1
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +3 -0
- package/dist/project.js.map +1 -1
- package/dist/testSupport.d.ts.map +1 -1
- package/dist/testSupport.js +1 -0
- package/dist/testSupport.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
- package/src/adapters/minikube/auth/oauthRuntime.ts +6 -12
- package/src/adapters/minikube/auth/supabase/oauthRuntime.test.ts +17 -6
- package/src/adapters/minikube/index.ts +18 -5
- package/src/generatedApiDatabase.test.ts +355 -0
- package/src/generatedApiDatabase.ts +558 -0
- package/src/index.ts +6 -2
- package/src/project.ts +3 -1
- package/src/testSupport.ts +1 -0
- package/src/types.ts +1 -1
- package/dist/apiArtifacts.d.ts +0 -8
- package/dist/apiArtifacts.d.ts.map +0 -1
- package/dist/apiArtifacts.js +0 -35
- package/dist/apiArtifacts.js.map +0 -1
- package/dist/apis.d.ts +0 -11
- package/dist/apis.d.ts.map +0 -1
- package/dist/apis.js +0 -354
- package/dist/apis.js.map +0 -1
- package/src/apiArtifacts.test.ts +0 -54
- package/src/apiArtifacts.ts +0 -107
- package/src/apis.test.ts +0 -212
- package/src/apis.ts +0 -534
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import type { GeneratedApiRegistry } from '@ankhorage/contracts';
|
|
2
|
+
import { describe, expect, test } from 'bun:test';
|
|
3
|
+
|
|
4
|
+
import { generateGeneratedApiDatabaseArtifacts } from './generatedApiDatabase';
|
|
5
|
+
import { generateInfrastructure } from './index';
|
|
6
|
+
import { createAppManifest } from './testSupport';
|
|
7
|
+
import type { InfraManifestInput } from './types';
|
|
8
|
+
|
|
9
|
+
function createGeneratedApis(): GeneratedApiRegistry {
|
|
10
|
+
return {
|
|
11
|
+
catalog: {
|
|
12
|
+
id: 'catalog',
|
|
13
|
+
protocol: 'rest',
|
|
14
|
+
name: 'Catalog API',
|
|
15
|
+
basePath: '/api/catalog',
|
|
16
|
+
database: { id: 'primary-db', kind: 'database' },
|
|
17
|
+
resources: [
|
|
18
|
+
{
|
|
19
|
+
id: 'products',
|
|
20
|
+
path: '/products',
|
|
21
|
+
operations: ['list', 'read', 'create', 'update', 'delete'],
|
|
22
|
+
collection: {
|
|
23
|
+
schema: 'app',
|
|
24
|
+
name: 'products',
|
|
25
|
+
primaryKey: 'id',
|
|
26
|
+
fields: [
|
|
27
|
+
{ name: 'id', type: 'uuid', required: true, unique: true },
|
|
28
|
+
{ name: 'name', type: 'text', required: true, unique: true },
|
|
29
|
+
{ name: 'price', type: 'number', defaultValue: 12.5 },
|
|
30
|
+
{ name: 'active', type: 'boolean', defaultValue: true },
|
|
31
|
+
{ name: 'created_at', type: 'datetime' },
|
|
32
|
+
{ name: 'metadata', type: 'json' },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
seed: [
|
|
36
|
+
{
|
|
37
|
+
name: 'Starter product',
|
|
38
|
+
price: 9.5,
|
|
39
|
+
active: true,
|
|
40
|
+
metadata: { source: 'fixture' },
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('generated API database bridge', () => {
|
|
50
|
+
test('generates deterministic provider-owned database artifacts', () => {
|
|
51
|
+
const input = {
|
|
52
|
+
generatedApis: createGeneratedApis(),
|
|
53
|
+
databaseProvider: 'supabase',
|
|
54
|
+
} as const;
|
|
55
|
+
const result = generateGeneratedApiDatabaseArtifacts(input);
|
|
56
|
+
const rerun = generateGeneratedApiDatabaseArtifacts(input);
|
|
57
|
+
const paths = result.files.map((file) => file.path);
|
|
58
|
+
|
|
59
|
+
expect(result.files).toEqual(rerun.files);
|
|
60
|
+
expect(result.diagnostics).toEqual([]);
|
|
61
|
+
expect(paths).toEqual([
|
|
62
|
+
'infra/minikube/db/generated-api-resources.json',
|
|
63
|
+
'infra/minikube/db/generated-api-seed.json',
|
|
64
|
+
'infra/minikube/db/migrations/001_generated_api_resources.sql',
|
|
65
|
+
'infra/minikube/db/seeds/001_generated_api_seed.sql',
|
|
66
|
+
'infra/minikube/db/README.md',
|
|
67
|
+
]);
|
|
68
|
+
expect(paths.some((path) => path.startsWith('src/generated/apis/'))).toBe(false);
|
|
69
|
+
expect(paths.some((path) => path.includes('/k8s/') && path.includes('api'))).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('maps canonical collection fields to Supabase/Postgres SQL', () => {
|
|
73
|
+
const result = generateGeneratedApiDatabaseArtifacts({
|
|
74
|
+
generatedApis: createGeneratedApis(),
|
|
75
|
+
databaseProvider: 'supabase',
|
|
76
|
+
});
|
|
77
|
+
const migration = result.files.find((file) =>
|
|
78
|
+
file.path.endsWith('001_generated_api_resources.sql'),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(migration?.content).toContain('create schema if not exists "app";');
|
|
82
|
+
expect(migration?.content).toContain('create table if not exists "app"."products"');
|
|
83
|
+
expect(migration?.content).toContain('"id" uuid default gen_random_uuid() primary key');
|
|
84
|
+
expect(migration?.content).toContain('"name" text not null unique');
|
|
85
|
+
expect(migration?.content).toContain('"price" numeric default 12.5');
|
|
86
|
+
expect(migration?.content).toContain('"active" boolean default true');
|
|
87
|
+
expect(migration?.content).toContain('"created_at" timestamptz');
|
|
88
|
+
expect(migration?.content).toContain('"metadata" jsonb');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('materializes deterministic UUID primary keys for seed records', () => {
|
|
92
|
+
const result = generateGeneratedApiDatabaseArtifacts({
|
|
93
|
+
generatedApis: createGeneratedApis(),
|
|
94
|
+
databaseProvider: 'supabase',
|
|
95
|
+
});
|
|
96
|
+
const seedJson = result.files.find((file) => file.path.endsWith('generated-api-seed.json'));
|
|
97
|
+
const seedSql = result.files.find((file) => file.path.endsWith('001_generated_api_seed.sql'));
|
|
98
|
+
|
|
99
|
+
expect(seedJson?.content).toMatch(/"id": "[0-9a-f-]{36}"/);
|
|
100
|
+
expect(seedSql?.content).toContain('insert into "app"."products"');
|
|
101
|
+
expect(seedSql?.content).toContain('Starter product');
|
|
102
|
+
expect(seedSql?.content).toContain('on conflict ("id") do nothing');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('enables RLS for explicit policy intent without inventing SQL policies', () => {
|
|
106
|
+
const generatedApis = createGeneratedApis();
|
|
107
|
+
const { catalog } = generatedApis;
|
|
108
|
+
expect(catalog).toBeDefined();
|
|
109
|
+
if (catalog === undefined) return;
|
|
110
|
+
|
|
111
|
+
const [products] = catalog.resources;
|
|
112
|
+
expect(products).toBeDefined();
|
|
113
|
+
if (products === undefined) return;
|
|
114
|
+
|
|
115
|
+
const result = generateGeneratedApiDatabaseArtifacts({
|
|
116
|
+
generatedApis: {
|
|
117
|
+
catalog: {
|
|
118
|
+
...catalog,
|
|
119
|
+
resources: [{ ...products, policies: [{ id: 'products-read', operation: 'read' }] }],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
databaseProvider: 'supabase',
|
|
123
|
+
});
|
|
124
|
+
const migration = result.files.find((file) =>
|
|
125
|
+
file.path.endsWith('001_generated_api_resources.sql'),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(migration?.content).toContain('alter table "app"."products" enable row level security;');
|
|
129
|
+
expect(migration?.content).not.toContain('create policy');
|
|
130
|
+
expect(
|
|
131
|
+
result.diagnostics.some(
|
|
132
|
+
(diagnostic) =>
|
|
133
|
+
diagnostic.code === 'unsupported-policy' && diagnostic.severity === 'warning',
|
|
134
|
+
),
|
|
135
|
+
).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('returns structured diagnostics and no partial artifacts for invalid definitions', () => {
|
|
139
|
+
const result = generateGeneratedApiDatabaseArtifacts({
|
|
140
|
+
databaseProvider: 'supabase',
|
|
141
|
+
generatedApis: {
|
|
142
|
+
broken: {
|
|
143
|
+
id: 'broken',
|
|
144
|
+
protocol: 'rest',
|
|
145
|
+
basePath: '/api/broken',
|
|
146
|
+
database: { id: 'db', kind: 'database' },
|
|
147
|
+
resources: [
|
|
148
|
+
{
|
|
149
|
+
id: 'items',
|
|
150
|
+
path: '/items',
|
|
151
|
+
operations: ['list'],
|
|
152
|
+
collection: {
|
|
153
|
+
name: 'invalid-name',
|
|
154
|
+
primaryKey: 'missing_id',
|
|
155
|
+
fields: [
|
|
156
|
+
{ name: 'title', type: 'text', required: true },
|
|
157
|
+
{ name: 'title', type: 'text' },
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
seed: [{ title: 'ok', extra: 'not-declared' }],
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
const diagnosticCodes = result.diagnostics.map((diagnostic) => diagnostic.code);
|
|
167
|
+
|
|
168
|
+
expect(result.files).toEqual([]);
|
|
169
|
+
expect(diagnosticCodes).toContain('invalid-identifier');
|
|
170
|
+
expect(diagnosticCodes).toContain('invalid-primary-key');
|
|
171
|
+
expect(diagnosticCodes).toContain('duplicate-field');
|
|
172
|
+
expect(diagnosticCodes).toContain('invalid-seed');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('rejects two generated resources targeting the same database table', () => {
|
|
176
|
+
const { catalog } = createGeneratedApis();
|
|
177
|
+
expect(catalog).toBeDefined();
|
|
178
|
+
if (catalog === undefined) return;
|
|
179
|
+
|
|
180
|
+
const [products] = catalog.resources;
|
|
181
|
+
expect(products).toBeDefined();
|
|
182
|
+
if (products === undefined) return;
|
|
183
|
+
|
|
184
|
+
const result = generateGeneratedApiDatabaseArtifacts({
|
|
185
|
+
databaseProvider: 'supabase',
|
|
186
|
+
generatedApis: {
|
|
187
|
+
catalog: {
|
|
188
|
+
...catalog,
|
|
189
|
+
resources: [products, { ...products, id: 'products-copy', path: '/products-copy' }],
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(result.files).toEqual([]);
|
|
195
|
+
expect(
|
|
196
|
+
result.diagnostics.some(
|
|
197
|
+
(diagnostic) => diagnostic.code === 'duplicate-target' && diagnostic.severity === 'error',
|
|
198
|
+
),
|
|
199
|
+
).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('rejects seed values that do not match collection field types', () => {
|
|
203
|
+
const { catalog } = createGeneratedApis();
|
|
204
|
+
expect(catalog).toBeDefined();
|
|
205
|
+
if (catalog === undefined) return;
|
|
206
|
+
|
|
207
|
+
const [products] = catalog.resources;
|
|
208
|
+
expect(products).toBeDefined();
|
|
209
|
+
if (products === undefined) return;
|
|
210
|
+
|
|
211
|
+
const result = generateGeneratedApiDatabaseArtifacts({
|
|
212
|
+
databaseProvider: 'supabase',
|
|
213
|
+
generatedApis: {
|
|
214
|
+
catalog: {
|
|
215
|
+
...catalog,
|
|
216
|
+
resources: [{ ...products, seed: [{ name: 'Broken product', price: 'free' }] }],
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(result.files).toEqual([]);
|
|
222
|
+
expect(
|
|
223
|
+
result.diagnostics.some(
|
|
224
|
+
(diagnostic) =>
|
|
225
|
+
diagnostic.code === 'invalid-seed' && diagnostic.path?.endsWith('seed.0.price') === true,
|
|
226
|
+
),
|
|
227
|
+
).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('rejects null seed values for required fields and primary keys', () => {
|
|
231
|
+
const { catalog } = createGeneratedApis();
|
|
232
|
+
expect(catalog).toBeDefined();
|
|
233
|
+
if (catalog === undefined) return;
|
|
234
|
+
|
|
235
|
+
const [products] = catalog.resources;
|
|
236
|
+
expect(products).toBeDefined();
|
|
237
|
+
if (products === undefined) return;
|
|
238
|
+
|
|
239
|
+
const result = generateGeneratedApiDatabaseArtifacts({
|
|
240
|
+
databaseProvider: 'supabase',
|
|
241
|
+
generatedApis: {
|
|
242
|
+
catalog: {
|
|
243
|
+
...catalog,
|
|
244
|
+
resources: [{ ...products, seed: [{ id: null, name: null }] }],
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
const invalidSeedPaths = result.diagnostics
|
|
249
|
+
.filter((diagnostic) => diagnostic.code === 'invalid-seed')
|
|
250
|
+
.map((diagnostic) => diagnostic.path);
|
|
251
|
+
|
|
252
|
+
expect(result.files).toEqual([]);
|
|
253
|
+
expect(invalidSeedPaths).toContain('generatedApis.catalog.resources.products.seed.0.id');
|
|
254
|
+
expect(invalidSeedPaths).toContain('generatedApis.catalog.resources.products.seed.0.name');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('uses SQL DEFAULT when seed records omit a column that another record supplies', () => {
|
|
258
|
+
const generatedApis: GeneratedApiRegistry = {
|
|
259
|
+
defaults: {
|
|
260
|
+
id: 'defaults',
|
|
261
|
+
protocol: 'rest',
|
|
262
|
+
basePath: '/api/defaults',
|
|
263
|
+
database: { id: 'db', kind: 'database' },
|
|
264
|
+
resources: [
|
|
265
|
+
{
|
|
266
|
+
id: 'items',
|
|
267
|
+
path: '/items',
|
|
268
|
+
operations: ['list'],
|
|
269
|
+
collection: {
|
|
270
|
+
name: 'items',
|
|
271
|
+
fields: [
|
|
272
|
+
{ name: 'name', type: 'text', required: true },
|
|
273
|
+
{ name: 'active', type: 'boolean', required: true, defaultValue: true },
|
|
274
|
+
],
|
|
275
|
+
},
|
|
276
|
+
seed: [{ name: 'A' }, { name: 'B', active: false }],
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
const result = generateGeneratedApiDatabaseArtifacts({
|
|
282
|
+
databaseProvider: 'supabase',
|
|
283
|
+
generatedApis,
|
|
284
|
+
});
|
|
285
|
+
const seedSql = result.files.find((file) => file.path.endsWith('001_generated_api_seed.sql'));
|
|
286
|
+
|
|
287
|
+
expect(result.diagnostics).toEqual([]);
|
|
288
|
+
expect(seedSql?.content).toContain("('A', default),\n ('B', false)");
|
|
289
|
+
expect(seedSql?.content).not.toContain("('A', null)");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('rejects invalid UUID and datetime defaults before SQL generation', () => {
|
|
293
|
+
const generatedApis: GeneratedApiRegistry = {
|
|
294
|
+
invalidDefaults: {
|
|
295
|
+
id: 'invalidDefaults',
|
|
296
|
+
protocol: 'rest',
|
|
297
|
+
basePath: '/api/defaults',
|
|
298
|
+
database: { id: 'db', kind: 'database' },
|
|
299
|
+
resources: [
|
|
300
|
+
{
|
|
301
|
+
id: 'items',
|
|
302
|
+
path: '/items',
|
|
303
|
+
operations: ['list'],
|
|
304
|
+
collection: {
|
|
305
|
+
name: 'items',
|
|
306
|
+
fields: [
|
|
307
|
+
{ name: 'external_id', type: 'uuid', defaultValue: 'banana' },
|
|
308
|
+
{ name: 'created_at', type: 'datetime', defaultValue: 'not-a-date' },
|
|
309
|
+
],
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
const result = generateGeneratedApiDatabaseArtifacts({
|
|
316
|
+
databaseProvider: 'supabase',
|
|
317
|
+
generatedApis,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
expect(result.files).toEqual([]);
|
|
321
|
+
expect(
|
|
322
|
+
result.diagnostics.filter((diagnostic) => diagnostic.code === 'invalid-default'),
|
|
323
|
+
).toHaveLength(2);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test('integrates generated DB artifacts without generating an API service workload', () => {
|
|
327
|
+
const infra: InfraManifestInput = {
|
|
328
|
+
deployment: { target: 'minikube', monitoring: false },
|
|
329
|
+
database: { provider: 'supabase', tier: 'dev' },
|
|
330
|
+
plugins: [],
|
|
331
|
+
};
|
|
332
|
+
const appManifest = {
|
|
333
|
+
...createAppManifest('catalog', infra),
|
|
334
|
+
generatedApis: createGeneratedApis(),
|
|
335
|
+
};
|
|
336
|
+
const result = generateInfrastructure(infra, { appManifest });
|
|
337
|
+
const paths = result.files.map((file) => file.path);
|
|
338
|
+
|
|
339
|
+
expect(paths).toContain('infra/minikube/db/migrations/001_generated_api_resources.sql');
|
|
340
|
+
expect(paths).not.toContain('src/generated/apis/appApi.ts');
|
|
341
|
+
expect(paths).not.toContain('src/generated/apis/apiHandlers.ts');
|
|
342
|
+
expect(paths).not.toContain('src/generated/apis/openapi.json');
|
|
343
|
+
expect(paths).not.toContain('infra/minikube/k8s/generated-api/deployment.yaml');
|
|
344
|
+
expect(paths).not.toContain('infra/minikube/k8s/generated-api/service.yaml');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test('generates no database bridge artifacts when no generated APIs exist', () => {
|
|
348
|
+
const result = generateGeneratedApiDatabaseArtifacts({
|
|
349
|
+
generatedApis: undefined,
|
|
350
|
+
databaseProvider: 'supabase',
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
expect(result).toEqual({ files: [], diagnostics: [] });
|
|
354
|
+
});
|
|
355
|
+
});
|