kysely-ddl 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.
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Postgres object names are limited to `NAMEDATALEN - 1` = **63 bytes**.
3
+ * Anything longer is silently truncated, which is more dangerous than it looks:
4
+ * the snapshot remembers the long name, the database holds the short one, and
5
+ * the next diff keeps trying to create the "missing" index forever.
6
+ *
7
+ * Hence two different behaviours:
8
+ *
9
+ * explicit name -> an error, so the author sees the problem and shortens it;
10
+ * auto-name -> shortened deterministically with a hash suffix, so that
11
+ * two long names do not collapse into one.
12
+ */
13
+ export declare const MAX_IDENTIFIER_BYTES = 63;
14
+ /**
15
+ * Shortens an auto-name to the postgres limit. The hash suffix is computed from
16
+ * the FULL name, so two different long names with a common prefix do not collapse.
17
+ */
18
+ export declare function fitIdentifier(name: string): string;
19
+ /** An explicit name is left alone, but exceeding the limit must not pass silently. */
20
+ export declare function assertIdentifier(name: string, what: string): void;
21
+ /**
22
+ * An auto-name in the `{table}_{columns}_{suffix}` style:
23
+ * `user_resource_transaction_user_id_resource_idx`.
24
+ */
25
+ export declare function autoName(parts: readonly string[], suffix: string): string;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Postgres object names are limited to `NAMEDATALEN - 1` = **63 bytes**.
3
+ * Anything longer is silently truncated, which is more dangerous than it looks:
4
+ * the snapshot remembers the long name, the database holds the short one, and
5
+ * the next diff keeps trying to create the "missing" index forever.
6
+ *
7
+ * Hence two different behaviours:
8
+ *
9
+ * explicit name -> an error, so the author sees the problem and shortens it;
10
+ * auto-name -> shortened deterministically with a hash suffix, so that
11
+ * two long names do not collapse into one.
12
+ */
13
+ export const MAX_IDENTIFIER_BYTES = 63;
14
+ function byteLength(value) {
15
+ return new TextEncoder().encode(value).length;
16
+ }
17
+ /** FNV-1a: a stable short hash is all that is needed, cryptography is irrelevant here. */
18
+ function hash(value) {
19
+ let h = 0x811c9dc5;
20
+ for (let i = 0; i < value.length; i++) {
21
+ h ^= value.charCodeAt(i);
22
+ h = Math.imul(h, 0x01000193) >>> 0;
23
+ }
24
+ return h.toString(16).padStart(8, '0');
25
+ }
26
+ /**
27
+ * Shortens an auto-name to the postgres limit. The hash suffix is computed from
28
+ * the FULL name, so two different long names with a common prefix do not collapse.
29
+ */
30
+ export function fitIdentifier(name) {
31
+ if (byteLength(name) <= MAX_IDENTIFIER_BYTES) {
32
+ return name;
33
+ }
34
+ const suffix = `_${hash(name)}`;
35
+ let head = name;
36
+ while (byteLength(head) + suffix.length > MAX_IDENTIFIER_BYTES) {
37
+ head = head.slice(0, -1);
38
+ }
39
+ return `${head.replace(/_+$/, '')}${suffix}`;
40
+ }
41
+ /** An explicit name is left alone, but exceeding the limit must not pass silently. */
42
+ export function assertIdentifier(name, what) {
43
+ const length = byteLength(name);
44
+ if (length > MAX_IDENTIFIER_BYTES) {
45
+ throw new Error(`${what}: name "${name}" is ${length} bytes long, postgres would truncate it to ` +
46
+ `${MAX_IDENTIFIER_BYTES}. Shorten it or drop it to have it generated.`);
47
+ }
48
+ if (name.length === 0) {
49
+ throw new Error(`${what}: empty name`);
50
+ }
51
+ }
52
+ /**
53
+ * An auto-name in the `{table}_{columns}_{suffix}` style:
54
+ * `user_resource_transaction_user_id_resource_idx`.
55
+ */
56
+ export function autoName(parts, suffix) {
57
+ return fitIdentifier([...parts, suffix].join('_'));
58
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A minimal SQL fragment: everything needed for defaults, check expressions
3
+ * and partial index conditions.
4
+ *
5
+ * The fragment is stored as chunks rather than a string, so that a column
6
+ * reference can be rendered with its DATABASE NAME (`"user_id"`) instead of
7
+ * the property name.
8
+ */
9
+ /** A column reference inside an expression. */
10
+ export interface ColumnRef {
11
+ readonly kind: 'column';
12
+ /** The column name in the database. */
13
+ readonly name: string;
14
+ }
15
+ /** A literal that must be escaped when rendered. */
16
+ export interface Literal {
17
+ readonly kind: 'literal';
18
+ readonly value: string | number | boolean | null;
19
+ }
20
+ export type SqlChunk = string | ColumnRef | Literal;
21
+ export interface Sql {
22
+ readonly kind: 'sql';
23
+ readonly chunks: readonly SqlChunk[];
24
+ }
25
+ export declare function isSql(value: unknown): value is Sql;
26
+ /**
27
+ * ```ts
28
+ * sql`${c.delta} <> 0`
29
+ * sql`uuidv7()`
30
+ * ```
31
+ */
32
+ export declare function sql(strings: TemplateStringsArray, ...values: unknown[]): Sql;
33
+ /** `"status" in ('new', 'closed')`: the most common form of a check in real schemas. */
34
+ export declare function inArray(column: ColumnRef, values: readonly (string | number)[]): Sql;
35
+ /**
36
+ * Column names referenced by the expression, in order of first appearance.
37
+ * Needed to build a check constraint name: `{table}_{columns}_check`.
38
+ */
39
+ export declare function collectColumns(expr: Sql): string[];
40
+ export declare function quoteIdentifier(name: string): string;
41
+ export declare function quoteLiteral(value: string | number | boolean | null): string;
42
+ /** Expands the fragment into an SQL string. */
43
+ export declare function renderSql(expr: Sql): string;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * A minimal SQL fragment: everything needed for defaults, check expressions
3
+ * and partial index conditions.
4
+ *
5
+ * The fragment is stored as chunks rather than a string, so that a column
6
+ * reference can be rendered with its DATABASE NAME (`"user_id"`) instead of
7
+ * the property name.
8
+ */
9
+ export function isSql(value) {
10
+ return typeof value === 'object' && value !== null && value.kind === 'sql';
11
+ }
12
+ /** For error messages: objects as JSON, everything else as is. */
13
+ function describeValue(value) {
14
+ switch (typeof value) {
15
+ case 'string':
16
+ case 'number':
17
+ case 'boolean':
18
+ case 'bigint':
19
+ case 'symbol':
20
+ case 'undefined':
21
+ return String(value);
22
+ case 'function':
23
+ return 'function';
24
+ default:
25
+ return JSON.stringify(value);
26
+ }
27
+ }
28
+ function toChunk(value) {
29
+ if (isSql(value)) {
30
+ // nested fragments would be expanded on render; a marker is enough here
31
+ throw new Error('nested sql`` is not supported: build the expression as a single template');
32
+ }
33
+ if (typeof value === 'object' && value !== null && value.kind === 'column') {
34
+ return value;
35
+ }
36
+ if (typeof value === 'string' ||
37
+ typeof value === 'number' ||
38
+ typeof value === 'boolean' ||
39
+ value === null) {
40
+ return { kind: 'literal', value };
41
+ }
42
+ throw new Error(`cannot interpolate into sql\`\`: ${describeValue(value)}`);
43
+ }
44
+ /**
45
+ * ```ts
46
+ * sql`${c.delta} <> 0`
47
+ * sql`uuidv7()`
48
+ * ```
49
+ */
50
+ export function sql(strings, ...values) {
51
+ const chunks = [];
52
+ strings.forEach((part, i) => {
53
+ if (part) {
54
+ chunks.push(part);
55
+ }
56
+ if (i < values.length) {
57
+ chunks.push(toChunk(values[i]));
58
+ }
59
+ });
60
+ return { kind: 'sql', chunks };
61
+ }
62
+ /** `"status" in ('new', 'closed')`: the most common form of a check in real schemas. */
63
+ export function inArray(column, values) {
64
+ const chunks = [column, ' in ('];
65
+ values.forEach((value, i) => {
66
+ if (i > 0) {
67
+ chunks.push(', ');
68
+ }
69
+ chunks.push({ kind: 'literal', value });
70
+ });
71
+ chunks.push(')');
72
+ return { kind: 'sql', chunks };
73
+ }
74
+ /**
75
+ * Column names referenced by the expression, in order of first appearance.
76
+ * Needed to build a check constraint name: `{table}_{columns}_check`.
77
+ */
78
+ export function collectColumns(expr) {
79
+ const names = [];
80
+ for (const chunk of expr.chunks) {
81
+ if (typeof chunk !== 'string' && chunk.kind === 'column' && !names.includes(chunk.name)) {
82
+ names.push(chunk.name);
83
+ }
84
+ }
85
+ return names;
86
+ }
87
+ export function quoteIdentifier(name) {
88
+ return `"${name.replace(/"/g, '""')}"`;
89
+ }
90
+ export function quoteLiteral(value) {
91
+ if (value === null) {
92
+ return 'NULL';
93
+ }
94
+ if (typeof value === 'number') {
95
+ return String(value);
96
+ }
97
+ if (typeof value === 'boolean') {
98
+ return value ? 'true' : 'false';
99
+ }
100
+ return `'${value.replace(/'/g, "''")}'`;
101
+ }
102
+ /** Expands the fragment into an SQL string. */
103
+ export function renderSql(expr) {
104
+ return expr.chunks
105
+ .map(chunk => {
106
+ if (typeof chunk === 'string') {
107
+ return chunk;
108
+ }
109
+ if (chunk.kind === 'column') {
110
+ return quoteIdentifier(chunk.name);
111
+ }
112
+ return quoteLiteral(chunk.value);
113
+ })
114
+ .join('');
115
+ }
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "kysely-ddl",
3
+ "version": "0.1.0",
4
+ "description": "PostgreSQL schema as TypeScript code: SQL migrations from snapshot diffs, Kysely table types and a migration runner",
5
+ "keywords": [
6
+ "postgres",
7
+ "postgresql",
8
+ "kysely",
9
+ "migrations",
10
+ "schema",
11
+ "ddl",
12
+ "schema-as-code",
13
+ "bun",
14
+ "typescript"
15
+ ],
16
+ "license": "MIT",
17
+ "type": "module",
18
+ "sideEffects": false,
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/hehmonke/kysely-ddl.git"
25
+ },
26
+ "homepage": "https://github.com/hehmonke/kysely-ddl#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/hehmonke/kysely-ddl/issues"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public",
32
+ "provenance": true
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "CHANGELOG.md"
37
+ ],
38
+ "types": "./dist/index.d.ts",
39
+ "exports": {
40
+ ".": {
41
+ "types": "./dist/index.d.ts",
42
+ "default": "./dist/index.js"
43
+ },
44
+ "./kysely": {
45
+ "types": "./dist/kysely/index.d.ts",
46
+ "default": "./dist/kysely/index.js"
47
+ },
48
+ "./package.json": "./package.json"
49
+ },
50
+ "scripts": {
51
+ "typecheck": "tsc -p tsconfig.json",
52
+ "lint": "oxlint",
53
+ "lint:fix": "oxlint --fix",
54
+ "test": "bun test",
55
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
56
+ "smoke:node": "node scripts/smoke-node.mjs",
57
+ "check": "bun run typecheck && bun run lint && bun test && bun run build",
58
+ "db:up": "docker compose up -d --wait",
59
+ "db:down": "docker compose down -v",
60
+ "prepublishOnly": "bun run check"
61
+ },
62
+ "peerDependencies": {
63
+ "kysely": ">=0.28"
64
+ },
65
+ "devDependencies": {
66
+ "@oxlint/plugins": "^1.80.0",
67
+ "@stylistic/eslint-plugin": "^5.10.0",
68
+ "@types/bun": "^1.4.0",
69
+ "@types/node": "^26.4.0",
70
+ "@types/pg": "^8.23.1",
71
+ "eslint-plugin-perfectionist": "^5.10.1",
72
+ "eslint-plugin-unused-imports": "^4.4.1",
73
+ "kysely": "^0.29.5",
74
+ "oxlint": "^1.80.0",
75
+ "oxlint-tsgolint": "^7.0.2001",
76
+ "pg": "^8.23.0",
77
+ "typescript": "^7.0.2"
78
+ }
79
+ }