@ankhorage/infra 1.0.6 → 2.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 +12 -0
- package/README.md +4 -1
- package/dist/adapters/minikube/auth/index.d.ts.map +1 -1
- package/dist/adapters/minikube/auth/index.js +13 -1
- package/dist/adapters/minikube/auth/index.js.map +1 -1
- package/dist/adapters/minikube/auth/oauthRuntime.d.ts +4 -0
- package/dist/adapters/minikube/auth/oauthRuntime.d.ts.map +1 -0
- package/dist/adapters/minikube/auth/oauthRuntime.js +144 -0
- package/dist/adapters/minikube/auth/oauthRuntime.js.map +1 -0
- package/dist/adapters/minikube/index.d.ts.map +1 -1
- package/dist/adapters/minikube/index.js +15 -6
- package/dist/adapters/minikube/index.js.map +1 -1
- package/dist/authRedirects.d.ts +19 -0
- package/dist/authRedirects.d.ts.map +1 -0
- package/dist/authRedirects.js +80 -0
- package/dist/authRedirects.js.map +1 -0
- 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 +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -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/index.ts +14 -1
- package/src/adapters/minikube/auth/oauthRuntime.ts +169 -0
- package/src/adapters/minikube/auth/supabase/oauthRuntime.test.ts +143 -0
- package/src/adapters/minikube/auth/supabase/providerAssignments.test.ts +36 -0
- package/src/adapters/minikube/index.ts +20 -6
- package/src/authRedirects.test.ts +63 -0
- package/src/authRedirects.ts +113 -0
- package/src/generatedApiDatabase.test.ts +355 -0
- package/src/generatedApiDatabase.ts +558 -0
- package/src/index.ts +14 -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
package/src/apis.ts
DELETED
|
@@ -1,534 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
AppApiDefinition,
|
|
3
|
-
AppApiEndpointDefinition,
|
|
4
|
-
AppDataManifest,
|
|
5
|
-
AppGeneratedApiDefinition,
|
|
6
|
-
DbFieldDefinition,
|
|
7
|
-
} from '@ankhorage/contracts';
|
|
8
|
-
|
|
9
|
-
import type { GeneratedInfrastructureFile } from './types';
|
|
10
|
-
|
|
11
|
-
export interface ApiInfrastructureArtifacts {
|
|
12
|
-
readonly files: readonly GeneratedInfrastructureFile[];
|
|
13
|
-
readonly warnings: readonly string[];
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
type GeneratedApiWithCollectionResource = AppGeneratedApiDefinition & {
|
|
17
|
-
readonly resource: NonNullable<AppGeneratedApiDefinition['resource']>;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
interface GeneratedApiResource {
|
|
21
|
-
readonly api: GeneratedApiWithCollectionResource;
|
|
22
|
-
readonly collection: GeneratedApiWithCollectionResource['resource']['collection'];
|
|
23
|
-
readonly seed: readonly Record<string, unknown>[];
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
interface GeneratedApiOperation {
|
|
27
|
-
readonly apiId: string;
|
|
28
|
-
readonly operationId: string;
|
|
29
|
-
readonly intent: AppApiEndpointDefinition['intent'];
|
|
30
|
-
readonly method: AppApiEndpointDefinition['method'];
|
|
31
|
-
readonly path: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
interface GeneratedApiOpenApiOperation {
|
|
35
|
-
readonly operationId: string;
|
|
36
|
-
readonly summary: string;
|
|
37
|
-
readonly responses: Readonly<Record<string, { readonly description: string }>>;
|
|
38
|
-
readonly 'x-ankh-api': {
|
|
39
|
-
readonly apiId: string;
|
|
40
|
-
readonly endpointId: string;
|
|
41
|
-
readonly intent?: AppApiEndpointDefinition['intent'];
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
interface GeneratedApiOpenApiPath {
|
|
46
|
-
readonly delete?: GeneratedApiOpenApiOperation;
|
|
47
|
-
readonly get?: GeneratedApiOpenApiOperation;
|
|
48
|
-
readonly head?: GeneratedApiOpenApiOperation;
|
|
49
|
-
readonly options?: GeneratedApiOpenApiOperation;
|
|
50
|
-
readonly patch?: GeneratedApiOpenApiOperation;
|
|
51
|
-
readonly post?: GeneratedApiOpenApiOperation;
|
|
52
|
-
readonly put?: GeneratedApiOpenApiOperation;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
interface GeneratedApiOpenApiDocument {
|
|
56
|
-
readonly openapi: '3.1.0';
|
|
57
|
-
readonly info: {
|
|
58
|
-
readonly title: string;
|
|
59
|
-
readonly version: string;
|
|
60
|
-
};
|
|
61
|
-
readonly paths: Readonly<Record<string, GeneratedApiOpenApiPath>>;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
interface GeneratedApiSeedManifestEntry {
|
|
65
|
-
readonly apiId: string;
|
|
66
|
-
readonly collection: GeneratedApiResource['collection'];
|
|
67
|
-
readonly records: readonly Record<string, unknown>[];
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const DEFAULT_DATABASE_SCHEMA = 'public';
|
|
71
|
-
|
|
72
|
-
export function generateApiInfrastructureArtifacts(args: {
|
|
73
|
-
readonly data: AppDataManifest | undefined;
|
|
74
|
-
readonly databaseProvider: string | undefined;
|
|
75
|
-
}): ApiInfrastructureArtifacts {
|
|
76
|
-
const generatedApis = listGeneratedApis(args.data);
|
|
77
|
-
if (generatedApis.length === 0) return { files: [], warnings: [] };
|
|
78
|
-
|
|
79
|
-
if (args.databaseProvider !== undefined && args.databaseProvider !== 'supabase') {
|
|
80
|
-
return {
|
|
81
|
-
files: [],
|
|
82
|
-
warnings: [
|
|
83
|
-
`API generation is only implemented for the supabase database provider; received ${args.databaseProvider}.`,
|
|
84
|
-
],
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const seedManifest = createSeedManifest(generatedApis);
|
|
89
|
-
|
|
90
|
-
return {
|
|
91
|
-
files: [
|
|
92
|
-
{
|
|
93
|
-
path: 'infra/minikube/db/apis.json',
|
|
94
|
-
content: `${JSON.stringify(
|
|
95
|
-
generatedApis.map((resource) => resource.collection),
|
|
96
|
-
null,
|
|
97
|
-
2,
|
|
98
|
-
)}\n`,
|
|
99
|
-
},
|
|
100
|
-
{
|
|
101
|
-
path: 'infra/minikube/db/api-seed.json',
|
|
102
|
-
content: `${JSON.stringify(seedManifest, null, 2)}\n`,
|
|
103
|
-
},
|
|
104
|
-
{
|
|
105
|
-
path: 'infra/minikube/db/migrations/001_apis.sql',
|
|
106
|
-
content: generateSupabaseMigrationSql(generatedApis),
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
path: 'infra/minikube/db/seeds/001_api_seed.sql',
|
|
110
|
-
content: generateSupabaseSeedSql(seedManifest),
|
|
111
|
-
},
|
|
112
|
-
{
|
|
113
|
-
path: 'infra/minikube/db/README.md',
|
|
114
|
-
content: generateReadme(generatedApis),
|
|
115
|
-
},
|
|
116
|
-
{
|
|
117
|
-
path: 'src/generated/apis/resources.ts',
|
|
118
|
-
content: generateResourcesTs(generatedApis),
|
|
119
|
-
},
|
|
120
|
-
{
|
|
121
|
-
path: 'src/generated/apis/appApi.ts',
|
|
122
|
-
content: generateApiTs(generatedApis),
|
|
123
|
-
},
|
|
124
|
-
{
|
|
125
|
-
path: 'src/generated/apis/apiHandlers.ts',
|
|
126
|
-
content: generateApiHandlersTs(generatedApis, seedManifest),
|
|
127
|
-
},
|
|
128
|
-
{
|
|
129
|
-
path: 'src/generated/apis/openapi.json',
|
|
130
|
-
content: `${JSON.stringify(createOpenApi(generatedApis), null, 2)}\n`,
|
|
131
|
-
},
|
|
132
|
-
],
|
|
133
|
-
warnings: createSeedWarnings(generatedApis),
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function listGeneratedApis(data: AppDataManifest | undefined): readonly GeneratedApiResource[] {
|
|
138
|
-
return Object.values(data?.apis ?? {})
|
|
139
|
-
.filter(isGeneratedApiWithCollectionResource)
|
|
140
|
-
.map((api) => ({
|
|
141
|
-
api,
|
|
142
|
-
collection: api.resource.collection,
|
|
143
|
-
seed: materializeSeedRecords(api),
|
|
144
|
-
}))
|
|
145
|
-
.sort((left, right) => left.api.id.localeCompare(right.api.id));
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function isGeneratedApiWithCollectionResource(
|
|
149
|
-
api: AppApiDefinition,
|
|
150
|
-
): api is GeneratedApiWithCollectionResource {
|
|
151
|
-
return api.kind === 'generated' && api.resource?.kind === 'collection';
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function createSeedManifest(
|
|
155
|
-
resources: readonly GeneratedApiResource[],
|
|
156
|
-
): readonly GeneratedApiSeedManifestEntry[] {
|
|
157
|
-
return resources.map((resource) => ({
|
|
158
|
-
apiId: resource.api.id,
|
|
159
|
-
collection: resource.collection,
|
|
160
|
-
records: resource.seed,
|
|
161
|
-
}));
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function materializeSeedRecords(
|
|
165
|
-
api: GeneratedApiWithCollectionResource,
|
|
166
|
-
): readonly Record<string, unknown>[] {
|
|
167
|
-
const { primaryKey } = api.resource.collection;
|
|
168
|
-
const primaryKeyField = findPrimaryKeyField(api);
|
|
169
|
-
|
|
170
|
-
return (api.resource.seed ?? []).map((record, index) => {
|
|
171
|
-
if (primaryKey === undefined || record[primaryKey] !== undefined) {
|
|
172
|
-
return record;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
if (primaryKeyField?.type !== 'uuid') {
|
|
176
|
-
return record;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return {
|
|
180
|
-
...record,
|
|
181
|
-
[primaryKey]: createDeterministicSeedUuid(api.id, index),
|
|
182
|
-
};
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function createSeedWarnings(resources: readonly GeneratedApiResource[]): readonly string[] {
|
|
187
|
-
return resources.flatMap((resource) => {
|
|
188
|
-
const { primaryKey } = resource.collection;
|
|
189
|
-
const primaryKeyField = findPrimaryKeyField(resource.api);
|
|
190
|
-
|
|
191
|
-
if (primaryKey === undefined || primaryKeyField?.type === 'uuid') {
|
|
192
|
-
return [];
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const hasMissingPrimaryKey = resource.seed.some((record) => record[primaryKey] === undefined);
|
|
196
|
-
if (!hasMissingPrimaryKey) {
|
|
197
|
-
return [];
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
return [
|
|
201
|
-
`API ${resource.api.id} has seed records without primary key ${primaryKey}; seed SQL cannot fully guarantee idempotence for those records.`,
|
|
202
|
-
];
|
|
203
|
-
});
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function generateReadme(resources: readonly GeneratedApiResource[]): string {
|
|
207
|
-
const rows = resources
|
|
208
|
-
.map((resource) => {
|
|
209
|
-
const seedCount = resource.seed.length;
|
|
210
|
-
return `- \`${resource.api.id}\` maps to collection \`${formatCollectionName(resource)}\` with ${resource.collection.fields.length} fields and ${seedCount} starter records.`;
|
|
211
|
-
})
|
|
212
|
-
.join('\n');
|
|
213
|
-
|
|
214
|
-
return `# Generated API Artifacts\n\nThese files are generated from API definitions.\n\n## APIs\n\n${rows}\n\n## Files\n\n- \`apis.json\` stores provider-neutral collection definitions for database adapters.\n- \`api-seed.json\` stores starter records configured for generated APIs, with deterministic UUID primary keys added when needed.\n- \`migrations/001_apis.sql\` creates Supabase/Postgres schemas and tables from generated API resource definitions.\n- \`seeds/001_api_seed.sql\` inserts starter records using idempotent conflict handling where possible.\n- \`src/generated/apis/appApi.ts\` exposes bindable API operation metadata.\n- \`src/generated/apis/apiHandlers.ts\` exposes app-owned handler helpers for generated API routes.\n- \`src/generated/apis/openapi.json\` exposes the generated API surface.\n`;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function generateResourcesTs(resources: readonly GeneratedApiResource[]): string {
|
|
218
|
-
return `import type { AppGeneratedApiDefinition } from '@ankhorage/contracts';\n\nexport const GENERATED_APIS = ${JSON.stringify(
|
|
219
|
-
resources.map((resource) => resource.api),
|
|
220
|
-
null,
|
|
221
|
-
2,
|
|
222
|
-
)} as const satisfies readonly AppGeneratedApiDefinition[];\n`;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function generateApiTs(resources: readonly GeneratedApiResource[]): string {
|
|
226
|
-
return `export interface GeneratedApiOperation {\n readonly apiId: string;\n readonly operationId: string;\n readonly intent?: 'create' | 'custom' | 'delete' | 'list' | 'read' | 'update';\n readonly method: 'DELETE' | 'GET' | 'HEAD' | 'OPTIONS' | 'PATCH' | 'POST' | 'PUT';\n readonly path: string;\n}\n\nexport const GENERATED_API_OPERATIONS = ${JSON.stringify(
|
|
227
|
-
resources.flatMap(createApiOperations),
|
|
228
|
-
null,
|
|
229
|
-
2,
|
|
230
|
-
)} as const satisfies readonly GeneratedApiOperation[];\n`;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function generateApiHandlersTs(
|
|
234
|
-
resources: readonly GeneratedApiResource[],
|
|
235
|
-
seedManifest: readonly GeneratedApiSeedManifestEntry[],
|
|
236
|
-
): string {
|
|
237
|
-
return `export type GeneratedApiRecord = Readonly<Record<string, unknown>>;\n\nexport interface GeneratedApiSeedManifestEntry {\n readonly apiId: string;\n readonly records: readonly GeneratedApiRecord[];\n}\n\nexport interface GeneratedApiStore {\n list(apiId: string): Promise<readonly GeneratedApiRecord[]>;\n read(apiId: string, id: string): Promise<GeneratedApiRecord | null>;\n create(apiId: string, values: GeneratedApiRecord): Promise<GeneratedApiRecord>;\n update(apiId: string, id: string, values: GeneratedApiRecord): Promise<GeneratedApiRecord | null>;\n delete(apiId: string, id: string): Promise<GeneratedApiRecord | null>;\n}\n\nexport interface GeneratedApiRequest {\n readonly apiId: string;\n readonly id?: string;\n readonly body?: GeneratedApiRecord;\n}\n\nexport interface GeneratedApiResponse<TData = unknown> {\n readonly status: number;\n readonly data: TData;\n}\n\nconst GENERATED_API_PRIMARY_KEYS = ${JSON.stringify(createPrimaryKeyRegistry(resources), null, 2)} as const satisfies Readonly<Record<string, string>>;\n\nexport const GENERATED_API_SEED = ${JSON.stringify(
|
|
238
|
-
seedManifest.map((entry) => ({ apiId: entry.apiId, records: entry.records })),
|
|
239
|
-
null,
|
|
240
|
-
2,
|
|
241
|
-
)} as const satisfies readonly GeneratedApiSeedManifestEntry[];\n\nexport function isGeneratedApiId(apiId: string): apiId is Extract<keyof typeof GENERATED_API_PRIMARY_KEYS, string> {\n return Object.prototype.hasOwnProperty.call(GENERATED_API_PRIMARY_KEYS, apiId);\n}\n\nexport function getGeneratedApiPrimaryKey(apiId: string): string | null {\n if (!isGeneratedApiId(apiId)) {\n return null;\n }\n\n return GENERATED_API_PRIMARY_KEYS[apiId];\n}\n\nexport function createGeneratedApiMemoryStore(\n seed: readonly GeneratedApiSeedManifestEntry[] = GENERATED_API_SEED,\n): GeneratedApiStore {\n const records = new Map<string, GeneratedApiRecord[]>();\n\n for (const apiId of Object.keys(GENERATED_API_PRIMARY_KEYS)) {\n records.set(apiId, []);\n }\n\n for (const entry of seed) {\n records.set(entry.apiId, entry.records.map(cloneRecord));\n }\n\n return {\n async list(apiId: string): Promise<readonly GeneratedApiRecord[]> {\n return getRows(records, apiId).map(cloneRecord);\n },\n\n async read(apiId: string, id: string): Promise<GeneratedApiRecord | null> {\n const primaryKey = getGeneratedApiPrimaryKey(apiId);\n if (primaryKey === null) return null;\n\n const record = getRows(records, apiId).find((row) => String(row[primaryKey]) === id);\n return record === undefined ? null : cloneRecord(record);\n },\n\n async create(apiId: string, values: GeneratedApiRecord): Promise<GeneratedApiRecord> {\n const primaryKey = getGeneratedApiPrimaryKey(apiId);\n const nextRecord =\n primaryKey === null || values[primaryKey] !== undefined\n ? cloneRecord(values)\n : { ...values, [primaryKey]: createRuntimeRecordId() };\n const nextRows = [...getRows(records, apiId), nextRecord];\n records.set(apiId, nextRows);\n return cloneRecord(nextRecord);\n },\n\n async update(\n apiId: string,\n id: string,\n values: GeneratedApiRecord,\n ): Promise<GeneratedApiRecord | null> {\n const primaryKey = getGeneratedApiPrimaryKey(apiId);\n if (primaryKey === null) return null;\n\n const rows = getRows(records, apiId);\n const index = rows.findIndex((row) => String(row[primaryKey]) === id);\n if (index < 0) return null;\n\n const currentRecord = rows[index] ?? {};\n const nextRecord = { ...currentRecord, ...values, [primaryKey]: currentRecord[primaryKey] ?? id };\n const nextRows = rows.map((row, rowIndex) => (rowIndex === index ? nextRecord : row));\n records.set(apiId, nextRows);\n return cloneRecord(nextRecord);\n },\n\n async delete(apiId: string, id: string): Promise<GeneratedApiRecord | null> {\n const primaryKey = getGeneratedApiPrimaryKey(apiId);\n if (primaryKey === null) return null;\n\n const rows = getRows(records, apiId);\n const deleted = rows.find((row) => String(row[primaryKey]) === id);\n if (deleted === undefined) return null;\n\n records.set(\n apiId,\n rows.filter((row) => String(row[primaryKey]) !== id),\n );\n return cloneRecord(deleted);\n },\n };\n}\n\nexport async function handleGeneratedApiList(\n store: GeneratedApiStore,\n request: GeneratedApiRequest,\n): Promise<GeneratedApiResponse> {\n if (!isGeneratedApiId(request.apiId)) {\n return notFoundResponse(request.apiId);\n }\n\n return { status: 200, data: await store.list(request.apiId) };\n}\n\nexport async function handleGeneratedApiRead(\n store: GeneratedApiStore,\n request: GeneratedApiRequest,\n): Promise<GeneratedApiResponse> {\n if (!isGeneratedApiId(request.apiId) || request.id === undefined) {\n return notFoundResponse(request.apiId);\n }\n\n const record = await store.read(request.apiId, request.id);\n return record === null ? notFoundResponse(request.apiId) : { status: 200, data: record };\n}\n\nexport async function handleGeneratedApiCreate(\n store: GeneratedApiStore,\n request: GeneratedApiRequest,\n): Promise<GeneratedApiResponse> {\n if (!isGeneratedApiId(request.apiId)) {\n return notFoundResponse(request.apiId);\n }\n\n return { status: 201, data: await store.create(request.apiId, request.body ?? {}) };\n}\n\nexport async function handleGeneratedApiUpdate(\n store: GeneratedApiStore,\n request: GeneratedApiRequest,\n): Promise<GeneratedApiResponse> {\n if (!isGeneratedApiId(request.apiId) || request.id === undefined) {\n return notFoundResponse(request.apiId);\n }\n\n const record = await store.update(request.apiId, request.id, request.body ?? {});\n return record === null ? notFoundResponse(request.apiId) : { status: 200, data: record };\n}\n\nexport async function handleGeneratedApiDelete(\n store: GeneratedApiStore,\n request: GeneratedApiRequest,\n): Promise<GeneratedApiResponse> {\n if (!isGeneratedApiId(request.apiId) || request.id === undefined) {\n return notFoundResponse(request.apiId);\n }\n\n const record = await store.delete(request.apiId, request.id);\n return record === null ? notFoundResponse(request.apiId) : { status: 200, data: record };\n}\n\nfunction getRows(\n records: ReadonlyMap<string, readonly GeneratedApiRecord[]>,\n apiId: string,\n): readonly GeneratedApiRecord[] {\n return records.get(apiId) ?? [];\n}\n\nfunction cloneRecord(record: GeneratedApiRecord): GeneratedApiRecord {\n return { ...record };\n}\n\nfunction createRuntimeRecordId(): string {\n const randomPart = Math.random().toString(16).slice(2);\n return \`generated-\${Date.now()}-\${randomPart}\`;\n}\n\nfunction notFoundResponse(apiId: string): GeneratedApiResponse {\n return {\n status: 404,\n data: {\n error: \`Unknown API or record: \${apiId}\`,\n },\n };\n}\n`;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
function createApiOperations(resource: GeneratedApiResource): readonly GeneratedApiOperation[] {
|
|
245
|
-
return resource.api.endpoints.map((endpoint) => ({
|
|
246
|
-
apiId: resource.api.id,
|
|
247
|
-
operationId: endpoint.id,
|
|
248
|
-
intent: endpoint.intent,
|
|
249
|
-
method: endpoint.method,
|
|
250
|
-
path: joinApiPath(resource.api.basePath, endpoint.path),
|
|
251
|
-
}));
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function createOpenApi(resources: readonly GeneratedApiResource[]): GeneratedApiOpenApiDocument {
|
|
255
|
-
const paths: Record<string, GeneratedApiOpenApiPath> = {};
|
|
256
|
-
|
|
257
|
-
for (const resource of resources) {
|
|
258
|
-
for (const operation of createApiOperations(resource)) {
|
|
259
|
-
const endpoint = resource.api.endpoints.find(
|
|
260
|
-
(candidate) => candidate.id === operation.operationId,
|
|
261
|
-
);
|
|
262
|
-
addOpenApiOperation(paths, operation, {
|
|
263
|
-
operationId: operation.operationId,
|
|
264
|
-
summary:
|
|
265
|
-
endpoint?.label ??
|
|
266
|
-
`${operation.intent ?? 'custom'} ${resource.api.label ?? resource.api.id}`,
|
|
267
|
-
responses: {
|
|
268
|
-
[operation.intent === 'create' ? '201' : '200']: { description: 'Successful response.' },
|
|
269
|
-
},
|
|
270
|
-
'x-ankh-api': {
|
|
271
|
-
apiId: resource.api.id,
|
|
272
|
-
endpointId: operation.operationId,
|
|
273
|
-
...(operation.intent === undefined ? {} : { intent: operation.intent }),
|
|
274
|
-
},
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
return {
|
|
280
|
-
openapi: '3.1.0',
|
|
281
|
-
info: {
|
|
282
|
-
title: 'Generated app API',
|
|
283
|
-
version: '1.0.0',
|
|
284
|
-
},
|
|
285
|
-
paths,
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function addOpenApiOperation(
|
|
290
|
-
paths: Record<string, GeneratedApiOpenApiPath>,
|
|
291
|
-
operation: GeneratedApiOperation,
|
|
292
|
-
openApiOperation: GeneratedApiOpenApiOperation,
|
|
293
|
-
) {
|
|
294
|
-
const path = paths[operation.path] ?? {};
|
|
295
|
-
const method = operation.method.toLowerCase() as Lowercase<AppApiEndpointDefinition['method']>;
|
|
296
|
-
paths[operation.path] = { ...path, [method]: openApiOperation };
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function generateSupabaseMigrationSql(resources: readonly GeneratedApiResource[]): string {
|
|
300
|
-
const statements = resources.flatMap((resource) => [
|
|
301
|
-
`create schema if not exists ${quoteSqlIdentifier(resource.collection.schema ?? DEFAULT_DATABASE_SCHEMA)};`,
|
|
302
|
-
generateCreateTableSql(resource),
|
|
303
|
-
]);
|
|
304
|
-
|
|
305
|
-
return [
|
|
306
|
-
'-- Generated from API definitions.',
|
|
307
|
-
'-- Safe to re-run: schemas and tables are created with if not exists.',
|
|
308
|
-
'create extension if not exists pgcrypto;',
|
|
309
|
-
'',
|
|
310
|
-
...statements,
|
|
311
|
-
'',
|
|
312
|
-
].join('\n');
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
function generateCreateTableSql(resource: GeneratedApiResource): string {
|
|
316
|
-
const schema = quoteSqlIdentifier(resource.collection.schema ?? DEFAULT_DATABASE_SCHEMA);
|
|
317
|
-
const table = quoteSqlIdentifier(resource.collection.name);
|
|
318
|
-
const columns = collectSqlFields(resource).map((field) =>
|
|
319
|
-
formatSqlColumn(field, field.name === (resource.collection.primaryKey ?? 'id')),
|
|
320
|
-
);
|
|
321
|
-
|
|
322
|
-
return `create table if not exists ${schema}.${table} (\n ${columns.join(',\n ')}\n);`;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function collectSqlFields(resource: GeneratedApiResource): readonly DbFieldDefinition[] {
|
|
326
|
-
const primaryKey = resource.collection.primaryKey ?? 'id';
|
|
327
|
-
const hasPrimaryKeyField = resource.collection.fields.some((field) => field.name === primaryKey);
|
|
328
|
-
|
|
329
|
-
if (hasPrimaryKeyField) {
|
|
330
|
-
return resource.collection.fields;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
return [
|
|
334
|
-
{ name: primaryKey, type: 'uuid', required: true, unique: true },
|
|
335
|
-
...resource.collection.fields,
|
|
336
|
-
];
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
function generateSupabaseSeedSql(seedManifest: readonly GeneratedApiSeedManifestEntry[]): string {
|
|
340
|
-
const inserts = seedManifest.flatMap((entry) => generateSeedInsertStatements(entry));
|
|
341
|
-
|
|
342
|
-
return [
|
|
343
|
-
'-- Generated from API seed records.',
|
|
344
|
-
'-- Safe to re-run when seed records have stable primary keys or unique constraints.',
|
|
345
|
-
...(inserts.length === 0 ? ['-- No API seed records configured.'] : inserts),
|
|
346
|
-
'',
|
|
347
|
-
].join('\n');
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
function generateSeedInsertStatements(entry: GeneratedApiSeedManifestEntry): readonly string[] {
|
|
351
|
-
if (entry.records.length === 0) {
|
|
352
|
-
return [];
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const { primaryKey } = entry.collection;
|
|
356
|
-
const columns = collectSeedColumns(entry);
|
|
357
|
-
if (columns.length === 0) {
|
|
358
|
-
return [];
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const schema = quoteSqlIdentifier(entry.collection.schema ?? DEFAULT_DATABASE_SCHEMA);
|
|
362
|
-
const table = quoteSqlIdentifier(entry.collection.name);
|
|
363
|
-
const quotedColumns = columns.map(quoteSqlIdentifier).join(', ');
|
|
364
|
-
const values = entry.records
|
|
365
|
-
.map((record) => `(${columns.map((column) => formatSqlValue(record[column])).join(', ')})`)
|
|
366
|
-
.join(',\n ');
|
|
367
|
-
const conflict =
|
|
368
|
-
primaryKey === undefined
|
|
369
|
-
? 'on conflict do nothing'
|
|
370
|
-
: `on conflict (${quoteSqlIdentifier(primaryKey)}) do nothing`;
|
|
371
|
-
|
|
372
|
-
return [`insert into ${schema}.${table} (${quotedColumns}) values\n ${values}\n${conflict};`];
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
function collectSeedColumns(entry: GeneratedApiSeedManifestEntry): readonly string[] {
|
|
376
|
-
const columns = new Set<string>();
|
|
377
|
-
if (entry.collection.primaryKey !== undefined) {
|
|
378
|
-
columns.add(entry.collection.primaryKey);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
for (const field of entry.collection.fields) {
|
|
382
|
-
if (entry.records.some((record) => record[field.name] !== undefined)) {
|
|
383
|
-
columns.add(field.name);
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
const fieldNames = new Set(entry.collection.fields.map((field) => field.name));
|
|
388
|
-
const extraColumns = entry.records
|
|
389
|
-
.flatMap((record) => Object.keys(record))
|
|
390
|
-
.filter((column) => !fieldNames.has(column) && column !== entry.collection.primaryKey)
|
|
391
|
-
.sort();
|
|
392
|
-
|
|
393
|
-
for (const column of extraColumns) {
|
|
394
|
-
columns.add(column);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
return [...columns];
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
function formatSqlColumn(field: DbFieldDefinition, primaryKey: boolean): string {
|
|
401
|
-
const defaultValue = formatSqlDefaultValue(field, primaryKey);
|
|
402
|
-
const primaryKeyClause = primaryKey ? ' primary key' : '';
|
|
403
|
-
const required = field.required === true && !primaryKey ? ' not null' : '';
|
|
404
|
-
const unique = field.unique === true && !primaryKey ? ' unique' : '';
|
|
405
|
-
|
|
406
|
-
return `${quoteSqlIdentifier(field.name)} ${mapSqlFieldType(field.type)}${defaultValue}${primaryKeyClause}${required}${unique}`;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
function mapSqlFieldType(type: DbFieldDefinition['type']): string {
|
|
410
|
-
switch (type) {
|
|
411
|
-
case 'text':
|
|
412
|
-
return 'text';
|
|
413
|
-
case 'number':
|
|
414
|
-
return 'double precision';
|
|
415
|
-
case 'boolean':
|
|
416
|
-
return 'boolean';
|
|
417
|
-
case 'datetime':
|
|
418
|
-
return 'timestamptz';
|
|
419
|
-
case 'json':
|
|
420
|
-
return 'jsonb';
|
|
421
|
-
case 'uuid':
|
|
422
|
-
return 'uuid';
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
function formatSqlDefaultValue(field: DbFieldDefinition, primaryKey: boolean): string {
|
|
427
|
-
if (primaryKey && field.type === 'uuid' && field.defaultValue === undefined) {
|
|
428
|
-
return ' default gen_random_uuid()';
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
if (field.defaultValue === undefined) {
|
|
432
|
-
return '';
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
if (field.defaultValue === null) {
|
|
436
|
-
return ' default null';
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
if (typeof field.defaultValue === 'string') {
|
|
440
|
-
return ` default '${escapeSqlString(field.defaultValue)}'`;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
return ` default ${String(field.defaultValue)}`;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
function formatSqlValue(value: unknown): string {
|
|
447
|
-
if (value === null || value === undefined) {
|
|
448
|
-
return 'null';
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
if (typeof value === 'string') {
|
|
452
|
-
return `'${escapeSqlString(value)}'`;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
if (typeof value === 'number') {
|
|
456
|
-
return Number.isFinite(value) ? String(value) : 'null';
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
if (typeof value === 'boolean') {
|
|
460
|
-
return value ? 'true' : 'false';
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
if (typeof value === 'bigint') {
|
|
464
|
-
return String(value);
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
return `'${escapeSqlString(stringifyJsonValue(value))}'::jsonb`;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
function stringifyJsonValue(value: unknown): string {
|
|
471
|
-
try {
|
|
472
|
-
return JSON.stringify(value);
|
|
473
|
-
} catch {
|
|
474
|
-
return 'null';
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
function quoteSqlIdentifier(identifier: string): string {
|
|
479
|
-
return `"${identifier.replaceAll('"', '""')}"`;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
function escapeSqlString(value: string): string {
|
|
483
|
-
return value.replaceAll("'", "''");
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
function createPrimaryKeyRegistry(
|
|
487
|
-
resources: readonly GeneratedApiResource[],
|
|
488
|
-
): Readonly<Record<string, string>> {
|
|
489
|
-
return Object.fromEntries(
|
|
490
|
-
resources.map((resource) => [resource.api.id, resource.collection.primaryKey ?? 'id']),
|
|
491
|
-
);
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
function findPrimaryKeyField(
|
|
495
|
-
api: GeneratedApiWithCollectionResource,
|
|
496
|
-
): DbFieldDefinition | undefined {
|
|
497
|
-
const { primaryKey } = api.resource.collection;
|
|
498
|
-
if (primaryKey === undefined) {
|
|
499
|
-
return undefined;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
return api.resource.collection.fields.find((field) => field.name === primaryKey);
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
function createDeterministicSeedUuid(apiId: string, index: number): string {
|
|
506
|
-
const input = `${apiId}:${index}`;
|
|
507
|
-
const hex = `${hashHex(input, 0)}${hashHex(input, 1)}${hashHex(input, 2)}${hashHex(input, 3)}`;
|
|
508
|
-
const variant = ((Number.parseInt(hex.charAt(16), 16) & 0x3) | 0x8).toString(16);
|
|
509
|
-
|
|
510
|
-
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
function hashHex(input: string, salt: number): string {
|
|
514
|
-
let hash = 2166136261 ^ salt;
|
|
515
|
-
|
|
516
|
-
for (const char of input) {
|
|
517
|
-
hash ^= char.charCodeAt(0);
|
|
518
|
-
hash = Math.imul(hash, 16777619);
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
function formatCollectionName(resource: GeneratedApiResource): string {
|
|
525
|
-
return resource.collection.schema
|
|
526
|
-
? `${resource.collection.schema}.${resource.collection.name}`
|
|
527
|
-
: resource.collection.name;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
function joinApiPath(basePath: string, endpointPath: string): string {
|
|
531
|
-
const base = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
|
|
532
|
-
const endpoint = endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}`;
|
|
533
|
-
return endpoint === '/' ? base || '/' : `${base}${endpoint}`;
|
|
534
|
-
}
|