@stardeck-customer-apps/data-store-sdk 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +6 -0
- package/dist/cli/generate-types.js +265 -0
- package/dist/cli/generate-types.mjs +265 -0
- package/package.json +3 -3
package/SKILL.md
CHANGED
|
@@ -208,8 +208,14 @@ Use this for application code where you want compile-time type safety.
|
|
|
208
208
|
```bash
|
|
209
209
|
npx stardeck-data-store generate-types --connection-string $DATA_STORE_MYSTORE_URL
|
|
210
210
|
# Outputs: ./src/generated/data-store-types.ts
|
|
211
|
+
# ./src/generated/data-store-schema.sql (DDL snapshot for the test harness)
|
|
211
212
|
```
|
|
212
213
|
|
|
214
|
+
Commit both generated files. Re-run this command after every schema change —
|
|
215
|
+
the schema snapshot is what `@stardeck-customer-apps/testing` applies to its
|
|
216
|
+
in-process Postgres, so a stale snapshot means tests run against the wrong
|
|
217
|
+
schema.
|
|
218
|
+
|
|
213
219
|
### Initialize
|
|
214
220
|
|
|
215
221
|
```typescript
|
|
@@ -26,6 +26,252 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
26
26
|
// src/cli/generate-types.ts
|
|
27
27
|
var import_fs = require("fs");
|
|
28
28
|
var import_serverless = require("@neondatabase/serverless");
|
|
29
|
+
|
|
30
|
+
// src/cli/schema-ddl.ts
|
|
31
|
+
function quoteIdent(name) {
|
|
32
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
33
|
+
}
|
|
34
|
+
var TRACKED_EXTENSIONS = /* @__PURE__ */ new Set(["pgcrypto", "pg_trgm", "uuid-ossp", "citext"]);
|
|
35
|
+
function resolveSqlType(row) {
|
|
36
|
+
const dataType = row.data_type;
|
|
37
|
+
if (dataType === "ARRAY") {
|
|
38
|
+
return `${row.udt_name.replace(/^_/, "")}[]`;
|
|
39
|
+
}
|
|
40
|
+
if (dataType === "USER-DEFINED") {
|
|
41
|
+
return quoteIdent(row.udt_name);
|
|
42
|
+
}
|
|
43
|
+
if (dataType === "character varying") {
|
|
44
|
+
return row.character_maximum_length ? `varchar(${row.character_maximum_length})` : "varchar";
|
|
45
|
+
}
|
|
46
|
+
if (dataType === "character") {
|
|
47
|
+
return row.character_maximum_length ? `char(${row.character_maximum_length})` : "char";
|
|
48
|
+
}
|
|
49
|
+
if (dataType === "numeric") {
|
|
50
|
+
return row.numeric_precision !== null && row.numeric_scale !== null ? `numeric(${row.numeric_precision},${row.numeric_scale})` : "numeric";
|
|
51
|
+
}
|
|
52
|
+
if (dataType === "timestamp with time zone") return "timestamptz";
|
|
53
|
+
if (dataType === "timestamp without time zone") return "timestamp";
|
|
54
|
+
if (dataType === "time with time zone") return "timetz";
|
|
55
|
+
if (dataType === "time without time zone") return "time";
|
|
56
|
+
return dataType;
|
|
57
|
+
}
|
|
58
|
+
async function introspectSchemaSnapshot(executor) {
|
|
59
|
+
const [extensions, enums, columns, constraints, fks, checks, indexes] = await Promise.all([
|
|
60
|
+
executor.query(`SELECT extname FROM pg_extension ORDER BY extname`),
|
|
61
|
+
executor.query(
|
|
62
|
+
`SELECT t.typname AS name, e.enumlabel AS value
|
|
63
|
+
FROM pg_type t
|
|
64
|
+
JOIN pg_enum e ON e.enumtypid = t.oid
|
|
65
|
+
JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
66
|
+
WHERE n.nspname = 'public'
|
|
67
|
+
ORDER BY t.typname, e.enumsortorder`
|
|
68
|
+
),
|
|
69
|
+
executor.query(
|
|
70
|
+
`SELECT c.table_name, c.column_name, c.data_type, c.udt_name, c.is_nullable,
|
|
71
|
+
c.column_default, c.character_maximum_length, c.numeric_precision, c.numeric_scale,
|
|
72
|
+
c.is_generated, c.generation_expression
|
|
73
|
+
FROM information_schema.columns c
|
|
74
|
+
JOIN information_schema.tables t
|
|
75
|
+
ON c.table_name = t.table_name AND c.table_schema = t.table_schema
|
|
76
|
+
WHERE c.table_schema = 'public'
|
|
77
|
+
AND t.table_type = 'BASE TABLE'
|
|
78
|
+
AND t.table_name NOT LIKE '\\_deleted\\_%'
|
|
79
|
+
ORDER BY c.table_name, c.ordinal_position`
|
|
80
|
+
),
|
|
81
|
+
executor.query(
|
|
82
|
+
`SELECT tc.table_name, tc.constraint_name, tc.constraint_type, ku.column_name
|
|
83
|
+
FROM information_schema.table_constraints tc
|
|
84
|
+
JOIN information_schema.key_column_usage ku
|
|
85
|
+
ON tc.constraint_name = ku.constraint_name AND tc.table_schema = ku.table_schema
|
|
86
|
+
WHERE tc.table_schema = 'public'
|
|
87
|
+
AND tc.constraint_type IN ('PRIMARY KEY', 'UNIQUE')
|
|
88
|
+
ORDER BY tc.table_name, tc.constraint_name, ku.ordinal_position`
|
|
89
|
+
),
|
|
90
|
+
executor.query(
|
|
91
|
+
`SELECT tc.constraint_name, tc.table_name, ku.column_name,
|
|
92
|
+
ccu.table_name AS referenced_table, ccu.column_name AS referenced_column,
|
|
93
|
+
rc.delete_rule, rc.update_rule
|
|
94
|
+
FROM information_schema.table_constraints tc
|
|
95
|
+
JOIN information_schema.key_column_usage ku
|
|
96
|
+
ON tc.constraint_name = ku.constraint_name AND tc.table_schema = ku.table_schema
|
|
97
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
98
|
+
ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema
|
|
99
|
+
JOIN information_schema.referential_constraints rc
|
|
100
|
+
ON tc.constraint_name = rc.constraint_name AND tc.table_schema = rc.constraint_schema
|
|
101
|
+
WHERE tc.table_schema = 'public' AND tc.constraint_type = 'FOREIGN KEY'
|
|
102
|
+
ORDER BY tc.table_name, tc.constraint_name, ku.ordinal_position`
|
|
103
|
+
),
|
|
104
|
+
executor.query(
|
|
105
|
+
`SELECT rel.relname AS table_name, con.conname AS name,
|
|
106
|
+
pg_get_constraintdef(con.oid) AS definition
|
|
107
|
+
FROM pg_constraint con
|
|
108
|
+
JOIN pg_class rel ON rel.oid = con.conrelid
|
|
109
|
+
JOIN pg_namespace ns ON ns.oid = rel.relnamespace
|
|
110
|
+
WHERE ns.nspname = 'public' AND con.contype = 'c'
|
|
111
|
+
AND rel.relname NOT LIKE '\\_deleted\\_%'
|
|
112
|
+
ORDER BY rel.relname, con.conname`
|
|
113
|
+
),
|
|
114
|
+
executor.query(
|
|
115
|
+
`SELECT i.indexdef
|
|
116
|
+
FROM pg_indexes i
|
|
117
|
+
WHERE i.schemaname = 'public'
|
|
118
|
+
AND i.tablename NOT LIKE '\\_deleted\\_%'
|
|
119
|
+
AND NOT EXISTS (
|
|
120
|
+
SELECT 1 FROM pg_constraint c
|
|
121
|
+
JOIN pg_class cls ON cls.oid = c.conindid
|
|
122
|
+
WHERE cls.relname = i.indexname
|
|
123
|
+
)
|
|
124
|
+
ORDER BY i.indexname`
|
|
125
|
+
)
|
|
126
|
+
]);
|
|
127
|
+
const enumMap = /* @__PURE__ */ new Map();
|
|
128
|
+
for (const row of enums.rows) {
|
|
129
|
+
const name = String(row.name);
|
|
130
|
+
if (!enumMap.has(name)) enumMap.set(name, []);
|
|
131
|
+
enumMap.get(name).push(String(row.value));
|
|
132
|
+
}
|
|
133
|
+
const tableMap = /* @__PURE__ */ new Map();
|
|
134
|
+
for (const row of columns.rows) {
|
|
135
|
+
const tableName = String(row.table_name);
|
|
136
|
+
if (!tableMap.has(tableName)) {
|
|
137
|
+
tableMap.set(tableName, { name: tableName, columns: [], primaryKey: [], uniques: [] });
|
|
138
|
+
}
|
|
139
|
+
const defaultExpr = row.column_default === null ? null : String(row.column_default);
|
|
140
|
+
const isSerial = defaultExpr !== null && /^nextval\(/.test(defaultExpr);
|
|
141
|
+
const isGenerated = row.is_generated === "ALWAYS";
|
|
142
|
+
tableMap.get(tableName).columns.push({
|
|
143
|
+
name: String(row.column_name),
|
|
144
|
+
sqlType: resolveSqlType({
|
|
145
|
+
data_type: String(row.data_type),
|
|
146
|
+
udt_name: String(row.udt_name),
|
|
147
|
+
character_maximum_length: row.character_maximum_length,
|
|
148
|
+
numeric_precision: row.numeric_precision,
|
|
149
|
+
numeric_scale: row.numeric_scale
|
|
150
|
+
}),
|
|
151
|
+
nullable: row.is_nullable === "YES",
|
|
152
|
+
defaultExpr: isGenerated ? null : defaultExpr,
|
|
153
|
+
isSerial: !isGenerated && isSerial,
|
|
154
|
+
generatedExpr: isGenerated ? String(row.generation_expression) : null
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const constraintGroups = /* @__PURE__ */ new Map();
|
|
158
|
+
for (const row of constraints.rows) {
|
|
159
|
+
const key = String(row.constraint_name);
|
|
160
|
+
if (!constraintGroups.has(key)) {
|
|
161
|
+
constraintGroups.set(key, {
|
|
162
|
+
table: String(row.table_name),
|
|
163
|
+
type: String(row.constraint_type),
|
|
164
|
+
columns: []
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
constraintGroups.get(key).columns.push(String(row.column_name));
|
|
168
|
+
}
|
|
169
|
+
for (const group of constraintGroups.values()) {
|
|
170
|
+
const table = tableMap.get(group.table);
|
|
171
|
+
if (!table) continue;
|
|
172
|
+
if (group.type === "PRIMARY KEY") {
|
|
173
|
+
table.primaryKey = group.columns;
|
|
174
|
+
} else if (group.type === "UNIQUE") {
|
|
175
|
+
table.uniques.push(group.columns);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const fkGroups = /* @__PURE__ */ new Map();
|
|
179
|
+
for (const row of fks.rows) {
|
|
180
|
+
const key = String(row.constraint_name);
|
|
181
|
+
if (!fkGroups.has(key)) {
|
|
182
|
+
fkGroups.set(key, {
|
|
183
|
+
constraintName: key,
|
|
184
|
+
table: String(row.table_name),
|
|
185
|
+
columns: [],
|
|
186
|
+
referencedTable: String(row.referenced_table),
|
|
187
|
+
referencedColumns: [],
|
|
188
|
+
onDelete: String(row.delete_rule),
|
|
189
|
+
onUpdate: String(row.update_rule)
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
const fk = fkGroups.get(key);
|
|
193
|
+
fk.columns.push(String(row.column_name));
|
|
194
|
+
fk.referencedColumns.push(String(row.referenced_column));
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
extensions: extensions.rows.map((r) => String(r.extname)).filter((name) => TRACKED_EXTENSIONS.has(name)),
|
|
198
|
+
enums: Array.from(enumMap, ([name, values]) => ({ name, values })),
|
|
199
|
+
tables: Array.from(tableMap.values()),
|
|
200
|
+
foreignKeys: Array.from(fkGroups.values()),
|
|
201
|
+
checks: checks.rows.map((r) => ({
|
|
202
|
+
table: String(r.table_name),
|
|
203
|
+
name: String(r.name),
|
|
204
|
+
definition: String(r.definition)
|
|
205
|
+
})),
|
|
206
|
+
indexes: indexes.rows.map((r) => ({ definition: String(r.indexdef) }))
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function columnDdl(column, isSinglePk) {
|
|
210
|
+
let sqlType = column.sqlType;
|
|
211
|
+
if (column.isSerial) {
|
|
212
|
+
sqlType = sqlType === "bigint" ? "bigserial" : sqlType === "smallint" ? "smallserial" : "serial";
|
|
213
|
+
}
|
|
214
|
+
let ddl = `${quoteIdent(column.name)} ${sqlType}`;
|
|
215
|
+
if (isSinglePk) ddl += " PRIMARY KEY";
|
|
216
|
+
if (!column.nullable && !isSinglePk) ddl += " NOT NULL";
|
|
217
|
+
if (column.generatedExpr) {
|
|
218
|
+
ddl += ` GENERATED ALWAYS AS (${column.generatedExpr}) STORED`;
|
|
219
|
+
} else if (column.defaultExpr && !column.isSerial) {
|
|
220
|
+
ddl += ` DEFAULT ${column.defaultExpr}`;
|
|
221
|
+
}
|
|
222
|
+
return ddl;
|
|
223
|
+
}
|
|
224
|
+
function generateSchemaSql(snapshot) {
|
|
225
|
+
const lines = [
|
|
226
|
+
"-- Auto-generated by @stardeck-customer-apps/data-store-sdk",
|
|
227
|
+
"-- Schema snapshot of the live data store, applied to PGlite by the test harness.",
|
|
228
|
+
"-- Do not edit manually \u2014 regenerate with: npx stardeck-data-store generate-types",
|
|
229
|
+
""
|
|
230
|
+
];
|
|
231
|
+
for (const ext of snapshot.extensions) {
|
|
232
|
+
lines.push(`CREATE EXTENSION IF NOT EXISTS ${ext === "uuid-ossp" ? '"uuid-ossp"' : ext};`);
|
|
233
|
+
}
|
|
234
|
+
if (snapshot.extensions.length > 0) lines.push("");
|
|
235
|
+
for (const enumType of snapshot.enums) {
|
|
236
|
+
const values = enumType.values.map((v) => `'${v.replace(/'/g, "''")}'`).join(", ");
|
|
237
|
+
lines.push(`CREATE TYPE ${quoteIdent(enumType.name)} AS ENUM (${values});`);
|
|
238
|
+
}
|
|
239
|
+
if (snapshot.enums.length > 0) lines.push("");
|
|
240
|
+
for (const table of snapshot.tables) {
|
|
241
|
+
const singlePk = table.primaryKey.length === 1 ? table.primaryKey[0] : null;
|
|
242
|
+
const parts = table.columns.map((col) => " " + columnDdl(col, col.name === singlePk));
|
|
243
|
+
if (table.primaryKey.length > 1) {
|
|
244
|
+
parts.push(` PRIMARY KEY (${table.primaryKey.map(quoteIdent).join(", ")})`);
|
|
245
|
+
}
|
|
246
|
+
for (const unique of table.uniques) {
|
|
247
|
+
parts.push(` UNIQUE (${unique.map(quoteIdent).join(", ")})`);
|
|
248
|
+
}
|
|
249
|
+
lines.push(`CREATE TABLE ${quoteIdent(table.name)} (`);
|
|
250
|
+
lines.push(parts.join(",\n"));
|
|
251
|
+
lines.push(`);`);
|
|
252
|
+
lines.push("");
|
|
253
|
+
}
|
|
254
|
+
for (const check of snapshot.checks) {
|
|
255
|
+
lines.push(
|
|
256
|
+
`ALTER TABLE ${quoteIdent(check.table)} ADD CONSTRAINT ${quoteIdent(check.name)} ${check.definition};`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
if (snapshot.checks.length > 0) lines.push("");
|
|
260
|
+
for (const fk of snapshot.foreignKeys) {
|
|
261
|
+
let stmt = `ALTER TABLE ${quoteIdent(fk.table)} ADD CONSTRAINT ${quoteIdent(fk.constraintName)} FOREIGN KEY (${fk.columns.map(quoteIdent).join(", ")}) REFERENCES ${quoteIdent(fk.referencedTable)} (${fk.referencedColumns.map(quoteIdent).join(", ")})`;
|
|
262
|
+
if (fk.onDelete !== "NO ACTION") stmt += ` ON DELETE ${fk.onDelete}`;
|
|
263
|
+
if (fk.onUpdate !== "NO ACTION") stmt += ` ON UPDATE ${fk.onUpdate}`;
|
|
264
|
+
lines.push(`${stmt};`);
|
|
265
|
+
}
|
|
266
|
+
if (snapshot.foreignKeys.length > 0) lines.push("");
|
|
267
|
+
for (const index of snapshot.indexes) {
|
|
268
|
+
lines.push(`${index.definition};`);
|
|
269
|
+
}
|
|
270
|
+
if (snapshot.indexes.length > 0) lines.push("");
|
|
271
|
+
return lines.join("\n");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// src/cli/generate-types.ts
|
|
29
275
|
var PG_TYPE_MAP = {
|
|
30
276
|
text: "string",
|
|
31
277
|
varchar: "string",
|
|
@@ -131,17 +377,25 @@ async function main() {
|
|
|
131
377
|
const args = process.argv.slice(2);
|
|
132
378
|
let connectionString = process.env.DATA_STORE_URL;
|
|
133
379
|
let outputPath = "./src/generated/data-store-types.ts";
|
|
380
|
+
let schemaOutputPath;
|
|
381
|
+
let emitSchema = true;
|
|
134
382
|
for (let i = 0; i < args.length; i++) {
|
|
135
383
|
if (args[i] === "--connection-string" && args[i + 1]) {
|
|
136
384
|
connectionString = args[++i];
|
|
137
385
|
} else if (args[i] === "--output" && args[i + 1]) {
|
|
138
386
|
outputPath = args[++i];
|
|
387
|
+
} else if (args[i] === "--schema-output" && args[i + 1]) {
|
|
388
|
+
schemaOutputPath = args[++i];
|
|
389
|
+
} else if (args[i] === "--no-schema") {
|
|
390
|
+
emitSchema = false;
|
|
139
391
|
} else if (args[i] === "--help") {
|
|
140
392
|
console.log(`Usage: stardeck-data-store generate-types [options]
|
|
141
393
|
|
|
142
394
|
Options:
|
|
143
395
|
--connection-string <url> Postgres connection string (default: DATA_STORE_URL env var)
|
|
144
396
|
--output <path> Output file path (default: ./src/generated/data-store-types.ts)
|
|
397
|
+
--schema-output <path> DDL snapshot path (default: data-store-schema.sql next to --output)
|
|
398
|
+
--no-schema Skip the DDL snapshot used by the test harness
|
|
145
399
|
--help Show this help message`);
|
|
146
400
|
process.exit(0);
|
|
147
401
|
}
|
|
@@ -167,6 +421,17 @@ Options:
|
|
|
167
421
|
}
|
|
168
422
|
(0, import_fs.writeFileSync)(outputPath, typeScript, "utf-8");
|
|
169
423
|
console.log(`Types written to ${outputPath}`);
|
|
424
|
+
if (emitSchema) {
|
|
425
|
+
const schemaPath = schemaOutputPath ?? `${dir ? `${dir}/` : ""}data-store-schema.sql`;
|
|
426
|
+
const pool = new import_serverless.Pool({ connectionString });
|
|
427
|
+
try {
|
|
428
|
+
const snapshot = await introspectSchemaSnapshot(pool);
|
|
429
|
+
(0, import_fs.writeFileSync)(schemaPath, generateSchemaSql(snapshot), "utf-8");
|
|
430
|
+
console.log(`Schema snapshot written to ${schemaPath} (used by the test harness)`);
|
|
431
|
+
} finally {
|
|
432
|
+
await pool.end();
|
|
433
|
+
}
|
|
434
|
+
}
|
|
170
435
|
}
|
|
171
436
|
main().catch((error) => {
|
|
172
437
|
console.error("Failed to generate types:", error);
|
|
@@ -3,6 +3,252 @@
|
|
|
3
3
|
// src/cli/generate-types.ts
|
|
4
4
|
import { writeFileSync } from "fs";
|
|
5
5
|
import { Pool } from "@neondatabase/serverless";
|
|
6
|
+
|
|
7
|
+
// src/cli/schema-ddl.ts
|
|
8
|
+
function quoteIdent(name) {
|
|
9
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
10
|
+
}
|
|
11
|
+
var TRACKED_EXTENSIONS = /* @__PURE__ */ new Set(["pgcrypto", "pg_trgm", "uuid-ossp", "citext"]);
|
|
12
|
+
function resolveSqlType(row) {
|
|
13
|
+
const dataType = row.data_type;
|
|
14
|
+
if (dataType === "ARRAY") {
|
|
15
|
+
return `${row.udt_name.replace(/^_/, "")}[]`;
|
|
16
|
+
}
|
|
17
|
+
if (dataType === "USER-DEFINED") {
|
|
18
|
+
return quoteIdent(row.udt_name);
|
|
19
|
+
}
|
|
20
|
+
if (dataType === "character varying") {
|
|
21
|
+
return row.character_maximum_length ? `varchar(${row.character_maximum_length})` : "varchar";
|
|
22
|
+
}
|
|
23
|
+
if (dataType === "character") {
|
|
24
|
+
return row.character_maximum_length ? `char(${row.character_maximum_length})` : "char";
|
|
25
|
+
}
|
|
26
|
+
if (dataType === "numeric") {
|
|
27
|
+
return row.numeric_precision !== null && row.numeric_scale !== null ? `numeric(${row.numeric_precision},${row.numeric_scale})` : "numeric";
|
|
28
|
+
}
|
|
29
|
+
if (dataType === "timestamp with time zone") return "timestamptz";
|
|
30
|
+
if (dataType === "timestamp without time zone") return "timestamp";
|
|
31
|
+
if (dataType === "time with time zone") return "timetz";
|
|
32
|
+
if (dataType === "time without time zone") return "time";
|
|
33
|
+
return dataType;
|
|
34
|
+
}
|
|
35
|
+
async function introspectSchemaSnapshot(executor) {
|
|
36
|
+
const [extensions, enums, columns, constraints, fks, checks, indexes] = await Promise.all([
|
|
37
|
+
executor.query(`SELECT extname FROM pg_extension ORDER BY extname`),
|
|
38
|
+
executor.query(
|
|
39
|
+
`SELECT t.typname AS name, e.enumlabel AS value
|
|
40
|
+
FROM pg_type t
|
|
41
|
+
JOIN pg_enum e ON e.enumtypid = t.oid
|
|
42
|
+
JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
43
|
+
WHERE n.nspname = 'public'
|
|
44
|
+
ORDER BY t.typname, e.enumsortorder`
|
|
45
|
+
),
|
|
46
|
+
executor.query(
|
|
47
|
+
`SELECT c.table_name, c.column_name, c.data_type, c.udt_name, c.is_nullable,
|
|
48
|
+
c.column_default, c.character_maximum_length, c.numeric_precision, c.numeric_scale,
|
|
49
|
+
c.is_generated, c.generation_expression
|
|
50
|
+
FROM information_schema.columns c
|
|
51
|
+
JOIN information_schema.tables t
|
|
52
|
+
ON c.table_name = t.table_name AND c.table_schema = t.table_schema
|
|
53
|
+
WHERE c.table_schema = 'public'
|
|
54
|
+
AND t.table_type = 'BASE TABLE'
|
|
55
|
+
AND t.table_name NOT LIKE '\\_deleted\\_%'
|
|
56
|
+
ORDER BY c.table_name, c.ordinal_position`
|
|
57
|
+
),
|
|
58
|
+
executor.query(
|
|
59
|
+
`SELECT tc.table_name, tc.constraint_name, tc.constraint_type, ku.column_name
|
|
60
|
+
FROM information_schema.table_constraints tc
|
|
61
|
+
JOIN information_schema.key_column_usage ku
|
|
62
|
+
ON tc.constraint_name = ku.constraint_name AND tc.table_schema = ku.table_schema
|
|
63
|
+
WHERE tc.table_schema = 'public'
|
|
64
|
+
AND tc.constraint_type IN ('PRIMARY KEY', 'UNIQUE')
|
|
65
|
+
ORDER BY tc.table_name, tc.constraint_name, ku.ordinal_position`
|
|
66
|
+
),
|
|
67
|
+
executor.query(
|
|
68
|
+
`SELECT tc.constraint_name, tc.table_name, ku.column_name,
|
|
69
|
+
ccu.table_name AS referenced_table, ccu.column_name AS referenced_column,
|
|
70
|
+
rc.delete_rule, rc.update_rule
|
|
71
|
+
FROM information_schema.table_constraints tc
|
|
72
|
+
JOIN information_schema.key_column_usage ku
|
|
73
|
+
ON tc.constraint_name = ku.constraint_name AND tc.table_schema = ku.table_schema
|
|
74
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
75
|
+
ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema
|
|
76
|
+
JOIN information_schema.referential_constraints rc
|
|
77
|
+
ON tc.constraint_name = rc.constraint_name AND tc.table_schema = rc.constraint_schema
|
|
78
|
+
WHERE tc.table_schema = 'public' AND tc.constraint_type = 'FOREIGN KEY'
|
|
79
|
+
ORDER BY tc.table_name, tc.constraint_name, ku.ordinal_position`
|
|
80
|
+
),
|
|
81
|
+
executor.query(
|
|
82
|
+
`SELECT rel.relname AS table_name, con.conname AS name,
|
|
83
|
+
pg_get_constraintdef(con.oid) AS definition
|
|
84
|
+
FROM pg_constraint con
|
|
85
|
+
JOIN pg_class rel ON rel.oid = con.conrelid
|
|
86
|
+
JOIN pg_namespace ns ON ns.oid = rel.relnamespace
|
|
87
|
+
WHERE ns.nspname = 'public' AND con.contype = 'c'
|
|
88
|
+
AND rel.relname NOT LIKE '\\_deleted\\_%'
|
|
89
|
+
ORDER BY rel.relname, con.conname`
|
|
90
|
+
),
|
|
91
|
+
executor.query(
|
|
92
|
+
`SELECT i.indexdef
|
|
93
|
+
FROM pg_indexes i
|
|
94
|
+
WHERE i.schemaname = 'public'
|
|
95
|
+
AND i.tablename NOT LIKE '\\_deleted\\_%'
|
|
96
|
+
AND NOT EXISTS (
|
|
97
|
+
SELECT 1 FROM pg_constraint c
|
|
98
|
+
JOIN pg_class cls ON cls.oid = c.conindid
|
|
99
|
+
WHERE cls.relname = i.indexname
|
|
100
|
+
)
|
|
101
|
+
ORDER BY i.indexname`
|
|
102
|
+
)
|
|
103
|
+
]);
|
|
104
|
+
const enumMap = /* @__PURE__ */ new Map();
|
|
105
|
+
for (const row of enums.rows) {
|
|
106
|
+
const name = String(row.name);
|
|
107
|
+
if (!enumMap.has(name)) enumMap.set(name, []);
|
|
108
|
+
enumMap.get(name).push(String(row.value));
|
|
109
|
+
}
|
|
110
|
+
const tableMap = /* @__PURE__ */ new Map();
|
|
111
|
+
for (const row of columns.rows) {
|
|
112
|
+
const tableName = String(row.table_name);
|
|
113
|
+
if (!tableMap.has(tableName)) {
|
|
114
|
+
tableMap.set(tableName, { name: tableName, columns: [], primaryKey: [], uniques: [] });
|
|
115
|
+
}
|
|
116
|
+
const defaultExpr = row.column_default === null ? null : String(row.column_default);
|
|
117
|
+
const isSerial = defaultExpr !== null && /^nextval\(/.test(defaultExpr);
|
|
118
|
+
const isGenerated = row.is_generated === "ALWAYS";
|
|
119
|
+
tableMap.get(tableName).columns.push({
|
|
120
|
+
name: String(row.column_name),
|
|
121
|
+
sqlType: resolveSqlType({
|
|
122
|
+
data_type: String(row.data_type),
|
|
123
|
+
udt_name: String(row.udt_name),
|
|
124
|
+
character_maximum_length: row.character_maximum_length,
|
|
125
|
+
numeric_precision: row.numeric_precision,
|
|
126
|
+
numeric_scale: row.numeric_scale
|
|
127
|
+
}),
|
|
128
|
+
nullable: row.is_nullable === "YES",
|
|
129
|
+
defaultExpr: isGenerated ? null : defaultExpr,
|
|
130
|
+
isSerial: !isGenerated && isSerial,
|
|
131
|
+
generatedExpr: isGenerated ? String(row.generation_expression) : null
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const constraintGroups = /* @__PURE__ */ new Map();
|
|
135
|
+
for (const row of constraints.rows) {
|
|
136
|
+
const key = String(row.constraint_name);
|
|
137
|
+
if (!constraintGroups.has(key)) {
|
|
138
|
+
constraintGroups.set(key, {
|
|
139
|
+
table: String(row.table_name),
|
|
140
|
+
type: String(row.constraint_type),
|
|
141
|
+
columns: []
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
constraintGroups.get(key).columns.push(String(row.column_name));
|
|
145
|
+
}
|
|
146
|
+
for (const group of constraintGroups.values()) {
|
|
147
|
+
const table = tableMap.get(group.table);
|
|
148
|
+
if (!table) continue;
|
|
149
|
+
if (group.type === "PRIMARY KEY") {
|
|
150
|
+
table.primaryKey = group.columns;
|
|
151
|
+
} else if (group.type === "UNIQUE") {
|
|
152
|
+
table.uniques.push(group.columns);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const fkGroups = /* @__PURE__ */ new Map();
|
|
156
|
+
for (const row of fks.rows) {
|
|
157
|
+
const key = String(row.constraint_name);
|
|
158
|
+
if (!fkGroups.has(key)) {
|
|
159
|
+
fkGroups.set(key, {
|
|
160
|
+
constraintName: key,
|
|
161
|
+
table: String(row.table_name),
|
|
162
|
+
columns: [],
|
|
163
|
+
referencedTable: String(row.referenced_table),
|
|
164
|
+
referencedColumns: [],
|
|
165
|
+
onDelete: String(row.delete_rule),
|
|
166
|
+
onUpdate: String(row.update_rule)
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const fk = fkGroups.get(key);
|
|
170
|
+
fk.columns.push(String(row.column_name));
|
|
171
|
+
fk.referencedColumns.push(String(row.referenced_column));
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
extensions: extensions.rows.map((r) => String(r.extname)).filter((name) => TRACKED_EXTENSIONS.has(name)),
|
|
175
|
+
enums: Array.from(enumMap, ([name, values]) => ({ name, values })),
|
|
176
|
+
tables: Array.from(tableMap.values()),
|
|
177
|
+
foreignKeys: Array.from(fkGroups.values()),
|
|
178
|
+
checks: checks.rows.map((r) => ({
|
|
179
|
+
table: String(r.table_name),
|
|
180
|
+
name: String(r.name),
|
|
181
|
+
definition: String(r.definition)
|
|
182
|
+
})),
|
|
183
|
+
indexes: indexes.rows.map((r) => ({ definition: String(r.indexdef) }))
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function columnDdl(column, isSinglePk) {
|
|
187
|
+
let sqlType = column.sqlType;
|
|
188
|
+
if (column.isSerial) {
|
|
189
|
+
sqlType = sqlType === "bigint" ? "bigserial" : sqlType === "smallint" ? "smallserial" : "serial";
|
|
190
|
+
}
|
|
191
|
+
let ddl = `${quoteIdent(column.name)} ${sqlType}`;
|
|
192
|
+
if (isSinglePk) ddl += " PRIMARY KEY";
|
|
193
|
+
if (!column.nullable && !isSinglePk) ddl += " NOT NULL";
|
|
194
|
+
if (column.generatedExpr) {
|
|
195
|
+
ddl += ` GENERATED ALWAYS AS (${column.generatedExpr}) STORED`;
|
|
196
|
+
} else if (column.defaultExpr && !column.isSerial) {
|
|
197
|
+
ddl += ` DEFAULT ${column.defaultExpr}`;
|
|
198
|
+
}
|
|
199
|
+
return ddl;
|
|
200
|
+
}
|
|
201
|
+
function generateSchemaSql(snapshot) {
|
|
202
|
+
const lines = [
|
|
203
|
+
"-- Auto-generated by @stardeck-customer-apps/data-store-sdk",
|
|
204
|
+
"-- Schema snapshot of the live data store, applied to PGlite by the test harness.",
|
|
205
|
+
"-- Do not edit manually \u2014 regenerate with: npx stardeck-data-store generate-types",
|
|
206
|
+
""
|
|
207
|
+
];
|
|
208
|
+
for (const ext of snapshot.extensions) {
|
|
209
|
+
lines.push(`CREATE EXTENSION IF NOT EXISTS ${ext === "uuid-ossp" ? '"uuid-ossp"' : ext};`);
|
|
210
|
+
}
|
|
211
|
+
if (snapshot.extensions.length > 0) lines.push("");
|
|
212
|
+
for (const enumType of snapshot.enums) {
|
|
213
|
+
const values = enumType.values.map((v) => `'${v.replace(/'/g, "''")}'`).join(", ");
|
|
214
|
+
lines.push(`CREATE TYPE ${quoteIdent(enumType.name)} AS ENUM (${values});`);
|
|
215
|
+
}
|
|
216
|
+
if (snapshot.enums.length > 0) lines.push("");
|
|
217
|
+
for (const table of snapshot.tables) {
|
|
218
|
+
const singlePk = table.primaryKey.length === 1 ? table.primaryKey[0] : null;
|
|
219
|
+
const parts = table.columns.map((col) => " " + columnDdl(col, col.name === singlePk));
|
|
220
|
+
if (table.primaryKey.length > 1) {
|
|
221
|
+
parts.push(` PRIMARY KEY (${table.primaryKey.map(quoteIdent).join(", ")})`);
|
|
222
|
+
}
|
|
223
|
+
for (const unique of table.uniques) {
|
|
224
|
+
parts.push(` UNIQUE (${unique.map(quoteIdent).join(", ")})`);
|
|
225
|
+
}
|
|
226
|
+
lines.push(`CREATE TABLE ${quoteIdent(table.name)} (`);
|
|
227
|
+
lines.push(parts.join(",\n"));
|
|
228
|
+
lines.push(`);`);
|
|
229
|
+
lines.push("");
|
|
230
|
+
}
|
|
231
|
+
for (const check of snapshot.checks) {
|
|
232
|
+
lines.push(
|
|
233
|
+
`ALTER TABLE ${quoteIdent(check.table)} ADD CONSTRAINT ${quoteIdent(check.name)} ${check.definition};`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (snapshot.checks.length > 0) lines.push("");
|
|
237
|
+
for (const fk of snapshot.foreignKeys) {
|
|
238
|
+
let stmt = `ALTER TABLE ${quoteIdent(fk.table)} ADD CONSTRAINT ${quoteIdent(fk.constraintName)} FOREIGN KEY (${fk.columns.map(quoteIdent).join(", ")}) REFERENCES ${quoteIdent(fk.referencedTable)} (${fk.referencedColumns.map(quoteIdent).join(", ")})`;
|
|
239
|
+
if (fk.onDelete !== "NO ACTION") stmt += ` ON DELETE ${fk.onDelete}`;
|
|
240
|
+
if (fk.onUpdate !== "NO ACTION") stmt += ` ON UPDATE ${fk.onUpdate}`;
|
|
241
|
+
lines.push(`${stmt};`);
|
|
242
|
+
}
|
|
243
|
+
if (snapshot.foreignKeys.length > 0) lines.push("");
|
|
244
|
+
for (const index of snapshot.indexes) {
|
|
245
|
+
lines.push(`${index.definition};`);
|
|
246
|
+
}
|
|
247
|
+
if (snapshot.indexes.length > 0) lines.push("");
|
|
248
|
+
return lines.join("\n");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// src/cli/generate-types.ts
|
|
6
252
|
var PG_TYPE_MAP = {
|
|
7
253
|
text: "string",
|
|
8
254
|
varchar: "string",
|
|
@@ -108,17 +354,25 @@ async function main() {
|
|
|
108
354
|
const args = process.argv.slice(2);
|
|
109
355
|
let connectionString = process.env.DATA_STORE_URL;
|
|
110
356
|
let outputPath = "./src/generated/data-store-types.ts";
|
|
357
|
+
let schemaOutputPath;
|
|
358
|
+
let emitSchema = true;
|
|
111
359
|
for (let i = 0; i < args.length; i++) {
|
|
112
360
|
if (args[i] === "--connection-string" && args[i + 1]) {
|
|
113
361
|
connectionString = args[++i];
|
|
114
362
|
} else if (args[i] === "--output" && args[i + 1]) {
|
|
115
363
|
outputPath = args[++i];
|
|
364
|
+
} else if (args[i] === "--schema-output" && args[i + 1]) {
|
|
365
|
+
schemaOutputPath = args[++i];
|
|
366
|
+
} else if (args[i] === "--no-schema") {
|
|
367
|
+
emitSchema = false;
|
|
116
368
|
} else if (args[i] === "--help") {
|
|
117
369
|
console.log(`Usage: stardeck-data-store generate-types [options]
|
|
118
370
|
|
|
119
371
|
Options:
|
|
120
372
|
--connection-string <url> Postgres connection string (default: DATA_STORE_URL env var)
|
|
121
373
|
--output <path> Output file path (default: ./src/generated/data-store-types.ts)
|
|
374
|
+
--schema-output <path> DDL snapshot path (default: data-store-schema.sql next to --output)
|
|
375
|
+
--no-schema Skip the DDL snapshot used by the test harness
|
|
122
376
|
--help Show this help message`);
|
|
123
377
|
process.exit(0);
|
|
124
378
|
}
|
|
@@ -144,6 +398,17 @@ Options:
|
|
|
144
398
|
}
|
|
145
399
|
writeFileSync(outputPath, typeScript, "utf-8");
|
|
146
400
|
console.log(`Types written to ${outputPath}`);
|
|
401
|
+
if (emitSchema) {
|
|
402
|
+
const schemaPath = schemaOutputPath ?? `${dir ? `${dir}/` : ""}data-store-schema.sql`;
|
|
403
|
+
const pool = new Pool({ connectionString });
|
|
404
|
+
try {
|
|
405
|
+
const snapshot = await introspectSchemaSnapshot(pool);
|
|
406
|
+
writeFileSync(schemaPath, generateSchemaSql(snapshot), "utf-8");
|
|
407
|
+
console.log(`Schema snapshot written to ${schemaPath} (used by the test harness)`);
|
|
408
|
+
} finally {
|
|
409
|
+
await pool.end();
|
|
410
|
+
}
|
|
411
|
+
}
|
|
147
412
|
}
|
|
148
413
|
main().catch((error) => {
|
|
149
414
|
console.error("Failed to generate types:", error);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stardeck-customer-apps/data-store-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "SDK for accessing Stardeck data stores from deployed projects",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -55,10 +55,10 @@
|
|
|
55
55
|
}
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
|
-
"@neondatabase/serverless": "^
|
|
59
|
-
"kysely-neon": "^2.0.0",
|
|
58
|
+
"@neondatabase/serverless": "^1.1.0",
|
|
60
59
|
"@stardeck-customer-apps/tsconfig": "*",
|
|
61
60
|
"@types/node": "^24.10.1",
|
|
61
|
+
"kysely-neon": "^2.0.0",
|
|
62
62
|
"tsup": "^8.0.0",
|
|
63
63
|
"typescript": "^5.0.0",
|
|
64
64
|
"vitest": "^3.2.4"
|