@prisma-next/ts-render 0.0.1
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 +48 -0
- package/dist/index.d.mts +90 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +147 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +44 -0
- package/src/index.ts +3 -0
- package/src/json-to-ts-source.ts +55 -0
- package/src/render-imports.ts +138 -0
- package/src/ts-expression.ts +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# @prisma-next/ts-render
|
|
2
|
+
|
|
3
|
+
> **Internal package.** This package is an implementation detail of [`prisma-next`](https://www.npmjs.com/package/prisma-next)
|
|
4
|
+
> and is published only to support its runtime. Its API is unstable and may change
|
|
5
|
+
> without notice. Do not depend on this package directly; install `prisma-next` instead.
|
|
6
|
+
|
|
7
|
+
TypeScript source-text rendering utilities shared by any Prisma Next component that has to emit hand-editable `.ts` files — today the Postgres and Mongo migration-authoring surfaces, later any new target that ships a migration-authoring experience.
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
Two small pieces:
|
|
12
|
+
|
|
13
|
+
- **`TsExpression`** — abstract base class for any node that renders as a TypeScript expression in a generated source file. Subclasses supply `renderTypeScript(): string` and `importRequirements(): readonly ImportRequirement[]`. Hierarchical so that composite nodes (e.g. a `DataTransformCall` containing slot expressions) can recurse into their children.
|
|
14
|
+
- **`jsonToTsSource(value)`** — pure JSON-to-TypeScript-source printer. Accepts `unknown` for ergonomics with structural types whose fields happen to be JSON-compatible, and throws at runtime on anything that is not a JSON primitive / array / object.
|
|
15
|
+
|
|
16
|
+
## Codec → TS pipeline
|
|
17
|
+
|
|
18
|
+
`jsonToTsSource` is deliberately the **second** stage of a two-stage pipeline:
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
jsValue → codec.encodeJson → JsonValue → jsonToTsSource → TS source text
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Stage 1 (`codec.encodeJson`) is a codec responsibility — date serialization, opaque domain types (vector, bigint, uuid), JSON canonicalization. Stage 2 (this module) is a pure printer that must never grow type-specific branches. To render a non-JSON JS value (`Date`, `Vector`, `BigInt`, `Buffer`, …), encode it through the relevant codec's `encodeJson` first.
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { type ImportRequirement, TsExpression, jsonToTsSource } from '@prisma-next/ts-render';
|
|
30
|
+
|
|
31
|
+
class CreateTableCall extends TsExpression {
|
|
32
|
+
constructor(
|
|
33
|
+
readonly schema: string,
|
|
34
|
+
readonly table: string,
|
|
35
|
+
readonly columns: ReadonlyArray<{ name: string; typeSql: string; nullable: boolean }>,
|
|
36
|
+
) {
|
|
37
|
+
super();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
override renderTypeScript(): string {
|
|
41
|
+
return `createTable(${jsonToTsSource(this.schema)}, ${jsonToTsSource(this.table)}, ${jsonToTsSource(this.columns)})`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
override importRequirements(): readonly ImportRequirement[] {
|
|
45
|
+
return [{ moduleSpecifier: '@prisma-next/target-postgres/migration', symbol: 'createTable' }];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
//#region src/json-to-ts-source.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Pure JSON-to-TypeScript-source printer.
|
|
4
|
+
*
|
|
5
|
+
* This module is the second stage of the codec → TS pipeline:
|
|
6
|
+
*
|
|
7
|
+
* jsValue → codec.encodeJson → JsonValue → jsonToTsSource → TS source text
|
|
8
|
+
*
|
|
9
|
+
* Stage 1 (`codec.encodeJson`) is a codec responsibility — date serialization,
|
|
10
|
+
* opaque domain types (vector, bigint, uuid), JSON canonicalization. Stage 2
|
|
11
|
+
* (this module) is a pure JSON-to-TS printer that must never grow type-specific
|
|
12
|
+
* branches.
|
|
13
|
+
*
|
|
14
|
+
* To render a non-JSON JS value (Date, Vector, BigInt, Buffer, …), encode it
|
|
15
|
+
* through the relevant codec's `encodeJson` first. Adding special cases to
|
|
16
|
+
* this file is not the answer — that's what codecs are for.
|
|
17
|
+
*/
|
|
18
|
+
type JsonValue = string | number | boolean | null | readonly JsonValue[] | JsonObject;
|
|
19
|
+
type JsonObject = {
|
|
20
|
+
readonly [key: string]: JsonValue | undefined;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Render a JSON-compatible value as a TypeScript source-text literal.
|
|
24
|
+
*
|
|
25
|
+
* Accepts `unknown` for ergonomics with structural types (e.g. `ColumnSpec`,
|
|
26
|
+
* `ForeignKeySpec`) whose fields are all JSON-compatible but whose interfaces
|
|
27
|
+
* lack the index signature TypeScript requires for `JsonObject` assignability.
|
|
28
|
+
* Non-JSON values (Date, Symbol, Function, etc.) throw at runtime.
|
|
29
|
+
*/
|
|
30
|
+
declare function jsonToTsSource(value: unknown): string;
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/ts-expression.d.ts
|
|
33
|
+
/**
|
|
34
|
+
* Declarative contribution to the `import` block of a rendered TypeScript
|
|
35
|
+
* source file. Each node in an IR declares which symbols it needs from which
|
|
36
|
+
* modules; the top-level renderer deduplicates across nodes and emits one
|
|
37
|
+
* `import { a, b, c } from "…"` line per module.
|
|
38
|
+
*
|
|
39
|
+
* `kind` defaults to `"named"` (e.g. `import { a } from "m"`). Setting it to
|
|
40
|
+
* `"default"` emits `import a from "m"`. `attributes`, if provided, emits an
|
|
41
|
+
* import attributes clause (`with { type: "json" }`) verbatim — required for
|
|
42
|
+
* JSON module imports in the rendered scaffolds.
|
|
43
|
+
*/
|
|
44
|
+
interface ImportRequirement {
|
|
45
|
+
readonly moduleSpecifier: string;
|
|
46
|
+
readonly symbol: string;
|
|
47
|
+
readonly kind?: 'named' | 'default';
|
|
48
|
+
readonly attributes?: Readonly<Record<string, string>>;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Abstract base class for any IR node that can be emitted as a TypeScript
|
|
52
|
+
* expression and declare its own import requirements.
|
|
53
|
+
*
|
|
54
|
+
* A top-level renderer walks an array of these polymorphically, concatenates
|
|
55
|
+
* `renderTypeScript()` results, and aggregates `importRequirements()` into a
|
|
56
|
+
* deduplicated import block.
|
|
57
|
+
*/
|
|
58
|
+
declare abstract class TsExpression {
|
|
59
|
+
abstract renderTypeScript(): string;
|
|
60
|
+
abstract importRequirements(): readonly ImportRequirement[];
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/render-imports.d.ts
|
|
64
|
+
/**
|
|
65
|
+
* Render an aggregated `import` block from a flat list of
|
|
66
|
+
* `ImportRequirement`s. Each target's migration renderer collects
|
|
67
|
+
* requirements polymorphically from its call nodes and pipes them here.
|
|
68
|
+
*
|
|
69
|
+
* The emitter invariants:
|
|
70
|
+
*
|
|
71
|
+
* - **One line per module specifier.** Named imports are aggregated and
|
|
72
|
+
* emitted sorted alphabetically; a single default symbol is combined
|
|
73
|
+
* onto the same line when attributes agree (`import def, { a, b } from "m";`).
|
|
74
|
+
* - **At most one default symbol per module.** Two conflicting default
|
|
75
|
+
* symbols on the same specifier throw — the user's renderer can't
|
|
76
|
+
* guess which one they meant.
|
|
77
|
+
* - **Attribute unanimity per module.** All requirements for the same
|
|
78
|
+
* module specifier must carry the same (or no) `attributes` map.
|
|
79
|
+
* Divergent attribute maps throw — they can't collapse to one line
|
|
80
|
+
* and there's no user-resolvable recovery at this layer.
|
|
81
|
+
* - **Deterministic ordering.** Modules are emitted sorted by specifier;
|
|
82
|
+
* within a module, named symbols are emitted sorted alphabetically.
|
|
83
|
+
*
|
|
84
|
+
* Returns a string containing one import line per module, joined by `\n`
|
|
85
|
+
* (no trailing newline). An empty requirement list returns `""`.
|
|
86
|
+
*/
|
|
87
|
+
declare function renderImports(requirements: readonly ImportRequirement[]): string;
|
|
88
|
+
//#endregion
|
|
89
|
+
export { type ImportRequirement, type JsonObject, type JsonValue, TsExpression, jsonToTsSource, renderImports };
|
|
90
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/json-to-ts-source.ts","../src/ts-expression.ts","../src/render-imports.ts"],"sourcesContent":[],"mappings":";;AAiBA;AACA;AAUA;;;;ACjBA;AAeA;;;;ACDA;;;;KFRY,SAAA,+CAAwD,cAAc;KACtE,UAAA;0BAAuC;;;;;;;;;;iBAUnC,cAAA;;;;AAXhB;AACA;AAUA;;;;ACjBA;AAeA;;;UAfiB,iBAAA;ECcD,SAAA,eAAa,EAAA,MAAwB;;;wBDV7B,SAAS;;;;;;;;;;uBAWX,YAAA;;0CAEoB;;;;ADX1C;AACA;AAUA;;;;ACjBA;AAeA;;;;ACDA;;;;;;;;;;;;iBAAgB,aAAA,wBAAqC"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
//#region src/json-to-ts-source.ts
|
|
2
|
+
/**
|
|
3
|
+
* Render a JSON-compatible value as a TypeScript source-text literal.
|
|
4
|
+
*
|
|
5
|
+
* Accepts `unknown` for ergonomics with structural types (e.g. `ColumnSpec`,
|
|
6
|
+
* `ForeignKeySpec`) whose fields are all JSON-compatible but whose interfaces
|
|
7
|
+
* lack the index signature TypeScript requires for `JsonObject` assignability.
|
|
8
|
+
* Non-JSON values (Date, Symbol, Function, etc.) throw at runtime.
|
|
9
|
+
*/
|
|
10
|
+
function jsonToTsSource(value) {
|
|
11
|
+
if (value === void 0) return "undefined";
|
|
12
|
+
if (value === null) return "null";
|
|
13
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
14
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
15
|
+
if (Array.isArray(value)) {
|
|
16
|
+
if (value.length === 0) return "[]";
|
|
17
|
+
const items = value.map((v) => jsonToTsSource(v));
|
|
18
|
+
const singleLine = `[${items.join(", ")}]`;
|
|
19
|
+
if (singleLine.length <= 80) return singleLine;
|
|
20
|
+
return `[\n${items.map((i) => ` ${i}`).join(",\n")},\n]`;
|
|
21
|
+
}
|
|
22
|
+
if (typeof value === "object") {
|
|
23
|
+
const entries = Object.entries(value).filter(([, v]) => v !== void 0);
|
|
24
|
+
if (entries.length === 0) return "{}";
|
|
25
|
+
const items = entries.map(([k, v]) => `${renderKey(k)}: ${jsonToTsSource(v)}`);
|
|
26
|
+
const singleLine = `{ ${items.join(", ")} }`;
|
|
27
|
+
if (singleLine.length <= 80) return singleLine;
|
|
28
|
+
return `{\n${items.map((i) => ` ${i}`).join(",\n")},\n}`;
|
|
29
|
+
}
|
|
30
|
+
throw new Error(`jsonToTsSource: unsupported value type "${typeof value}"`);
|
|
31
|
+
}
|
|
32
|
+
function renderKey(key) {
|
|
33
|
+
if (key === "__proto__") return JSON.stringify(key);
|
|
34
|
+
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/render-imports.ts
|
|
39
|
+
/**
|
|
40
|
+
* Render an aggregated `import` block from a flat list of
|
|
41
|
+
* `ImportRequirement`s. Each target's migration renderer collects
|
|
42
|
+
* requirements polymorphically from its call nodes and pipes them here.
|
|
43
|
+
*
|
|
44
|
+
* The emitter invariants:
|
|
45
|
+
*
|
|
46
|
+
* - **One line per module specifier.** Named imports are aggregated and
|
|
47
|
+
* emitted sorted alphabetically; a single default symbol is combined
|
|
48
|
+
* onto the same line when attributes agree (`import def, { a, b } from "m";`).
|
|
49
|
+
* - **At most one default symbol per module.** Two conflicting default
|
|
50
|
+
* symbols on the same specifier throw — the user's renderer can't
|
|
51
|
+
* guess which one they meant.
|
|
52
|
+
* - **Attribute unanimity per module.** All requirements for the same
|
|
53
|
+
* module specifier must carry the same (or no) `attributes` map.
|
|
54
|
+
* Divergent attribute maps throw — they can't collapse to one line
|
|
55
|
+
* and there's no user-resolvable recovery at this layer.
|
|
56
|
+
* - **Deterministic ordering.** Modules are emitted sorted by specifier;
|
|
57
|
+
* within a module, named symbols are emitted sorted alphabetically.
|
|
58
|
+
*
|
|
59
|
+
* Returns a string containing one import line per module, joined by `\n`
|
|
60
|
+
* (no trailing newline). An empty requirement list returns `""`.
|
|
61
|
+
*/
|
|
62
|
+
function renderImports(requirements) {
|
|
63
|
+
return [...aggregateByModule(requirements).entries()].sort(([a], [b]) => a.localeCompare(b)).map(([moduleSpecifier, group]) => renderModuleImport(moduleSpecifier, group)).join("\n");
|
|
64
|
+
}
|
|
65
|
+
function aggregateByModule(requirements) {
|
|
66
|
+
const byModule = /* @__PURE__ */ new Map();
|
|
67
|
+
for (const req of requirements) {
|
|
68
|
+
let group = byModule.get(req.moduleSpecifier);
|
|
69
|
+
if (!group) {
|
|
70
|
+
group = {
|
|
71
|
+
named: /* @__PURE__ */ new Set(),
|
|
72
|
+
defaultSymbol: null,
|
|
73
|
+
attributes: null,
|
|
74
|
+
attributesSet: false
|
|
75
|
+
};
|
|
76
|
+
byModule.set(req.moduleSpecifier, group);
|
|
77
|
+
}
|
|
78
|
+
mergeRequirementIntoGroup(req, group);
|
|
79
|
+
}
|
|
80
|
+
return byModule;
|
|
81
|
+
}
|
|
82
|
+
function mergeRequirementIntoGroup(req, group) {
|
|
83
|
+
if ((req.kind ?? "named") === "default") {
|
|
84
|
+
if (group.defaultSymbol !== null && group.defaultSymbol !== req.symbol) throw new Error(`Conflicting default imports for module "${req.moduleSpecifier}": "${group.defaultSymbol}" and "${req.symbol}". Only one default symbol is allowed per module.`);
|
|
85
|
+
group.defaultSymbol = req.symbol;
|
|
86
|
+
} else group.named.add(req.symbol);
|
|
87
|
+
mergeAttributes(req, group);
|
|
88
|
+
}
|
|
89
|
+
function mergeAttributes(req, group) {
|
|
90
|
+
const incoming = req.attributes ?? null;
|
|
91
|
+
if (!group.attributesSet) {
|
|
92
|
+
group.attributes = incoming;
|
|
93
|
+
group.attributesSet = true;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (!attributesEqual(group.attributes, incoming)) throw new Error(`Conflicting import attributes for module "${req.moduleSpecifier}": ${stringifyAttributes(group.attributes)} vs ${stringifyAttributes(incoming)}.`);
|
|
97
|
+
}
|
|
98
|
+
function attributesEqual(a, b) {
|
|
99
|
+
if (a === b) return true;
|
|
100
|
+
if (a === null || b === null) return false;
|
|
101
|
+
const aKeys = Object.keys(a).sort();
|
|
102
|
+
const bKeys = Object.keys(b).sort();
|
|
103
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
104
|
+
for (let i = 0; i < aKeys.length; i++) {
|
|
105
|
+
const key = aKeys[i];
|
|
106
|
+
if (key !== bKeys[i]) return false;
|
|
107
|
+
if (a[key] !== b[key]) return false;
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
function stringifyAttributes(attrs) {
|
|
112
|
+
if (attrs === null) return "(none)";
|
|
113
|
+
return `{ ${Object.entries(attrs).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(", ")} }`;
|
|
114
|
+
}
|
|
115
|
+
function renderModuleImport(moduleSpecifier, group) {
|
|
116
|
+
return `import ${buildImportClause(group)} from '${moduleSpecifier}'${buildAttributesClause(group.attributes)};`;
|
|
117
|
+
}
|
|
118
|
+
function buildImportClause(group) {
|
|
119
|
+
const named = [...group.named].sort();
|
|
120
|
+
const hasNamed = named.length > 0;
|
|
121
|
+
const hasDefault = group.defaultSymbol !== null;
|
|
122
|
+
if (hasDefault && hasNamed) return `${group.defaultSymbol}, { ${named.join(", ")} }`;
|
|
123
|
+
if (hasDefault) return group.defaultSymbol;
|
|
124
|
+
return `{ ${named.join(", ")} }`;
|
|
125
|
+
}
|
|
126
|
+
function buildAttributesClause(attrs) {
|
|
127
|
+
if (attrs === null) return "";
|
|
128
|
+
const entries = Object.entries(attrs).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}: ${JSON.stringify(v)}`);
|
|
129
|
+
if (entries.length === 0) return "";
|
|
130
|
+
return ` with { ${entries.join(", ")} }`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region src/ts-expression.ts
|
|
135
|
+
/**
|
|
136
|
+
* Abstract base class for any IR node that can be emitted as a TypeScript
|
|
137
|
+
* expression and declare its own import requirements.
|
|
138
|
+
*
|
|
139
|
+
* A top-level renderer walks an array of these polymorphically, concatenates
|
|
140
|
+
* `renderTypeScript()` results, and aggregates `importRequirements()` into a
|
|
141
|
+
* deduplicated import block.
|
|
142
|
+
*/
|
|
143
|
+
var TsExpression = class {};
|
|
144
|
+
|
|
145
|
+
//#endregion
|
|
146
|
+
export { TsExpression, jsonToTsSource, renderImports };
|
|
147
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/json-to-ts-source.ts","../src/render-imports.ts","../src/ts-expression.ts"],"sourcesContent":["/**\n * Pure JSON-to-TypeScript-source printer.\n *\n * This module is the second stage of the codec → TS pipeline:\n *\n * jsValue → codec.encodeJson → JsonValue → jsonToTsSource → TS source text\n *\n * Stage 1 (`codec.encodeJson`) is a codec responsibility — date serialization,\n * opaque domain types (vector, bigint, uuid), JSON canonicalization. Stage 2\n * (this module) is a pure JSON-to-TS printer that must never grow type-specific\n * branches.\n *\n * To render a non-JSON JS value (Date, Vector, BigInt, Buffer, …), encode it\n * through the relevant codec's `encodeJson` first. Adding special cases to\n * this file is not the answer — that's what codecs are for.\n */\n\nexport type JsonValue = string | number | boolean | null | readonly JsonValue[] | JsonObject;\nexport type JsonObject = { readonly [key: string]: JsonValue | undefined };\n\n/**\n * Render a JSON-compatible value as a TypeScript source-text literal.\n *\n * Accepts `unknown` for ergonomics with structural types (e.g. `ColumnSpec`,\n * `ForeignKeySpec`) whose fields are all JSON-compatible but whose interfaces\n * lack the index signature TypeScript requires for `JsonObject` assignability.\n * Non-JSON values (Date, Symbol, Function, etc.) throw at runtime.\n */\nexport function jsonToTsSource(value: unknown): string {\n if (value === undefined) return 'undefined';\n if (value === null) return 'null';\n if (typeof value === 'string') return JSON.stringify(value);\n if (typeof value === 'number' || typeof value === 'boolean') return String(value);\n if (Array.isArray(value)) {\n if (value.length === 0) return '[]';\n const items = value.map((v: unknown) => jsonToTsSource(v));\n const singleLine = `[${items.join(', ')}]`;\n if (singleLine.length <= 80) return singleLine;\n return `[\\n${items.map((i) => ` ${i}`).join(',\\n')},\\n]`;\n }\n if (typeof value === 'object') {\n const entries = Object.entries(value).filter(([, v]) => v !== undefined);\n if (entries.length === 0) return '{}';\n const items = entries.map(([k, v]) => `${renderKey(k)}: ${jsonToTsSource(v)}`);\n const singleLine = `{ ${items.join(', ')} }`;\n if (singleLine.length <= 80) return singleLine;\n return `{\\n${items.map((i) => ` ${i}`).join(',\\n')},\\n}`;\n }\n throw new Error(`jsonToTsSource: unsupported value type \"${typeof value}\"`);\n}\n\nfunction renderKey(key: string): string {\n if (key === '__proto__') return JSON.stringify(key);\n return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);\n}\n","import type { ImportRequirement } from './ts-expression';\n\n/**\n * Render an aggregated `import` block from a flat list of\n * `ImportRequirement`s. Each target's migration renderer collects\n * requirements polymorphically from its call nodes and pipes them here.\n *\n * The emitter invariants:\n *\n * - **One line per module specifier.** Named imports are aggregated and\n * emitted sorted alphabetically; a single default symbol is combined\n * onto the same line when attributes agree (`import def, { a, b } from \"m\";`).\n * - **At most one default symbol per module.** Two conflicting default\n * symbols on the same specifier throw — the user's renderer can't\n * guess which one they meant.\n * - **Attribute unanimity per module.** All requirements for the same\n * module specifier must carry the same (or no) `attributes` map.\n * Divergent attribute maps throw — they can't collapse to one line\n * and there's no user-resolvable recovery at this layer.\n * - **Deterministic ordering.** Modules are emitted sorted by specifier;\n * within a module, named symbols are emitted sorted alphabetically.\n *\n * Returns a string containing one import line per module, joined by `\\n`\n * (no trailing newline). An empty requirement list returns `\"\"`.\n */\nexport function renderImports(requirements: readonly ImportRequirement[]): string {\n const byModule = aggregateByModule(requirements);\n const entries = [...byModule.entries()].sort(([a], [b]) => a.localeCompare(b));\n return entries\n .map(([moduleSpecifier, group]) => renderModuleImport(moduleSpecifier, group))\n .join('\\n');\n}\n\ninterface ModuleImportGroup {\n readonly named: Set<string>;\n defaultSymbol: string | null;\n attributes: Readonly<Record<string, string>> | null;\n attributesSet: boolean;\n}\n\nfunction aggregateByModule(\n requirements: readonly ImportRequirement[],\n): Map<string, ModuleImportGroup> {\n const byModule = new Map<string, ModuleImportGroup>();\n for (const req of requirements) {\n let group = byModule.get(req.moduleSpecifier);\n if (!group) {\n group = { named: new Set(), defaultSymbol: null, attributes: null, attributesSet: false };\n byModule.set(req.moduleSpecifier, group);\n }\n mergeRequirementIntoGroup(req, group);\n }\n return byModule;\n}\n\nfunction mergeRequirementIntoGroup(req: ImportRequirement, group: ModuleImportGroup): void {\n const kind = req.kind ?? 'named';\n if (kind === 'default') {\n if (group.defaultSymbol !== null && group.defaultSymbol !== req.symbol) {\n throw new Error(\n `Conflicting default imports for module \"${req.moduleSpecifier}\": ` +\n `\"${group.defaultSymbol}\" and \"${req.symbol}\". Only one default symbol is allowed per module.`,\n );\n }\n group.defaultSymbol = req.symbol;\n } else {\n group.named.add(req.symbol);\n }\n mergeAttributes(req, group);\n}\n\nfunction mergeAttributes(req: ImportRequirement, group: ModuleImportGroup): void {\n const incoming = req.attributes ?? null;\n if (!group.attributesSet) {\n group.attributes = incoming;\n group.attributesSet = true;\n return;\n }\n if (!attributesEqual(group.attributes, incoming)) {\n throw new Error(\n `Conflicting import attributes for module \"${req.moduleSpecifier}\": ` +\n `${stringifyAttributes(group.attributes)} vs ${stringifyAttributes(incoming)}.`,\n );\n }\n}\n\nfunction attributesEqual(\n a: Readonly<Record<string, string>> | null,\n b: Readonly<Record<string, string>> | null,\n): boolean {\n if (a === b) return true;\n if (a === null || b === null) return false;\n const aKeys = Object.keys(a).sort();\n const bKeys = Object.keys(b).sort();\n if (aKeys.length !== bKeys.length) return false;\n for (let i = 0; i < aKeys.length; i++) {\n const key = aKeys[i];\n if (key !== bKeys[i]) return false;\n if (a[key as string] !== b[key as string]) return false;\n }\n return true;\n}\n\nfunction stringifyAttributes(attrs: Readonly<Record<string, string>> | null): string {\n if (attrs === null) return '(none)';\n const entries = Object.entries(attrs)\n .sort(([a], [b]) => a.localeCompare(b))\n .map(([k, v]) => `${k}: ${JSON.stringify(v)}`);\n return `{ ${entries.join(', ')} }`;\n}\n\nfunction renderModuleImport(moduleSpecifier: string, group: ModuleImportGroup): string {\n const clause = buildImportClause(group);\n const attrs = buildAttributesClause(group.attributes);\n return `import ${clause} from '${moduleSpecifier}'${attrs};`;\n}\n\nfunction buildImportClause(group: ModuleImportGroup): string {\n const named = [...group.named].sort();\n const hasNamed = named.length > 0;\n const hasDefault = group.defaultSymbol !== null;\n if (hasDefault && hasNamed) {\n return `${group.defaultSymbol}, { ${named.join(', ')} }`;\n }\n if (hasDefault) {\n return group.defaultSymbol as string;\n }\n return `{ ${named.join(', ')} }`;\n}\n\nfunction buildAttributesClause(attrs: Readonly<Record<string, string>> | null): string {\n if (attrs === null) return '';\n const entries = Object.entries(attrs)\n .sort(([a], [b]) => a.localeCompare(b))\n .map(([k, v]) => `${k}: ${JSON.stringify(v)}`);\n if (entries.length === 0) return '';\n return ` with { ${entries.join(', ')} }`;\n}\n","/**\n * Declarative contribution to the `import` block of a rendered TypeScript\n * source file. Each node in an IR declares which symbols it needs from which\n * modules; the top-level renderer deduplicates across nodes and emits one\n * `import { a, b, c } from \"…\"` line per module.\n *\n * `kind` defaults to `\"named\"` (e.g. `import { a } from \"m\"`). Setting it to\n * `\"default\"` emits `import a from \"m\"`. `attributes`, if provided, emits an\n * import attributes clause (`with { type: \"json\" }`) verbatim — required for\n * JSON module imports in the rendered scaffolds.\n */\nexport interface ImportRequirement {\n readonly moduleSpecifier: string;\n readonly symbol: string;\n readonly kind?: 'named' | 'default';\n readonly attributes?: Readonly<Record<string, string>>;\n}\n\n/**\n * Abstract base class for any IR node that can be emitted as a TypeScript\n * expression and declare its own import requirements.\n *\n * A top-level renderer walks an array of these polymorphically, concatenates\n * `renderTypeScript()` results, and aggregates `importRequirements()` into a\n * deduplicated import block.\n */\nexport abstract class TsExpression {\n abstract renderTypeScript(): string;\n abstract importRequirements(): readonly ImportRequirement[];\n}\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@prisma-next/ts-render",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"sideEffects": false,
|
|
6
|
+
"description": "TypeScript source-text rendering utilities: JSON-to-TS literal printer and abstract expression base class",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsdown",
|
|
9
|
+
"test": "vitest run",
|
|
10
|
+
"test:coverage": "vitest run --coverage",
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"lint": "biome check . --error-on-warnings",
|
|
13
|
+
"lint:fix": "biome check --write .",
|
|
14
|
+
"lint:fix:unsafe": "biome check --write --unsafe .",
|
|
15
|
+
"clean": "rm -rf dist dist-tsc dist-tsc-prod coverage .tmp-output"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@prisma-next/tsconfig": "workspace:*",
|
|
20
|
+
"@prisma-next/tsdown": "workspace:*",
|
|
21
|
+
"tsdown": "catalog:",
|
|
22
|
+
"typescript": "catalog:",
|
|
23
|
+
"vitest": "catalog:"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"src"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
32
|
+
"exports": {
|
|
33
|
+
".": "./dist/index.mjs",
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"main": "./dist/index.mjs",
|
|
37
|
+
"module": "./dist/index.mjs",
|
|
38
|
+
"types": "./dist/index.d.mts",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "https://github.com/prisma/prisma-next.git",
|
|
42
|
+
"directory": "packages/1-framework/1-core/ts-render"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure JSON-to-TypeScript-source printer.
|
|
3
|
+
*
|
|
4
|
+
* This module is the second stage of the codec → TS pipeline:
|
|
5
|
+
*
|
|
6
|
+
* jsValue → codec.encodeJson → JsonValue → jsonToTsSource → TS source text
|
|
7
|
+
*
|
|
8
|
+
* Stage 1 (`codec.encodeJson`) is a codec responsibility — date serialization,
|
|
9
|
+
* opaque domain types (vector, bigint, uuid), JSON canonicalization. Stage 2
|
|
10
|
+
* (this module) is a pure JSON-to-TS printer that must never grow type-specific
|
|
11
|
+
* branches.
|
|
12
|
+
*
|
|
13
|
+
* To render a non-JSON JS value (Date, Vector, BigInt, Buffer, …), encode it
|
|
14
|
+
* through the relevant codec's `encodeJson` first. Adding special cases to
|
|
15
|
+
* this file is not the answer — that's what codecs are for.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type JsonValue = string | number | boolean | null | readonly JsonValue[] | JsonObject;
|
|
19
|
+
export type JsonObject = { readonly [key: string]: JsonValue | undefined };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Render a JSON-compatible value as a TypeScript source-text literal.
|
|
23
|
+
*
|
|
24
|
+
* Accepts `unknown` for ergonomics with structural types (e.g. `ColumnSpec`,
|
|
25
|
+
* `ForeignKeySpec`) whose fields are all JSON-compatible but whose interfaces
|
|
26
|
+
* lack the index signature TypeScript requires for `JsonObject` assignability.
|
|
27
|
+
* Non-JSON values (Date, Symbol, Function, etc.) throw at runtime.
|
|
28
|
+
*/
|
|
29
|
+
export function jsonToTsSource(value: unknown): string {
|
|
30
|
+
if (value === undefined) return 'undefined';
|
|
31
|
+
if (value === null) return 'null';
|
|
32
|
+
if (typeof value === 'string') return JSON.stringify(value);
|
|
33
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
34
|
+
if (Array.isArray(value)) {
|
|
35
|
+
if (value.length === 0) return '[]';
|
|
36
|
+
const items = value.map((v: unknown) => jsonToTsSource(v));
|
|
37
|
+
const singleLine = `[${items.join(', ')}]`;
|
|
38
|
+
if (singleLine.length <= 80) return singleLine;
|
|
39
|
+
return `[\n${items.map((i) => ` ${i}`).join(',\n')},\n]`;
|
|
40
|
+
}
|
|
41
|
+
if (typeof value === 'object') {
|
|
42
|
+
const entries = Object.entries(value).filter(([, v]) => v !== undefined);
|
|
43
|
+
if (entries.length === 0) return '{}';
|
|
44
|
+
const items = entries.map(([k, v]) => `${renderKey(k)}: ${jsonToTsSource(v)}`);
|
|
45
|
+
const singleLine = `{ ${items.join(', ')} }`;
|
|
46
|
+
if (singleLine.length <= 80) return singleLine;
|
|
47
|
+
return `{\n${items.map((i) => ` ${i}`).join(',\n')},\n}`;
|
|
48
|
+
}
|
|
49
|
+
throw new Error(`jsonToTsSource: unsupported value type "${typeof value}"`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function renderKey(key: string): string {
|
|
53
|
+
if (key === '__proto__') return JSON.stringify(key);
|
|
54
|
+
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
|
|
55
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { ImportRequirement } from './ts-expression';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render an aggregated `import` block from a flat list of
|
|
5
|
+
* `ImportRequirement`s. Each target's migration renderer collects
|
|
6
|
+
* requirements polymorphically from its call nodes and pipes them here.
|
|
7
|
+
*
|
|
8
|
+
* The emitter invariants:
|
|
9
|
+
*
|
|
10
|
+
* - **One line per module specifier.** Named imports are aggregated and
|
|
11
|
+
* emitted sorted alphabetically; a single default symbol is combined
|
|
12
|
+
* onto the same line when attributes agree (`import def, { a, b } from "m";`).
|
|
13
|
+
* - **At most one default symbol per module.** Two conflicting default
|
|
14
|
+
* symbols on the same specifier throw — the user's renderer can't
|
|
15
|
+
* guess which one they meant.
|
|
16
|
+
* - **Attribute unanimity per module.** All requirements for the same
|
|
17
|
+
* module specifier must carry the same (or no) `attributes` map.
|
|
18
|
+
* Divergent attribute maps throw — they can't collapse to one line
|
|
19
|
+
* and there's no user-resolvable recovery at this layer.
|
|
20
|
+
* - **Deterministic ordering.** Modules are emitted sorted by specifier;
|
|
21
|
+
* within a module, named symbols are emitted sorted alphabetically.
|
|
22
|
+
*
|
|
23
|
+
* Returns a string containing one import line per module, joined by `\n`
|
|
24
|
+
* (no trailing newline). An empty requirement list returns `""`.
|
|
25
|
+
*/
|
|
26
|
+
export function renderImports(requirements: readonly ImportRequirement[]): string {
|
|
27
|
+
const byModule = aggregateByModule(requirements);
|
|
28
|
+
const entries = [...byModule.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
29
|
+
return entries
|
|
30
|
+
.map(([moduleSpecifier, group]) => renderModuleImport(moduleSpecifier, group))
|
|
31
|
+
.join('\n');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ModuleImportGroup {
|
|
35
|
+
readonly named: Set<string>;
|
|
36
|
+
defaultSymbol: string | null;
|
|
37
|
+
attributes: Readonly<Record<string, string>> | null;
|
|
38
|
+
attributesSet: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function aggregateByModule(
|
|
42
|
+
requirements: readonly ImportRequirement[],
|
|
43
|
+
): Map<string, ModuleImportGroup> {
|
|
44
|
+
const byModule = new Map<string, ModuleImportGroup>();
|
|
45
|
+
for (const req of requirements) {
|
|
46
|
+
let group = byModule.get(req.moduleSpecifier);
|
|
47
|
+
if (!group) {
|
|
48
|
+
group = { named: new Set(), defaultSymbol: null, attributes: null, attributesSet: false };
|
|
49
|
+
byModule.set(req.moduleSpecifier, group);
|
|
50
|
+
}
|
|
51
|
+
mergeRequirementIntoGroup(req, group);
|
|
52
|
+
}
|
|
53
|
+
return byModule;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function mergeRequirementIntoGroup(req: ImportRequirement, group: ModuleImportGroup): void {
|
|
57
|
+
const kind = req.kind ?? 'named';
|
|
58
|
+
if (kind === 'default') {
|
|
59
|
+
if (group.defaultSymbol !== null && group.defaultSymbol !== req.symbol) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Conflicting default imports for module "${req.moduleSpecifier}": ` +
|
|
62
|
+
`"${group.defaultSymbol}" and "${req.symbol}". Only one default symbol is allowed per module.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
group.defaultSymbol = req.symbol;
|
|
66
|
+
} else {
|
|
67
|
+
group.named.add(req.symbol);
|
|
68
|
+
}
|
|
69
|
+
mergeAttributes(req, group);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function mergeAttributes(req: ImportRequirement, group: ModuleImportGroup): void {
|
|
73
|
+
const incoming = req.attributes ?? null;
|
|
74
|
+
if (!group.attributesSet) {
|
|
75
|
+
group.attributes = incoming;
|
|
76
|
+
group.attributesSet = true;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!attributesEqual(group.attributes, incoming)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Conflicting import attributes for module "${req.moduleSpecifier}": ` +
|
|
82
|
+
`${stringifyAttributes(group.attributes)} vs ${stringifyAttributes(incoming)}.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function attributesEqual(
|
|
88
|
+
a: Readonly<Record<string, string>> | null,
|
|
89
|
+
b: Readonly<Record<string, string>> | null,
|
|
90
|
+
): boolean {
|
|
91
|
+
if (a === b) return true;
|
|
92
|
+
if (a === null || b === null) return false;
|
|
93
|
+
const aKeys = Object.keys(a).sort();
|
|
94
|
+
const bKeys = Object.keys(b).sort();
|
|
95
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
96
|
+
for (let i = 0; i < aKeys.length; i++) {
|
|
97
|
+
const key = aKeys[i];
|
|
98
|
+
if (key !== bKeys[i]) return false;
|
|
99
|
+
if (a[key as string] !== b[key as string]) return false;
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function stringifyAttributes(attrs: Readonly<Record<string, string>> | null): string {
|
|
105
|
+
if (attrs === null) return '(none)';
|
|
106
|
+
const entries = Object.entries(attrs)
|
|
107
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
108
|
+
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`);
|
|
109
|
+
return `{ ${entries.join(', ')} }`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function renderModuleImport(moduleSpecifier: string, group: ModuleImportGroup): string {
|
|
113
|
+
const clause = buildImportClause(group);
|
|
114
|
+
const attrs = buildAttributesClause(group.attributes);
|
|
115
|
+
return `import ${clause} from '${moduleSpecifier}'${attrs};`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function buildImportClause(group: ModuleImportGroup): string {
|
|
119
|
+
const named = [...group.named].sort();
|
|
120
|
+
const hasNamed = named.length > 0;
|
|
121
|
+
const hasDefault = group.defaultSymbol !== null;
|
|
122
|
+
if (hasDefault && hasNamed) {
|
|
123
|
+
return `${group.defaultSymbol}, { ${named.join(', ')} }`;
|
|
124
|
+
}
|
|
125
|
+
if (hasDefault) {
|
|
126
|
+
return group.defaultSymbol as string;
|
|
127
|
+
}
|
|
128
|
+
return `{ ${named.join(', ')} }`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function buildAttributesClause(attrs: Readonly<Record<string, string>> | null): string {
|
|
132
|
+
if (attrs === null) return '';
|
|
133
|
+
const entries = Object.entries(attrs)
|
|
134
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
135
|
+
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`);
|
|
136
|
+
if (entries.length === 0) return '';
|
|
137
|
+
return ` with { ${entries.join(', ')} }`;
|
|
138
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative contribution to the `import` block of a rendered TypeScript
|
|
3
|
+
* source file. Each node in an IR declares which symbols it needs from which
|
|
4
|
+
* modules; the top-level renderer deduplicates across nodes and emits one
|
|
5
|
+
* `import { a, b, c } from "…"` line per module.
|
|
6
|
+
*
|
|
7
|
+
* `kind` defaults to `"named"` (e.g. `import { a } from "m"`). Setting it to
|
|
8
|
+
* `"default"` emits `import a from "m"`. `attributes`, if provided, emits an
|
|
9
|
+
* import attributes clause (`with { type: "json" }`) verbatim — required for
|
|
10
|
+
* JSON module imports in the rendered scaffolds.
|
|
11
|
+
*/
|
|
12
|
+
export interface ImportRequirement {
|
|
13
|
+
readonly moduleSpecifier: string;
|
|
14
|
+
readonly symbol: string;
|
|
15
|
+
readonly kind?: 'named' | 'default';
|
|
16
|
+
readonly attributes?: Readonly<Record<string, string>>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Abstract base class for any IR node that can be emitted as a TypeScript
|
|
21
|
+
* expression and declare its own import requirements.
|
|
22
|
+
*
|
|
23
|
+
* A top-level renderer walks an array of these polymorphically, concatenates
|
|
24
|
+
* `renderTypeScript()` results, and aggregates `importRequirements()` into a
|
|
25
|
+
* deduplicated import block.
|
|
26
|
+
*/
|
|
27
|
+
export abstract class TsExpression {
|
|
28
|
+
abstract renderTypeScript(): string;
|
|
29
|
+
abstract importRequirements(): readonly ImportRequirement[];
|
|
30
|
+
}
|