@saacms/core 0.1.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/README.md +25 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/access/index.d.ts +37 -0
- package/dist/access/index.d.ts.map +1 -0
- package/dist/access/index.js +6 -0
- package/dist/auth/index.d.ts +30 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/codegen/content-migration.d.ts +167 -0
- package/dist/codegen/content-migration.d.ts.map +1 -0
- package/dist/codegen/filter-openapi-for-user.d.ts +100 -0
- package/dist/codegen/filter-openapi-for-user.d.ts.map +1 -0
- package/dist/codegen/index.d.ts +19 -0
- package/dist/codegen/index.d.ts.map +1 -0
- package/dist/codegen/index.js +43 -0
- package/dist/codegen/openapi-types.d.ts +125 -0
- package/dist/codegen/openapi-types.d.ts.map +1 -0
- package/dist/codegen/to-d1-migration.d.ts +88 -0
- package/dist/codegen/to-d1-migration.d.ts.map +1 -0
- package/dist/codegen/to-drizzle.d.ts +131 -0
- package/dist/codegen/to-drizzle.d.ts.map +1 -0
- package/dist/codegen/to-openapi.d.ts +80 -0
- package/dist/codegen/to-openapi.d.ts.map +1 -0
- package/dist/codegen/to-puck-fields.d.ts +109 -0
- package/dist/codegen/to-puck-fields.d.ts.map +1 -0
- package/dist/codegen/to-ts-types.d.ts +59 -0
- package/dist/codegen/to-ts-types.d.ts.map +1 -0
- package/dist/hooks/index.d.ts +94 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +8 -0
- package/dist/host/index.d.ts +109 -0
- package/dist/host/index.d.ts.map +1 -0
- package/dist/host/index.js +16 -0
- package/dist/index-172n82sz.js +4 -0
- package/dist/index-8g8ymd37.js +275 -0
- package/dist/index-a3pnt8yz.js +1494 -0
- package/dist/index-b59hfany.js +3078 -0
- package/dist/index-b7z43xwp.js +6 -0
- package/dist/index-r0at8zaw.js +13 -0
- package/dist/index-zgbq60fy.js +74 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +261 -0
- package/dist/observability/audit.d.ts +65 -0
- package/dist/observability/audit.d.ts.map +1 -0
- package/dist/observability/index.d.ts +2 -0
- package/dist/observability/index.d.ts.map +1 -0
- package/dist/publish/compile.d.ts +76 -0
- package/dist/publish/compile.d.ts.map +1 -0
- package/dist/runtime/auth-middleware.d.ts +34 -0
- package/dist/runtime/auth-middleware.d.ts.map +1 -0
- package/dist/runtime/boolean-columns.d.ts +65 -0
- package/dist/runtime/boolean-columns.d.ts.map +1 -0
- package/dist/runtime/cache.d.ts +62 -0
- package/dist/runtime/cache.d.ts.map +1 -0
- package/dist/runtime/create-route.d.ts +26 -0
- package/dist/runtime/create-route.d.ts.map +1 -0
- package/dist/runtime/delete-route.d.ts +15 -0
- package/dist/runtime/delete-route.d.ts.map +1 -0
- package/dist/runtime/drafts-route.d.ts +65 -0
- package/dist/runtime/drafts-route.d.ts.map +1 -0
- package/dist/runtime/health-route.d.ts +23 -0
- package/dist/runtime/health-route.d.ts.map +1 -0
- package/dist/runtime/index.d.ts +24 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +17 -0
- package/dist/runtime/json-columns.d.ts +50 -0
- package/dist/runtime/json-columns.d.ts.map +1 -0
- package/dist/runtime/list-route.d.ts +20 -0
- package/dist/runtime/list-route.d.ts.map +1 -0
- package/dist/runtime/openapi-route.d.ts +30 -0
- package/dist/runtime/openapi-route.d.ts.map +1 -0
- package/dist/runtime/pattern-route.d.ts +48 -0
- package/dist/runtime/pattern-route.d.ts.map +1 -0
- package/dist/runtime/problem-details.d.ts +32 -0
- package/dist/runtime/problem-details.d.ts.map +1 -0
- package/dist/runtime/put-route.d.ts +19 -0
- package/dist/runtime/put-route.d.ts.map +1 -0
- package/dist/runtime/read-route.d.ts +26 -0
- package/dist/runtime/read-route.d.ts.map +1 -0
- package/dist/runtime/scale-cost.d.ts +84 -0
- package/dist/runtime/scale-cost.d.ts.map +1 -0
- package/dist/runtime/scheme-route.d.ts +49 -0
- package/dist/runtime/scheme-route.d.ts.map +1 -0
- package/dist/runtime/services.d.ts +42 -0
- package/dist/runtime/services.d.ts.map +1 -0
- package/dist/runtime/update-route.d.ts +20 -0
- package/dist/runtime/update-route.d.ts.map +1 -0
- package/dist/runtime/upload-route.d.ts +46 -0
- package/dist/runtime/upload-route.d.ts.map +1 -0
- package/dist/schema/index.d.ts +185 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/index.js +40 -0
- package/dist/schema/media.d.ts +237 -0
- package/dist/schema/media.d.ts.map +1 -0
- package/dist/schema/plugin-trust.d.ts +144 -0
- package/dist/schema/plugin-trust.d.ts.map +1 -0
- package/dist/signals/index.d.ts +10 -0
- package/dist/signals/index.d.ts.map +1 -0
- package/dist/signals/index.js +10 -0
- package/dist/storage/index.d.ts +120 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +13 -0
- package/dist/tenant/index.d.ts +105 -0
- package/dist/tenant/index.d.ts.map +1 -0
- package/dist/theme/index.d.ts +56 -0
- package/dist/theme/index.d.ts.map +1 -0
- package/dist/types/ids.d.ts +45 -0
- package/dist/types/ids.d.ts.map +1 -0
- package/dist/types/ids.js +15 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +6 -0
- package/dist/types/user.d.ts +14 -0
- package/dist/types/user.d.ts.map +1 -0
- package/dist/types/user.js +1 -0
- package/package.json +116 -0
|
@@ -0,0 +1,1494 @@
|
|
|
1
|
+
import {
|
|
2
|
+
withMediaFields
|
|
3
|
+
} from "./index-8g8ymd37.js";
|
|
4
|
+
// src/codegen/to-openapi.ts
|
|
5
|
+
import { JSONSchema, Schema, SchemaAST } from "effect";
|
|
6
|
+
var DEFAULT_BASE_PATH = "/api/saacms/v1";
|
|
7
|
+
var SECURITY = [
|
|
8
|
+
{ cookieAuth: [] },
|
|
9
|
+
{ bearerAuth: [] }
|
|
10
|
+
];
|
|
11
|
+
var PROBLEM_DETAILS_REF = {
|
|
12
|
+
$ref: "#/components/schemas/ProblemDetails"
|
|
13
|
+
};
|
|
14
|
+
var LINK_HEADER = {
|
|
15
|
+
description: "Web Linking per RFC 8288. Carries `self`, `next`, `prev`, `first`, `last`, `up` rels.",
|
|
16
|
+
schema: { type: "string" }
|
|
17
|
+
};
|
|
18
|
+
var ETAG_HEADER = {
|
|
19
|
+
description: "Strong validator per RFC 9110 §13. Use with `If-Match` on writes and `If-None-Match` on reads.",
|
|
20
|
+
schema: { type: "string" }
|
|
21
|
+
};
|
|
22
|
+
var LOCATION_HEADER = {
|
|
23
|
+
description: "URI of the newly created resource.",
|
|
24
|
+
schema: { type: "string", format: "uri-reference" }
|
|
25
|
+
};
|
|
26
|
+
var RETRY_AFTER_HEADER = {
|
|
27
|
+
description: "Per RFC 9110 §10.2.3 — number of seconds (or HTTP date) before the client should retry.",
|
|
28
|
+
schema: { type: "string" }
|
|
29
|
+
};
|
|
30
|
+
var idPathParam = {
|
|
31
|
+
name: "id",
|
|
32
|
+
in: "path",
|
|
33
|
+
required: true,
|
|
34
|
+
description: "Opaque record identifier.",
|
|
35
|
+
schema: { type: "string" }
|
|
36
|
+
};
|
|
37
|
+
var cursorParam = {
|
|
38
|
+
name: "cursor",
|
|
39
|
+
in: "query",
|
|
40
|
+
required: false,
|
|
41
|
+
description: "Opaque base64-encoded keyset cursor over the chosen sort. Per ADR 0018 §4 / Geewax §21.",
|
|
42
|
+
schema: { type: "string" }
|
|
43
|
+
};
|
|
44
|
+
var limitParam = {
|
|
45
|
+
name: "limit",
|
|
46
|
+
in: "query",
|
|
47
|
+
required: false,
|
|
48
|
+
description: "Max records to return. Default 20, max 100. Per ADR 0018 §4.",
|
|
49
|
+
schema: { type: "integer", minimum: 1, maximum: 100, default: 20 }
|
|
50
|
+
};
|
|
51
|
+
var sortParam = {
|
|
52
|
+
name: "sort",
|
|
53
|
+
in: "query",
|
|
54
|
+
required: false,
|
|
55
|
+
description: "Comma-separated sort fields, minus-prefix for descending (`-publishedAt,title`). Per ADR 0018 §6 / Massé §6.1 / AIP-132.",
|
|
56
|
+
schema: { type: "string" }
|
|
57
|
+
};
|
|
58
|
+
var includeCountParam = {
|
|
59
|
+
name: "include_count",
|
|
60
|
+
in: "query",
|
|
61
|
+
required: false,
|
|
62
|
+
description: "Opt-in to populate `_meta.totalCount`. Off by default (count() can be expensive).",
|
|
63
|
+
schema: { type: "boolean", default: false }
|
|
64
|
+
};
|
|
65
|
+
var ifMatchParam = {
|
|
66
|
+
name: "If-Match",
|
|
67
|
+
in: "header",
|
|
68
|
+
required: true,
|
|
69
|
+
description: "Strong ETag the client expects to match the current resource version. Mismatch → 412 (RFC 9110 §13.1.1).",
|
|
70
|
+
schema: { type: "string" }
|
|
71
|
+
};
|
|
72
|
+
var ifNoneMatchParam = {
|
|
73
|
+
name: "If-None-Match",
|
|
74
|
+
in: "header",
|
|
75
|
+
required: false,
|
|
76
|
+
description: "Conditional GET — server returns 304 Not Modified if the current ETag matches (RFC 9110 §13.1.2).",
|
|
77
|
+
schema: { type: "string" }
|
|
78
|
+
};
|
|
79
|
+
var idempotencyKeyParam = {
|
|
80
|
+
name: "Idempotency-Key",
|
|
81
|
+
in: "header",
|
|
82
|
+
required: false,
|
|
83
|
+
description: "Per draft-ietf-httpapi-idempotency-key-header. Advisory in v1 — the header is accepted but the server does NOT enforce a per-(user,key) response cache in v1. Duplicate-request deduplication is achieved via declarative collection `unique` constraints (ADR 0030); a duplicate insert against a `unique` field returns 409 Conflict.",
|
|
84
|
+
schema: { type: "string" }
|
|
85
|
+
};
|
|
86
|
+
var filterParam = {
|
|
87
|
+
name: "filter",
|
|
88
|
+
in: "query",
|
|
89
|
+
required: false,
|
|
90
|
+
description: "Field filters per ADR 0018 §5. Use bracket syntax: `filter[status]=published`, `filter[publishedAt.gte]=2026-01-01`, `filter[id]=a,b,c` (IN), `filter[status.ne]=draft` (negation). Operators per field type are documented per Collection.",
|
|
91
|
+
schema: {
|
|
92
|
+
type: "object",
|
|
93
|
+
additionalProperties: { type: "string" }
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
var DATE_SCHEMA_IDENTIFIERS = new Set([
|
|
97
|
+
"DateFromSelf",
|
|
98
|
+
"Date",
|
|
99
|
+
"DateTimeUtc",
|
|
100
|
+
"DateTimeUtcFromSelf",
|
|
101
|
+
"DateFromString",
|
|
102
|
+
"DateFromNumber"
|
|
103
|
+
]);
|
|
104
|
+
var DATE_TIME_JSON_SCHEMA = { type: "string", format: "date-time" };
|
|
105
|
+
function astIdentifier(ast) {
|
|
106
|
+
const id = SchemaAST.getIdentifierAnnotation(ast);
|
|
107
|
+
return id._tag === "Some" ? id.value : undefined;
|
|
108
|
+
}
|
|
109
|
+
function isDateClassAst(ast) {
|
|
110
|
+
const id = astIdentifier(ast);
|
|
111
|
+
if (id != null && DATE_SCHEMA_IDENTIFIERS.has(id))
|
|
112
|
+
return true;
|
|
113
|
+
if (ast._tag === "Declaration") {
|
|
114
|
+
const schemaId = ast.annotations[SchemaAST.SchemaIdAnnotationId];
|
|
115
|
+
if (typeof schemaId === "symbol" && String(schemaId).includes("DateFromSelf")) {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
function rewriteDateAst(ast) {
|
|
122
|
+
if (isDateClassAst(ast)) {
|
|
123
|
+
return SchemaAST.annotations(ast, {
|
|
124
|
+
[SchemaAST.JSONSchemaAnnotationId]: DATE_TIME_JSON_SCHEMA,
|
|
125
|
+
[SchemaAST.DescriptionAnnotationId]: undefined,
|
|
126
|
+
[SchemaAST.TitleAnnotationId]: undefined
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
switch (ast._tag) {
|
|
130
|
+
case "TypeLiteral": {
|
|
131
|
+
const propertySignatures = ast.propertySignatures.map((p) => new SchemaAST.PropertySignature(p.name, rewriteDateAst(p.type), p.isOptional, p.isReadonly, p.annotations));
|
|
132
|
+
const indexSignatures = ast.indexSignatures.map((s) => new SchemaAST.IndexSignature(s.parameter, rewriteDateAst(s.type), s.isReadonly));
|
|
133
|
+
return new SchemaAST.TypeLiteral(propertySignatures, indexSignatures, ast.annotations);
|
|
134
|
+
}
|
|
135
|
+
case "TupleType": {
|
|
136
|
+
const elements = ast.elements.map((e) => new SchemaAST.OptionalType(rewriteDateAst(e.type), e.isOptional, e.annotations));
|
|
137
|
+
const rest = ast.rest.map((r) => new SchemaAST.Type(rewriteDateAst(r.type), r.annotations));
|
|
138
|
+
return new SchemaAST.TupleType(elements, rest, ast.isReadonly, ast.annotations);
|
|
139
|
+
}
|
|
140
|
+
case "Union":
|
|
141
|
+
return SchemaAST.Union.make(ast.types.map(rewriteDateAst), ast.annotations);
|
|
142
|
+
case "Suspend":
|
|
143
|
+
return new SchemaAST.Suspend(() => rewriteDateAst(ast.f()), ast.annotations);
|
|
144
|
+
case "Refinement":
|
|
145
|
+
return new SchemaAST.Refinement(rewriteDateAst(ast.from), ast.filter, ast.annotations);
|
|
146
|
+
case "Transformation":
|
|
147
|
+
return new SchemaAST.Transformation(rewriteDateAst(ast.from), rewriteDateAst(ast.to), ast.transformation, ast.annotations);
|
|
148
|
+
default:
|
|
149
|
+
return ast;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function normalizeDateSchemas(schema) {
|
|
153
|
+
return Schema.make(rewriteDateAst(schema.ast));
|
|
154
|
+
}
|
|
155
|
+
function schemaToOpenApiSchema(schema) {
|
|
156
|
+
const normalized = normalizeDateSchemas(schema);
|
|
157
|
+
const base = JSONSchema.make(normalized, { target: "openApi3.1" });
|
|
158
|
+
const out = base;
|
|
159
|
+
const top = readSaacmsAnnotations(schema.ast);
|
|
160
|
+
return mergeAnnotationsOntoSchema(out, top, schema.ast);
|
|
161
|
+
}
|
|
162
|
+
function readSaacmsAnnotations(annotated) {
|
|
163
|
+
const a = annotated.annotations;
|
|
164
|
+
const out = {};
|
|
165
|
+
if (typeof a["saacmsLabel"] === "string")
|
|
166
|
+
out.saacmsLabel = a["saacmsLabel"];
|
|
167
|
+
if (typeof a["saacmsHelp"] === "string")
|
|
168
|
+
out.saacmsHelp = a["saacmsHelp"];
|
|
169
|
+
if (typeof a["saacmsFormat"] === "string")
|
|
170
|
+
out.saacmsFormat = a["saacmsFormat"];
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
function findSaacmsAnnotationsDeep(ast) {
|
|
174
|
+
let current = ast;
|
|
175
|
+
let acc = {};
|
|
176
|
+
for (let depth = 0;depth < 8 && current != null; depth++) {
|
|
177
|
+
const here = readSaacmsAnnotations(current);
|
|
178
|
+
acc = {
|
|
179
|
+
saacmsLabel: acc.saacmsLabel ?? here.saacmsLabel,
|
|
180
|
+
saacmsHelp: acc.saacmsHelp ?? here.saacmsHelp,
|
|
181
|
+
saacmsFormat: acc.saacmsFormat ?? here.saacmsFormat
|
|
182
|
+
};
|
|
183
|
+
if (acc.saacmsLabel != null && acc.saacmsHelp != null && acc.saacmsFormat != null) {
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
const next = current.from;
|
|
187
|
+
if (next == null || next === current)
|
|
188
|
+
break;
|
|
189
|
+
current = next;
|
|
190
|
+
}
|
|
191
|
+
return acc;
|
|
192
|
+
}
|
|
193
|
+
function mergeAnnotationsOntoSchema(schema, ann, ast) {
|
|
194
|
+
let next = { ...schema };
|
|
195
|
+
if (ann.saacmsLabel != null) {
|
|
196
|
+
const help = ann.saacmsHelp != null ? ` (${ann.saacmsHelp})` : "";
|
|
197
|
+
next = { ...next, description: `${ann.saacmsLabel}${help}` };
|
|
198
|
+
} else if (ann.saacmsHelp != null && next.description == null) {
|
|
199
|
+
next = { ...next, description: ann.saacmsHelp };
|
|
200
|
+
}
|
|
201
|
+
if (ann.saacmsFormat != null) {
|
|
202
|
+
next = { ...next, format: ann.saacmsFormat };
|
|
203
|
+
}
|
|
204
|
+
if (SchemaAST.isTypeLiteral(ast) && next.properties != null) {
|
|
205
|
+
const properties = { ...next.properties };
|
|
206
|
+
for (const prop of ast.propertySignatures) {
|
|
207
|
+
const name = String(prop.name);
|
|
208
|
+
const child = properties[name];
|
|
209
|
+
if (child == null)
|
|
210
|
+
continue;
|
|
211
|
+
const sigAnn = readSaacmsAnnotations(prop);
|
|
212
|
+
const typeAnn = findSaacmsAnnotationsDeep(prop.type);
|
|
213
|
+
const merged = {
|
|
214
|
+
saacmsLabel: sigAnn.saacmsLabel ?? typeAnn.saacmsLabel,
|
|
215
|
+
saacmsHelp: sigAnn.saacmsHelp ?? typeAnn.saacmsHelp,
|
|
216
|
+
saacmsFormat: sigAnn.saacmsFormat ?? typeAnn.saacmsFormat
|
|
217
|
+
};
|
|
218
|
+
properties[name] = mergeAnnotationsOntoSchema(child, merged, prop.type);
|
|
219
|
+
}
|
|
220
|
+
next = { ...next, properties };
|
|
221
|
+
}
|
|
222
|
+
return next;
|
|
223
|
+
}
|
|
224
|
+
function collectionToOpenApiPaths(coll, opts = {}) {
|
|
225
|
+
coll = withMediaFields(coll);
|
|
226
|
+
const basePath = opts.basePath ?? DEFAULT_BASE_PATH;
|
|
227
|
+
const slug = String(coll.slug);
|
|
228
|
+
const tag = slug;
|
|
229
|
+
const recordName = pascalCase(slug);
|
|
230
|
+
const recordSchema = schemaToOpenApiSchema(coll.schema);
|
|
231
|
+
const recordRef = {
|
|
232
|
+
$ref: `#/components/schemas/${recordName}`
|
|
233
|
+
};
|
|
234
|
+
const collectionPath = `${basePath}/${slug}`;
|
|
235
|
+
const recordPath = `${collectionPath}/{id}`;
|
|
236
|
+
const envelopeSingle = singletonEnvelopeSchema(recordRef);
|
|
237
|
+
const envelopeList = listEnvelopeSchema(recordRef);
|
|
238
|
+
const paths = {
|
|
239
|
+
[collectionPath]: {
|
|
240
|
+
get: listOperation({ tag, slug, envelope: envelopeList }),
|
|
241
|
+
post: createOperation({ tag, slug, recordName, envelope: envelopeSingle, recordPath })
|
|
242
|
+
},
|
|
243
|
+
[recordPath]: {
|
|
244
|
+
get: getOneOperation({ tag, slug, envelope: envelopeSingle }),
|
|
245
|
+
put: replaceOperation({ tag, slug, recordName, envelope: envelopeSingle }),
|
|
246
|
+
patch: patchOperation({ tag, slug, recordName, envelope: envelopeSingle }),
|
|
247
|
+
delete: deleteOperation({ tag, slug })
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
return {
|
|
251
|
+
paths,
|
|
252
|
+
components: {
|
|
253
|
+
schemas: {
|
|
254
|
+
[recordName]: recordSchema,
|
|
255
|
+
ProblemDetails: PROBLEM_DETAILS_SCHEMA
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function listOperation(args) {
|
|
261
|
+
return {
|
|
262
|
+
tags: [args.tag],
|
|
263
|
+
summary: `List ${args.slug}`,
|
|
264
|
+
description: `Cursor-paginated list of ${args.slug}. Per ADR 0018 §4 (cursor pagination), §5 (filter brackets), §6 (sort). Returns a HAL-light envelope per ADR 0018 §2.`,
|
|
265
|
+
operationId: `list${pascalCase(args.slug)}`,
|
|
266
|
+
parameters: [cursorParam, limitParam, sortParam, includeCountParam, filterParam],
|
|
267
|
+
responses: {
|
|
268
|
+
"200": {
|
|
269
|
+
description: "List page.",
|
|
270
|
+
headers: { Link: LINK_HEADER },
|
|
271
|
+
content: { "application/json": { schema: args.envelope } }
|
|
272
|
+
},
|
|
273
|
+
...standardErrorResponses({ includePreconditions: false, includeUnprocessable: false })
|
|
274
|
+
},
|
|
275
|
+
security: [...SECURITY]
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
function createOperation(args) {
|
|
279
|
+
return {
|
|
280
|
+
tags: [args.tag],
|
|
281
|
+
summary: `Create a ${args.slug} record`,
|
|
282
|
+
description: `Creates a new ${args.slug}. Per RFC 9110 §15.3.2 returns 201 Created with \`Location\` header. When the collection declares a \`unique\` constraint, a duplicate insert returns 409 Conflict (ADR 0030). The \`Idempotency-Key\` header is accepted as advisory in v1 (not server-enforced).`,
|
|
283
|
+
operationId: `create${args.recordName}`,
|
|
284
|
+
parameters: [idempotencyKeyParam],
|
|
285
|
+
requestBody: {
|
|
286
|
+
required: true,
|
|
287
|
+
content: {
|
|
288
|
+
"application/json": {
|
|
289
|
+
schema: { $ref: `#/components/schemas/${args.recordName}` }
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
responses: {
|
|
294
|
+
"201": {
|
|
295
|
+
description: "Created.",
|
|
296
|
+
headers: {
|
|
297
|
+
Location: LOCATION_HEADER,
|
|
298
|
+
ETag: ETAG_HEADER
|
|
299
|
+
},
|
|
300
|
+
content: { "application/json": { schema: args.envelope } }
|
|
301
|
+
},
|
|
302
|
+
...standardErrorResponses({ includePreconditions: false, includeUnprocessable: true })
|
|
303
|
+
},
|
|
304
|
+
security: [...SECURITY]
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function getOneOperation(args) {
|
|
308
|
+
return {
|
|
309
|
+
tags: [args.tag],
|
|
310
|
+
summary: `Get one ${args.slug}`,
|
|
311
|
+
description: `Returns a single ${args.slug} record. Per ADR 0018 §7, response includes a strong \`ETag\` header; clients may use \`If-None-Match\` for conditional GET (304 Not Modified).`,
|
|
312
|
+
operationId: `get${pascalCase(args.slug)}ById`,
|
|
313
|
+
parameters: [idPathParam, ifNoneMatchParam],
|
|
314
|
+
responses: {
|
|
315
|
+
"200": {
|
|
316
|
+
description: "OK.",
|
|
317
|
+
headers: {
|
|
318
|
+
ETag: ETAG_HEADER,
|
|
319
|
+
Link: LINK_HEADER
|
|
320
|
+
},
|
|
321
|
+
content: { "application/json": { schema: args.envelope } }
|
|
322
|
+
},
|
|
323
|
+
"304": { description: "Not Modified — `If-None-Match` matched current ETag." },
|
|
324
|
+
...standardErrorResponses({ includePreconditions: false, includeUnprocessable: false })
|
|
325
|
+
},
|
|
326
|
+
security: [...SECURITY]
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
function replaceOperation(args) {
|
|
330
|
+
return {
|
|
331
|
+
tags: [args.tag],
|
|
332
|
+
summary: `Replace a ${args.slug} record`,
|
|
333
|
+
description: `Full replacement (PUT) per RFC 9110 §15.3.4. Requires \`If-Match\` per ADR 0018 §7; missing precondition returns 428.`,
|
|
334
|
+
operationId: `replace${args.recordName}`,
|
|
335
|
+
parameters: [idPathParam, ifMatchParam, idempotencyKeyParam],
|
|
336
|
+
requestBody: {
|
|
337
|
+
required: true,
|
|
338
|
+
content: {
|
|
339
|
+
"application/json": {
|
|
340
|
+
schema: { $ref: `#/components/schemas/${args.recordName}` }
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
responses: {
|
|
345
|
+
"200": {
|
|
346
|
+
description: "Replaced.",
|
|
347
|
+
headers: { ETag: ETAG_HEADER },
|
|
348
|
+
content: { "application/json": { schema: args.envelope } }
|
|
349
|
+
},
|
|
350
|
+
...standardErrorResponses({ includePreconditions: true, includeUnprocessable: true })
|
|
351
|
+
},
|
|
352
|
+
security: [...SECURITY]
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
function patchOperation(args) {
|
|
356
|
+
return {
|
|
357
|
+
tags: [args.tag],
|
|
358
|
+
summary: `Patch a ${args.slug} record`,
|
|
359
|
+
description: `Partial update (PATCH) per RFC 5789 / RFC 9110. Body is a partial of the record schema. Requires \`If-Match\` per ADR 0018 §7.`,
|
|
360
|
+
operationId: `patch${args.recordName}`,
|
|
361
|
+
parameters: [idPathParam, ifMatchParam, idempotencyKeyParam],
|
|
362
|
+
requestBody: {
|
|
363
|
+
required: true,
|
|
364
|
+
content: {
|
|
365
|
+
"application/json": {
|
|
366
|
+
schema: {
|
|
367
|
+
allOf: [{ $ref: `#/components/schemas/${args.recordName}` }]
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
responses: {
|
|
373
|
+
"200": {
|
|
374
|
+
description: "Patched.",
|
|
375
|
+
headers: { ETag: ETAG_HEADER },
|
|
376
|
+
content: { "application/json": { schema: args.envelope } }
|
|
377
|
+
},
|
|
378
|
+
...standardErrorResponses({ includePreconditions: true, includeUnprocessable: true })
|
|
379
|
+
},
|
|
380
|
+
security: [...SECURITY]
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
function deleteOperation(args) {
|
|
384
|
+
return {
|
|
385
|
+
tags: [args.tag],
|
|
386
|
+
summary: `Delete a ${args.slug} record`,
|
|
387
|
+
description: `Deletes a record. Requires \`If-Match\` per ADR 0018 §7. Returns 204 No Content on success.`,
|
|
388
|
+
operationId: `delete${pascalCase(args.slug)}`,
|
|
389
|
+
parameters: [idPathParam, ifMatchParam, idempotencyKeyParam],
|
|
390
|
+
responses: {
|
|
391
|
+
"204": { description: "Deleted." },
|
|
392
|
+
...standardErrorResponses({ includePreconditions: true, includeUnprocessable: false })
|
|
393
|
+
},
|
|
394
|
+
security: [...SECURITY]
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function problemResponse(description) {
|
|
398
|
+
return {
|
|
399
|
+
description,
|
|
400
|
+
content: {
|
|
401
|
+
"application/problem+json": { schema: PROBLEM_DETAILS_REF }
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
function standardErrorResponses(opts) {
|
|
406
|
+
const base = {
|
|
407
|
+
"400": problemResponse("Bad Request — malformed request (RFC 9110 §15.5.1)."),
|
|
408
|
+
"401": problemResponse("Unauthorized — missing or invalid auth (RFC 9110 §15.5.2)."),
|
|
409
|
+
"403": problemResponse("Forbidden — authenticated but not allowed (RFC 9110 §15.5.4)."),
|
|
410
|
+
"404": problemResponse("Not Found (RFC 9110 §15.5.5). Note: hidden resources also return 404 by default per OWASP API Security Top 10 #1."),
|
|
411
|
+
"409": problemResponse("Conflict — state conflict, duplicate unique field, or idempotency-key reuse with different payload (RFC 9110 §15.5.10)."),
|
|
412
|
+
"429": {
|
|
413
|
+
description: "Too Many Requests — rate-limited (RFC 6585 §4).",
|
|
414
|
+
headers: { "Retry-After": RETRY_AFTER_HEADER },
|
|
415
|
+
content: {
|
|
416
|
+
"application/problem+json": { schema: PROBLEM_DETAILS_REF }
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
if (opts.includeUnprocessable) {
|
|
421
|
+
base["422"] = problemResponse("Unprocessable Content — body parsed but failed business validation (RFC 9110 §15.5.21).");
|
|
422
|
+
}
|
|
423
|
+
if (opts.includePreconditions) {
|
|
424
|
+
base["412"] = problemResponse("Precondition Failed — `If-Match` ETag did not match current state (RFC 9110 §15.5.13).");
|
|
425
|
+
base["428"] = problemResponse("Precondition Required — unsafe write without `If-Match` (RFC 6585 §3).");
|
|
426
|
+
}
|
|
427
|
+
return base;
|
|
428
|
+
}
|
|
429
|
+
var linkObjectSchema = {
|
|
430
|
+
type: "object",
|
|
431
|
+
required: ["href"],
|
|
432
|
+
properties: {
|
|
433
|
+
href: { type: "string", format: "uri-reference" }
|
|
434
|
+
},
|
|
435
|
+
additionalProperties: true
|
|
436
|
+
};
|
|
437
|
+
var linksMapSchema = {
|
|
438
|
+
type: "object",
|
|
439
|
+
description: "RFC 8288-style link map. Keys are link-relation names (`self`, `next`, `prev`, `up`, `first`, `last`, custom rels).",
|
|
440
|
+
additionalProperties: linkObjectSchema
|
|
441
|
+
};
|
|
442
|
+
var actionSchema = {
|
|
443
|
+
type: "object",
|
|
444
|
+
required: ["rel", "method", "href"],
|
|
445
|
+
properties: {
|
|
446
|
+
rel: { type: "string" },
|
|
447
|
+
method: { type: "string" },
|
|
448
|
+
href: { type: "string", format: "uri-reference" }
|
|
449
|
+
},
|
|
450
|
+
additionalProperties: true
|
|
451
|
+
};
|
|
452
|
+
function singletonEnvelopeSchema(dataRef) {
|
|
453
|
+
return {
|
|
454
|
+
type: "object",
|
|
455
|
+
required: ["data"],
|
|
456
|
+
properties: {
|
|
457
|
+
data: dataRef,
|
|
458
|
+
_links: linksMapSchema,
|
|
459
|
+
_actions: { type: "array", items: actionSchema },
|
|
460
|
+
_meta: { type: "object", additionalProperties: true }
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
function listEnvelopeSchema(itemRef) {
|
|
465
|
+
return {
|
|
466
|
+
type: "object",
|
|
467
|
+
required: ["data"],
|
|
468
|
+
properties: {
|
|
469
|
+
data: { type: "array", items: itemRef },
|
|
470
|
+
_links: linksMapSchema,
|
|
471
|
+
_meta: {
|
|
472
|
+
type: "object",
|
|
473
|
+
properties: {
|
|
474
|
+
hasMore: { type: "boolean" },
|
|
475
|
+
totalCount: { type: "integer", description: "Present only when `?include_count=true`." }
|
|
476
|
+
},
|
|
477
|
+
additionalProperties: true
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
var PROBLEM_DETAILS_SCHEMA = {
|
|
483
|
+
type: "object",
|
|
484
|
+
description: "RFC 9457 Problem Details document. saacms returns this for every 4xx/5xx response. Per ADR 0018 §3.",
|
|
485
|
+
required: ["type", "title", "status"],
|
|
486
|
+
properties: {
|
|
487
|
+
type: {
|
|
488
|
+
type: "string",
|
|
489
|
+
format: "uri",
|
|
490
|
+
description: "URI identifying the error class. saacms hosts the registry at saacms.dev/errors/<class>."
|
|
491
|
+
},
|
|
492
|
+
title: { type: "string", description: "Short, human-readable summary." },
|
|
493
|
+
status: { type: "integer", description: "Mirror of the HTTP status code (RFC 9457 §3.1.4)." },
|
|
494
|
+
detail: { type: "string", description: "Human-readable explanation specific to this occurrence." },
|
|
495
|
+
instance: { type: "string", description: "URI identifying the specific occurrence." },
|
|
496
|
+
errors: {
|
|
497
|
+
type: "array",
|
|
498
|
+
description: "saacms extension (per RFC 9457 §3.2, extensions are explicitly allowed). Per-field validation details.",
|
|
499
|
+
items: {
|
|
500
|
+
type: "object",
|
|
501
|
+
required: ["path", "code", "message"],
|
|
502
|
+
properties: {
|
|
503
|
+
path: { type: "string" },
|
|
504
|
+
code: { type: "string" },
|
|
505
|
+
message: { type: "string" }
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
additionalProperties: true
|
|
511
|
+
};
|
|
512
|
+
function pascalCase(input) {
|
|
513
|
+
return input.split(/[-_\s]+/).filter((s) => s.length > 0).map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
|
|
514
|
+
}
|
|
515
|
+
// src/codegen/to-drizzle.ts
|
|
516
|
+
import { SchemaAST as AST, Option } from "effect";
|
|
517
|
+
import {
|
|
518
|
+
sqliteTable,
|
|
519
|
+
text,
|
|
520
|
+
integer,
|
|
521
|
+
real
|
|
522
|
+
} from "drizzle-orm/sqlite-core";
|
|
523
|
+
function makePlaceholderRegistry() {
|
|
524
|
+
const cache = new Map;
|
|
525
|
+
return {
|
|
526
|
+
getIdColumn(slug) {
|
|
527
|
+
let placeholder = cache.get(slug);
|
|
528
|
+
if (placeholder === undefined) {
|
|
529
|
+
placeholder = sqliteTable(slug, {
|
|
530
|
+
id: text("id").primaryKey()
|
|
531
|
+
});
|
|
532
|
+
cache.set(slug, placeholder);
|
|
533
|
+
}
|
|
534
|
+
return placeholder.id;
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
function readSaacmsAnnotation(ast, key) {
|
|
539
|
+
const v = ast.annotations[key];
|
|
540
|
+
return v === undefined ? undefined : v;
|
|
541
|
+
}
|
|
542
|
+
function resolveFieldType(signature) {
|
|
543
|
+
let t = signature.type;
|
|
544
|
+
let isNullable = signature.isOptional;
|
|
545
|
+
if (AST.isUnion(t)) {
|
|
546
|
+
const nonNullish = t.types.filter((m) => !AST.isUndefinedKeyword(m) && !isNullKeyword(m));
|
|
547
|
+
if (nonNullish.length !== t.types.length) {
|
|
548
|
+
isNullable = true;
|
|
549
|
+
}
|
|
550
|
+
if (nonNullish.length === 1) {
|
|
551
|
+
t = nonNullish[0];
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
while (AST.isRefinement(t)) {
|
|
555
|
+
t = t.from;
|
|
556
|
+
}
|
|
557
|
+
return { inner: t, isNullable };
|
|
558
|
+
}
|
|
559
|
+
function isNullKeyword(ast) {
|
|
560
|
+
return AST.isLiteral(ast) && ast.literal === null;
|
|
561
|
+
}
|
|
562
|
+
function buildColumn(name, resolved, registry) {
|
|
563
|
+
const { inner, isNullable } = resolved;
|
|
564
|
+
const refSlug = readSaacmsAnnotation(inner, "saacmsRef");
|
|
565
|
+
const isInt = readSaacmsAnnotation(inner, "saacmsInt") === true;
|
|
566
|
+
const length = readSaacmsAnnotation(inner, "saacmsLength");
|
|
567
|
+
const onDelete = readSaacmsAnnotation(inner, "saacmsRefOnDelete") ?? "cascade";
|
|
568
|
+
let col;
|
|
569
|
+
if (isDateLikeAst(inner)) {
|
|
570
|
+
col = integer(name, { mode: "timestamp" });
|
|
571
|
+
} else if (AST.isStringKeyword(inner)) {
|
|
572
|
+
col = length !== undefined ? text(name, { length }) : text(name);
|
|
573
|
+
} else if (AST.isNumberKeyword(inner)) {
|
|
574
|
+
col = isInt ? integer(name) : real(name);
|
|
575
|
+
} else if (AST.isBooleanKeyword(inner)) {
|
|
576
|
+
col = integer(name, { mode: "boolean" });
|
|
577
|
+
} else if (AST.isTupleType(inner)) {
|
|
578
|
+
col = text(name, { mode: "json" });
|
|
579
|
+
} else if (AST.isTypeLiteral(inner)) {
|
|
580
|
+
col = text(name, { mode: "json" });
|
|
581
|
+
} else {
|
|
582
|
+
col = text(name);
|
|
583
|
+
}
|
|
584
|
+
if (refSlug !== undefined && AST.isStringKeyword(inner)) {
|
|
585
|
+
const refsCol = col;
|
|
586
|
+
col = refsCol.references(() => registry.getIdColumn(refSlug), { onDelete });
|
|
587
|
+
}
|
|
588
|
+
if (!isNullable) {
|
|
589
|
+
const notNullable = col;
|
|
590
|
+
col = notNullable.notNull();
|
|
591
|
+
}
|
|
592
|
+
return col;
|
|
593
|
+
}
|
|
594
|
+
function isDateLikeAst(ast) {
|
|
595
|
+
const id = Option.getOrUndefined(AST.getIdentifierAnnotation(ast));
|
|
596
|
+
if (id === "DateFromSelf" || id === "Date" || id === "ValidDateFromSelf") {
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
if (id === "DateFromString" || id === "DateFromNumber") {
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
function schemaToDrizzle(slug, schema) {
|
|
605
|
+
const root = schema.ast;
|
|
606
|
+
if (!AST.isTypeLiteral(root)) {
|
|
607
|
+
throw new Error(`schemaToDrizzle: root schema for "${slug}" must be a Struct (TypeLiteral); got ${root._tag}`);
|
|
608
|
+
}
|
|
609
|
+
const registry = makePlaceholderRegistry();
|
|
610
|
+
const columns = {};
|
|
611
|
+
for (const ps of root.propertySignatures) {
|
|
612
|
+
const name = String(ps.name);
|
|
613
|
+
if (name === "id" || name === "createdAt" || name === "updatedAt") {
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
const resolved = resolveFieldType(ps);
|
|
617
|
+
columns[name] = buildColumn(name, resolved, registry);
|
|
618
|
+
}
|
|
619
|
+
columns["id"] = text("id").primaryKey();
|
|
620
|
+
columns["createdAt"] = integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date);
|
|
621
|
+
columns["updatedAt"] = integer("updated_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date);
|
|
622
|
+
return sqliteTable(slug, columns);
|
|
623
|
+
}
|
|
624
|
+
// src/codegen/to-d1-migration.ts
|
|
625
|
+
import { SchemaAST as AST2, Option as Option2 } from "effect";
|
|
626
|
+
function readSaacmsAnnotation2(ast, key) {
|
|
627
|
+
const v = ast.annotations[key];
|
|
628
|
+
return v === undefined ? undefined : v;
|
|
629
|
+
}
|
|
630
|
+
function resolveFieldType2(signature) {
|
|
631
|
+
let t = signature.type;
|
|
632
|
+
let isNullable = signature.isOptional;
|
|
633
|
+
if (AST2.isUnion(t)) {
|
|
634
|
+
const nonNullish = t.types.filter((m) => !AST2.isUndefinedKeyword(m) && !isNullKeyword2(m));
|
|
635
|
+
if (nonNullish.length !== t.types.length) {
|
|
636
|
+
isNullable = true;
|
|
637
|
+
}
|
|
638
|
+
if (nonNullish.length === 1) {
|
|
639
|
+
t = nonNullish[0];
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
while (AST2.isRefinement(t)) {
|
|
643
|
+
t = t.from;
|
|
644
|
+
}
|
|
645
|
+
return { inner: t, isNullable };
|
|
646
|
+
}
|
|
647
|
+
function isNullKeyword2(ast) {
|
|
648
|
+
return AST2.isLiteral(ast) && ast.literal === null;
|
|
649
|
+
}
|
|
650
|
+
function isDateLikeAst2(ast) {
|
|
651
|
+
const id = Option2.getOrUndefined(AST2.getIdentifierAnnotation(ast));
|
|
652
|
+
if (id === "DateFromSelf" || id === "Date" || id === "ValidDateFromSelf") {
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
655
|
+
if (id === "DateFromString" || id === "DateFromNumber") {
|
|
656
|
+
return true;
|
|
657
|
+
}
|
|
658
|
+
return false;
|
|
659
|
+
}
|
|
660
|
+
function tryGetStringLiteralUnion(ast) {
|
|
661
|
+
if (!AST2.isUnion(ast))
|
|
662
|
+
return;
|
|
663
|
+
const literals = [];
|
|
664
|
+
for (const m of ast.types) {
|
|
665
|
+
if (AST2.isUndefinedKeyword(m) || isNullKeyword2(m))
|
|
666
|
+
continue;
|
|
667
|
+
if (AST2.isLiteral(m) && typeof m.literal === "string") {
|
|
668
|
+
literals.push(m.literal);
|
|
669
|
+
} else {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return literals.length > 0 ? literals : undefined;
|
|
674
|
+
}
|
|
675
|
+
function camelToSnakeCase(name) {
|
|
676
|
+
return name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
|
|
677
|
+
}
|
|
678
|
+
function quoteIdent(name) {
|
|
679
|
+
return `"${name}"`;
|
|
680
|
+
}
|
|
681
|
+
function slugToTableName(slug) {
|
|
682
|
+
let name = String(slug).replace(/[^A-Za-z0-9_]/g, "_");
|
|
683
|
+
if (name.length === 0 || /^[0-9]/.test(name)) {
|
|
684
|
+
name = `_${name}`;
|
|
685
|
+
}
|
|
686
|
+
if (name.length > 64) {
|
|
687
|
+
name = name.slice(0, 64);
|
|
688
|
+
}
|
|
689
|
+
return name;
|
|
690
|
+
}
|
|
691
|
+
function assertNoTableNameCollisions(colls) {
|
|
692
|
+
const seen = new Map;
|
|
693
|
+
for (const c of colls) {
|
|
694
|
+
const slug = String(c.slug);
|
|
695
|
+
const table = slugToTableName(slug);
|
|
696
|
+
const prior = seen.get(table);
|
|
697
|
+
if (prior !== undefined && prior !== slug) {
|
|
698
|
+
throw new Error(`assertNoTableNameCollisions: collection slugs "${prior}" and "${slug}" ` + `both project to the physical table name "${table}". External slugs are ` + `the data contract and cannot be changed; rename one collection's slug ` + `so the projected table identifiers are distinct.`);
|
|
699
|
+
}
|
|
700
|
+
if (prior === undefined)
|
|
701
|
+
seen.set(table, slug);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
function escapeSqlString(s) {
|
|
705
|
+
return s.replace(/'/g, "''");
|
|
706
|
+
}
|
|
707
|
+
function buildColumnSql(collSlug, jsName, signature) {
|
|
708
|
+
const { inner, isNullable } = resolveFieldType2(signature);
|
|
709
|
+
const sqlName = camelToSnakeCase(jsName);
|
|
710
|
+
const refSlug = readSaacmsAnnotation2(inner, "saacmsRef");
|
|
711
|
+
const isInt = readSaacmsAnnotation2(inner, "saacmsInt") === true;
|
|
712
|
+
const onDelete = readSaacmsAnnotation2(inner, "saacmsRefOnDelete") ?? "cascade";
|
|
713
|
+
let sqlType;
|
|
714
|
+
let checkClause;
|
|
715
|
+
const literalUnion = tryGetStringLiteralUnion(inner);
|
|
716
|
+
if (literalUnion !== undefined) {
|
|
717
|
+
sqlType = "TEXT";
|
|
718
|
+
const values = literalUnion.map((v) => `'${escapeSqlString(v)}'`).join(", ");
|
|
719
|
+
checkClause = `CHECK (${quoteIdent(sqlName)} IN (${values}))`;
|
|
720
|
+
} else if (isDateLikeAst2(inner)) {
|
|
721
|
+
sqlType = "TEXT";
|
|
722
|
+
} else if (AST2.isStringKeyword(inner)) {
|
|
723
|
+
sqlType = "TEXT";
|
|
724
|
+
} else if (AST2.isNumberKeyword(inner)) {
|
|
725
|
+
sqlType = isInt ? "INTEGER" : "REAL";
|
|
726
|
+
} else if (AST2.isBooleanKeyword(inner)) {
|
|
727
|
+
sqlType = "INTEGER";
|
|
728
|
+
} else if (AST2.isTupleType(inner)) {
|
|
729
|
+
sqlType = "TEXT";
|
|
730
|
+
} else if (AST2.isTypeLiteral(inner)) {
|
|
731
|
+
sqlType = "TEXT";
|
|
732
|
+
} else {
|
|
733
|
+
sqlType = "TEXT";
|
|
734
|
+
}
|
|
735
|
+
const parts = [quoteIdent(sqlName), sqlType];
|
|
736
|
+
if (!isNullable) {
|
|
737
|
+
parts.push("NOT NULL");
|
|
738
|
+
}
|
|
739
|
+
if (refSlug !== undefined) {
|
|
740
|
+
if (onDelete === "set null" && !isNullable) {
|
|
741
|
+
throw new Error(`schemaToD1Migration: field "${jsName}" on collection "${collSlug}" ` + `has saacmsRefOnDelete: "set null" but the column is NOT NULL — ` + `set null requires the column to be nullable.`);
|
|
742
|
+
}
|
|
743
|
+
parts.push(`REFERENCES ${quoteIdent(slugToTableName(refSlug))}("id") ON DELETE ${onDelete.toUpperCase()}`);
|
|
744
|
+
}
|
|
745
|
+
if (checkClause !== undefined) {
|
|
746
|
+
parts.push(checkClause);
|
|
747
|
+
}
|
|
748
|
+
return parts.join(" ");
|
|
749
|
+
}
|
|
750
|
+
function schemaToD1Migration(coll) {
|
|
751
|
+
coll = withMediaFields(coll);
|
|
752
|
+
const root = coll.schema.ast;
|
|
753
|
+
if (!AST2.isTypeLiteral(root)) {
|
|
754
|
+
throw new Error(`schemaToD1Migration: root schema for "${coll.slug}" must be a Struct (TypeLiteral); got ${root._tag}`);
|
|
755
|
+
}
|
|
756
|
+
const collSlug = String(coll.slug);
|
|
757
|
+
const knownFields = new Set(["id", "createdAt", "updatedAt"]);
|
|
758
|
+
for (const ps of root.propertySignatures) {
|
|
759
|
+
knownFields.add(String(ps.name));
|
|
760
|
+
}
|
|
761
|
+
const lines = [];
|
|
762
|
+
lines.push(`${quoteIdent("id")} TEXT PRIMARY KEY NOT NULL`);
|
|
763
|
+
for (const ps of root.propertySignatures) {
|
|
764
|
+
const name = String(ps.name);
|
|
765
|
+
if (name === "id" || name === "createdAt" || name === "updatedAt")
|
|
766
|
+
continue;
|
|
767
|
+
lines.push(buildColumnSql(collSlug, name, ps));
|
|
768
|
+
}
|
|
769
|
+
lines.push(`${quoteIdent("created_at")} TEXT NOT NULL DEFAULT (datetime('now'))`);
|
|
770
|
+
lines.push(`${quoteIdent("updated_at")} TEXT NOT NULL DEFAULT (datetime('now'))`);
|
|
771
|
+
if (coll.unique != null && coll.unique.length > 0) {
|
|
772
|
+
for (const group of coll.unique) {
|
|
773
|
+
if (group.length === 0)
|
|
774
|
+
continue;
|
|
775
|
+
const snakeCols = [];
|
|
776
|
+
for (const camelName of group) {
|
|
777
|
+
if (!knownFields.has(camelName)) {
|
|
778
|
+
throw new Error(`schemaToD1Migration: unique group references unknown field "${camelName}" ` + `on collection "${collSlug}". Known fields: ${[...knownFields].sort().join(", ")}.`);
|
|
779
|
+
}
|
|
780
|
+
snakeCols.push(quoteIdent(camelToSnakeCase(camelName)));
|
|
781
|
+
}
|
|
782
|
+
lines.push(`UNIQUE(${snakeCols.join(", ")})`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
const body = lines.map((l) => ` ${l}`).join(`,
|
|
786
|
+
`);
|
|
787
|
+
return `CREATE TABLE IF NOT EXISTS ${quoteIdent(slugToTableName(collSlug))} (
|
|
788
|
+
${body}
|
|
789
|
+
);`;
|
|
790
|
+
}
|
|
791
|
+
function schemaToD1Migrations(colls) {
|
|
792
|
+
assertNoTableNameCollisions(colls);
|
|
793
|
+
return colls.map((c) => schemaToD1Migration(c)).join(`
|
|
794
|
+
|
|
795
|
+
`);
|
|
796
|
+
}
|
|
797
|
+
// src/codegen/content-migration.ts
|
|
798
|
+
import { SchemaAST as AST3 } from "effect";
|
|
799
|
+
function isNullKeyword3(ast) {
|
|
800
|
+
return AST3.isLiteral(ast) && ast.literal === null;
|
|
801
|
+
}
|
|
802
|
+
function resolveFieldType3(sig) {
|
|
803
|
+
let t = sig.type;
|
|
804
|
+
let optional = sig.isOptional;
|
|
805
|
+
if (AST3.isUnion(t)) {
|
|
806
|
+
const nonNullish = t.types.filter((m) => !AST3.isUndefinedKeyword(m) && !isNullKeyword3(m));
|
|
807
|
+
if (nonNullish.length !== t.types.length)
|
|
808
|
+
optional = true;
|
|
809
|
+
if (nonNullish.length === 1)
|
|
810
|
+
t = nonNullish[0];
|
|
811
|
+
}
|
|
812
|
+
while (AST3.isRefinement(t))
|
|
813
|
+
t = t.from;
|
|
814
|
+
return { inner: t, optional };
|
|
815
|
+
}
|
|
816
|
+
function literalUnionValues(ast) {
|
|
817
|
+
if (!AST3.isUnion(ast)) {
|
|
818
|
+
if (AST3.isLiteral(ast))
|
|
819
|
+
return [String(ast.literal)];
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const out = [];
|
|
823
|
+
for (const m of ast.types) {
|
|
824
|
+
if (AST3.isUndefinedKeyword(m) || isNullKeyword3(m))
|
|
825
|
+
continue;
|
|
826
|
+
if (AST3.isLiteral(m))
|
|
827
|
+
out.push(String(m.literal));
|
|
828
|
+
else
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
return out.length > 0 ? out : undefined;
|
|
832
|
+
}
|
|
833
|
+
function projectType(ast) {
|
|
834
|
+
const lits = literalUnionValues(ast);
|
|
835
|
+
if (lits !== undefined)
|
|
836
|
+
return `literal:${[...lits].sort().join("|")}`;
|
|
837
|
+
if (AST3.isStringKeyword(ast))
|
|
838
|
+
return "string";
|
|
839
|
+
if (AST3.isNumberKeyword(ast))
|
|
840
|
+
return "number";
|
|
841
|
+
if (AST3.isBooleanKeyword(ast))
|
|
842
|
+
return "boolean";
|
|
843
|
+
if (AST3.isTupleType(ast))
|
|
844
|
+
return "array";
|
|
845
|
+
if (AST3.isTypeLiteral(ast))
|
|
846
|
+
return "object";
|
|
847
|
+
return "unknown";
|
|
848
|
+
}
|
|
849
|
+
function fingerprintBlockSchema(block) {
|
|
850
|
+
const root = block.schema.ast;
|
|
851
|
+
if (!AST3.isTypeLiteral(root)) {
|
|
852
|
+
throw new Error(`fingerprintBlockSchema: root schema for Block "${block.slug}" must be a Struct (TypeLiteral); got ${root._tag}`);
|
|
853
|
+
}
|
|
854
|
+
const fields = root.propertySignatures.map((ps) => {
|
|
855
|
+
const { inner, optional } = resolveFieldType3(ps);
|
|
856
|
+
return { name: String(ps.name), type: projectType(inner), optional };
|
|
857
|
+
});
|
|
858
|
+
fields.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
859
|
+
return { fields };
|
|
860
|
+
}
|
|
861
|
+
function fingerprintToString(fp) {
|
|
862
|
+
return JSON.stringify(fp.fields);
|
|
863
|
+
}
|
|
864
|
+
function diffBlockSchemas(prev, next) {
|
|
865
|
+
const prevByName = new Map(prev.fields.map((f) => [f.name, f]));
|
|
866
|
+
const nextByName = new Map(next.fields.map((f) => [f.name, f]));
|
|
867
|
+
const retyped = [];
|
|
868
|
+
for (const f of next.fields) {
|
|
869
|
+
const p = prevByName.get(f.name);
|
|
870
|
+
if (p !== undefined && p.type !== f.type) {
|
|
871
|
+
retyped.push({ name: f.name, fromType: p.type, toType: f.type });
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
const removedNames = prev.fields.filter((f) => !nextByName.has(f.name)).map((f) => f.name);
|
|
875
|
+
const addedFields = next.fields.filter((f) => !prevByName.has(f.name));
|
|
876
|
+
const renamed = [];
|
|
877
|
+
const consumedAdds = new Set;
|
|
878
|
+
const consumedRemoves = new Set;
|
|
879
|
+
for (const rm of removedNames) {
|
|
880
|
+
const rmShape = prevByName.get(rm);
|
|
881
|
+
const match = addedFields.find((a) => !consumedAdds.has(a.name) && a.type === rmShape.type);
|
|
882
|
+
if (match !== undefined) {
|
|
883
|
+
renamed.push({ from: rm, to: match.name, type: match.type });
|
|
884
|
+
consumedAdds.add(match.name);
|
|
885
|
+
consumedRemoves.add(rm);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
const added = addedFields.filter((f) => !consumedAdds.has(f.name)).map((f) => ({ name: f.name, type: f.type, optional: f.optional }));
|
|
889
|
+
const removed = removedNames.filter((n) => !consumedRemoves.has(n)).map((n) => ({ name: n }));
|
|
890
|
+
const changed = added.length > 0 || removed.length > 0 || renamed.length > 0 || retyped.length > 0;
|
|
891
|
+
return { added, removed, renamed, retyped, changed };
|
|
892
|
+
}
|
|
893
|
+
function defaultLiteralFor(type) {
|
|
894
|
+
if (type === "string")
|
|
895
|
+
return `""`;
|
|
896
|
+
if (type === "number")
|
|
897
|
+
return `0`;
|
|
898
|
+
if (type === "boolean")
|
|
899
|
+
return `false`;
|
|
900
|
+
if (type === "array")
|
|
901
|
+
return `[]`;
|
|
902
|
+
if (type === "object")
|
|
903
|
+
return `{}`;
|
|
904
|
+
if (type.startsWith("literal:")) {
|
|
905
|
+
const first = type.slice("literal:".length).split("|")[0] ?? "";
|
|
906
|
+
return JSON.stringify(first);
|
|
907
|
+
}
|
|
908
|
+
return `null`;
|
|
909
|
+
}
|
|
910
|
+
function jsKey(name) {
|
|
911
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? `.${name}` : `[${JSON.stringify(name)}]`;
|
|
912
|
+
}
|
|
913
|
+
function contentMigrationModuleSource(blockSlug, fromVersion, toVersion, diff) {
|
|
914
|
+
const slugLit = JSON.stringify(blockSlug);
|
|
915
|
+
const ops = [];
|
|
916
|
+
for (const r of diff.renamed) {
|
|
917
|
+
ops.push(` // renamed: ${r.from} -> ${r.to} (type ${r.type}) — copy then drop
|
|
918
|
+
` + ` if (${JSON.stringify(r.from)} in props) {
|
|
919
|
+
` + ` props${jsKey(r.to)} = props${jsKey(r.from)}
|
|
920
|
+
` + ` delete props${jsKey(r.from)}
|
|
921
|
+
` + ` }`);
|
|
922
|
+
}
|
|
923
|
+
for (const a of diff.added) {
|
|
924
|
+
ops.push(` // added: ${a.name} (type ${a.type}${a.optional ? ", optional" : ""}) — backfill default
|
|
925
|
+
` + ` if (!(${JSON.stringify(a.name)} in props)) {
|
|
926
|
+
` + ` props${jsKey(a.name)} = ${defaultLiteralFor(a.type)}
|
|
927
|
+
` + ` }`);
|
|
928
|
+
}
|
|
929
|
+
for (const rm of diff.removed) {
|
|
930
|
+
ops.push(` // removed: ${rm.name} — drop
|
|
931
|
+
` + ` delete props${jsKey(rm.name)}`);
|
|
932
|
+
}
|
|
933
|
+
for (const rt of diff.retyped) {
|
|
934
|
+
ops.push(` // RETYPED: ${rt.name} ${rt.fromType} -> ${rt.toType}
|
|
935
|
+
` + ` // TODO(dev): a type change cannot be migrated automatically — convert
|
|
936
|
+
` + ` // props${jsKey(rt.name)} from ${rt.fromType} to ${rt.toType} here, then
|
|
937
|
+
` + ` // delete this TODO. Left as a no-op so the migration is reviewable.`);
|
|
938
|
+
}
|
|
939
|
+
const opsBody = ops.length > 0 ? ops.join(`
|
|
940
|
+
|
|
941
|
+
`) : " // (no structural change)";
|
|
942
|
+
return `/**
|
|
943
|
+
* AUTO-GENERATED content migration (saacms migrate generate) per ADR 0012.
|
|
944
|
+
*
|
|
945
|
+
* Block ${slugLit}: v${fromVersion} -> v${toVersion}.
|
|
946
|
+
*
|
|
947
|
+
* Review this transform before committing. It is deterministic and runs over
|
|
948
|
+
* all stored Page JSON (Git files in dev, DB-stored Pages in prod). Forward-
|
|
949
|
+
* only: rollback is git-revert + re-run (ADR 0012 "Deferred to v2").
|
|
950
|
+
*
|
|
951
|
+
* ACTION REQUIRED: bump \`version: ${toVersion}\` on the \`defineBlock({...})\`
|
|
952
|
+
* for ${slugLit} in your Block source. saacms does not edit your authored
|
|
953
|
+
* structure source — only stored instances (CONTEXT.md "Schema is the single
|
|
954
|
+
* source of truth; projections are mechanical").
|
|
955
|
+
*/
|
|
956
|
+
|
|
957
|
+
export const meta = {
|
|
958
|
+
block: ${slugLit},
|
|
959
|
+
fromVersion: ${fromVersion},
|
|
960
|
+
toVersion: ${toVersion},
|
|
961
|
+
} as const
|
|
962
|
+
|
|
963
|
+
const BLOCK_TYPE = ${slugLit}
|
|
964
|
+
|
|
965
|
+
type Json = unknown
|
|
966
|
+
interface BlockNode {
|
|
967
|
+
type?: unknown
|
|
968
|
+
props?: Record<string, unknown>
|
|
969
|
+
[key: string]: unknown
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function isRecord(v: Json): v is Record<string, unknown> {
|
|
973
|
+
return typeof v === "object" && v !== null && !Array.isArray(v)
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/** Apply the v${fromVersion}->v${toVersion} field changes to one matching Block node's props. */
|
|
977
|
+
function applyToBlock(node: BlockNode): BlockNode {
|
|
978
|
+
const props: Record<string, unknown> = { ...(node.props ?? {}) }
|
|
979
|
+
|
|
980
|
+
${opsBody}
|
|
981
|
+
|
|
982
|
+
return { ...node, props }
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** Deep-walk any Page JSON value; transform every ${slugLit} Block instance. */
|
|
986
|
+
function walk(value: Json): Json {
|
|
987
|
+
if (Array.isArray(value)) return value.map(walk)
|
|
988
|
+
if (isRecord(value)) {
|
|
989
|
+
let node: Record<string, unknown> = value
|
|
990
|
+
if (typeof node["type"] === "string" && node["type"] === BLOCK_TYPE) {
|
|
991
|
+
node = applyToBlock(node as BlockNode) as Record<string, unknown>
|
|
992
|
+
}
|
|
993
|
+
const out: Record<string, unknown> = {}
|
|
994
|
+
for (const k of Object.keys(node)) out[k] = walk(node[k])
|
|
995
|
+
return out
|
|
996
|
+
}
|
|
997
|
+
return value
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Transform stored Page JSON from Block ${slugLit}@v${fromVersion} to @v${toVersion}.
|
|
1002
|
+
* Pure: returns a new value; never mutates the input.
|
|
1003
|
+
*/
|
|
1004
|
+
export function up(pageJson: unknown): unknown {
|
|
1005
|
+
return walk(pageJson)
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/** Forward-only: v1 has no rollback machinery (ADR 0012 "Deferred to v2"). */
|
|
1009
|
+
export function down(_pageJson: unknown): never {
|
|
1010
|
+
throw new Error(
|
|
1011
|
+
"content migration ${blockSlug} v${toVersion}->v${fromVersion}: down() is not implemented (forward-only per ADR 0012; roll back via git-revert + re-run)",
|
|
1012
|
+
)
|
|
1013
|
+
}
|
|
1014
|
+
`;
|
|
1015
|
+
}
|
|
1016
|
+
function detectPendingContentMigrations(input) {
|
|
1017
|
+
const pending = [];
|
|
1018
|
+
for (const block of input.blocks) {
|
|
1019
|
+
const slug = String(block.slug);
|
|
1020
|
+
const entry = input.snapshot[slug];
|
|
1021
|
+
if (entry === undefined)
|
|
1022
|
+
continue;
|
|
1023
|
+
const currentFp = fingerprintToString(fingerprintBlockSchema(block));
|
|
1024
|
+
const fingerprintDrifted = currentFp !== entry.fingerprint;
|
|
1025
|
+
const expectedFile = `${slug}@v${entry.version}`;
|
|
1026
|
+
const notApplied = input.appliedMigrations !== undefined && !input.appliedMigrations.some((f) => f.includes(slug));
|
|
1027
|
+
if (!fingerprintDrifted && !notApplied)
|
|
1028
|
+
continue;
|
|
1029
|
+
const fromVersion = entry.version;
|
|
1030
|
+
const toVersion = fingerprintDrifted ? entry.version + 1 : entry.version;
|
|
1031
|
+
const reason = fingerprintDrifted ? `Page Blocks may be on \`${slug}@v${fromVersion}\`; current schema is ` + `\`${slug}@v${toVersion}\`; run \`saacms migrate generate\` then apply ` + `\`saacms/migrations/content/NNNN_*.ts\`` : `content migration \`${expectedFile}\` was generated for \`${slug}\` ` + `but has not been applied; run \`saacms migrate run\``;
|
|
1032
|
+
pending.push({ slug, fromVersion, toVersion, reason });
|
|
1033
|
+
}
|
|
1034
|
+
return { pending, clean: pending.length === 0 };
|
|
1035
|
+
}
|
|
1036
|
+
// src/codegen/to-puck-fields.ts
|
|
1037
|
+
import { SchemaAST as AST4 } from "effect";
|
|
1038
|
+
function readSaacmsAnnotation3(ast, key) {
|
|
1039
|
+
const v = ast.annotations[key];
|
|
1040
|
+
return v === undefined ? undefined : v;
|
|
1041
|
+
}
|
|
1042
|
+
function isNullKeyword4(ast) {
|
|
1043
|
+
return AST4.isLiteral(ast) && ast.literal === null;
|
|
1044
|
+
}
|
|
1045
|
+
function peel(ast) {
|
|
1046
|
+
let t = ast;
|
|
1047
|
+
if (AST4.isUnion(t)) {
|
|
1048
|
+
const nonNullish = t.types.filter((m) => !AST4.isUndefinedKeyword(m) && !isNullKeyword4(m));
|
|
1049
|
+
if (nonNullish.length === 1 && nonNullish.length !== t.types.length) {
|
|
1050
|
+
t = nonNullish[0];
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
while (AST4.isRefinement(t)) {
|
|
1054
|
+
t = t.from;
|
|
1055
|
+
}
|
|
1056
|
+
return t;
|
|
1057
|
+
}
|
|
1058
|
+
function tryGetStringLiteralUnion2(ast) {
|
|
1059
|
+
if (!AST4.isUnion(ast))
|
|
1060
|
+
return;
|
|
1061
|
+
const out = [];
|
|
1062
|
+
for (const m of ast.types) {
|
|
1063
|
+
if (AST4.isUndefinedKeyword(m) || isNullKeyword4(m))
|
|
1064
|
+
continue;
|
|
1065
|
+
if (AST4.isLiteral(m) && typeof m.literal === "string") {
|
|
1066
|
+
out.push(m.literal);
|
|
1067
|
+
} else {
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return out.length > 0 ? out : undefined;
|
|
1072
|
+
}
|
|
1073
|
+
function defaultLabel(jsName) {
|
|
1074
|
+
if (jsName.length === 0)
|
|
1075
|
+
return jsName;
|
|
1076
|
+
const spaced = jsName.replace(/([A-Z])/g, " $1").trim();
|
|
1077
|
+
return spaced.split(/\s+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
1078
|
+
}
|
|
1079
|
+
function literalToOption(value) {
|
|
1080
|
+
return {
|
|
1081
|
+
label: value.charAt(0).toUpperCase() + value.slice(1),
|
|
1082
|
+
value
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
function astToPuckField(ast, jsName) {
|
|
1086
|
+
const inner = peel(ast);
|
|
1087
|
+
const labelOverride = readSaacmsAnnotation3(inner, "saacmsLabel");
|
|
1088
|
+
const label = labelOverride ?? defaultLabel(jsName);
|
|
1089
|
+
const literals = tryGetStringLiteralUnion2(inner);
|
|
1090
|
+
if (literals !== undefined) {
|
|
1091
|
+
const options = literals.map(literalToOption);
|
|
1092
|
+
const isRadio = readSaacmsAnnotation3(inner, "saacmsRadio") === true;
|
|
1093
|
+
return isRadio ? { type: "radio", label, options } : { type: "select", label, options };
|
|
1094
|
+
}
|
|
1095
|
+
if (AST4.isStringKeyword(inner)) {
|
|
1096
|
+
const isMultiline = readSaacmsAnnotation3(inner, "saacmsMultiline") === true;
|
|
1097
|
+
return isMultiline ? { type: "textarea", label } : { type: "text", label };
|
|
1098
|
+
}
|
|
1099
|
+
if (AST4.isNumberKeyword(inner)) {
|
|
1100
|
+
return { type: "number", label };
|
|
1101
|
+
}
|
|
1102
|
+
if (AST4.isBooleanKeyword(inner)) {
|
|
1103
|
+
return {
|
|
1104
|
+
type: "radio",
|
|
1105
|
+
label,
|
|
1106
|
+
options: [
|
|
1107
|
+
{ label: "Yes", value: "true" },
|
|
1108
|
+
{ label: "No", value: "false" }
|
|
1109
|
+
]
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
if (AST4.isTupleType(inner)) {
|
|
1113
|
+
const elementAst = inner.rest[0]?.type;
|
|
1114
|
+
if (elementAst !== undefined) {
|
|
1115
|
+
const peeledEl = peel(elementAst);
|
|
1116
|
+
if (AST4.isTypeLiteral(peeledEl)) {
|
|
1117
|
+
return {
|
|
1118
|
+
type: "array",
|
|
1119
|
+
label,
|
|
1120
|
+
arrayFields: structToFields(peeledEl)
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
return { type: "text", label };
|
|
1125
|
+
}
|
|
1126
|
+
if (AST4.isTypeLiteral(inner)) {
|
|
1127
|
+
return {
|
|
1128
|
+
type: "object",
|
|
1129
|
+
label,
|
|
1130
|
+
objectFields: structToFields(inner)
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
return { type: "text", label };
|
|
1134
|
+
}
|
|
1135
|
+
function structToFields(struct) {
|
|
1136
|
+
const out = {};
|
|
1137
|
+
for (const ps of struct.propertySignatures) {
|
|
1138
|
+
const name = String(ps.name);
|
|
1139
|
+
out[name] = astToPuckField(ps.type, name);
|
|
1140
|
+
}
|
|
1141
|
+
return out;
|
|
1142
|
+
}
|
|
1143
|
+
function schemaToPuckFields(coll) {
|
|
1144
|
+
coll = withMediaFields(coll);
|
|
1145
|
+
const root = coll.schema.ast;
|
|
1146
|
+
if (!AST4.isTypeLiteral(root)) {
|
|
1147
|
+
throw new Error(`schemaToPuckFields: root schema for "${coll.slug}" must be a Struct (TypeLiteral); got ${root._tag}`);
|
|
1148
|
+
}
|
|
1149
|
+
return structToFields(root);
|
|
1150
|
+
}
|
|
1151
|
+
// src/codegen/to-ts-types.ts
|
|
1152
|
+
import { Option as Option3, SchemaAST as AST5 } from "effect";
|
|
1153
|
+
function readSaacmsAnnotation4(ast, key) {
|
|
1154
|
+
const v = ast.annotations[key];
|
|
1155
|
+
return v === undefined ? undefined : v;
|
|
1156
|
+
}
|
|
1157
|
+
function isNullKeyword5(ast) {
|
|
1158
|
+
return AST5.isLiteral(ast) && ast.literal === null;
|
|
1159
|
+
}
|
|
1160
|
+
function innerNode(ast) {
|
|
1161
|
+
let t = ast;
|
|
1162
|
+
if (AST5.isUnion(t)) {
|
|
1163
|
+
const nonNullish = t.types.filter((m) => !AST5.isUndefinedKeyword(m) && !isNullKeyword5(m));
|
|
1164
|
+
if (nonNullish.length === 1 && nonNullish.length !== t.types.length) {
|
|
1165
|
+
t = nonNullish[0];
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
while (AST5.isRefinement(t)) {
|
|
1169
|
+
t = t.from;
|
|
1170
|
+
}
|
|
1171
|
+
return t;
|
|
1172
|
+
}
|
|
1173
|
+
function isDateLikeAst3(ast) {
|
|
1174
|
+
const id = Option3.getOrUndefined(AST5.getIdentifierAnnotation(ast));
|
|
1175
|
+
return id === "DateFromSelf" || id === "Date" || id === "ValidDateFromSelf" || id === "DateFromString" || id === "DateFromNumber";
|
|
1176
|
+
}
|
|
1177
|
+
function literalToTs(literal) {
|
|
1178
|
+
if (literal === null)
|
|
1179
|
+
return "null";
|
|
1180
|
+
if (typeof literal === "bigint")
|
|
1181
|
+
return `${literal.toString()}n`;
|
|
1182
|
+
return JSON.stringify(literal);
|
|
1183
|
+
}
|
|
1184
|
+
function astToTsType(ast) {
|
|
1185
|
+
let t = ast;
|
|
1186
|
+
while (AST5.isRefinement(t)) {
|
|
1187
|
+
t = t.from;
|
|
1188
|
+
}
|
|
1189
|
+
if (AST5.isUnion(t)) {
|
|
1190
|
+
const members = t.types.filter((m) => !AST5.isUndefinedKeyword(m));
|
|
1191
|
+
const hasNull = members.some(isNullKeyword5);
|
|
1192
|
+
const nonNull = members.filter((m) => !isNullKeyword5(m));
|
|
1193
|
+
let base;
|
|
1194
|
+
if (nonNull.length > 0 && nonNull.every((m) => AST5.isLiteral(m))) {
|
|
1195
|
+
base = nonNull.map((m) => literalToTs(m.literal)).join(" | ");
|
|
1196
|
+
} else if (nonNull.length === 1) {
|
|
1197
|
+
base = astToTsType(nonNull[0]);
|
|
1198
|
+
} else if (nonNull.length === 0) {
|
|
1199
|
+
base = "never";
|
|
1200
|
+
} else {
|
|
1201
|
+
base = "unknown";
|
|
1202
|
+
}
|
|
1203
|
+
return hasNull ? `${base} | null` : base;
|
|
1204
|
+
}
|
|
1205
|
+
if (AST5.isLiteral(t)) {
|
|
1206
|
+
return literalToTs(t.literal);
|
|
1207
|
+
}
|
|
1208
|
+
if (isDateLikeAst3(t)) {
|
|
1209
|
+
return "string";
|
|
1210
|
+
}
|
|
1211
|
+
if (AST5.isStringKeyword(t)) {
|
|
1212
|
+
return "string";
|
|
1213
|
+
}
|
|
1214
|
+
if (AST5.isNumberKeyword(t)) {
|
|
1215
|
+
return "number";
|
|
1216
|
+
}
|
|
1217
|
+
if (AST5.isBooleanKeyword(t)) {
|
|
1218
|
+
return "boolean";
|
|
1219
|
+
}
|
|
1220
|
+
if (AST5.isTupleType(t)) {
|
|
1221
|
+
const elementAst = t.rest[0]?.type;
|
|
1222
|
+
if (elementAst !== undefined) {
|
|
1223
|
+
const el = astToTsType(elementAst);
|
|
1224
|
+
return el.includes(" | ") ? `(${el})[]` : `${el}[]`;
|
|
1225
|
+
}
|
|
1226
|
+
return "unknown";
|
|
1227
|
+
}
|
|
1228
|
+
if (AST5.isTypeLiteral(t)) {
|
|
1229
|
+
return structToInlineType(t);
|
|
1230
|
+
}
|
|
1231
|
+
return "unknown";
|
|
1232
|
+
}
|
|
1233
|
+
function structToInlineType(struct) {
|
|
1234
|
+
const props = struct.propertySignatures.map((ps) => emitProperty(ps));
|
|
1235
|
+
if (props.length === 0)
|
|
1236
|
+
return "{}";
|
|
1237
|
+
return `{ ${props.join("; ")} }`;
|
|
1238
|
+
}
|
|
1239
|
+
function emitProperty(ps) {
|
|
1240
|
+
const inner = innerNode(ps.type);
|
|
1241
|
+
const nameOverride = readSaacmsAnnotation4(inner, "saacmsTsName");
|
|
1242
|
+
const name = nameOverride ?? String(ps.name);
|
|
1243
|
+
const hasUndefinedMember = AST5.isUnion(ps.type) && ps.type.types.some(AST5.isUndefinedKeyword);
|
|
1244
|
+
const optional = ps.isOptional || hasUndefinedMember;
|
|
1245
|
+
const tsType = astToTsType(ps.type);
|
|
1246
|
+
return `${name}${optional ? "?" : ""}: ${tsType}`;
|
|
1247
|
+
}
|
|
1248
|
+
function pascalCase2(slug) {
|
|
1249
|
+
return slug.split(/[^a-zA-Z0-9]+/).filter((s) => s.length > 0).map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
|
|
1250
|
+
}
|
|
1251
|
+
var HEADER = "// generated by saacms — do not edit";
|
|
1252
|
+
function schemaToTsType(coll) {
|
|
1253
|
+
coll = withMediaFields(coll);
|
|
1254
|
+
const root = coll.schema.ast;
|
|
1255
|
+
if (!AST5.isTypeLiteral(root)) {
|
|
1256
|
+
throw new Error(`schemaToTsType: root schema for "${coll.slug}" must be a Struct (TypeLiteral); got ${root._tag}`);
|
|
1257
|
+
}
|
|
1258
|
+
const name = pascalCase2(String(coll.slug));
|
|
1259
|
+
const lines = ["id: string;"];
|
|
1260
|
+
for (const ps of root.propertySignatures) {
|
|
1261
|
+
const key = String(ps.name);
|
|
1262
|
+
if (key === "id" || key === "createdAt" || key === "updatedAt")
|
|
1263
|
+
continue;
|
|
1264
|
+
lines.push(`${emitProperty(ps)};`);
|
|
1265
|
+
}
|
|
1266
|
+
lines.push("createdAt: string;");
|
|
1267
|
+
lines.push("updatedAt: string;");
|
|
1268
|
+
const body = lines.map((l) => ` ${l}`).join(`
|
|
1269
|
+
`);
|
|
1270
|
+
return `export interface ${name} {
|
|
1271
|
+
${body}
|
|
1272
|
+
}`;
|
|
1273
|
+
}
|
|
1274
|
+
function schemaToTsTypes(colls) {
|
|
1275
|
+
return `${HEADER}
|
|
1276
|
+
|
|
1277
|
+
${colls.map((c) => schemaToTsType(c)).join(`
|
|
1278
|
+
|
|
1279
|
+
`)}`;
|
|
1280
|
+
}
|
|
1281
|
+
// src/codegen/filter-openapi-for-user.ts
|
|
1282
|
+
import { SchemaAST as SchemaAST2 } from "effect";
|
|
1283
|
+
async function filterOpenApiForUser(input) {
|
|
1284
|
+
const cloned = structuredClone({
|
|
1285
|
+
paths: input.spec.paths,
|
|
1286
|
+
components: {
|
|
1287
|
+
schemas: { ...input.spec.components.schemas }
|
|
1288
|
+
}
|
|
1289
|
+
});
|
|
1290
|
+
const slugMap = new Map;
|
|
1291
|
+
for (const c of input.collections)
|
|
1292
|
+
slugMap.set(String(c.slug), c);
|
|
1293
|
+
const newPaths = {};
|
|
1294
|
+
for (const [path, item] of Object.entries(cloned.paths)) {
|
|
1295
|
+
if (item == null)
|
|
1296
|
+
continue;
|
|
1297
|
+
const slug = matchSlug(path, slugMap);
|
|
1298
|
+
const coll = slug != null ? slugMap.get(slug) : undefined;
|
|
1299
|
+
const filtered = await filterPathItem(path, item, coll, input.user);
|
|
1300
|
+
if (filtered != null)
|
|
1301
|
+
newPaths[path] = filtered;
|
|
1302
|
+
}
|
|
1303
|
+
const newSchemas = {
|
|
1304
|
+
...cloned.components.schemas
|
|
1305
|
+
};
|
|
1306
|
+
for (const coll of input.collections) {
|
|
1307
|
+
const recordName = pascalCase3(String(coll.slug));
|
|
1308
|
+
const schemaObj = newSchemas[recordName];
|
|
1309
|
+
if (schemaObj == null)
|
|
1310
|
+
continue;
|
|
1311
|
+
newSchemas[recordName] = await filterFieldsOnSchema(coll, schemaObj, input.user);
|
|
1312
|
+
}
|
|
1313
|
+
const referenced = collectReferenced(newPaths, newSchemas);
|
|
1314
|
+
referenced.add("ProblemDetails");
|
|
1315
|
+
const prunedSchemas = {};
|
|
1316
|
+
for (const [name, s] of Object.entries(newSchemas)) {
|
|
1317
|
+
if (referenced.has(name))
|
|
1318
|
+
prunedSchemas[name] = s;
|
|
1319
|
+
}
|
|
1320
|
+
return {
|
|
1321
|
+
paths: newPaths,
|
|
1322
|
+
components: { schemas: prunedSchemas }
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
function matchSlug(path, slugMap) {
|
|
1326
|
+
const cleaned = path.split(":")[0] ?? path;
|
|
1327
|
+
const segments = cleaned.split("/").filter((s) => s.length > 0);
|
|
1328
|
+
for (const seg of segments) {
|
|
1329
|
+
if (slugMap.has(seg))
|
|
1330
|
+
return seg;
|
|
1331
|
+
}
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
var METHOD_KEYS = [
|
|
1335
|
+
"get",
|
|
1336
|
+
"put",
|
|
1337
|
+
"post",
|
|
1338
|
+
"patch",
|
|
1339
|
+
"delete",
|
|
1340
|
+
"options",
|
|
1341
|
+
"head",
|
|
1342
|
+
"trace"
|
|
1343
|
+
];
|
|
1344
|
+
function methodToVerb(path, method) {
|
|
1345
|
+
if (method === "post" && path.includes(":"))
|
|
1346
|
+
return "update";
|
|
1347
|
+
switch (method) {
|
|
1348
|
+
case "get":
|
|
1349
|
+
return "read";
|
|
1350
|
+
case "post":
|
|
1351
|
+
return "create";
|
|
1352
|
+
case "put":
|
|
1353
|
+
case "patch":
|
|
1354
|
+
return "update";
|
|
1355
|
+
case "delete":
|
|
1356
|
+
return "delete";
|
|
1357
|
+
case "options":
|
|
1358
|
+
case "head":
|
|
1359
|
+
case "trace":
|
|
1360
|
+
return "read";
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
async function filterPathItem(path, item, coll, user) {
|
|
1364
|
+
if (coll == null)
|
|
1365
|
+
return item;
|
|
1366
|
+
const mutable = item;
|
|
1367
|
+
let kept = 0;
|
|
1368
|
+
let seenOperation = false;
|
|
1369
|
+
for (const m of METHOD_KEYS) {
|
|
1370
|
+
if (mutable[m] == null)
|
|
1371
|
+
continue;
|
|
1372
|
+
seenOperation = true;
|
|
1373
|
+
const verb = methodToVerb(path, m);
|
|
1374
|
+
const predicate = coll.access?.[verb];
|
|
1375
|
+
if (predicate == null) {
|
|
1376
|
+
kept++;
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
const allowed = await evaluatePredicateAllow(predicate, user);
|
|
1380
|
+
if (allowed)
|
|
1381
|
+
kept++;
|
|
1382
|
+
else
|
|
1383
|
+
delete mutable[m];
|
|
1384
|
+
}
|
|
1385
|
+
if (seenOperation && kept === 0)
|
|
1386
|
+
return null;
|
|
1387
|
+
return mutable;
|
|
1388
|
+
}
|
|
1389
|
+
async function evaluatePredicateAllow(predicate, user) {
|
|
1390
|
+
try {
|
|
1391
|
+
const result = await Promise.resolve(predicate({ user, record: undefined }));
|
|
1392
|
+
return result !== false;
|
|
1393
|
+
} catch {
|
|
1394
|
+
return true;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
async function filterFieldsOnSchema(coll, schemaObj, user) {
|
|
1398
|
+
const ast = coll.schema.ast;
|
|
1399
|
+
if (!SchemaAST2.isTypeLiteral(ast))
|
|
1400
|
+
return schemaObj;
|
|
1401
|
+
if (schemaObj.properties == null)
|
|
1402
|
+
return schemaObj;
|
|
1403
|
+
const toRemove = new Set;
|
|
1404
|
+
for (const prop of ast.propertySignatures) {
|
|
1405
|
+
const fa = findFieldAccessDeep(prop);
|
|
1406
|
+
const readPred = fa?.read;
|
|
1407
|
+
if (readPred == null)
|
|
1408
|
+
continue;
|
|
1409
|
+
const allowed = await evaluatePredicateAllow(readPred, user);
|
|
1410
|
+
if (!allowed)
|
|
1411
|
+
toRemove.add(String(prop.name));
|
|
1412
|
+
}
|
|
1413
|
+
if (toRemove.size === 0)
|
|
1414
|
+
return schemaObj;
|
|
1415
|
+
const nextProps = {};
|
|
1416
|
+
for (const [k, v] of Object.entries(schemaObj.properties)) {
|
|
1417
|
+
if (!toRemove.has(k))
|
|
1418
|
+
nextProps[k] = v;
|
|
1419
|
+
}
|
|
1420
|
+
const next = { ...schemaObj, properties: nextProps };
|
|
1421
|
+
if (schemaObj.required != null) {
|
|
1422
|
+
const nextRequired = schemaObj.required.filter((r) => !toRemove.has(r));
|
|
1423
|
+
if (nextRequired.length === 0)
|
|
1424
|
+
delete next["required"];
|
|
1425
|
+
else
|
|
1426
|
+
next["required"] = nextRequired;
|
|
1427
|
+
}
|
|
1428
|
+
return next;
|
|
1429
|
+
}
|
|
1430
|
+
function findFieldAccessDeep(prop) {
|
|
1431
|
+
const sig = readFieldAccess(prop);
|
|
1432
|
+
if (sig != null)
|
|
1433
|
+
return sig;
|
|
1434
|
+
let current = prop.type;
|
|
1435
|
+
for (let depth = 0;depth < 8 && current != null; depth++) {
|
|
1436
|
+
const here = readFieldAccess(current);
|
|
1437
|
+
if (here != null)
|
|
1438
|
+
return here;
|
|
1439
|
+
const next = current.from;
|
|
1440
|
+
if (next == null || next === current)
|
|
1441
|
+
break;
|
|
1442
|
+
current = next;
|
|
1443
|
+
}
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
function readFieldAccess(annotated) {
|
|
1447
|
+
const a = annotated.annotations;
|
|
1448
|
+
const fa = a["saacmsAccess"];
|
|
1449
|
+
if (fa != null && typeof fa === "object")
|
|
1450
|
+
return fa;
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
function collectReferenced(paths, schemas) {
|
|
1454
|
+
const referenced = new Set;
|
|
1455
|
+
walkRefs(paths, referenced);
|
|
1456
|
+
let changed = true;
|
|
1457
|
+
while (changed) {
|
|
1458
|
+
changed = false;
|
|
1459
|
+
for (const name of [...referenced]) {
|
|
1460
|
+
const s = schemas[name];
|
|
1461
|
+
if (s == null)
|
|
1462
|
+
continue;
|
|
1463
|
+
const before = referenced.size;
|
|
1464
|
+
walkRefs(s, referenced);
|
|
1465
|
+
if (referenced.size > before)
|
|
1466
|
+
changed = true;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
return referenced;
|
|
1470
|
+
}
|
|
1471
|
+
function walkRefs(value, out) {
|
|
1472
|
+
if (value == null)
|
|
1473
|
+
return;
|
|
1474
|
+
if (Array.isArray(value)) {
|
|
1475
|
+
for (const v of value)
|
|
1476
|
+
walkRefs(v, out);
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
if (typeof value !== "object")
|
|
1480
|
+
return;
|
|
1481
|
+
const obj = value;
|
|
1482
|
+
const ref = obj["$ref"];
|
|
1483
|
+
if (typeof ref === "string") {
|
|
1484
|
+
const m = ref.match(/^#\/components\/schemas\/(.+)$/);
|
|
1485
|
+
if (m != null && m[1] != null)
|
|
1486
|
+
out.add(m[1]);
|
|
1487
|
+
}
|
|
1488
|
+
for (const k of Object.keys(obj))
|
|
1489
|
+
walkRefs(obj[k], out);
|
|
1490
|
+
}
|
|
1491
|
+
function pascalCase3(input) {
|
|
1492
|
+
return input.split(/[-_\s]+/).filter((s) => s.length > 0).map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
|
|
1493
|
+
}
|
|
1494
|
+
export { camelToSnakeCase, slugToTableName, assertNoTableNameCollisions, schemaToD1Migration, schemaToD1Migrations, normalizeDateSchemas, schemaToOpenApiSchema, collectionToOpenApiPaths, schemaToDrizzle, fingerprintBlockSchema, fingerprintToString, diffBlockSchemas, contentMigrationModuleSource, detectPendingContentMigrations, defaultLabel, schemaToPuckFields, schemaToTsType, schemaToTsTypes, filterOpenApiForUser };
|