@ankhorage/infra 0.0.0 → 0.2.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.
@@ -10,10 +10,12 @@ export function generateSupabaseAuthArtifacts(args: {
10
10
  const root = 'infra/minikube/k8s/auth/supabase';
11
11
  const resourceRoot = 'auth/supabase';
12
12
  const docsRoot = 'infra/minikube/auth';
13
+ const migrationRoot = 'infra/minikube/supabase/migrations';
13
14
  const scope = manifest.auth?.scope ?? 'global';
14
15
  const authzEngine = manifest.auth?.authorization.engine ?? 'native';
15
16
  const authzKind = manifest.auth?.authorization.kind ?? 'RBAC';
16
17
  const authFieldModel = resolveAuthFieldModel(manifest);
18
+ const profileModel = resolveProfileModel(manifest);
17
19
 
18
20
  const warnings: string[] = [];
19
21
  if (scope !== 'global') {
@@ -32,6 +34,7 @@ export function generateSupabaseAuthArtifacts(args: {
32
34
  authzEngine,
33
35
  authzKind,
34
36
  authFieldModel,
37
+ profileModel,
35
38
  }),
36
39
  },
37
40
  {
@@ -46,11 +49,20 @@ export function generateSupabaseAuthArtifacts(args: {
46
49
  authzEngine,
47
50
  authzKind,
48
51
  authFieldModel,
52
+ profileModel,
49
53
  }),
50
54
  },
55
+ ...(profileModel.enabled
56
+ ? [
57
+ {
58
+ path: `${migrationRoot}/0001_auth_profiles.sql`,
59
+ content: getSupabaseProfileMigration(profileModel),
60
+ },
61
+ ]
62
+ : []),
51
63
  {
52
64
  path: `${docsRoot}/supabase-runtime-wiring.md`,
53
- content: getSupabaseRuntimeWiringGuide(),
65
+ content: getSupabaseRuntimeWiringGuide(profileModel),
54
66
  },
55
67
  ],
56
68
  resources: [
@@ -77,8 +89,9 @@ function getSupabaseConfigMap(args: {
77
89
  authzEngine: string;
78
90
  authzKind: string;
79
91
  authFieldModel: ResolvedAuthFieldModel;
92
+ profileModel: ResolvedProfileModel;
80
93
  }) {
81
- const { namespace, scope, authzEngine, authzKind, authFieldModel } = args;
94
+ const { namespace, scope, authzEngine, authzKind, authFieldModel, profileModel } = args;
82
95
 
83
96
  return `apiVersion: v1
84
97
  kind: ConfigMap
@@ -95,6 +108,10 @@ data:
95
108
  AUTH_SIGN_UP_OPTIONAL_FIELDS: "${escapeYamlDoubleQuoted(authFieldModel.signUpOptionalFieldsCsv)}"
96
109
  AUTH_SIGN_UP_POLICY: "${authFieldModel.signUpPolicy}"
97
110
  AUTH_PROFILE_FIELDS: "${escapeYamlDoubleQuoted(authFieldModel.profileFieldsCsv)}"
111
+ AUTH_PROFILE_TABLE: "${escapeYamlDoubleQuoted(profileModel.table)}"
112
+ AUTH_PROFILE_PRIMARY_KEY: "${profileModel.primaryKey}"
113
+ AUTH_PROFILE_CREATE_STRATEGY: "${profileModel.createStrategy}"
114
+ AUTH_PROFILE_UPDATE_STRATEGY: "${profileModel.updateStrategy}"
98
115
  `;
99
116
  }
100
117
 
@@ -121,8 +138,9 @@ function getRuntimeAuthEnvConfigMap(args: {
121
138
  authzEngine: string;
122
139
  authzKind: string;
123
140
  authFieldModel: ResolvedAuthFieldModel;
141
+ profileModel: ResolvedProfileModel;
124
142
  }) {
125
- const { namespace, scope, authzEngine, authzKind, authFieldModel } = args;
143
+ const { namespace, scope, authzEngine, authzKind, authFieldModel, profileModel } = args;
126
144
 
127
145
  return `apiVersion: v1
128
146
  kind: ConfigMap
@@ -140,12 +158,33 @@ data:
140
158
  AUTH_SIGN_UP_OPTIONAL_FIELDS: "${escapeYamlDoubleQuoted(authFieldModel.signUpOptionalFieldsCsv)}"
141
159
  AUTH_SIGN_UP_POLICY: "${authFieldModel.signUpPolicy}"
142
160
  AUTH_PROFILE_FIELDS: "${escapeYamlDoubleQuoted(authFieldModel.profileFieldsCsv)}"
161
+ AUTH_PROFILE_TABLE: "${escapeYamlDoubleQuoted(profileModel.table)}"
162
+ AUTH_PROFILE_PRIMARY_KEY: "${profileModel.primaryKey}"
163
+ AUTH_PROFILE_CREATE_STRATEGY: "${profileModel.createStrategy}"
164
+ AUTH_PROFILE_UPDATE_STRATEGY: "${profileModel.updateStrategy}"
143
165
  AUTH_TOKEN_HEADER: "Authorization"
144
166
  AUTH_TOKEN_PREFIX: "Bearer"
145
167
  `;
146
168
  }
147
169
 
148
- function getSupabaseRuntimeWiringGuide() {
170
+ function getSupabaseRuntimeWiringGuide(profileModel: ResolvedProfileModel) {
171
+ const profileSection = profileModel.enabled
172
+ ? `
173
+ ## App Profile Table
174
+
175
+ Identity remains provider-owned in \`auth.users\`. App-facing profile data is generated in
176
+ \`public.${profileModel.table}\` and linked through \`${profileModel.table}.id -> auth.users.id\`.
177
+
178
+ The generated migration:
179
+
180
+ - creates the profile table
181
+ - enables row-level security
182
+ - allows signed-in users to read and update their own profile row
183
+ - protects the generated \`role\` column from self-service profile updates
184
+ - creates a trigger for new auth users when \`AUTH_PROFILE_CREATE_STRATEGY=trigger\`
185
+ `
186
+ : '';
187
+
149
188
  return `# Supabase Runtime Wiring
150
189
 
151
190
  This file documents the runtime conventions generated by the minikube Supabase adapter.
@@ -188,6 +227,10 @@ with \`optional: true\` for auth sources.
188
227
  - \`AUTH_SIGN_UP_OPTIONAL_FIELDS\`
189
228
  - \`AUTH_SIGN_UP_POLICY\`
190
229
  - \`AUTH_PROFILE_FIELDS\`
230
+ - \`AUTH_PROFILE_TABLE\`
231
+ - \`AUTH_PROFILE_PRIMARY_KEY\`
232
+ - \`AUTH_PROFILE_CREATE_STRATEGY\`
233
+ - \`AUTH_PROFILE_UPDATE_STRATEGY\`
191
234
  - \`SUPABASE_SECRET_SYNC_ENABLED\`
192
235
  - \`SUPABASE_URL\`
193
236
  - \`SUPABASE_ANON_KEY\`
@@ -195,7 +238,7 @@ with \`optional: true\` for auth sources.
195
238
  - \`SUPABASE_JWT_SECRET\`
196
239
  - \`EXPO_PUBLIC_SUPABASE_URL\`
197
240
  - \`EXPO_PUBLIC_SUPABASE_ANON_KEY\`
198
-
241
+ ${profileSection}
199
242
  ## Secret Sync
200
243
 
201
244
  When \`SUPABASE_SECRET_SYNC_ENABLED=true\`, \`scripts/up.sh\` will create/update
@@ -208,6 +251,9 @@ const DEFAULT_SIGN_UP_REQUIRED_FIELDS = ['email', 'password'];
208
251
  const DEFAULT_SIGN_UP_OPTIONAL_FIELDS = ['firstName', 'lastName'];
209
252
  const DEFAULT_SIGN_UP_POLICY = 'autoSignIn';
210
253
  const DEFAULT_PROFILE_FIELDS = ['email', 'firstName', 'lastName'];
254
+ const DEFAULT_PROFILE_PRIMARY_KEY = 'authUserId';
255
+ const DEFAULT_PROFILE_CREATE_STRATEGY = 'trigger';
256
+ const DEFAULT_PROFILE_UPDATE_STRATEGY = 'api';
211
257
 
212
258
  interface ResolvedAuthFieldModel {
213
259
  signInIdentifiersCsv: string;
@@ -217,6 +263,22 @@ interface ResolvedAuthFieldModel {
217
263
  profileFieldsCsv: string;
218
264
  }
219
265
 
266
+ interface ProfileColumnSpec {
267
+ readonly field: string;
268
+ readonly column: string;
269
+ readonly sqlType: string;
270
+ readonly fromNewUser: string;
271
+ }
272
+
273
+ interface ResolvedProfileModel {
274
+ readonly enabled: boolean;
275
+ readonly table: string;
276
+ readonly primaryKey: string;
277
+ readonly createStrategy: string;
278
+ readonly updateStrategy: string;
279
+ readonly columns: readonly ProfileColumnSpec[];
280
+ }
281
+
220
282
  function resolveAuthFieldModel(manifest: InfraManifestInput): ResolvedAuthFieldModel {
221
283
  const signInIdentifiers = normalizeFieldList(
222
284
  manifest.auth?.signIn?.identifiers,
@@ -242,6 +304,164 @@ function resolveAuthFieldModel(manifest: InfraManifestInput): ResolvedAuthFieldM
242
304
  };
243
305
  }
244
306
 
307
+ function resolveProfileModel(manifest: InfraManifestInput): ResolvedProfileModel {
308
+ const table = normalizeIdentifier(manifest.auth?.profile?.table ?? '');
309
+ const fields = normalizeFieldList(manifest.auth?.profile?.fields, DEFAULT_PROFILE_FIELDS);
310
+
311
+ return {
312
+ enabled: table.length > 0,
313
+ table,
314
+ primaryKey: manifest.auth?.profile?.primaryKey ?? DEFAULT_PROFILE_PRIMARY_KEY,
315
+ createStrategy: manifest.auth?.profile?.createStrategy ?? DEFAULT_PROFILE_CREATE_STRATEGY,
316
+ updateStrategy: manifest.auth?.profile?.updateStrategy ?? DEFAULT_PROFILE_UPDATE_STRATEGY,
317
+ columns: resolveProfileColumns(fields),
318
+ };
319
+ }
320
+
321
+ function getSupabaseProfileMigration(profileModel: ResolvedProfileModel): string {
322
+ const table = quoteIdentifier(profileModel.table);
323
+ const functionName = quoteIdentifier(`handle_new_${profileModel.table}_user`);
324
+ const triggerName = quoteIdentifier(`on_auth_user_created_${profileModel.table}`);
325
+ const columnDefinitions = profileModel.columns
326
+ .map((column) => ` ${quoteIdentifier(column.column)} ${column.sqlType}`)
327
+ .join(',\n');
328
+ const insertColumns = ['id', ...profileModel.columns.map((column) => column.column)]
329
+ .map(quoteIdentifier)
330
+ .join(', ');
331
+ const insertValues = ['new.id', ...profileModel.columns.map((column) => column.fromNewUser)].join(
332
+ ', ',
333
+ );
334
+ const updateAssignments = profileModel.columns
335
+ .filter((column) => column.field === 'email')
336
+ .map(
337
+ (column) => `${quoteIdentifier(column.column)} = excluded.${quoteIdentifier(column.column)}`,
338
+ );
339
+ const conflictUpdate = [...updateAssignments, 'updated_at = now()'].join(',\n ');
340
+ const updateGrantColumns = profileModel.columns.map((column) => quoteIdentifier(column.column));
341
+ const updateGrantSql =
342
+ updateGrantColumns.length > 0
343
+ ? `grant update (${updateGrantColumns.join(', ')}) on table public.${table} to authenticated;\n`
344
+ : '';
345
+ const triggerSql =
346
+ profileModel.createStrategy === 'trigger'
347
+ ? `
348
+ create or replace function public.${functionName}()
349
+ returns trigger
350
+ language plpgsql
351
+ security definer
352
+ set search_path = public
353
+ as $$
354
+ begin
355
+ insert into public.${table} (${insertColumns})
356
+ values (${insertValues})
357
+ on conflict (id) do update set
358
+ ${conflictUpdate};
359
+
360
+ return new;
361
+ end;
362
+ $$;
363
+
364
+ drop trigger if exists ${triggerName} on auth.users;
365
+ create trigger ${triggerName}
366
+ after insert on auth.users
367
+ for each row execute function public.${functionName}();
368
+ `
369
+ : '';
370
+
371
+ return `-- Generated by @ankhorage/infra from manifest.infra.auth.profile.
372
+ -- Supabase Auth owns identity in auth.users. App-facing profile data lives in public.${profileModel.table}.
373
+
374
+ create table if not exists public.${table} (
375
+ id uuid primary key references auth.users(id) on delete cascade${
376
+ columnDefinitions ? `,\n${columnDefinitions}` : ''
377
+ },
378
+ role text not null default 'user',
379
+ created_at timestamptz not null default now(),
380
+ updated_at timestamptz not null default now()
381
+ );
382
+
383
+ alter table public.${table} enable row level security;
384
+
385
+ revoke all on table public.${table} from anon, authenticated;
386
+ grant select on table public.${table} to authenticated;
387
+ ${updateGrantSql}
388
+ create policy ${quoteIdentifier(`${profileModel.table}_select_own`)}
389
+ on public.${table}
390
+ for select
391
+ using (auth.uid() = id);
392
+
393
+ create policy ${quoteIdentifier(`${profileModel.table}_update_own`)}
394
+ on public.${table}
395
+ for update
396
+ using (auth.uid() = id)
397
+ with check (auth.uid() = id);
398
+ ${triggerSql}`;
399
+ }
400
+
401
+ function resolveProfileColumns(fields: readonly string[]): ProfileColumnSpec[] {
402
+ const columns = new Map<string, ProfileColumnSpec>();
403
+
404
+ for (const field of fields) {
405
+ const column = mapProfileFieldToColumn(field);
406
+ if (!column || columns.has(column.column)) {
407
+ continue;
408
+ }
409
+ columns.set(column.column, column);
410
+ }
411
+
412
+ return [...columns.values()];
413
+ }
414
+
415
+ function mapProfileFieldToColumn(field: string): ProfileColumnSpec | null {
416
+ switch (field) {
417
+ case 'email':
418
+ return { field, column: 'email', sqlType: 'text', fromNewUser: 'new.email' };
419
+ case 'displayName':
420
+ return {
421
+ field,
422
+ column: 'display_name',
423
+ sqlType: 'text',
424
+ fromNewUser:
425
+ "coalesce(new.raw_user_meta_data->>'displayName', new.raw_user_meta_data->>'display_name', split_part(new.email, '@', 1))",
426
+ };
427
+ case 'firstName':
428
+ return {
429
+ field,
430
+ column: 'first_name',
431
+ sqlType: 'text',
432
+ fromNewUser:
433
+ "coalesce(new.raw_user_meta_data->>'firstName', new.raw_user_meta_data->>'first_name')",
434
+ };
435
+ case 'lastName':
436
+ return {
437
+ field,
438
+ column: 'last_name',
439
+ sqlType: 'text',
440
+ fromNewUser:
441
+ "coalesce(new.raw_user_meta_data->>'lastName', new.raw_user_meta_data->>'last_name')",
442
+ };
443
+ case 'avatarUrl':
444
+ return {
445
+ field,
446
+ column: 'avatar_url',
447
+ sqlType: 'text',
448
+ fromNewUser:
449
+ "coalesce(new.raw_user_meta_data->>'avatarUrl', new.raw_user_meta_data->>'avatar_url')",
450
+ };
451
+ case 'username':
452
+ return {
453
+ field,
454
+ column: 'username',
455
+ sqlType: 'text',
456
+ fromNewUser: "new.raw_user_meta_data->>'username'",
457
+ };
458
+ case 'phone':
459
+ return { field, column: 'phone', sqlType: 'text', fromNewUser: 'new.phone' };
460
+ default:
461
+ return null;
462
+ }
463
+ }
464
+
245
465
  function normalizeFieldList(
246
466
  values: readonly string[] | undefined,
247
467
  fallback: readonly string[],
@@ -264,6 +484,23 @@ function normalizeFieldList(
264
484
  return [...fallback];
265
485
  }
266
486
 
487
+ function normalizeIdentifier(value: string): string {
488
+ const trimmed = value.trim();
489
+ if (!trimmed) return '';
490
+
491
+ if (!/^[a-z_][a-z0-9_]*$/.test(trimmed)) {
492
+ throw new Error(
493
+ `Invalid Supabase profile table identifier "${trimmed}". Use snake_case letters, numbers, and underscores.`,
494
+ );
495
+ }
496
+
497
+ return trimmed;
498
+ }
499
+
500
+ function quoteIdentifier(value: string): string {
501
+ return `"${value.replaceAll('"', '""')}"`;
502
+ }
503
+
267
504
  function escapeYamlDoubleQuoted(value: string): string {
268
505
  return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
269
506
  }
@@ -1,4 +1,4 @@
1
- import { generateDatasetInfrastructureArtifacts } from '../../datasets';
1
+ import { generateApiInfrastructureArtifacts } from '../../apis';
2
2
  import type {
3
3
  GeneratedPackageDependency,
4
4
  InfraManifestInput,
@@ -37,7 +37,7 @@ export function generateMinikubeInfra(
37
37
  appManifest: options.appManifest,
38
38
  });
39
39
  const storageArtifacts = generateStorageArtifacts({ manifest, namespace });
40
- const datasetArtifacts = generateDatasetInfrastructureArtifacts({
40
+ const apiArtifacts = generateApiInfrastructureArtifacts({
41
41
  data: options.appManifest?.data,
42
42
  databaseProvider: manifest.database?.provider,
43
43
  });
@@ -65,7 +65,7 @@ export function generateMinikubeInfra(
65
65
  ...authArtifacts.warnings,
66
66
  ...authzArtifacts.warnings,
67
67
  ...storageArtifacts.warnings,
68
- ...datasetArtifacts.warnings,
68
+ ...apiArtifacts.warnings,
69
69
  ]);
70
70
 
71
71
  return {
@@ -74,7 +74,7 @@ export function generateMinikubeInfra(
74
74
  ...authArtifacts.files,
75
75
  ...authzArtifacts.files,
76
76
  ...storageArtifacts.files,
77
- ...datasetArtifacts.files,
77
+ ...apiArtifacts.files,
78
78
  ],
79
79
  warnings,
80
80
  meta: {
@@ -0,0 +1,211 @@
1
+ import type { AppManifest } from '@ankhorage/contracts';
2
+ import { describe, expect, test } from 'bun:test';
3
+
4
+ import { generateInfrastructure } from './index';
5
+ import type { InfraManifestInput } from './types';
6
+
7
+ describe('API infrastructure generation', () => {
8
+ test('generates provider-neutral API, seed, handlers, and OpenAPI artifacts', () => {
9
+ const manifest: InfraManifestInput = {
10
+ deployment: {
11
+ target: 'minikube',
12
+ monitoring: false,
13
+ },
14
+ database: {
15
+ provider: 'supabase',
16
+ tier: 'dev',
17
+ },
18
+ plugins: [],
19
+ };
20
+
21
+ const appManifest: Pick<
22
+ AppManifest,
23
+ 'data' | 'metadata' | 'navigator' | 'screens' | 'settings'
24
+ > = {
25
+ metadata: {
26
+ name: 'Cards',
27
+ slug: 'cards',
28
+ version: '1.0.0',
29
+ themeId: 'default',
30
+ },
31
+ data: {
32
+ apis: {
33
+ poker_situations: {
34
+ id: 'poker_situations',
35
+ kind: 'generated',
36
+ label: 'Poker situations',
37
+ description: 'App-owned poker trainer situations.',
38
+ basePath: '/api/poker-situations',
39
+ preset: 'crud',
40
+ resource: {
41
+ kind: 'collection',
42
+ collection: {
43
+ name: 'poker_situations',
44
+ primaryKey: 'id',
45
+ fields: [
46
+ { name: 'id', type: 'uuid', required: true, unique: true },
47
+ { name: 'title', type: 'text', required: true },
48
+ { name: 'description', type: 'text', required: true },
49
+ { name: 'availableActions', type: 'json' },
50
+ { name: 'correctAction', type: 'text' },
51
+ ],
52
+ },
53
+ seed: [
54
+ {
55
+ title: 'Button faces a raise',
56
+ description: 'Choose the best action with position and stack depth in mind.',
57
+ availableActions: [
58
+ { label: 'Fold', value: 'fold' },
59
+ { label: 'Call', value: 'call' },
60
+ { label: 'Raise', value: 'raise' },
61
+ ],
62
+ correctAction: 'raise',
63
+ },
64
+ ],
65
+ },
66
+ endpoints: [
67
+ {
68
+ id: 'poker_situations.list',
69
+ method: 'GET',
70
+ path: '/',
71
+ intent: 'list',
72
+ },
73
+ {
74
+ id: 'poker_situations.read',
75
+ method: 'GET',
76
+ path: '/{id}',
77
+ intent: 'read',
78
+ },
79
+ {
80
+ id: 'poker_situations.create',
81
+ method: 'POST',
82
+ path: '/',
83
+ intent: 'create',
84
+ },
85
+ {
86
+ id: 'poker_situations.leaderboard',
87
+ method: 'GET',
88
+ path: '/leaderboard',
89
+ intent: 'custom',
90
+ },
91
+ ],
92
+ },
93
+ },
94
+ },
95
+ navigator: {
96
+ type: 'stack',
97
+ routes: [],
98
+ },
99
+ screens: {},
100
+ settings: {
101
+ localization: {
102
+ defaultLocale: 'en',
103
+ locales: ['en'],
104
+ },
105
+ authFlow: {
106
+ signInRoute: '/sign-in',
107
+ signUpRoute: '/sign-up',
108
+ signOutRoute: '/sign-out',
109
+ unauthorizedRoute: '/sign-in',
110
+ postSignInRoute: '/',
111
+ },
112
+ },
113
+ };
114
+
115
+ const result = generateInfrastructure(manifest, { namespaceHint: 'cards', appManifest });
116
+ const rerun = generateInfrastructure(manifest, { namespaceHint: 'cards', appManifest });
117
+ const paths = result.files.map((file) => file.path);
118
+
119
+ expect(result.files).toEqual(rerun.files);
120
+ expect(paths).toContain('infra/minikube/db/apis.json');
121
+ expect(paths).toContain('infra/minikube/db/api-seed.json');
122
+ expect(paths).toContain('infra/minikube/db/migrations/001_apis.sql');
123
+ expect(paths).toContain('infra/minikube/db/seeds/001_api_seed.sql');
124
+ expect(paths).toContain('infra/minikube/db/README.md');
125
+ expect(paths).toContain('src/generated/apis/resources.ts');
126
+ expect(paths).toContain('src/generated/apis/appApi.ts');
127
+ expect(paths).toContain('src/generated/apis/apiHandlers.ts');
128
+ expect(paths).toContain('src/generated/apis/openapi.json');
129
+ expect(paths.some((path) => path.includes('datasets'))).toBe(false);
130
+
131
+ const resourcesFile = result.files.find(
132
+ (file) => file.path === 'src/generated/apis/resources.ts',
133
+ );
134
+ expect(resourcesFile?.content).toContain('GENERATED_APIS');
135
+ expect(resourcesFile?.content).toContain('poker_situations');
136
+ expect(resourcesFile?.content).toContain('availableActions');
137
+
138
+ const apiFile = result.files.find((file) => file.path === 'src/generated/apis/appApi.ts');
139
+ expect(apiFile?.content).toContain('poker_situations.list');
140
+ expect(apiFile?.content).toContain('/api/poker-situations');
141
+ expect(apiFile?.content).toContain('poker_situations.read');
142
+ expect(apiFile?.content).toContain('/api/poker-situations/{id}');
143
+ expect(apiFile?.content).toContain('poker_situations.leaderboard');
144
+ expect(apiFile?.content).toContain('/api/poker-situations/leaderboard');
145
+
146
+ const handlersFile = result.files.find(
147
+ (file) => file.path === 'src/generated/apis/apiHandlers.ts',
148
+ );
149
+ expect(handlersFile?.content).toContain('handleGeneratedApiList');
150
+ expect(handlersFile?.content).toContain('handleGeneratedApiCreate');
151
+ expect(handlersFile?.content).toContain('createGeneratedApiMemoryStore');
152
+ expect(handlersFile?.content).toContain('poker_situations');
153
+ expect(handlersFile?.content).not.toContain('Dataset');
154
+
155
+ const openApiFile = result.files.find(
156
+ (file) => file.path === 'src/generated/apis/openapi.json',
157
+ );
158
+ expect(openApiFile?.content).toContain('Generated app API');
159
+ expect(openApiFile?.content).toContain('poker_situations.create');
160
+ expect(openApiFile?.content).toContain('poker_situations.leaderboard');
161
+ expect(openApiFile?.content).toContain('x-ankh-api');
162
+ expect(openApiFile?.content).not.toContain('x-ankh-dataset');
163
+
164
+ const migrationFile = result.files.find(
165
+ (file) => file.path === 'infra/minikube/db/migrations/001_apis.sql',
166
+ );
167
+ expect(migrationFile?.content).toContain('create extension if not exists pgcrypto');
168
+ expect(migrationFile?.content).toContain(
169
+ 'create table if not exists "public"."poker_situations"',
170
+ );
171
+ expect(migrationFile?.content).toContain('"id" uuid default gen_random_uuid() primary key');
172
+ expect(migrationFile?.content).toContain('"availableActions" jsonb');
173
+
174
+ const seedFile = result.files.find((file) => file.path === 'infra/minikube/db/api-seed.json');
175
+ expect(seedFile?.content).toContain('Button faces a raise');
176
+ expect(seedFile?.content).toContain('correctAction');
177
+ expect(seedFile?.content).toContain('"id"');
178
+
179
+ const seedSqlFile = result.files.find(
180
+ (file) => file.path === 'infra/minikube/db/seeds/001_api_seed.sql',
181
+ );
182
+ expect(seedSqlFile?.content).toContain('insert into "public"."poker_situations"');
183
+ expect(seedSqlFile?.content).toContain('Button faces a raise');
184
+ expect(seedSqlFile?.content).toContain('on conflict ("id") do nothing');
185
+ });
186
+
187
+ test('reports selected state adapter dependencies', () => {
188
+ const manifest: InfraManifestInput = {
189
+ deployment: {
190
+ target: 'minikube',
191
+ monitoring: false,
192
+ },
193
+ state: {
194
+ provider: 'legend',
195
+ persistence: 'none',
196
+ },
197
+ plugins: [],
198
+ };
199
+
200
+ const result = generateInfrastructure(manifest, { namespaceHint: 'cards' });
201
+
202
+ expect(result.meta.providers).toContain('legend');
203
+ expect(result.dependencies).toEqual([
204
+ {
205
+ name: '@ankhorage/state-legend',
206
+ version: '^0.1.0',
207
+ reason: 'Selected by infra.state.provider=legend.',
208
+ },
209
+ ]);
210
+ });
211
+ });