shapecraft 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CLAUDE.md +227 -0
  2. package/README.md +22 -0
  3. package/apps/cli/node_modules/.bin/prettier +21 -0
  4. package/apps/cli/node_modules/.bin/tsc +21 -0
  5. package/apps/cli/node_modules/.bin/tsserver +21 -0
  6. package/apps/cli/node_modules/.bin/tsx +21 -0
  7. package/apps/cli/node_modules/.bin/vitest +21 -0
  8. package/apps/cli/package.json +47 -0
  9. package/apps/cli/src/index.ts +98 -0
  10. package/apps/cli/tsconfig.cjs.json +10 -0
  11. package/apps/cli/tsconfig.esm.json +10 -0
  12. package/apps/cli/tsconfig.json +22 -0
  13. package/package.json +16 -0
  14. package/packages/core/node_modules/.bin/prettier +21 -0
  15. package/packages/core/node_modules/.bin/tsc +21 -0
  16. package/packages/core/node_modules/.bin/tsserver +21 -0
  17. package/packages/core/node_modules/.bin/tsx +21 -0
  18. package/packages/core/node_modules/.bin/vitest +21 -0
  19. package/packages/core/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  20. package/packages/core/package.json +44 -0
  21. package/packages/core/src/common/array.test.ts +19 -0
  22. package/packages/core/src/common/array.ts +15 -0
  23. package/packages/core/src/common/index.ts +5 -0
  24. package/packages/core/src/common/is.ts +23 -0
  25. package/packages/core/src/common/object.ts +35 -0
  26. package/packages/core/src/common/phantom.ts +1 -0
  27. package/packages/core/src/common/result.ts +43 -0
  28. package/packages/core/src/common/string.ts +28 -0
  29. package/packages/core/src/common/types.ts +34 -0
  30. package/packages/core/src/index.ts +1 -0
  31. package/packages/core/src/shape/annotate.ts +139 -0
  32. package/packages/core/src/shape/annotation.ts +47 -0
  33. package/packages/core/src/shape/base.ts +71 -0
  34. package/packages/core/src/shape/builder.test.ts +728 -0
  35. package/packages/core/src/shape/builder.ts +475 -0
  36. package/packages/core/src/shape/error.ts +4 -0
  37. package/packages/core/src/shape/index.ts +3 -0
  38. package/packages/core/src/shape/number.ts +118 -0
  39. package/packages/core/src/shape/shape.test.ts +792 -0
  40. package/packages/core/src/shape/shape.ts +377 -0
  41. package/packages/core/src/shape/tags.ts +14 -0
  42. package/packages/core/src/shape/transforms/index.ts +3 -0
  43. package/packages/core/src/shape/transforms/json-schema/index.ts +2 -0
  44. package/packages/core/src/shape/transforms/json-schema/transform.test.ts +850 -0
  45. package/packages/core/src/shape/transforms/json-schema/transform.ts +882 -0
  46. package/packages/core/src/shape/transforms/json-schema/types.ts +132 -0
  47. package/packages/core/src/shape/transforms/sql/dialects/dialect.ts +89 -0
  48. package/packages/core/src/shape/transforms/sql/dialects/index.ts +14 -0
  49. package/packages/core/src/shape/transforms/sql/dialects/postgres.ts +392 -0
  50. package/packages/core/src/shape/transforms/sql/dialects/sqlite.ts +333 -0
  51. package/packages/core/src/shape/transforms/sql/from-sql.test.ts +704 -0
  52. package/packages/core/src/shape/transforms/sql/from-sql.ts +210 -0
  53. package/packages/core/src/shape/transforms/sql/index.ts +3 -0
  54. package/packages/core/src/shape/transforms/sql/options.ts +6 -0
  55. package/packages/core/src/shape/transforms/sql/parser/check-decoder.ts +457 -0
  56. package/packages/core/src/shape/transforms/sql/parser/create-domain.ts +105 -0
  57. package/packages/core/src/shape/transforms/sql/parser/create-table.ts +809 -0
  58. package/packages/core/src/shape/transforms/sql/parser/create-type.ts +91 -0
  59. package/packages/core/src/shape/transforms/sql/parser/cursor.ts +179 -0
  60. package/packages/core/src/shape/transforms/sql/parser/default-decoder.ts +129 -0
  61. package/packages/core/src/shape/transforms/sql/parser/lexer.ts +289 -0
  62. package/packages/core/src/shape/transforms/sql/parser/pg-types.ts +247 -0
  63. package/packages/core/src/shape/transforms/sql/parser/sqlite-types.ts +103 -0
  64. package/packages/core/src/shape/transforms/sql/parser/statements.ts +127 -0
  65. package/packages/core/src/shape/transforms/sql/parser/type-spec.ts +159 -0
  66. package/packages/core/src/shape/transforms/sql/transform.sqlite.test.ts +448 -0
  67. package/packages/core/src/shape/transforms/sql/transform.test.ts +880 -0
  68. package/packages/core/src/shape/transforms/sql/transform.ts +295 -0
  69. package/packages/core/src/shape/transforms/typescript/index.ts +1 -0
  70. package/packages/core/src/shape/transforms/typescript/transform.ts +211 -0
  71. package/packages/core/src/shape/tuple.test.ts +171 -0
  72. package/packages/core/src/shape/validate.ts +413 -0
  73. package/packages/core/tsconfig.cjs.json +11 -0
  74. package/packages/core/tsconfig.esm.json +10 -0
  75. package/packages/core/tsconfig.json +23 -0
  76. package/packages/samples/node_modules/.bin/prettier +21 -0
  77. package/packages/samples/node_modules/.bin/tsc +21 -0
  78. package/packages/samples/node_modules/.bin/tsserver +21 -0
  79. package/packages/samples/node_modules/.bin/tsx +21 -0
  80. package/packages/samples/node_modules/.bin/vitest +21 -0
  81. package/packages/samples/package.json +47 -0
  82. package/packages/samples/src/blog.ts +49 -0
  83. package/packages/samples/src/config.ts +50 -0
  84. package/packages/samples/src/ecommerce.ts +65 -0
  85. package/packages/samples/src/embeddings.ts +43 -0
  86. package/packages/samples/src/events.ts +52 -0
  87. package/packages/samples/src/geometry.ts +62 -0
  88. package/packages/samples/src/index.ts +9 -0
  89. package/packages/samples/src/relational.ts +17 -0
  90. package/packages/samples/src/tuples.ts +67 -0
  91. package/packages/samples/src/user.ts +9 -0
  92. package/packages/samples/tsconfig.cjs.json +11 -0
  93. package/packages/samples/tsconfig.esm.json +10 -0
  94. package/packages/samples/tsconfig.json +23 -0
  95. package/pnpm-workspace.yaml +3 -0
  96. package/test-data/json-schema/address.json +35 -0
  97. package/test-data/json-schema/array-of-things.json +36 -0
  98. package/test-data/json-schema/basic.json +21 -0
  99. package/test-data/json-schema/blog-post.json +29 -0
  100. package/test-data/json-schema/calendar.json +48 -0
  101. package/test-data/json-schema/complex-object-with-nested-properties.json +41 -0
  102. package/test-data/json-schema/ecommerce-complex.json +344 -0
  103. package/test-data/json-schema/ecommerce-system.json +27 -0
  104. package/test-data/json-schema/enumerated-values.json +11 -0
  105. package/test-data/json-schema/fstab-entry.json +92 -0
  106. package/test-data/json-schema/geographical-location.json +20 -0
  107. package/test-data/json-schema/health-record.json +41 -0
  108. package/test-data/json-schema/job-posting.json +33 -0
  109. package/test-data/json-schema/movie.json +35 -0
  110. package/test-data/json-schema/regular-expression-pattern.json +12 -0
  111. package/test-data/json-schema/user-profile.json +33 -0
  112. package/test-data/sql/ecommerce.sql +641 -0
@@ -0,0 +1,295 @@
1
+ import { R } from "../../../common/result";
2
+ import { shapes } from "../../shape";
3
+ import {
4
+ ColumnDef,
5
+ Dialect,
6
+ getDialect,
7
+ Prelude,
8
+ quoteIdent,
9
+ renderReferentialActions,
10
+ } from "./dialects";
11
+ import { resolveDialect, SQLOptions } from "./options";
12
+
13
+ const collectComments = (shape: shapes.Shape): string[] => {
14
+ const comments: string[] = [];
15
+ if (shape.anno.description) comments.push(shape.anno.description);
16
+ if (shape.anno.comment) comments.push(shape.anno.comment);
17
+ if (shape.anno.format) comments.push(`(format: ${shape.anno.format})`);
18
+ if (shape.type === "union" && !shape.anno.format) {
19
+ const formats = shape.input
20
+ .map((m) => m.anno.format)
21
+ .filter((f): f is string => typeof f === "string");
22
+ if (formats.length > 0 && formats.length === shape.input.length) {
23
+ comments.push(`(format: ${formats.join(" | ")})`);
24
+ }
25
+ }
26
+ return comments;
27
+ };
28
+
29
+ const renderComments = (comments: string[], pad: string): string[] => {
30
+ if (comments.length === 0) return [];
31
+ if (comments.length === 1) return [`${pad}-- ${comments[0]}`];
32
+ return [`${pad}/*`, ...comments.map((c) => `${pad} * ${c}`), `${pad} */`];
33
+ };
34
+
35
+ type FieldOk = { column: ColumnDef; prelude: Prelude[] };
36
+ type FieldResult = R.Result<FieldOk, string[]>;
37
+
38
+ const convertField = (
39
+ name: string,
40
+ shape: shapes.Shape,
41
+ dialect: Dialect,
42
+ ): FieldResult => {
43
+ const errors: string[] = [];
44
+ const rendered = dialect.renderType(name, shape);
45
+ if (!R.isOk(rendered)) return R.err(rendered.error);
46
+ const { type, checks, prelude } = rendered.value;
47
+
48
+ const column: ColumnDef = {
49
+ name,
50
+ type,
51
+ notNull: shape.anno.optional !== true,
52
+ primary: shape.anno.primary === true,
53
+ unique: shape.anno.unique === true,
54
+ identity: false,
55
+ checks: [...checks],
56
+ comments: collectComments(shape),
57
+ };
58
+
59
+ if (column.primary) column.notNull = true;
60
+
61
+ if (shape.anno.autoIncrement === true) {
62
+ const aiErrors = dialect.applyAutoIncrement(name, shape, column);
63
+ errors.push(...aiErrors);
64
+ }
65
+
66
+ if (shape.anno.default !== undefined) {
67
+ const enc = dialect.formatDefault(shape.anno.default, type);
68
+ if (enc.ok) {
69
+ column.defaultExpr = enc.expr;
70
+ } else {
71
+ errors.push(`field '${name}': ${enc.reason}`);
72
+ }
73
+ } else if (shape.anno.defaultExpr !== undefined) {
74
+ column.defaultExpr = shape.anno.defaultExpr;
75
+ }
76
+
77
+ if (shape.anno.foreign !== undefined) {
78
+ column.references =
79
+ `${quoteIdent(shape.anno.foreign.table)}(${quoteIdent(
80
+ shape.anno.foreign.column,
81
+ )})` + renderReferentialActions(shape.anno.foreign);
82
+ }
83
+
84
+ if (shape.anno.check !== undefined) {
85
+ column.checks.push(shape.anno.check);
86
+ }
87
+
88
+ if (shape.anno.generated !== undefined) {
89
+ column.generated = shape.anno.generated;
90
+ }
91
+
92
+ if (errors.length > 0) return R.err(errors);
93
+
94
+ return R.ok({ column, prelude });
95
+ };
96
+
97
+ type TableResult = {
98
+ tableName: string;
99
+ schema?: string;
100
+ sql: string;
101
+ prelude: Prelude[];
102
+ deps: Set<string>;
103
+ };
104
+
105
+ const toSQLTable = (
106
+ shape: shapes.ShapeMapping,
107
+ dialect: Dialect,
108
+ ): R.Result<TableResult, string[]> => {
109
+ const title = shape.anno.title;
110
+ if (title === undefined) {
111
+ return R.err(["mapping is missing required title (table name)"]);
112
+ }
113
+
114
+ const annoErrors = dialect.validateMappingAnno(shape.anno, title);
115
+ if (annoErrors.length > 0) return R.err(annoErrors);
116
+
117
+ const errors: string[] = [];
118
+ const columns: ColumnDef[] = [];
119
+ const prelude: Prelude[] = [];
120
+ const deps = new Set<string>();
121
+
122
+ for (const [k, child] of Object.entries(shape.input)) {
123
+ const res = convertField(k, child, dialect);
124
+ if (R.isOk(res)) {
125
+ columns.push(res.value.column);
126
+ for (const p of res.value.prelude) prelude.push(p);
127
+ if (child.anno.foreign !== undefined) deps.add(child.anno.foreign.table);
128
+ } else {
129
+ errors.push(...res.error);
130
+ }
131
+ }
132
+
133
+ if (errors.length > 0) return R.err(errors);
134
+
135
+ const finalizeErrors = dialect.finalizeColumns(columns);
136
+ if (finalizeErrors.length > 0) return R.err(finalizeErrors);
137
+
138
+ const primaryCols = columns.filter((c) => c.primary);
139
+ let tableLevelPk: string | null = null;
140
+ if (primaryCols.length > 1) {
141
+ tableLevelPk = `PRIMARY KEY (${primaryCols
142
+ .map((c) => quoteIdent(c.name))
143
+ .join(", ")})`;
144
+ for (const c of primaryCols) c.primary = false;
145
+ }
146
+
147
+ const tableLevelUniques: string[] = [];
148
+ const ucs = shape.anno.uniqueConstraints;
149
+ if (ucs !== undefined) {
150
+ for (const uc of ucs) {
151
+ const cols = uc.columns.map(quoteIdent).join(", ");
152
+ const head =
153
+ uc.name !== undefined ?
154
+ `CONSTRAINT ${quoteIdent(uc.name)} UNIQUE (${cols})`
155
+ : `UNIQUE (${cols})`;
156
+ tableLevelUniques.push(head);
157
+ }
158
+ }
159
+
160
+ const renderedColumns: string[] = columns.map((c) => {
161
+ const commentLines = renderComments(c.comments, " ");
162
+ const line = ` ${dialect.renderColumn(c)}`;
163
+ return commentLines.length > 0 ? [...commentLines, line].join("\n") : line;
164
+ });
165
+ if (tableLevelPk !== null) renderedColumns.push(` ${tableLevelPk}`);
166
+ for (const u of tableLevelUniques) renderedColumns.push(` ${u}`);
167
+ if (shape.anno.check !== undefined) {
168
+ renderedColumns.push(` CHECK (${shape.anno.check})`);
169
+ }
170
+
171
+ const schema = shape.anno.schema;
172
+ const tableCommentLines = renderComments(collectComments(shape), "");
173
+ const header = [
174
+ ...tableCommentLines,
175
+ `CREATE TABLE IF NOT EXISTS ${dialect.qualifiedTable(schema, title)} (`,
176
+ ].join("\n");
177
+
178
+ const trailer = dialect.tableTrailer;
179
+ const sql = `${header}\n${renderedColumns.join(",\n")}\n)${trailer};`;
180
+
181
+ const result: TableResult = { tableName: title, sql, prelude, deps };
182
+ if (schema !== undefined && dialect.supportsSchema) result.schema = schema;
183
+ return R.ok(result);
184
+ };
185
+
186
+ const topoSortTables = (
187
+ tables: TableResult[],
188
+ ): R.Result<TableResult[], string[]> => {
189
+ const byName = new Map<string, TableResult>();
190
+ for (const t of tables) byName.set(t.tableName, t);
191
+
192
+ const indegree = new Map<string, number>();
193
+ for (const t of tables) {
194
+ let d = 0;
195
+ for (const dep of t.deps) {
196
+ if (dep !== t.tableName && byName.has(dep)) d += 1;
197
+ }
198
+ indegree.set(t.tableName, d);
199
+ }
200
+
201
+ const result: TableResult[] = [];
202
+ const ready = Array.from(indegree.entries())
203
+ .filter(([, d]) => d === 0)
204
+ .map(([n]) => n)
205
+ .sort();
206
+ while (ready.length > 0) {
207
+ const next = ready.shift()!;
208
+ const t = byName.get(next)!;
209
+ result.push(t);
210
+ for (const other of tables) {
211
+ if (other.tableName === next) continue;
212
+ if (other.deps.has(next)) {
213
+ const d = (indegree.get(other.tableName) ?? 0) - 1;
214
+ indegree.set(other.tableName, d);
215
+ if (d === 0 && !result.some((r) => r.tableName === other.tableName))
216
+ insertSorted(ready, other.tableName);
217
+ }
218
+ }
219
+ }
220
+
221
+ if (result.length < tables.length) {
222
+ const cycle = tables
223
+ .filter((t) => !result.some((r) => r.tableName === t.tableName))
224
+ .map((t) => t.tableName)
225
+ .sort();
226
+ return R.err([`FK cycle among tables: ${cycle.join(", ")}`]);
227
+ }
228
+
229
+ return R.ok(result);
230
+ };
231
+
232
+ const insertSorted = (arr: string[], v: string): void => {
233
+ let i = 0;
234
+ while (i < arr.length && (arr[i] ?? "") < v) i += 1;
235
+ arr.splice(i, 0, v);
236
+ };
237
+
238
+ export const toSQL = (
239
+ shape: shapes.Shape,
240
+ opts?: SQLOptions,
241
+ ): R.Result<string, string[]> => {
242
+ const dialect = getDialect(resolveDialect(opts));
243
+
244
+ if (shape.type === "mapping") {
245
+ const res = toSQLTable(shape, dialect);
246
+ if (!R.isOk(res)) return R.err(res.error);
247
+ const sections: string[] = [];
248
+ if (res.value.schema !== undefined) {
249
+ sections.push(dialect.schemaPrelude(res.value.schema));
250
+ }
251
+ const preludeSeen = new Set<string>();
252
+ for (const p of res.value.prelude) {
253
+ if (!preludeSeen.has(p.sql)) {
254
+ preludeSeen.add(p.sql);
255
+ sections.push(p.sql);
256
+ }
257
+ }
258
+ sections.push(res.value.sql);
259
+ return R.ok(sections.join("\n\n"));
260
+ }
261
+ if (shape.type === "module") {
262
+ const errors: string[] = [];
263
+ const results: TableResult[] = [];
264
+ for (const [tableKey, table] of Object.entries(shape.input)) {
265
+ const res = toSQLTable(table, dialect);
266
+ if (R.isOk(res)) results.push(res.value);
267
+ else errors.push(...res.error.map((e) => `table '${tableKey}': ${e}`));
268
+ }
269
+ if (errors.length > 0) return R.err(errors);
270
+
271
+ const sorted = topoSortTables(results);
272
+ if (!R.isOk(sorted)) return R.err(sorted.error);
273
+
274
+ const sections: string[] = [];
275
+ const schemasSeen = new Set<string>();
276
+ for (const t of sorted.value) {
277
+ if (t.schema !== undefined && !schemasSeen.has(t.schema)) {
278
+ schemasSeen.add(t.schema);
279
+ sections.push(dialect.schemaPrelude(t.schema));
280
+ }
281
+ }
282
+ const preludeSeen = new Set<string>();
283
+ for (const t of sorted.value) {
284
+ for (const p of t.prelude) {
285
+ if (!preludeSeen.has(p.sql)) {
286
+ preludeSeen.add(p.sql);
287
+ sections.push(p.sql);
288
+ }
289
+ }
290
+ }
291
+ for (const t of sorted.value) sections.push(t.sql);
292
+ return R.ok(sections.join("\n\n"));
293
+ }
294
+ return R.err(["toSQL expects a top-level mapping (table) or module"]);
295
+ };
@@ -0,0 +1 @@
1
+ export * from "./transform";
@@ -0,0 +1,211 @@
1
+ import { repeat } from "../../../common";
2
+ import { shapes } from "../../shape";
3
+
4
+ type Context = {
5
+ depth: number;
6
+ padding: number;
7
+ };
8
+
9
+ const incPadding = (ctx: Context): Context => ({
10
+ ...ctx,
11
+ padding: ctx.padding + 2,
12
+ });
13
+ const incDepth = (ctx: Context): Context => ({ ...ctx, depth: ctx.depth + 1 });
14
+ const inc = (ctx: Context): Context => incPadding(incDepth(ctx));
15
+ const mkpad = (n: number): string => repeat(" ", n);
16
+
17
+ const getName = (shape: shapes.Shape): string | null => {
18
+ if (shape.anno.title && shape.anno.title.trim().length > 0)
19
+ return shape.anno.title;
20
+ const meta = shape.anno.meta;
21
+ if (!meta) return null;
22
+ const mTitle = meta["title"] || meta["name"];
23
+ if (mTitle && typeof mTitle === "string" && mTitle.trim().length > 0)
24
+ return mTitle;
25
+ return null;
26
+ };
27
+
28
+ const isSafeName = (x: string): boolean =>
29
+ !(x.includes(" ") || x.includes("-"));
30
+
31
+ const autoQuote = (x: string): string => {
32
+ if (x.includes(" ") || x.includes("-")) return `"${x}"`;
33
+ return x;
34
+ };
35
+
36
+ const createComments = (shape: shapes.Shape): string[] => {
37
+ const comments: string[] = [];
38
+
39
+ if (shape.anno.description) {
40
+ comments.push(shape.anno.description);
41
+ }
42
+ if (shape.anno.comment) {
43
+ comments.push(shape.anno.comment);
44
+ }
45
+ if (shape.anno.primary) {
46
+ comments.push(`(primary)`);
47
+ }
48
+ if (shape.anno.pattern) {
49
+ comments.push(`must match pattern: ${shape.anno.pattern.toString()}`);
50
+ }
51
+ if (shape.anno.format) {
52
+ comments.push(`(format: ${shape.anno.format})`);
53
+ }
54
+ // Lift format from union members: a union of format-annotated strings
55
+ // collapses to `string` at the type level, but the formats themselves
56
+ // are useful to surface as a hint.
57
+ if (shape.type === "union" && !shape.anno.format) {
58
+ const formats = shape.input
59
+ .map((m) => m.anno.format)
60
+ .filter((f): f is string => typeof f === "string");
61
+ if (formats.length > 0 && formats.length === shape.input.length) {
62
+ comments.push(`(format: ${formats.join(" | ")})`);
63
+ }
64
+ }
65
+ if (typeof shape.anno.min === "number") {
66
+ comments.push(`must be >= ${shape.anno.min}`);
67
+ }
68
+ if (typeof shape.anno.max === "number") {
69
+ comments.push(`must be <= ${shape.anno.max}`);
70
+ }
71
+ if (shape.anno.foreign) {
72
+ comments.push(
73
+ `(foreign) references '${shape.anno.foreign.table}.${shape.anno.foreign.column}'`,
74
+ );
75
+ }
76
+ if (shape.anno.uniqueItems) {
77
+ comments.push(`(uniqueItems)`);
78
+ }
79
+ if (shape.anno.unique) {
80
+ comments.push(`(unique)`);
81
+ }
82
+ if (shape.anno.default) {
83
+ comments.push(`(default = ${shape.anno.default})`);
84
+ }
85
+
86
+ if (comments.length > 1) {
87
+ return ["/**", ...comments.map((x) => `* ${x}`), "**/"];
88
+ }
89
+
90
+ return comments.map((x) => `// ${x}`);
91
+ };
92
+
93
+ const renderComments = (comments: string[], pad: number): string => {
94
+ if (comments.length <= 0) return "";
95
+ return comments.map((x) => `${mkpad(pad)}${x}`).join("\n") + "\n";
96
+ };
97
+
98
+ const blockOfFields = (
99
+ rec: Record<string, shapes.Shape>,
100
+ ctx: Context,
101
+ ): string => {
102
+ return [
103
+ `{\n`,
104
+ Object.entries(rec)
105
+ .map(([field, s]) => {
106
+ const name = getName(s) || field;
107
+ return [
108
+ renderComments(createComments(s), ctx.padding + 2),
109
+ mkpad(ctx.padding + 2),
110
+ autoQuote(name),
111
+ s.anno.optional ? "?" : "",
112
+ ": ",
113
+ toTs(s, inc(ctx)),
114
+ ";",
115
+ ].join("");
116
+ })
117
+ .join("\n"),
118
+ `\n${mkpad(ctx.padding)}}`,
119
+ ].join("");
120
+ };
121
+
122
+ const listOfTypes = (
123
+ rec: Record<string, shapes.Shape>,
124
+ ctx: Context,
125
+ ): string => {
126
+ return [
127
+ Object.entries(rec)
128
+ .map(([field, s]) => {
129
+ const name = (getName(s) || field).replaceAll(" ", "_");
130
+ return [
131
+ renderComments(createComments(s), ctx.padding),
132
+ mkpad(ctx.padding),
133
+ `export type ${name} = `,
134
+ toTs(s, incDepth(ctx)),
135
+ `;`,
136
+ ].join("");
137
+ })
138
+ .join("\n"),
139
+ ].join("");
140
+ };
141
+
142
+ const toTs = (shape: shapes.Shape, ctx: Context): string => {
143
+ switch (shape.type) {
144
+ case "string":
145
+ if (shape.literal) return JSON.stringify(shape.input);
146
+ return shape.type;
147
+ case "number":
148
+ case "boolean":
149
+ if (shape.literal) return String(shape.input);
150
+ return shape.type;
151
+ case "undefined":
152
+ case "unknown":
153
+ return shape.type;
154
+ case "date":
155
+ return "Date";
156
+ case "nil":
157
+ return "null";
158
+ case "union": {
159
+ const parts = shape.input.map((x) => toTs(x, incDepth(ctx)));
160
+ return Array.from(new Set(parts)).join(" | ");
161
+ }
162
+ case "array":
163
+ return `Array<${toTs(shape.input, inc(ctx))}>`;
164
+ case "binary":
165
+ return `Uint8Array | Array<number>`;
166
+ case "vector":
167
+ return `Array<number>`;
168
+ case "tuple":
169
+ return `[${shape.input.map((s) => toTs(s, inc(ctx))).join(", ")}]`;
170
+ case "range": {
171
+ const item = toTs(shape.input, inc(ctx));
172
+ return `{ lower: ${item} | null; upper: ${item} | null; lowerInclusive: boolean; upperInclusive: boolean; }`;
173
+ }
174
+ case "record":
175
+ return `Record<${toTs(shape.input[0], incDepth(ctx))}, ${toTs(shape.input[1], inc(ctx))}>`;
176
+ case "module": {
177
+ const name = getName(shape);
178
+ if (name && isSafeName(name)) {
179
+ return [
180
+ `export namespace ${name} {\n`,
181
+ listOfTypes(shape.input, inc(ctx)),
182
+ `\n}`,
183
+ ].join("");
184
+ }
185
+ return listOfTypes(shape.input, ctx);
186
+ }
187
+ case "mapping": {
188
+ const name = getName(shape);
189
+ if (name && ctx.depth <= 0) {
190
+ const cleanName = name.replaceAll(/-| /g, "_");
191
+ return [
192
+ `export type ${cleanName} = `,
193
+ blockOfFields(shape.input, ctx),
194
+ ].join("");
195
+ }
196
+
197
+ return blockOfFields(shape.input, ctx);
198
+ }
199
+ }
200
+ };
201
+
202
+ export const toTypescript = (shape: shapes.Shape): string => {
203
+ const ctx: Context = {
204
+ depth: 0,
205
+ padding: 0,
206
+ };
207
+
208
+ const out = toTs(shape, ctx);
209
+
210
+ return out;
211
+ };
@@ -0,0 +1,171 @@
1
+ import { describe, expect, expectTypeOf } from "vitest";
2
+ import { R } from "../common";
3
+ import { B } from "./builder";
4
+ import { shapes } from "./shape";
5
+ import { toJSONSchema, fromJSONSchema } from "./transforms/json-schema";
6
+ import { toSQL } from "./transforms/sql";
7
+ import { toTypescript } from "./transforms/typescript";
8
+
9
+ describe("shapes.tuple — construction & types", (it) => {
10
+ it("constructs with shapes.tuple", () => {
11
+ const t = shapes.tuple(shapes.float32(), shapes.string(), shapes.boolean());
12
+ expect(t.type).toBe("tuple");
13
+ expect(t.input).toHaveLength(3);
14
+ expect(t.input[0]!.type).toBe("number");
15
+ expect(t.input[1]!.type).toBe("string");
16
+ expect(t.input[2]!.type).toBe("boolean");
17
+ });
18
+
19
+ it("constructs with B.tuple (rest args)", () => {
20
+ const t = B.tuple(B.float32(), B.float32(), B.float32());
21
+ expect(t.shape.type).toBe("tuple");
22
+ expect(t.shape.input).toHaveLength(3);
23
+ });
24
+
25
+ it("constructs with B.tuple (array form)", () => {
26
+ const t = B.tuple([B.string(), B.number()] as const);
27
+ expect(t.shape.type).toBe("tuple");
28
+ expect(t.shape.input).toHaveLength(2);
29
+ });
30
+
31
+ it("infers a positional tuple output type", () => {
32
+ const t = shapes.tuple(shapes.float32(), shapes.string(), shapes.boolean());
33
+ expectTypeOf<shapes.InferShapeOut<typeof t>>().toEqualTypeOf<
34
+ [number, string, boolean]
35
+ >();
36
+ });
37
+
38
+ it("supports an empty tuple", () => {
39
+ const t = shapes.tuple();
40
+ expect(t.input).toEqual([]);
41
+ expect(shapes.validate(t, [])).toEqual([]);
42
+ });
43
+ });
44
+
45
+ describe("shapes.validate — tuple", (it) => {
46
+ it("accepts an array of matching length and element types", () => {
47
+ const t = shapes.tuple(
48
+ shapes.float32(),
49
+ shapes.float32(),
50
+ shapes.float32(),
51
+ );
52
+ expect(shapes.validate(t, [1.5, 2.5, 3.5])).toEqual([]);
53
+ });
54
+
55
+ it("rejects a non-array input", () => {
56
+ const t = shapes.tuple(shapes.string(), shapes.number());
57
+ const errors = shapes.validate(t, "not an array");
58
+ expect(errors).toHaveLength(1);
59
+ expect(errors[0]!.message).toMatch(/tuple/);
60
+ });
61
+
62
+ it("rejects a length mismatch", () => {
63
+ const t = shapes.tuple(shapes.string(), shapes.number());
64
+ const errors = shapes.validate(t, ["a"]);
65
+ expect(errors).toHaveLength(1);
66
+ expect(errors[0]!.message).toMatch(/length/);
67
+ });
68
+
69
+ it("reports the index of a type-mismatched position", () => {
70
+ const t = shapes.tuple(shapes.string(), shapes.number(), shapes.boolean());
71
+ const errors = shapes.validate(t, ["a", "not a number", true]);
72
+ expect(errors.length).toBeGreaterThan(0);
73
+ expect(errors[0]!.path).toEqual([1]);
74
+ });
75
+
76
+ it("validates heterogeneous element types independently", () => {
77
+ const t = shapes.tuple(shapes.string(), shapes.number(), shapes.boolean());
78
+ expect(shapes.validate(t, ["hi", 42, false])).toEqual([]);
79
+ expect(shapes.validate(t, [1, "x", false]).length).toBeGreaterThan(0);
80
+ });
81
+ });
82
+
83
+ describe("B.tuple — builder API", (it) => {
84
+ it(".is() narrows the input correctly", () => {
85
+ const t = B.tuple(B.float32(), B.float32(), B.float32());
86
+ const v: unknown = [1.5, 2.5, 3.5];
87
+ if (t.is(v)) {
88
+ expectTypeOf<typeof v>().toEqualTypeOf<[number, number, number]>();
89
+ }
90
+ expect(t.is([1.5, 2.5, 3.5])).toBe(true);
91
+ expect(t.is([1.5, 2.5])).toBe(false);
92
+ });
93
+
94
+ it(".parse() returns a Result", () => {
95
+ const t = B.tuple(B.string(), B.number());
96
+ const ok = t.parse(["x", 1]);
97
+ expect(R.isOk(ok)).toBe(true);
98
+ const err = t.parse(["x"]);
99
+ expect(R.isErr(err)).toBe(true);
100
+ });
101
+ });
102
+
103
+ describe("toJSONSchema / fromJSONSchema — tuple", (it) => {
104
+ it("emits prefixItems with min/max items and tuple meta", () => {
105
+ const t = shapes.tuple(shapes.string(), shapes.number());
106
+ const js = toJSONSchema(t);
107
+ expect(js.type).toBe("array");
108
+ expect(Array.isArray(js.prefixItems)).toBe(true);
109
+ expect(js.prefixItems).toHaveLength(2);
110
+ expect(js.minItems).toBe(2);
111
+ expect(js.maxItems).toBe(2);
112
+ expect(js["x-shapecraft"]?.kind).toBe("tuple");
113
+ expect(js["x-shapecraft"]?.tupleLen).toBe(2);
114
+ });
115
+
116
+ it("round-trips through fromJSONSchema", () => {
117
+ const t = shapes.tuple(shapes.string(), shapes.float32(), shapes.boolean());
118
+ const round = fromJSONSchema(toJSONSchema(t));
119
+ expect(round.type).toBe("tuple");
120
+ if (round.type !== "tuple") return;
121
+ expect(round.input).toHaveLength(3);
122
+ expect(round.input[0]!.type).toBe("string");
123
+ expect(round.input[1]!.type).toBe("number");
124
+ expect(round.input[2]!.type).toBe("boolean");
125
+ });
126
+
127
+ it("accepts legacy items: [...] form (length >= 2) as a tuple", () => {
128
+ const parsed = fromJSONSchema({
129
+ type: "array",
130
+ items: [{ type: "string" }, { type: "number" }],
131
+ });
132
+ expect(parsed.type).toBe("tuple");
133
+ if (parsed.type !== "tuple") return;
134
+ expect(parsed.input).toHaveLength(2);
135
+ expect(parsed.input[0]!.type).toBe("string");
136
+ expect(parsed.input[1]!.type).toBe("number");
137
+ });
138
+ });
139
+
140
+ describe("toSQL — tuple", (it) => {
141
+ it("emits jsonb with a length CHECK on a tuple column", () => {
142
+ const table = shapes.mapping({
143
+ id: { ...shapes.uint64(), anno: { primary: true } } as shapes.Shape,
144
+ coords: shapes.tuple(shapes.float32(), shapes.float32()),
145
+ });
146
+ const titled: shapes.ShapeMapping = {
147
+ ...table,
148
+ anno: { ...table.anno, title: "thing" },
149
+ };
150
+ const res = toSQL(titled);
151
+ expect(R.isOk(res)).toBe(true);
152
+ if (!R.isOk(res)) return;
153
+ expect(res.value).toMatch(/"coords" jsonb/);
154
+ expect(res.value).toMatch(/jsonb_array_length\("coords"\) = 2/);
155
+ });
156
+ });
157
+
158
+ describe("toTypescript — tuple", (it) => {
159
+ it("renders as [T1, T2, T3]", () => {
160
+ const t = shapes.tuple(shapes.string(), shapes.number(), shapes.boolean());
161
+ expect(toTypescript(t)).toBe("[string, number, boolean]");
162
+ });
163
+
164
+ it("renders nested tuples", () => {
165
+ const t = shapes.tuple(
166
+ shapes.tuple(shapes.float32(), shapes.float32()),
167
+ shapes.string(),
168
+ );
169
+ expect(toTypescript(t)).toBe("[[number, number], string]");
170
+ });
171
+ });