@devmoods/postgres-admin 0.20.0 → 0.21.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/dist/api/app.d.ts +11 -0
- package/dist/api/structure.d.ts +6 -0
- package/dist/api/utils.d.ts +9 -0
- package/dist/basename.d.ts +3 -0
- package/dist/client/.vite/manifest.json +2 -2
- package/dist/client/_index.html +2 -2
- package/dist/client/assets/index-BfFE0lHC.js +15 -0
- package/dist/client/assets/index-RuE672an.css +1 -0
- package/dist/entry-client.d.ts +1 -0
- package/dist/index.d.ts +2 -17
- package/dist/index.js +229 -119
- package/dist/setupTests.d.ts +41 -0
- package/dist/setupTests.global.d.ts +2 -0
- package/dist/types.d.ts +39 -0
- package/dist/web/App.d.ts +1 -0
- package/dist/web/ErrorPage.d.ts +1 -0
- package/dist/web/ForeignKeyWidget.d.ts +5 -0
- package/dist/web/FormRenderer.d.ts +4 -0
- package/dist/web/OverviewPage.d.ts +1 -0
- package/dist/web/Root.d.ts +6 -0
- package/dist/web/RowEditorModal.d.ts +6 -0
- package/dist/web/TableConfigEditor.d.ts +8 -0
- package/dist/web/TableRowsPage.d.ts +8 -0
- package/dist/web/api.client.d.ts +8 -0
- package/dist/web/labels.d.ts +2 -0
- package/dist/web/queries.d.ts +23 -0
- package/dist/web/router.d.ts +2 -0
- package/dist/web/rowValues.d.ts +2 -0
- package/dist/web/state.d.ts +107 -0
- package/package.json +18 -18
- package/dist/client/assets/index-BbRnfFjX.css +0 -1
- package/dist/client/assets/index-CHDAgLcL.js +0 -15
package/dist/index.js
CHANGED
|
@@ -3,17 +3,22 @@ import { promises as fs } from "fs";
|
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import {
|
|
5
5
|
BadRequest,
|
|
6
|
-
createPostgres,
|
|
6
|
+
createPostgres as createPostgres2,
|
|
7
7
|
generateCsrfToken,
|
|
8
8
|
joinStatements,
|
|
9
|
-
sql,
|
|
9
|
+
sql as sql2,
|
|
10
10
|
staticMiddleware,
|
|
11
11
|
createRouter
|
|
12
12
|
} from "@devmoods/express-extras";
|
|
13
13
|
import { type } from "arktype";
|
|
14
14
|
|
|
15
15
|
// src/api/structure.ts
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
createPostgres,
|
|
18
|
+
NotFound,
|
|
19
|
+
readEnv,
|
|
20
|
+
sql
|
|
21
|
+
} from "@devmoods/express-extras";
|
|
17
22
|
import "pg-structure";
|
|
18
23
|
import { pgStructure } from "pg-structure/dist/main.js";
|
|
19
24
|
var getDisplayType = (dbType) => {
|
|
@@ -58,33 +63,40 @@ var getPrimitiveType = (dbType) => {
|
|
|
58
63
|
case "uuid":
|
|
59
64
|
return "string";
|
|
60
65
|
case "integer":
|
|
61
|
-
case "numeric":
|
|
62
66
|
case "smallint":
|
|
63
|
-
case "bigint":
|
|
64
67
|
return "number";
|
|
68
|
+
case "numeric":
|
|
69
|
+
case "bigint":
|
|
70
|
+
return "string";
|
|
65
71
|
case "boolean":
|
|
66
72
|
return "boolean";
|
|
67
73
|
default:
|
|
68
74
|
return "string";
|
|
69
75
|
}
|
|
70
76
|
};
|
|
71
|
-
function
|
|
72
|
-
|
|
73
|
-
|
|
77
|
+
function getDefault(column) {
|
|
78
|
+
const value = column.default;
|
|
79
|
+
if (typeof value !== "string") {
|
|
80
|
+
return value;
|
|
74
81
|
}
|
|
75
|
-
if (value.
|
|
76
|
-
return
|
|
82
|
+
if (value.startsWith("'") && value.endsWith("'")) {
|
|
83
|
+
return value.slice(1, -1).replaceAll("''", "'");
|
|
77
84
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
85
|
+
if (column.type.name === "boolean") {
|
|
86
|
+
if (value === "true") {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
if (value === "false") {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
83
92
|
}
|
|
84
|
-
|
|
93
|
+
if (/^[+-]?(?:\d+\.?\d*|\.\d+)$/.test(value)) {
|
|
94
|
+
return ["numeric", "bigint"].includes(column.type.name) ? value : Number(value);
|
|
95
|
+
}
|
|
96
|
+
return void 0;
|
|
85
97
|
}
|
|
86
|
-
function toJsonSchemaProperty(column) {
|
|
87
|
-
const defaultValue = getDefault(column
|
|
98
|
+
function toJsonSchemaProperty(column, tableId) {
|
|
99
|
+
const defaultValue = getDefault(column);
|
|
88
100
|
const type2 = getPrimitiveType(column.type.name);
|
|
89
101
|
const description = column.comment;
|
|
90
102
|
if (column.arrayDimension > 0) {
|
|
@@ -92,33 +104,38 @@ function toJsonSchemaProperty(column) {
|
|
|
92
104
|
type: "array",
|
|
93
105
|
title: column.name,
|
|
94
106
|
items: { type: type2 },
|
|
95
|
-
default: defaultValue || [],
|
|
96
107
|
description
|
|
97
108
|
};
|
|
98
109
|
}
|
|
99
110
|
const $bitiforms = {};
|
|
100
111
|
if (column.isForeignKey) {
|
|
112
|
+
const foreignKey = column.foreignKeys.at(0);
|
|
101
113
|
$bitiforms.widget = "ForeignKeyWidget";
|
|
102
114
|
$bitiforms.widgetProps = {
|
|
103
|
-
foreignTable:
|
|
104
|
-
foreignColumn:
|
|
115
|
+
foreignTable: foreignKey ? tableId(foreignKey.referencedTable) : void 0,
|
|
116
|
+
foreignColumn: foreignKey?.referencedColumns.at(0)?.name,
|
|
117
|
+
nullable: !column.notNull
|
|
105
118
|
};
|
|
106
119
|
}
|
|
107
120
|
return {
|
|
108
121
|
type: type2,
|
|
109
122
|
title: column.name,
|
|
110
|
-
default: defaultValue,
|
|
123
|
+
...defaultValue === void 0 ? {} : { default: defaultValue },
|
|
111
124
|
description,
|
|
112
125
|
$bitiforms
|
|
113
126
|
};
|
|
114
127
|
}
|
|
115
128
|
var tableCache = /* @__PURE__ */ new Map();
|
|
116
|
-
function toJsonSchema(table) {
|
|
129
|
+
function toJsonSchema(table, tableId, identityColumns) {
|
|
117
130
|
const required = [];
|
|
118
131
|
const properties = {};
|
|
119
132
|
for (const column of table.columns) {
|
|
120
|
-
|
|
121
|
-
if (column.
|
|
133
|
+
const columnId = `${table.schema.name}\0${table.name}\0${column.name}`;
|
|
134
|
+
if (column.isGenerated || identityColumns.has(columnId)) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
properties[column.name] = toJsonSchemaProperty(column, tableId);
|
|
138
|
+
if (column.notNull && !column.isSerial && column.default == null) {
|
|
122
139
|
required.push(column.name);
|
|
123
140
|
}
|
|
124
141
|
}
|
|
@@ -128,40 +145,70 @@ function toJsonSchema(table) {
|
|
|
128
145
|
required
|
|
129
146
|
};
|
|
130
147
|
}
|
|
131
|
-
async function getTable(tableId) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
tableCache.set(table.tableName, table);
|
|
136
|
-
}
|
|
148
|
+
async function getTable(tableId, connectionString = readEnv("POSTGRES_URL")) {
|
|
149
|
+
const cacheKey = `${connectionString}\0${tableId}`;
|
|
150
|
+
if (!tableCache.has(cacheKey)) {
|
|
151
|
+
await readTables(connectionString);
|
|
137
152
|
}
|
|
138
|
-
const value = tableCache.get(
|
|
153
|
+
const value = tableCache.get(cacheKey);
|
|
139
154
|
if (value == null) {
|
|
140
155
|
throw NotFound();
|
|
141
156
|
}
|
|
142
157
|
return value;
|
|
143
158
|
}
|
|
144
|
-
async function readTables() {
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
159
|
+
async function readTables(connectionString = readEnv("POSTGRES_URL")) {
|
|
160
|
+
const [structure, identityRows] = await Promise.all([
|
|
161
|
+
pgStructure({ connectionString }),
|
|
162
|
+
(async () => {
|
|
163
|
+
const postgres = createPostgres({ url: connectionString });
|
|
164
|
+
try {
|
|
165
|
+
return await postgres.all(sql`
|
|
166
|
+
SELECT table_schema, table_name, column_name
|
|
167
|
+
FROM information_schema.columns
|
|
168
|
+
WHERE is_identity = 'YES'
|
|
169
|
+
`);
|
|
170
|
+
} finally {
|
|
171
|
+
await postgres.end();
|
|
172
|
+
}
|
|
173
|
+
})()
|
|
174
|
+
]);
|
|
175
|
+
const identityColumns = new Set(
|
|
176
|
+
identityRows.map(
|
|
177
|
+
(row) => `${row.table_schema}\0${row.table_name}\0${row.column_name}`
|
|
178
|
+
)
|
|
179
|
+
);
|
|
180
|
+
const tableId = (table) => `${table.schema.name}.${table.name}`;
|
|
149
181
|
const toMetadata = (column) => ({
|
|
150
182
|
type: getPrimitiveType(column.type.name),
|
|
151
183
|
displayType: getDisplayType(column.type.name),
|
|
152
184
|
dbType: column.type.name,
|
|
153
|
-
|
|
185
|
+
hasDefault: column.default != null || column.isSerial,
|
|
186
|
+
isGenerated: column.isGenerated,
|
|
187
|
+
isIdentity: identityColumns.has(
|
|
188
|
+
`${column.table.schema.name}\0${column.table.name}\0${column.name}`
|
|
189
|
+
),
|
|
190
|
+
nullable: !column.notNull,
|
|
191
|
+
foreignKeys: column.foreignKeys.filter(
|
|
192
|
+
(fk) => fk.columns.length === 1 && fk.referencedColumns.length === 1
|
|
193
|
+
).mapToArray((fk) => ({
|
|
154
194
|
table: fk.referencedTable.name,
|
|
195
|
+
tableId: tableId(fk.referencedTable),
|
|
155
196
|
column: fk.referencedColumns.at(0).name
|
|
156
197
|
}))
|
|
157
198
|
});
|
|
158
199
|
const toBelongsTo = (table) => {
|
|
159
|
-
return table.
|
|
160
|
-
|
|
161
|
-
|
|
200
|
+
return table.foreignKeys.filter(
|
|
201
|
+
(fk) => fk.columns.length === 1 && fk.referencedColumns.length === 1
|
|
202
|
+
).mapToArray((fk) => {
|
|
203
|
+
const foreignKey = fk.columns.at(0).name;
|
|
204
|
+
const referencedTable = fk.referencedTable;
|
|
205
|
+
const belongsToTableColumns = referencedTable.columns.mapToArray(
|
|
206
|
+
(c) => c.name
|
|
207
|
+
);
|
|
162
208
|
return {
|
|
163
209
|
foreignKey,
|
|
164
|
-
belongsToTable:
|
|
210
|
+
belongsToTable: referencedTable.name,
|
|
211
|
+
belongsToTableId: tableId(referencedTable),
|
|
165
212
|
includeColumnNames: belongsToTableColumns.map(
|
|
166
213
|
(c) => `${foreignKey.replace(/_id$/, "")}__${c}`
|
|
167
214
|
)
|
|
@@ -172,18 +219,33 @@ async function readTables() {
|
|
|
172
219
|
for (const table of structure.tables) {
|
|
173
220
|
const belongsTo = toBelongsTo(table);
|
|
174
221
|
const columnsArray = table.columns.mapToArray((column) => column);
|
|
222
|
+
const primaryKeyColumns = table.primaryKey?.columns.mapToArray((column) => column.name) ?? [];
|
|
175
223
|
tables.push({
|
|
224
|
+
tableId: tableId(table),
|
|
225
|
+
schemaName: table.schema.name,
|
|
176
226
|
tableName: table.name,
|
|
177
|
-
primaryKey:
|
|
227
|
+
primaryKey: primaryKeyColumns.length === 1 ? primaryKeyColumns.at(0) : null,
|
|
178
228
|
belongsTo,
|
|
179
229
|
columnNames: columnsArray.map((column) => column.name),
|
|
180
230
|
relatedColumnNames: belongsTo.flatMap((b) => b.includeColumnNames),
|
|
181
231
|
columnMetadata: Object.fromEntries(
|
|
182
232
|
columnsArray.map((column) => [column.name, toMetadata(column)])
|
|
183
233
|
),
|
|
184
|
-
schema: toJsonSchema(table)
|
|
234
|
+
schema: toJsonSchema(table, tableId, identityColumns)
|
|
185
235
|
});
|
|
186
236
|
}
|
|
237
|
+
const cachePrefix = `${connectionString}\0`;
|
|
238
|
+
for (const key of tableCache.keys()) {
|
|
239
|
+
if (key.startsWith(cachePrefix)) {
|
|
240
|
+
tableCache.delete(key);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
for (const table of tables) {
|
|
244
|
+
tableCache.set(`${cachePrefix}${table.tableId}`, table);
|
|
245
|
+
if (table.schemaName === "public") {
|
|
246
|
+
tableCache.set(`${cachePrefix}${table.tableName}`, table);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
187
249
|
return {
|
|
188
250
|
databaseName: new URL(connectionString).pathname.slice(1),
|
|
189
251
|
tables
|
|
@@ -191,9 +253,6 @@ async function readTables() {
|
|
|
191
253
|
}
|
|
192
254
|
|
|
193
255
|
// src/api/utils.ts
|
|
194
|
-
function stripIdentifier(tableName) {
|
|
195
|
-
return (tableName ?? "").replace(/[^\w+]/g, "");
|
|
196
|
-
}
|
|
197
256
|
function parseOrderBy(order) {
|
|
198
257
|
if (!order) {
|
|
199
258
|
return null;
|
|
@@ -224,32 +283,48 @@ function counter() {
|
|
|
224
283
|
};
|
|
225
284
|
}
|
|
226
285
|
|
|
286
|
+
// src/basename.ts
|
|
287
|
+
function normalizeBasename(basename) {
|
|
288
|
+
return basename.replace(/^\/+|\/+$/g, "");
|
|
289
|
+
}
|
|
290
|
+
|
|
227
291
|
// src/api/app.ts
|
|
292
|
+
function escapeHtml(value) {
|
|
293
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
294
|
+
}
|
|
295
|
+
function qualifiedTable(table) {
|
|
296
|
+
return sql2`${sql2.identifier(table.schemaName)}.${sql2.identifier(table.tableName)}`;
|
|
297
|
+
}
|
|
228
298
|
function createAdminRouter({
|
|
229
299
|
postgres: defaultPostgres,
|
|
230
300
|
basename = "/admin",
|
|
231
301
|
indexHtml = "node_modules/@devmoods/postgres-admin/dist/client/_index.html",
|
|
232
302
|
uiOptions = {}
|
|
233
303
|
}) {
|
|
234
|
-
const postgres = defaultPostgres ??
|
|
304
|
+
const postgres = defaultPostgres ?? createPostgres2();
|
|
235
305
|
const router = createRouter();
|
|
236
306
|
if (!basename.startsWith("/")) {
|
|
237
307
|
throw new Error(`Option 'basename' must start with a slash /`);
|
|
238
308
|
}
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
);
|
|
309
|
+
const clientBasename = normalizeBasename(basename);
|
|
310
|
+
const unknownTableRow = type("Record<string, unknown>");
|
|
242
311
|
router.get("/api/tables", {}, async () => {
|
|
243
|
-
const { databaseName, tables } = await readTables(
|
|
312
|
+
const { databaseName, tables } = await readTables(
|
|
313
|
+
postgres.connectionString
|
|
314
|
+
);
|
|
315
|
+
const tableCounts = await Promise.all(
|
|
316
|
+
tables.map(async (table) => {
|
|
317
|
+
return [
|
|
318
|
+
table,
|
|
319
|
+
await postgres.get(sql2`SELECT COUNT(*) FROM ${qualifiedTable(table)}`).then((r) => +r.count)
|
|
320
|
+
];
|
|
321
|
+
})
|
|
322
|
+
);
|
|
244
323
|
const rowCounts = Object.fromEntries(
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
await postgres.get(sql`SELECT COUNT(*) FROM ${sql.identifier(table.tableName)}`).then((r) => +r.count)
|
|
250
|
-
];
|
|
251
|
-
})
|
|
252
|
-
)
|
|
324
|
+
tableCounts.flatMap(([table, count]) => [
|
|
325
|
+
[table.tableId, count],
|
|
326
|
+
...table.schemaName === "public" ? [[table.tableName, count]] : []
|
|
327
|
+
])
|
|
253
328
|
);
|
|
254
329
|
return {
|
|
255
330
|
databaseName,
|
|
@@ -258,19 +333,19 @@ function createAdminRouter({
|
|
|
258
333
|
};
|
|
259
334
|
});
|
|
260
335
|
router.get("/api/tables/:table/suggestions/:column", {}, async (req) => {
|
|
261
|
-
const table =
|
|
262
|
-
const definition = await getTable(table);
|
|
263
|
-
const column =
|
|
336
|
+
const table = req.params.table;
|
|
337
|
+
const definition = await getTable(table, postgres.connectionString);
|
|
338
|
+
const column = req.params.column;
|
|
264
339
|
if (!definition.columnNames.includes(column)) {
|
|
265
340
|
throw BadRequest(
|
|
266
341
|
`Invalid column ${column}. Must be one of: ${definition.columnNames.join(", ")}`
|
|
267
342
|
);
|
|
268
343
|
}
|
|
269
|
-
const statement =
|
|
270
|
-
SELECT
|
|
271
|
-
FROM ${
|
|
272
|
-
LIMIT 500
|
|
344
|
+
const statement = sql2`
|
|
345
|
+
SELECT *
|
|
346
|
+
FROM ${qualifiedTable(definition)}
|
|
273
347
|
`;
|
|
348
|
+
statement.append(sql2` ORDER BY ${sql2.identifier(column)} LIMIT 500`);
|
|
274
349
|
return postgres.all(statement);
|
|
275
350
|
});
|
|
276
351
|
router.get(
|
|
@@ -284,12 +359,18 @@ function createAdminRouter({
|
|
|
284
359
|
})
|
|
285
360
|
},
|
|
286
361
|
async (req) => {
|
|
287
|
-
const table =
|
|
362
|
+
const table = req.params.table;
|
|
288
363
|
const orders = queryParamToArray(req.query.order);
|
|
289
364
|
const limit = parseInt(req.query.limit ?? "50");
|
|
290
365
|
const page = parseInt(req.query.page ?? "1");
|
|
291
366
|
const search = String(req.query.search || "");
|
|
292
|
-
|
|
367
|
+
if (limit < 1 || limit > 500) {
|
|
368
|
+
throw BadRequest("Limit must be between 1 and 500");
|
|
369
|
+
}
|
|
370
|
+
if (page < 1) {
|
|
371
|
+
throw BadRequest("Page must be 1 or greater");
|
|
372
|
+
}
|
|
373
|
+
const definition = await getTable(table, postgres.connectionString);
|
|
293
374
|
const orderBy = orders.map((order) => parseOrderBy(order)).filter((x) => x != null);
|
|
294
375
|
const allowedColumnNames = definition.columnNames.concat(
|
|
295
376
|
definition.relatedColumnNames
|
|
@@ -299,13 +380,15 @@ function createAdminRouter({
|
|
|
299
380
|
`Invalid order value ${orderBy.map((x) => x.column).join(", ")}. Must be one of: ${allowedColumnNames.join(", ")}`
|
|
300
381
|
);
|
|
301
382
|
}
|
|
302
|
-
const fieldsToSelect = [
|
|
383
|
+
const fieldsToSelect = [sql2`${sql2.identifier("__source")}.*`];
|
|
303
384
|
const foreignTables = Object.fromEntries(
|
|
304
385
|
await Promise.all(
|
|
305
386
|
definition.columnNames.flatMap((column) => {
|
|
306
387
|
const metadata = definition.columnMetadata[column];
|
|
307
|
-
return metadata.foreignKeys.map((x) => x.
|
|
308
|
-
}).map(
|
|
388
|
+
return metadata.foreignKeys.map((x) => x.tableId);
|
|
389
|
+
}).map(
|
|
390
|
+
async (t) => [t, await getTable(t, postgres.connectionString)]
|
|
391
|
+
)
|
|
309
392
|
)
|
|
310
393
|
);
|
|
311
394
|
const aliasCounter = counter();
|
|
@@ -313,7 +396,7 @@ function createAdminRouter({
|
|
|
313
396
|
for (const column of definition.columnNames) {
|
|
314
397
|
const metadata = definition.columnMetadata[column];
|
|
315
398
|
for (const {
|
|
316
|
-
|
|
399
|
+
tableId: fkTable,
|
|
317
400
|
column: fkColumn
|
|
318
401
|
} of metadata.foreignKeys) {
|
|
319
402
|
const alias = `${fkTable}_${aliasCounter.get(fkTable)}`;
|
|
@@ -321,79 +404,89 @@ function createAdminRouter({
|
|
|
321
404
|
const prefix = column.replace(/_id$/, "");
|
|
322
405
|
for (const foreignColumn of foreignColumns) {
|
|
323
406
|
fieldsToSelect.push(
|
|
324
|
-
|
|
407
|
+
sql2`${sql2.identifier(alias)}.${sql2.identifier(foreignColumn)} AS ${sql2.identifier(`${prefix}__${foreignColumn}`)}`
|
|
325
408
|
);
|
|
326
409
|
}
|
|
327
410
|
joins.push(
|
|
328
|
-
|
|
329
|
-
LEFT JOIN ${
|
|
330
|
-
ON ${
|
|
411
|
+
sql2`
|
|
412
|
+
LEFT JOIN ${qualifiedTable(foreignTables[fkTable])} ${sql2.identifier(alias)}
|
|
413
|
+
ON ${sql2.identifier(alias)}.${sql2.identifier(fkColumn)} = ${sql2.identifier("__source")}.${sql2.identifier(column)}`
|
|
331
414
|
);
|
|
332
415
|
}
|
|
333
416
|
}
|
|
334
|
-
const statement =
|
|
417
|
+
const statement = sql2`
|
|
335
418
|
SELECT ${joinStatements(fieldsToSelect, ", ")}
|
|
336
|
-
FROM ${
|
|
419
|
+
FROM ${qualifiedTable(definition)} ${sql2.identifier("__source")} ${joinStatements(joins, " ")}
|
|
337
420
|
`;
|
|
338
421
|
if (search && search.length > 2) {
|
|
339
422
|
statement.append(" WHERE ");
|
|
340
423
|
statement.append(
|
|
341
424
|
joinStatements(
|
|
342
425
|
definition.columnNames.map(
|
|
343
|
-
(c) =>
|
|
426
|
+
(c) => sql2`${sql2.identifier("__source")}.${sql2.identifier(c)}::text ILIKE ${`%${search}%`}`
|
|
344
427
|
),
|
|
345
428
|
" OR "
|
|
346
429
|
)
|
|
347
430
|
);
|
|
348
431
|
}
|
|
349
432
|
if (orderBy.length > 0) {
|
|
350
|
-
statement.append(
|
|
433
|
+
statement.append(sql2` ORDER BY `);
|
|
351
434
|
statement.append(
|
|
352
435
|
joinStatements(
|
|
353
|
-
orderBy.map(
|
|
354
|
-
(o)
|
|
355
|
-
|
|
436
|
+
orderBy.map((o) => {
|
|
437
|
+
const column = definition.columnNames.includes(o.column) ? sql2`${sql2.identifier("__source")}.${sql2.identifier(o.column)}` : sql2`${sql2.identifier(o.column)}`;
|
|
438
|
+
return sql2`${column} ${sql2.raw(o.direction)}`;
|
|
439
|
+
}),
|
|
356
440
|
", "
|
|
357
441
|
)
|
|
358
442
|
);
|
|
359
443
|
} else if (definition.primaryKey) {
|
|
360
444
|
statement.append(
|
|
361
|
-
|
|
445
|
+
sql2` ORDER BY ${sql2.identifier("__source")}.${sql2.identifier(definition.primaryKey)}`
|
|
362
446
|
);
|
|
363
447
|
}
|
|
364
|
-
statement.append(
|
|
448
|
+
statement.append(sql2` LIMIT ${limit} OFFSET ${limit * (page - 1)}`);
|
|
365
449
|
return await postgres.all(statement);
|
|
366
450
|
}
|
|
367
451
|
);
|
|
368
452
|
router.get("/api/tables/:table/:pk", {}, async (req) => {
|
|
369
|
-
const table =
|
|
370
|
-
const definition = await getTable(table);
|
|
453
|
+
const table = req.params.table;
|
|
454
|
+
const definition = await getTable(table, postgres.connectionString);
|
|
371
455
|
const primaryKey = definition.primaryKey;
|
|
372
456
|
if (!primaryKey) {
|
|
373
457
|
throw BadRequest(`Table ${table} has no primary key`);
|
|
374
458
|
}
|
|
375
|
-
const statement =
|
|
459
|
+
const statement = sql2`
|
|
376
460
|
SELECT *
|
|
377
|
-
FROM ${
|
|
378
|
-
WHERE ${
|
|
461
|
+
FROM ${qualifiedTable(definition)}
|
|
462
|
+
WHERE ${sql2.identifier(primaryKey)} = ${req.params.pk}
|
|
379
463
|
`;
|
|
380
|
-
return postgres.
|
|
464
|
+
return postgres.get(statement);
|
|
381
465
|
});
|
|
382
466
|
router.post(
|
|
383
467
|
"/api/tables/:table",
|
|
384
468
|
{ request: unknownTableRow },
|
|
385
469
|
async (req) => {
|
|
386
|
-
const table =
|
|
387
|
-
const definition = await getTable(table);
|
|
470
|
+
const table = req.params.table;
|
|
471
|
+
const definition = await getTable(table, postgres.connectionString);
|
|
388
472
|
const primaryKey = definition.primaryKey;
|
|
389
|
-
|
|
390
|
-
|
|
473
|
+
const data = { ...req.body };
|
|
474
|
+
for (const column of definition.columnNames) {
|
|
475
|
+
const metadata = definition.columnMetadata[column];
|
|
476
|
+
if (metadata.isGenerated || metadata.isIdentity) {
|
|
477
|
+
delete data[column];
|
|
478
|
+
}
|
|
391
479
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
480
|
+
if (primaryKey && (data[primaryKey] == null || data[primaryKey] === "")) {
|
|
481
|
+
delete data[primaryKey];
|
|
482
|
+
}
|
|
483
|
+
const invalidColumns = Object.keys(data).filter(
|
|
484
|
+
(column) => !definition.columnNames.includes(column)
|
|
485
|
+
);
|
|
486
|
+
if (invalidColumns.length > 0) {
|
|
487
|
+
throw BadRequest(`Invalid columns: ${invalidColumns.join(", ")}`);
|
|
488
|
+
}
|
|
489
|
+
const statement = Object.keys(data).length > 0 ? sql2`INSERT INTO ${qualifiedTable(definition)} ${sql2.spreadInsert(data)} RETURNING *` : sql2`INSERT INTO ${qualifiedTable(definition)} DEFAULT VALUES RETURNING *`;
|
|
397
490
|
return postgres.first(statement);
|
|
398
491
|
}
|
|
399
492
|
);
|
|
@@ -401,48 +494,64 @@ function createAdminRouter({
|
|
|
401
494
|
"/api/tables/:table/:pk",
|
|
402
495
|
{ request: unknownTableRow },
|
|
403
496
|
async (req) => {
|
|
404
|
-
const table =
|
|
497
|
+
const table = req.params.table;
|
|
405
498
|
const pk = req.params.pk;
|
|
406
|
-
const definition = await getTable(table);
|
|
499
|
+
const definition = await getTable(table, postgres.connectionString);
|
|
407
500
|
const primaryKey = definition.primaryKey;
|
|
408
501
|
if (!primaryKey) {
|
|
409
502
|
throw BadRequest(`Table ${table} has no primary key`);
|
|
410
503
|
}
|
|
411
504
|
const updatedAtColumns = definition.columnNames.filter(
|
|
412
|
-
(
|
|
505
|
+
(column) => ["updated_at", "modified_time"].includes(column) && !definition.columnMetadata[column].isGenerated && !definition.columnMetadata[column].isIdentity
|
|
413
506
|
);
|
|
414
507
|
const data = {
|
|
415
508
|
...req.body
|
|
416
509
|
};
|
|
510
|
+
delete data[primaryKey];
|
|
511
|
+
for (const column of definition.columnNames) {
|
|
512
|
+
const metadata = definition.columnMetadata[column];
|
|
513
|
+
if (metadata.isGenerated || metadata.isIdentity) {
|
|
514
|
+
delete data[column];
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const invalidColumns = Object.keys(data).filter(
|
|
518
|
+
(column) => !definition.columnNames.includes(column)
|
|
519
|
+
);
|
|
520
|
+
if (invalidColumns.length > 0) {
|
|
521
|
+
throw BadRequest(`Invalid columns: ${invalidColumns.join(", ")}`);
|
|
522
|
+
}
|
|
417
523
|
for (const updateAtColumn of updatedAtColumns) {
|
|
418
|
-
data[updateAtColumn] =
|
|
524
|
+
data[updateAtColumn] = sql2.raw("NOW()");
|
|
525
|
+
}
|
|
526
|
+
if (Object.keys(data).length === 0) {
|
|
527
|
+
throw BadRequest("No values to update");
|
|
419
528
|
}
|
|
420
529
|
return await postgres.transaction(async () => {
|
|
421
|
-
await postgres.get(
|
|
422
|
-
SELECT * FROM ${
|
|
423
|
-
WHERE ${
|
|
530
|
+
await postgres.get(sql2`
|
|
531
|
+
SELECT * FROM ${qualifiedTable(definition)}
|
|
532
|
+
WHERE ${sql2.identifier(primaryKey)} = ${req.params.pk}
|
|
424
533
|
FOR UPDATE
|
|
425
534
|
`);
|
|
426
|
-
return postgres.get(
|
|
427
|
-
UPDATE ${
|
|
428
|
-
SET ${
|
|
429
|
-
WHERE ${
|
|
535
|
+
return postgres.get(sql2`
|
|
536
|
+
UPDATE ${qualifiedTable(definition)}
|
|
537
|
+
SET ${sql2.spreadUpdate(data)}
|
|
538
|
+
WHERE ${sql2.identifier(primaryKey)} = ${pk}
|
|
430
539
|
RETURNING *
|
|
431
540
|
`);
|
|
432
541
|
});
|
|
433
542
|
}
|
|
434
543
|
);
|
|
435
544
|
router.delete("/api/tables/:table/:pk", {}, async (req) => {
|
|
436
|
-
const table =
|
|
437
|
-
const pk =
|
|
438
|
-
const definition = await getTable(table);
|
|
545
|
+
const table = req.params.table;
|
|
546
|
+
const pk = req.params.pk;
|
|
547
|
+
const definition = await getTable(table, postgres.connectionString);
|
|
439
548
|
const primaryKey = definition.primaryKey;
|
|
440
549
|
if (!primaryKey) {
|
|
441
550
|
throw BadRequest(`Table ${table} has no primary key`);
|
|
442
551
|
}
|
|
443
|
-
const statement =
|
|
444
|
-
DELETE FROM ${
|
|
445
|
-
WHERE ${
|
|
552
|
+
const statement = sql2`
|
|
553
|
+
DELETE FROM ${qualifiedTable(definition)}
|
|
554
|
+
WHERE ${sql2.identifier(primaryKey)} = ${pk}
|
|
446
555
|
`;
|
|
447
556
|
await postgres.query(statement);
|
|
448
557
|
return 204;
|
|
@@ -454,7 +563,8 @@ function createAdminRouter({
|
|
|
454
563
|
);
|
|
455
564
|
router.get("*", {}, async (req, res) => {
|
|
456
565
|
const content = await fs.readFile(indexHtml, "utf-8");
|
|
457
|
-
|
|
566
|
+
const title = escapeHtml(uiOptions.title ?? "Posterboi");
|
|
567
|
+
return content.replace(/{{ csrf_token }}/g, generateCsrfToken(req, res)).replace(/{{ basename }}/g, escapeHtml(clientBasename)).replace(/{{ title }}/g, title);
|
|
458
568
|
});
|
|
459
569
|
return router.getRouter();
|
|
460
570
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
declare module '@devmoods/config' {
|
|
3
|
+
interface Config extends ConfigFromDefs<typeof appConfig> {
|
|
4
|
+
}
|
|
5
|
+
}
|
|
6
|
+
declare const appConfig: import("@devmoods/config").Defs<{
|
|
7
|
+
CI: boolean;
|
|
8
|
+
PORT: number;
|
|
9
|
+
PUBLIC_URL: string;
|
|
10
|
+
AUTH_TOKEN_EXPIRES: number;
|
|
11
|
+
AUTH_TEMP_TOKEN_EXPIRES: number;
|
|
12
|
+
AUTH_OTP_EXPIRES: number;
|
|
13
|
+
AUTH_OTP_MAX_ATTEMPTS: number;
|
|
14
|
+
AUTH_JWT_SECRET: string;
|
|
15
|
+
AUTH_COOKIE_SAME_SITE: string;
|
|
16
|
+
REDIS_URL: string;
|
|
17
|
+
REDIS_KEY_PREFIX: string;
|
|
18
|
+
SENTRY_DSN: string;
|
|
19
|
+
SENTRY_TRACES_SAMPLE_RATE: number;
|
|
20
|
+
SENTRY_PROFILES_SAMPLE_RATE: number;
|
|
21
|
+
SENTRY_EVENT_LOOP_THRESHOLD: number;
|
|
22
|
+
LOG_LEVEL: string;
|
|
23
|
+
LOG_SQL: boolean;
|
|
24
|
+
NODE_ENV: string;
|
|
25
|
+
CSRF_SECRET: string;
|
|
26
|
+
READINESS_PERIOD_MS: number;
|
|
27
|
+
GRACE_PERIOD_MS: number;
|
|
28
|
+
SLACK_WEBHOOK_URL: string;
|
|
29
|
+
VITE_USE_DEV_SERVER: boolean;
|
|
30
|
+
EMAIL_HOST: string;
|
|
31
|
+
EMAIL_PORT: number;
|
|
32
|
+
EMAIL_USER: string;
|
|
33
|
+
EMAIL_PASS: string;
|
|
34
|
+
EMAIL_SENDER_NAME: string;
|
|
35
|
+
EMAIL_SENDER_ADDRESS: string;
|
|
36
|
+
GOOGLE_AUTH_CLIENT_ID: string;
|
|
37
|
+
GOOGLE_AUTH_CLIENT_SECRET: string;
|
|
38
|
+
POSTGRES_URL: string;
|
|
39
|
+
POSTGRES_PRINT_SQL: boolean;
|
|
40
|
+
}>;
|
|
41
|
+
export {};
|