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