@ankhorage/infra 5.1.6 → 6.0.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @ankhorage/infra
2
2
 
3
+ ## 6.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 99a0dbd: Align Infra with Contracts 20 and add production S3 backup/recovery acceptance for the Supabase production slice.
8
+
3
9
  ## 5.1.6
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ankhorage/infra",
3
3
  "type": "module",
4
- "version": "5.1.6",
4
+ "version": "6.0.0",
5
5
  "description": "Project-aware infrastructure generation library and executable Ankh provider for Ankhorage.",
6
6
  "homepage": "https://github.com/ankhorage/infra#readme",
7
7
  "bugs": {
@@ -35,7 +35,7 @@
35
35
  "openapi"
36
36
  ],
37
37
  "dependencies": {
38
- "@ankhorage/contracts": "^19.4.0"
38
+ "@ankhorage/contracts": "^20.0.0"
39
39
  },
40
40
  "files": [
41
41
  "dist",
@@ -102,6 +102,7 @@
102
102
  "test:e2e": "ANKH_INFRA_COMPOSITION_E2E=1 bun test src/infraComposition.integration.test.ts",
103
103
  "test:e2e:docker-compose-providers": "ANKH_INFRA_DOCKER_COMPOSE_PROVIDERS_E2E=1 bun test src/localDockerComposeProviders.integration.test.ts",
104
104
  "test:e2e:minikube-supabase": "ANKH_INFRA_MINIKUBE_SUPABASE_E2E=1 bun test src/localMinikubeSupabase.integration.test.ts",
105
+ "test:e2e:minikube-supabase-recovery": "ANKH_INFRA_MINIKUBE_SUPABASE_RECOVERY_E2E=1 bun test src/localMinikubeSupabaseRecovery.integration.test.ts",
105
106
  "typecheck": "bun x tsc --noEmit -p tsconfig.json",
106
107
  "check-types": "bun run typecheck",
107
108
  "version-packages": "ankhorage-changeset version",
@@ -116,10 +117,10 @@
116
117
  "@ankhorage/hetzner": "^0.3.0",
117
118
  "@ankhorage/k3s": "^0.4.1",
118
119
  "@ankhorage/local": "^0.3.0",
119
- "@ankhorage/minikube": "^0.4.2",
120
+ "@ankhorage/minikube": "^0.4.4",
120
121
  "@ankhorage/paradox": "^0.1.24",
121
122
  "@ankhorage/r2": "^0.2.0",
122
- "@ankhorage/supabase": "^0.5.5",
123
+ "@ankhorage/supabase": "^0.5.9",
123
124
  "@ankhorage/supabase-vault": "^0.3.1",
124
125
  "@types/bun": "^1.4.1",
125
126
  "@types/node": "^25.6.0",
@@ -40,6 +40,13 @@ import type { InfraOrchestrationDependencies } from './types/infraOrchestration.
40
40
  const projectId = 'infra145-production';
41
41
  const publicBaseUrl = 'https://api.production.example.test';
42
42
  const domain = 'api.production.example.test';
43
+ const persistenceTarget = {
44
+ endpoint: 'https://s3.production.example.test',
45
+ region: 'eu-central-1',
46
+ bucket: 'infra145-database-backups',
47
+ credentials: { source: 'control-plane', name: 'S3_PERSISTENCE' },
48
+ forcePathStyle: true,
49
+ } as const;
43
50
 
44
51
  const manifest = {
45
52
  environments: {
@@ -54,8 +61,17 @@ const manifest = {
54
61
  compute: { provider: 'hetzner', location: 'fsn1', serverType: 'cx23' },
55
62
  runtime: { provider: 'k3s', topology: { servers: 1, agents: 0 } },
56
63
  },
57
- database: { provider: 'supabase', tier: 'prod' },
64
+ database: {
65
+ provider: 'supabase',
66
+ tier: 'prod',
67
+ backup: { mode: 'scheduled', target: persistenceTarget, intervalHours: 24 },
68
+ },
58
69
  auth: { provider: 'supabase' },
70
+ objectStorage: {
71
+ provider: 'supabase',
72
+ buckets: ['media'],
73
+ backend: { ...persistenceTarget, bucket: 'infra145-storage' },
74
+ },
59
75
  networking: {
60
76
  domain,
61
77
  publicBaseUrl,
@@ -106,10 +122,15 @@ test('composes Hetzner, k3s, Kubernetes and Supabase while retaining compute for
106
122
  expect(access?.transport.kind === 'ssh' && access.transport.host).toBe('203.0.113.10');
107
123
  expect(JSON.stringify(firstUp)).not.toContain('phase10-private-key');
108
124
  expect(JSON.stringify(firstUp)).not.toContain('phase10-service-role-key');
125
+ expect(JSON.stringify(firstUp)).not.toContain('phase10-s3-secret-key');
109
126
  expect(fixture.kubernetes.serializedResources()).toContain(`"host":"${domain}"`);
110
127
  expect(fixture.kubernetes.serializedResources()).toContain('PersistentVolumeClaim');
111
128
  expect(fixture.kubernetes.serializedResources()).toContain('/etc/postgresql-custom');
112
129
  expect(fixture.kubernetes.serializedResources()).toContain('.ankhorage-image-seeded');
130
+ expect(fixture.kubernetes.serializedResources()).toContain('supabase-db-backup');
131
+ expect(
132
+ fixture.kubernetes.resources.filter(({ kind }) => kind === 'PersistentVolumeClaim'),
133
+ ).toHaveLength(2);
113
134
  expect(firstUp.resources.some(({ persistent }) => persistent)).toBe(true);
114
135
  const urlOutput = firstUp.outputs.find(({ name }) => name === 'url');
115
136
  expect(urlOutput?.value).toBe(publicBaseUrl);
@@ -232,6 +253,9 @@ function resolveCredential(name: string): InfraResult<Readonly<Record<string, st
232
253
  privateKey: 'phase10-private-key',
233
254
  });
234
255
  }
256
+ if (name === 'S3_PERSISTENCE') {
257
+ return success({ accessKeyId: 'phase10-s3-access', secretAccessKey: 'phase10-s3-secret-key' });
258
+ }
235
259
  if (name === 'SUPABASE_BOOTSTRAP') {
236
260
  return success({
237
261
  postgresPassword: 'phase10-postgres-password',
@@ -0,0 +1,527 @@
1
+ import { createHmac } from 'node:crypto';
2
+
3
+ import type {
4
+ InfraLedger,
5
+ InfraManifest,
6
+ InfraResourceIdentity,
7
+ InfraResult,
8
+ } from '@ankhorage/contracts/infra';
9
+ import { expect, test } from 'bun:test';
10
+
11
+ import { createNodeInfraAdapterPackageResolver } from './features/environment-lifecycle/adapters/outbound/createNodeInfraAdapterPackageResolver.js';
12
+ import { destroyInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/destroyInfraEnvironmentAsync.js';
13
+ import { upInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/upInfraEnvironmentAsync.js';
14
+ import type { InfraOrchestrationDependencies } from './types/infraOrchestration.js';
15
+
16
+ const projectId = 'infra145-minikube-supabase-recovery';
17
+ const profile = projectId;
18
+ const namespace = `${projectId}-local`;
19
+ const baseUrl = 'http://127.0.0.1:54322';
20
+ const logicalBucket = 'phase10-objects';
21
+ const physicalBucket = 'phase10-persistence';
22
+ const minioContainer = 'infra145-minio-recovery';
23
+ const minioHostEndpoint = 'http://127.0.0.1:19000';
24
+ const minioPodEndpoint = 'http://host.minikube.internal:19000';
25
+ const minioImage = 'quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z';
26
+ const s3AccessKey = 'phase10-access';
27
+ const s3SecretKey = 'phase10-secret-key';
28
+ const databaseMarker = 'database-survived-from-s3-backup';
29
+ const objectMarker = 'storage-object-survived-off-cluster';
30
+ const jwtSecret = 'phase10-jwt-secret-that-is-at-least-thirty-two-characters';
31
+ const serviceRoleKey = createJwt('service_role');
32
+ const persistenceTarget = {
33
+ endpoint: minioPodEndpoint,
34
+ region: 'us-east-1',
35
+ bucket: physicalBucket,
36
+ credentials: { source: 'control-plane', name: 'S3_PERSISTENCE' },
37
+ forcePathStyle: true,
38
+ } as const;
39
+ const manifest = {
40
+ environments: {
41
+ local: {
42
+ deployment: {
43
+ compute: { provider: 'local' },
44
+ runtime: {
45
+ provider: 'minikube',
46
+ profile,
47
+ driver: 'docker',
48
+ cpus: 2,
49
+ memoryMiB: 5_120,
50
+ },
51
+ },
52
+ database: {
53
+ provider: 'supabase',
54
+ tier: 'prod',
55
+ backup: { mode: 'scheduled', target: persistenceTarget, intervalHours: 24 },
56
+ },
57
+ auth: { provider: 'supabase' },
58
+ objectStorage: {
59
+ provider: 'supabase',
60
+ buckets: [logicalBucket],
61
+ backend: persistenceTarget,
62
+ },
63
+ networking: { publicBaseUrl: baseUrl },
64
+ },
65
+ },
66
+ modules: [],
67
+ } as const satisfies InfraManifest;
68
+
69
+ test.skipIf(process.env.ANKH_INFRA_MINIKUBE_SUPABASE_RECOVERY_E2E !== '1')(
70
+ 'recovers a fresh Minikube Supabase database while S3-backed Storage remains off-cluster',
71
+ async () => {
72
+ await stopMinioAsync();
73
+ await startMinioAsync();
74
+ try {
75
+ const dependencies = createDependencies();
76
+ const firstUp = requireSuccess(
77
+ await upInfraEnvironmentAsync({ projectId, manifest }, dependencies),
78
+ );
79
+ await writeDatabaseMarkerAsync();
80
+ await uploadStorageObjectAsync();
81
+ await forceFreshBackupAsync();
82
+ expect(await readLatestBackupPointerAsync()).toMatch(/^database\/\d{8}T\d{6}Z$/);
83
+
84
+ const destroyed = requireSuccess(
85
+ await destroyInfraEnvironmentAsync(
86
+ createDestroyRequest(firstUp.ledger, clusterPersistentIdentities(firstUp.ledger)),
87
+ dependencies,
88
+ ),
89
+ );
90
+ expectRetainedStorageBucket(destroyed.ledger);
91
+
92
+ const recovered = requireSuccess(
93
+ await upInfraEnvironmentAsync({ projectId, manifest }, dependencies),
94
+ );
95
+ expect(await readDatabaseMarkerAsync()).toBe(databaseMarker);
96
+ expect(await downloadStorageObjectAsync()).toBe(objectMarker);
97
+
98
+ const finalDestroy = requireSuccess(
99
+ await destroyInfraEnvironmentAsync(
100
+ createDestroyRequest(recovered.ledger, clusterPersistentIdentities(recovered.ledger)),
101
+ dependencies,
102
+ ),
103
+ );
104
+ expectRetainedStorageBucket(finalDestroy.ledger);
105
+ } catch (error) {
106
+ const diagnostics = await collectFailureDiagnosticsAsync();
107
+ const message = error instanceof Error ? error.message : String(error);
108
+ throw new Error(`${message}\nRecovery diagnostics: ${diagnostics}`, { cause: error });
109
+ } finally {
110
+ await runAllowFailureAsync(['minikube', 'delete', '-p', profile]);
111
+ await stopMinioAsync();
112
+ }
113
+ },
114
+ 1_200_000,
115
+ );
116
+
117
+ async function startMinioAsync(): Promise<void> {
118
+ await runAsync(
119
+ [
120
+ 'docker',
121
+ 'run',
122
+ '--detach',
123
+ '--rm',
124
+ '--name',
125
+ minioContainer,
126
+ '--publish',
127
+ '19000:9000',
128
+ '--env',
129
+ `MINIO_ROOT_USER=${s3AccessKey}`,
130
+ '--env',
131
+ `MINIO_ROOT_PASSWORD=${s3SecretKey}`,
132
+ minioImage,
133
+ 'server',
134
+ '/data',
135
+ '--address',
136
+ ':9000',
137
+ ],
138
+ 'start MinIO',
139
+ );
140
+ await waitForMinioAsync();
141
+ await runAsync(
142
+ [
143
+ 'curl',
144
+ '--fail',
145
+ '--silent',
146
+ '--show-error',
147
+ '--request',
148
+ 'PUT',
149
+ '--aws-sigv4',
150
+ 'aws:amz:us-east-1:s3',
151
+ '--user',
152
+ `${s3AccessKey}:${s3SecretKey}`,
153
+ `${minioHostEndpoint}/${physicalBucket}`,
154
+ ],
155
+ 'create MinIO bucket',
156
+ );
157
+ }
158
+
159
+ async function waitForMinioAsync(): Promise<void> {
160
+ for (const _attempt of Array.from({ length: 60 }, (_, index) => index)) {
161
+ const response = await fetch(`${minioHostEndpoint}/minio/health/live`).catch(() => undefined);
162
+ if (response?.ok === true) return;
163
+ await Bun.sleep(500);
164
+ }
165
+ throw new Error('MinIO did not become ready.');
166
+ }
167
+
168
+ async function stopMinioAsync(): Promise<void> {
169
+ await runAllowFailureAsync(['docker', 'rm', '--force', minioContainer]);
170
+ }
171
+
172
+ async function writeDatabaseMarkerAsync(): Promise<void> {
173
+ const pod = await findPodAsync('supabase-db-', 'supabase-db-backup-');
174
+ await runAsync(
175
+ [
176
+ 'kubectl',
177
+ 'exec',
178
+ '--namespace',
179
+ namespace,
180
+ pod,
181
+ '--',
182
+ 'psql',
183
+ '-U',
184
+ 'postgres',
185
+ '-d',
186
+ 'postgres',
187
+ '-v',
188
+ 'ON_ERROR_STOP=1',
189
+ '-c',
190
+ `CREATE TABLE public.phase10_recovery_marker(value text NOT NULL); INSERT INTO public.phase10_recovery_marker(value) VALUES ('${databaseMarker}');`,
191
+ ],
192
+ 'write database recovery marker',
193
+ );
194
+ }
195
+
196
+ async function readDatabaseMarkerAsync(): Promise<string> {
197
+ const pod = await findPodAsync('supabase-db-', 'supabase-db-backup-');
198
+ return runAsync(
199
+ [
200
+ 'kubectl',
201
+ 'exec',
202
+ '--namespace',
203
+ namespace,
204
+ pod,
205
+ '--',
206
+ 'psql',
207
+ '-U',
208
+ 'postgres',
209
+ '-d',
210
+ 'postgres',
211
+ '-Atc',
212
+ 'SELECT value FROM public.phase10_recovery_marker LIMIT 1;',
213
+ ],
214
+ 'read database recovery marker',
215
+ );
216
+ }
217
+
218
+ async function uploadStorageObjectAsync(): Promise<void> {
219
+ const response = await fetch(`${baseUrl}/storage/v1/object/${logicalBucket}/recovery.txt`, {
220
+ method: 'POST',
221
+ headers: {
222
+ Authorization: `Bearer ${serviceRoleKey}`,
223
+ apikey: serviceRoleKey,
224
+ 'Content-Type': 'text/plain',
225
+ },
226
+ body: objectMarker,
227
+ });
228
+ if (!response.ok) throw new Error(`Storage upload failed with HTTP ${response.status}.`);
229
+ }
230
+
231
+ async function downloadStorageObjectAsync(): Promise<string> {
232
+ const response = await fetch(`${baseUrl}/storage/v1/object/${logicalBucket}/recovery.txt`, {
233
+ headers: { Authorization: `Bearer ${serviceRoleKey}`, apikey: serviceRoleKey },
234
+ });
235
+ if (!response.ok) throw new Error(`Storage download failed with HTTP ${response.status}.`);
236
+ return response.text();
237
+ }
238
+
239
+ async function forceFreshBackupAsync(): Promise<void> {
240
+ await runAsync(
241
+ ['kubectl', 'rollout', 'restart', 'deployment/supabase-db-backup', '--namespace', namespace],
242
+ 'restart database backup workload',
243
+ );
244
+ await runAsync(
245
+ [
246
+ 'kubectl',
247
+ 'rollout',
248
+ 'status',
249
+ 'deployment/supabase-db-backup',
250
+ '--namespace',
251
+ namespace,
252
+ '--timeout=180s',
253
+ ],
254
+ 'wait for database backup workload',
255
+ );
256
+ }
257
+
258
+ async function readLatestBackupPointerAsync(): Promise<string> {
259
+ return runAsync(
260
+ [
261
+ 'curl',
262
+ '--fail',
263
+ '--silent',
264
+ '--show-error',
265
+ '--aws-sigv4',
266
+ 'aws:amz:us-east-1:s3',
267
+ '--user',
268
+ `${s3AccessKey}:${s3SecretKey}`,
269
+ `${minioHostEndpoint}/${physicalBucket}/database/latest`,
270
+ ],
271
+ 'read latest database backup pointer',
272
+ );
273
+ }
274
+
275
+ async function findPodAsync(prefix: string, excludedPrefix?: string): Promise<string> {
276
+ const names = (
277
+ await runAsync(
278
+ [
279
+ 'kubectl',
280
+ 'get',
281
+ 'pods',
282
+ '--namespace',
283
+ namespace,
284
+ '-o',
285
+ 'jsonpath={.items[*].metadata.name}',
286
+ ],
287
+ 'list Supabase pods',
288
+ )
289
+ ).split(' ');
290
+ const name = names.find(
291
+ (candidate) => candidate.startsWith(prefix) && !candidate.startsWith(excludedPrefix ?? '\0'),
292
+ );
293
+ if (name === undefined) throw new Error(`No pod matched ${prefix}.`);
294
+ return name;
295
+ }
296
+
297
+ async function collectFailureDiagnosticsAsync(): Promise<string> {
298
+ const [
299
+ auth,
300
+ storageStatus,
301
+ buckets,
302
+ databaseState,
303
+ databaseLogs,
304
+ previousDatabaseLogs,
305
+ dataRestoreState,
306
+ dataRestoreLogs,
307
+ previousDataRestoreLogs,
308
+ ] = await Promise.all([
309
+ readEndpointDiagnosticAsync('/auth/v1/health', false),
310
+ readEndpointDiagnosticAsync('/storage/v1/status', false),
311
+ readEndpointDiagnosticAsync('/storage/v1/bucket', true),
312
+ readDatabaseStateAsync(),
313
+ readDatabaseLogsAsync(false),
314
+ readDatabaseLogsAsync(true),
315
+ readDataRestoreStateAsync(),
316
+ readDataRestoreLogsAsync(false),
317
+ readDataRestoreLogsAsync(true),
318
+ ]);
319
+ return [
320
+ auth,
321
+ storageStatus,
322
+ buckets,
323
+ databaseState,
324
+ databaseLogs,
325
+ previousDatabaseLogs,
326
+ dataRestoreState,
327
+ dataRestoreLogs,
328
+ previousDataRestoreLogs,
329
+ ].join(' | ');
330
+ }
331
+
332
+ async function readDatabaseStateAsync(): Promise<string> {
333
+ const pod = await findPodAsync('supabase-db-', 'supabase-db-backup-').catch(() => undefined);
334
+ if (pod === undefined) return 'database-state=unavailable';
335
+ const state = await runAsync(
336
+ [
337
+ 'kubectl',
338
+ 'get',
339
+ 'pod',
340
+ pod,
341
+ '--namespace',
342
+ namespace,
343
+ '-o',
344
+ 'jsonpath={.status.containerStatuses[?(@.name=="supabase-db")].restartCount}',
345
+ ],
346
+ 'read database restart state',
347
+ ).catch((error: unknown) => (error instanceof Error ? error.message : String(error)));
348
+ return `database-restarts=${redactDiagnosticText(state)}`;
349
+ }
350
+
351
+ async function readDatabaseLogsAsync(previous: boolean): Promise<string> {
352
+ const pod = await findPodAsync('supabase-db-', 'supabase-db-backup-').catch(() => undefined);
353
+ const label = previous ? 'database-previous-logs' : 'database-logs';
354
+ if (pod === undefined) return `${label}=unavailable`;
355
+ const logs = await runAsync(
356
+ [
357
+ 'kubectl',
358
+ 'logs',
359
+ '--namespace',
360
+ namespace,
361
+ pod,
362
+ '--container',
363
+ 'supabase-db',
364
+ ...(previous ? ['--previous'] : []),
365
+ '--tail=240',
366
+ ],
367
+ `read ${label}`,
368
+ ).catch((error: unknown) => (error instanceof Error ? error.message : String(error)));
369
+ return `${label}=${redactDiagnosticText(logs).slice(-6_000)}`;
370
+ }
371
+
372
+ async function readDataRestoreStateAsync(): Promise<string> {
373
+ const pod = await findPodAsync('supabase-db-data-restore-').catch(() => undefined);
374
+ if (pod === undefined) return 'data-restore-state=unavailable';
375
+ const state = await runAsync(
376
+ [
377
+ 'kubectl',
378
+ 'get',
379
+ 'pod',
380
+ pod,
381
+ '--namespace',
382
+ namespace,
383
+ '-o',
384
+ 'jsonpath={.status.containerStatuses[?(@.name=="supabase-db-data-restore")].restartCount}',
385
+ ],
386
+ 'read data restore restart state',
387
+ ).catch((error: unknown) => (error instanceof Error ? error.message : String(error)));
388
+ return `data-restore-restarts=${redactDiagnosticText(state)}`;
389
+ }
390
+
391
+ async function readDataRestoreLogsAsync(previous: boolean): Promise<string> {
392
+ const pod = await findPodAsync('supabase-db-data-restore-').catch(() => undefined);
393
+ const label = previous ? 'data-restore-previous-logs' : 'data-restore-logs';
394
+ if (pod === undefined) return `${label}=unavailable`;
395
+ const logs = await runAsync(
396
+ [
397
+ 'kubectl',
398
+ 'logs',
399
+ '--namespace',
400
+ namespace,
401
+ pod,
402
+ '--container',
403
+ 'supabase-db-data-restore',
404
+ ...(previous ? ['--previous'] : []),
405
+ '--tail=240',
406
+ ],
407
+ `read ${label}`,
408
+ ).catch((error: unknown) => (error instanceof Error ? error.message : String(error)));
409
+ return `${label}=${redactDiagnosticText(logs).slice(-6_000)}`;
410
+ }
411
+
412
+ async function readEndpointDiagnosticAsync(path: string, authenticated: boolean): Promise<string> {
413
+ const response = await fetch(`${baseUrl}${path}`, {
414
+ headers: authenticated
415
+ ? { Authorization: `Bearer ${serviceRoleKey}`, apikey: serviceRoleKey }
416
+ : undefined,
417
+ }).catch(() => undefined);
418
+ if (response === undefined) return `${path}=unreachable`;
419
+ const body = redactDiagnosticText((await response.text()).slice(0, 500));
420
+ return `${path}=${response.status}:${body}`;
421
+ }
422
+
423
+ function redactDiagnosticText(value: string): string {
424
+ return value
425
+ .replaceAll(s3SecretKey, '[redacted]')
426
+ .replaceAll(serviceRoleKey, '[redacted]')
427
+ .replaceAll(jwtSecret, '[redacted]');
428
+ }
429
+
430
+ function createDependencies(): InfraOrchestrationDependencies {
431
+ return {
432
+ adapterResolver: createNodeInfraAdapterPackageResolver(),
433
+ credentials: {
434
+ resolveAsync: ({ name }) =>
435
+ Promise.resolve(
436
+ name === 'SUPABASE_BOOTSTRAP'
437
+ ? success(supabaseCredentials())
438
+ : name === 'S3_PERSISTENCE'
439
+ ? success({ accessKeyId: s3AccessKey, secretAccessKey: s3SecretKey })
440
+ : failure('unexpected-credential', `Unexpected credential reference: ${name}`),
441
+ ),
442
+ },
443
+ secrets: {
444
+ resolveAsync: ({ ref, key }) =>
445
+ Promise.resolve(failure('unexpected-secret', `Unexpected managed secret ${ref}/${key}.`)),
446
+ },
447
+ };
448
+ }
449
+
450
+ function createDestroyRequest(
451
+ previous: InfraLedger,
452
+ confirmedResources: readonly InfraResourceIdentity[],
453
+ ) {
454
+ return {
455
+ projectId,
456
+ manifest,
457
+ environment: 'local' as const,
458
+ previous,
459
+ confirmation: { projectId, environment: 'local' as const },
460
+ persistence: { policy: 'delete' as const, confirmedResources },
461
+ };
462
+ }
463
+
464
+ function clusterPersistentIdentities(ledger: InfraLedger): readonly InfraResourceIdentity[] {
465
+ return ledger.resources
466
+ .filter(({ identity, persistent }) => persistent && identity.adapter !== 'supabase')
467
+ .map(({ identity }) => identity);
468
+ }
469
+
470
+ function expectRetainedStorageBucket(ledger: InfraLedger | null): void {
471
+ expect(
472
+ ledger?.resources.map(({ identity }) => `${identity.adapter}:${identity.resourceId}`),
473
+ ).toEqual([`supabase:bucket/${logicalBucket}`]);
474
+ }
475
+
476
+ function supabaseCredentials() {
477
+ return {
478
+ postgresPassword: 'phase10-postgres-password',
479
+ jwtSecret,
480
+ anonKey: createJwt('anon'),
481
+ serviceRoleKey,
482
+ realtimeSecretKeyBase: 'r'.repeat(64),
483
+ realtimeDatabaseEncryptionKey: '0123456789abcdef',
484
+ pgMetaCryptoKey: 'phase10-meta-crypto-key-that-is-at-least-32-characters',
485
+ } as const;
486
+ }
487
+
488
+ function createJwt(role: 'anon' | 'service_role'): string {
489
+ const header = encodeJwtPart({ alg: 'HS256', typ: 'JWT' });
490
+ const payload = encodeJwtPart({ role, iss: 'supabase', iat: 1_700_000_000, exp: 4_102_444_800 });
491
+ const unsigned = `${header}.${payload}`;
492
+ const signature = createHmac('sha256', jwtSecret).update(unsigned).digest('base64url');
493
+ return `${unsigned}.${signature}`;
494
+ }
495
+
496
+ function encodeJwtPart(value: Readonly<Record<string, string | number>>): string {
497
+ return Buffer.from(JSON.stringify(value)).toString('base64url');
498
+ }
499
+
500
+ async function runAsync(command: readonly string[], label: string): Promise<string> {
501
+ const process = Bun.spawn(command, { stdout: 'pipe', stderr: 'pipe' });
502
+ const [exitCode, stdout, stderr] = await Promise.all([
503
+ process.exited,
504
+ new Response(process.stdout).text(),
505
+ new Response(process.stderr).text(),
506
+ ]);
507
+ if (exitCode !== 0) throw new Error(`${label} failed: ${stderr.trim()}`);
508
+ return stdout.trim();
509
+ }
510
+
511
+ async function runAllowFailureAsync(command: readonly string[]): Promise<void> {
512
+ const process = Bun.spawn(command, { stdout: 'ignore', stderr: 'ignore' });
513
+ await process.exited;
514
+ }
515
+
516
+ function requireSuccess<T>(result: InfraResult<T>): T {
517
+ if (!result.ok) throw new Error(result.diagnostics.map(({ message }) => message).join(' '));
518
+ return result.value;
519
+ }
520
+
521
+ function success<T>(value: T): InfraResult<T> {
522
+ return { ok: true, value, diagnostics: [] };
523
+ }
524
+
525
+ function failure(code: string, message: string): InfraResult<never> {
526
+ return { ok: false, diagnostics: [{ severity: 'error', code, message }] };
527
+ }