@zmdb/cli 1.0.0-beta.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.
Files changed (207) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +19 -0
  3. package/dist/__fixtures__/http-client/src/contract.d.ts +38 -0
  4. package/dist/__fixtures__/http-client/src/contract.d.ts.map +1 -0
  5. package/dist/__fixtures__/http-client/src/contract.js +121 -0
  6. package/dist/__fixtures__/http-client/src/contract.js.map +1 -0
  7. package/dist/__fixtures__/http-client/src/models.d.ts +11 -0
  8. package/dist/__fixtures__/http-client/src/models.d.ts.map +1 -0
  9. package/dist/__fixtures__/http-client/src/models.js +2 -0
  10. package/dist/__fixtures__/http-client/src/models.js.map +1 -0
  11. package/dist/__fixtures__/http-client/src/schema.d.ts +4 -0
  12. package/dist/__fixtures__/http-client/src/schema.d.ts.map +1 -0
  13. package/dist/__fixtures__/http-client/src/schema.js +2 -0
  14. package/dist/__fixtures__/http-client/src/schema.js.map +1 -0
  15. package/dist/__fixtures__/http-client/src/unrelated.d.ts +2 -0
  16. package/dist/__fixtures__/http-client/src/unrelated.d.ts.map +1 -0
  17. package/dist/__fixtures__/http-client/src/unrelated.js +2 -0
  18. package/dist/__fixtures__/http-client/src/unrelated.js.map +1 -0
  19. package/dist/__fixtures__/http-client/zmdb.config.d.ts +16 -0
  20. package/dist/__fixtures__/http-client/zmdb.config.d.ts.map +1 -0
  21. package/dist/__fixtures__/http-client/zmdb.config.js +12 -0
  22. package/dist/__fixtures__/http-client/zmdb.config.js.map +1 -0
  23. package/dist/__fixtures__/project/src/schema.d.ts +6 -0
  24. package/dist/__fixtures__/project/src/schema.d.ts.map +1 -0
  25. package/dist/__fixtures__/project/src/schema.js +2 -0
  26. package/dist/__fixtures__/project/src/schema.js.map +1 -0
  27. package/dist/__fixtures__/project/zmdb.config.d.ts +22 -0
  28. package/dist/__fixtures__/project/zmdb.config.d.ts.map +1 -0
  29. package/dist/__fixtures__/project/zmdb.config.js +55 -0
  30. package/dist/__fixtures__/project/zmdb.config.js.map +1 -0
  31. package/dist/application-loader.d.ts +4 -0
  32. package/dist/application-loader.d.ts.map +1 -0
  33. package/dist/application-loader.js +102 -0
  34. package/dist/application-loader.js.map +1 -0
  35. package/dist/args.d.ts +23 -0
  36. package/dist/args.d.ts.map +1 -0
  37. package/dist/args.js +353 -0
  38. package/dist/args.js.map +1 -0
  39. package/dist/atomic.d.ts +13 -0
  40. package/dist/atomic.d.ts.map +1 -0
  41. package/dist/atomic.js +28 -0
  42. package/dist/atomic.js.map +1 -0
  43. package/dist/bin.d.ts +3 -0
  44. package/dist/bin.d.ts.map +1 -0
  45. package/dist/bin.js +4 -0
  46. package/dist/bin.js.map +1 -0
  47. package/dist/commands/check.d.ts +5 -0
  48. package/dist/commands/check.d.ts.map +1 -0
  49. package/dist/commands/check.js +7 -0
  50. package/dist/commands/check.js.map +1 -0
  51. package/dist/commands/client.d.ts +38 -0
  52. package/dist/commands/client.d.ts.map +1 -0
  53. package/dist/commands/client.js +347 -0
  54. package/dist/commands/client.js.map +1 -0
  55. package/dist/commands/codegen.d.ts +9 -0
  56. package/dist/commands/codegen.d.ts.map +1 -0
  57. package/dist/commands/codegen.js +56 -0
  58. package/dist/commands/codegen.js.map +1 -0
  59. package/dist/commands/embed.d.ts +8 -0
  60. package/dist/commands/embed.d.ts.map +1 -0
  61. package/dist/commands/embed.js +17 -0
  62. package/dist/commands/embed.js.map +1 -0
  63. package/dist/commands/export.d.ts +5 -0
  64. package/dist/commands/export.d.ts.map +1 -0
  65. package/dist/commands/export.js +6 -0
  66. package/dist/commands/export.js.map +1 -0
  67. package/dist/commands/generate.d.ts +5 -0
  68. package/dist/commands/generate.d.ts.map +1 -0
  69. package/dist/commands/generate.js +6 -0
  70. package/dist/commands/generate.js.map +1 -0
  71. package/dist/commands/migrate.d.ts +9 -0
  72. package/dist/commands/migrate.d.ts.map +1 -0
  73. package/dist/commands/migrate.js +18 -0
  74. package/dist/commands/migrate.js.map +1 -0
  75. package/dist/commands/new.d.ts +4 -0
  76. package/dist/commands/new.d.ts.map +1 -0
  77. package/dist/commands/new.js +35 -0
  78. package/dist/commands/new.js.map +1 -0
  79. package/dist/commands/pull.d.ts +5 -0
  80. package/dist/commands/pull.d.ts.map +1 -0
  81. package/dist/commands/pull.js +14 -0
  82. package/dist/commands/pull.js.map +1 -0
  83. package/dist/commands/push.d.ts +6 -0
  84. package/dist/commands/push.d.ts.map +1 -0
  85. package/dist/commands/push.js +11 -0
  86. package/dist/commands/push.js.map +1 -0
  87. package/dist/commands/studio.d.ts +31 -0
  88. package/dist/commands/studio.d.ts.map +1 -0
  89. package/dist/commands/studio.js +154 -0
  90. package/dist/commands/studio.js.map +1 -0
  91. package/dist/commands/upgrade.d.ts +5 -0
  92. package/dist/commands/upgrade.d.ts.map +1 -0
  93. package/dist/commands/upgrade.js +11 -0
  94. package/dist/commands/upgrade.js.map +1 -0
  95. package/dist/database.d.ts +4 -0
  96. package/dist/database.d.ts.map +1 -0
  97. package/dist/database.js +8 -0
  98. package/dist/database.js.map +1 -0
  99. package/dist/errors.d.ts +7 -0
  100. package/dist/errors.d.ts.map +1 -0
  101. package/dist/errors.js +20 -0
  102. package/dist/errors.js.map +1 -0
  103. package/dist/index.d.ts +34 -0
  104. package/dist/index.d.ts.map +1 -0
  105. package/dist/index.js +626 -0
  106. package/dist/index.js.map +1 -0
  107. package/dist/lifecycle.d.ts +6 -0
  108. package/dist/lifecycle.d.ts.map +1 -0
  109. package/dist/lifecycle.js +75 -0
  110. package/dist/lifecycle.js.map +1 -0
  111. package/dist/migration-project.d.ts +14 -0
  112. package/dist/migration-project.d.ts.map +1 -0
  113. package/dist/migration-project.js +74 -0
  114. package/dist/migration-project.js.map +1 -0
  115. package/dist/output.d.ts +27 -0
  116. package/dist/output.d.ts.map +1 -0
  117. package/dist/output.js +60 -0
  118. package/dist/output.js.map +1 -0
  119. package/dist/repl.d.ts +48 -0
  120. package/dist/repl.d.ts.map +1 -0
  121. package/dist/repl.js +265 -0
  122. package/dist/repl.js.map +1 -0
  123. package/dist/scaffold.d.ts +28 -0
  124. package/dist/scaffold.d.ts.map +1 -0
  125. package/dist/scaffold.js +414 -0
  126. package/dist/scaffold.js.map +1 -0
  127. package/dist/studio/index.d.ts +20 -0
  128. package/dist/studio/index.d.ts.map +1 -0
  129. package/dist/studio/index.js +607 -0
  130. package/dist/studio/index.js.map +1 -0
  131. package/dist/templates/command.d.ts +3 -0
  132. package/dist/templates/command.d.ts.map +1 -0
  133. package/dist/templates/command.js +65 -0
  134. package/dist/templates/command.js.map +1 -0
  135. package/dist/templates/controller.d.ts +3 -0
  136. package/dist/templates/controller.d.ts.map +1 -0
  137. package/dist/templates/controller.js +41 -0
  138. package/dist/templates/controller.js.map +1 -0
  139. package/dist/templates/index.d.ts +4 -0
  140. package/dist/templates/index.d.ts.map +1 -0
  141. package/dist/templates/index.js +18 -0
  142. package/dist/templates/index.js.map +1 -0
  143. package/dist/templates/module.d.ts +3 -0
  144. package/dist/templates/module.d.ts.map +1 -0
  145. package/dist/templates/module.js +41 -0
  146. package/dist/templates/module.js.map +1 -0
  147. package/dist/templates/project.d.ts +3 -0
  148. package/dist/templates/project.d.ts.map +1 -0
  149. package/dist/templates/project.js +280 -0
  150. package/dist/templates/project.js.map +1 -0
  151. package/dist/templates/repository.d.ts +3 -0
  152. package/dist/templates/repository.d.ts.map +1 -0
  153. package/dist/templates/repository.js +66 -0
  154. package/dist/templates/repository.js.map +1 -0
  155. package/dist/templates/schema.d.ts +3 -0
  156. package/dist/templates/schema.d.ts.map +1 -0
  157. package/dist/templates/schema.js +36 -0
  158. package/dist/templates/schema.js.map +1 -0
  159. package/dist/templates/types.d.ts +25 -0
  160. package/dist/templates/types.d.ts.map +1 -0
  161. package/dist/templates/types.js +17 -0
  162. package/dist/templates/types.js.map +1 -0
  163. package/package.json +72 -0
  164. package/src/__fixtures__/http-client/package.json +5 -0
  165. package/src/__fixtures__/http-client/src/contract.ts +85 -0
  166. package/src/__fixtures__/http-client/src/models.ts +12 -0
  167. package/src/__fixtures__/http-client/src/schema.ts +3 -0
  168. package/src/__fixtures__/http-client/src/unrelated.ts +1 -0
  169. package/src/__fixtures__/http-client/tsconfig.json +17 -0
  170. package/src/__fixtures__/http-client/zmdb.config.ts +12 -0
  171. package/src/__fixtures__/project/package.json +5 -0
  172. package/src/__fixtures__/project/src/schema.ts +6 -0
  173. package/src/__fixtures__/project/tsconfig.json +24 -0
  174. package/src/__fixtures__/project/zmdb.config.ts +57 -0
  175. package/src/application-loader.ts +108 -0
  176. package/src/args.ts +391 -0
  177. package/src/atomic.ts +43 -0
  178. package/src/bin.ts +5 -0
  179. package/src/commands/check.ts +11 -0
  180. package/src/commands/client.ts +415 -0
  181. package/src/commands/codegen.ts +73 -0
  182. package/src/commands/embed.ts +36 -0
  183. package/src/commands/export.ts +10 -0
  184. package/src/commands/generate.ts +14 -0
  185. package/src/commands/migrate.ts +38 -0
  186. package/src/commands/new.ts +48 -0
  187. package/src/commands/pull.ts +32 -0
  188. package/src/commands/push.ts +21 -0
  189. package/src/commands/studio.ts +200 -0
  190. package/src/commands/upgrade.ts +14 -0
  191. package/src/database.ts +9 -0
  192. package/src/errors.ts +19 -0
  193. package/src/index.ts +710 -0
  194. package/src/lifecycle.ts +73 -0
  195. package/src/migration-project.ts +91 -0
  196. package/src/output.ts +77 -0
  197. package/src/repl.ts +328 -0
  198. package/src/scaffold.ts +485 -0
  199. package/src/studio/index.ts +674 -0
  200. package/src/templates/command.ts +66 -0
  201. package/src/templates/controller.ts +42 -0
  202. package/src/templates/index.ts +29 -0
  203. package/src/templates/module.ts +42 -0
  204. package/src/templates/project.ts +281 -0
  205. package/src/templates/repository.ts +67 -0
  206. package/src/templates/schema.ts +37 -0
  207. package/src/templates/types.ts +46 -0
@@ -0,0 +1,674 @@
1
+ import { type Driver } from '@zmdb/orm';
2
+ import { isRecord, type CoreSchema } from '@zmdb/schema';
3
+ import { type ColumnIR } from '@zmdb/schema/ir';
4
+ import { toJsonSchema } from '@zmdb/schema/openapi';
5
+ import { resolveRelation, type ResolvedRelation } from '@zmdb/schema/relations';
6
+ import { createQueryCompiler, type DialectTarget, type SelectBuilder } from '@zmdb/sql';
7
+ import { aggregateSelectFrom, type AggregateSelect } from '@zmdb/sql/aggregations';
8
+ import { Controller, createRouter, Get, respond, type Ctx, type QueryValues, type WebResponse } from '@zmdb/web';
9
+
10
+ const DEFAULT_PAGE_SIZE = 25;
11
+ const MAX_PAGE_SIZE = 50;
12
+ const HTML_HEADERS = Object.freeze({
13
+ 'content-type': 'text/html; charset=utf-8',
14
+ 'content-security-policy':
15
+ "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
16
+ 'referrer-policy': 'no-referrer',
17
+ 'x-content-type-options': 'nosniff',
18
+ });
19
+ const PAGE_QUERY_KEYS = new Set(['page', 'pageSize', 'orderBy', 'direction']);
20
+
21
+ export interface StudioInput {
22
+ readonly schemas: readonly CoreSchema<string>[];
23
+ readonly driver: Driver;
24
+ readonly dialect?: DialectTarget;
25
+ }
26
+
27
+ export interface StudioApp extends AsyncDisposable {
28
+ fetch(request: Request): Promise<Response>;
29
+ }
30
+
31
+ interface StudioTable {
32
+ readonly schema: CoreSchema<string>;
33
+ readonly visibleColumns: readonly ColumnIR[];
34
+ }
35
+
36
+ interface PageOptions {
37
+ readonly page: number;
38
+ readonly pageSize: number;
39
+ readonly offset: number;
40
+ readonly orderBy: ColumnIR;
41
+ readonly orderByParameter: string | undefined;
42
+ readonly direction: 'asc' | 'desc';
43
+ }
44
+
45
+ interface Predicate {
46
+ readonly column: ColumnIR;
47
+ readonly value: unknown;
48
+ }
49
+
50
+ interface PageResult {
51
+ readonly rows: readonly Record<string, unknown>[];
52
+ readonly total: number;
53
+ readonly options: PageOptions;
54
+ }
55
+
56
+ class StudioRequestError extends Error {
57
+ readonly status: 400 | 404;
58
+
59
+ constructor(status: 400 | 404, message: string) {
60
+ super(message);
61
+ this.name = 'StudioRequestError';
62
+ this.status = status;
63
+ }
64
+ }
65
+
66
+ class Studio {
67
+ readonly #driver: Driver;
68
+ readonly #dialect: DialectTarget;
69
+ readonly #tables: readonly StudioTable[];
70
+ readonly #tablesByName: ReadonlyMap<string, StudioTable>;
71
+
72
+ constructor(input: StudioInput) {
73
+ this.#driver = input.driver;
74
+ const dialect = input.dialect ?? input.driver.dialect;
75
+ if (dialect === undefined) {
76
+ throw new Error('zmdb studio needs a dialect on its input or driver');
77
+ }
78
+ this.#dialect = dialect;
79
+
80
+ const byName = new Map<string, StudioTable>();
81
+ const tables: StudioTable[] = [];
82
+ for (const schema of input.schemas) {
83
+ if (byName.has(schema.ir.table)) {
84
+ throw new Error(`zmdb studio received duplicate configured table "${schema.ir.table}"`);
85
+ }
86
+ const visible = new Set(Object.keys(toJsonSchema(schema).properties));
87
+ const table = {
88
+ schema,
89
+ visibleColumns: schema.ir.columns.filter(column => visible.has(column.name)),
90
+ };
91
+ byName.set(schema.ir.table, table);
92
+ tables.push(table);
93
+ }
94
+ this.#tables = tables.toSorted((left, right) => left.schema.ir.table.localeCompare(right.schema.ir.table));
95
+ this.#tablesByName = byName;
96
+ }
97
+
98
+ index(query: QueryValues): WebResponse {
99
+ assertQueryKeys(query, new Set());
100
+ const items =
101
+ this.#tables.length === 0
102
+ ? '<p>No tables are configured.</p>'
103
+ : `<ul class="tables">${this.#tables
104
+ .map(
105
+ table =>
106
+ `<li><a href="${escapeHtml(tablePath(table.schema))}">${escapeHtml(table.schema.ir.table)}</a>` +
107
+ `<span>${String(table.visibleColumns.length)} visible columns</span></li>`,
108
+ )
109
+ .join('')}</ul>`;
110
+ return htmlResponse(
111
+ 'Declared tables',
112
+ `<h1>Declared tables</h1><p>The index comes only from the config&apos;s schema set.</p>${items}`,
113
+ );
114
+ }
115
+
116
+ async tablePage(tableName: string, query: QueryValues): Promise<WebResponse> {
117
+ const table = this.table(tableName);
118
+ const options = pageOptions(table, query);
119
+ const page = await this.page(table, options, []);
120
+ return htmlResponse(
121
+ table.schema.ir.table,
122
+ [
123
+ breadcrumbs([{ href: '/', label: 'tables' }, { label: table.schema.ir.table }]),
124
+ `<h1>${escapeHtml(table.schema.ir.table)}</h1>`,
125
+ `<p>${String(page.total)} rows. Page ${String(options.page)} of ${String(pageCount(page))}.</p>`,
126
+ renderRows(table, page.rows),
127
+ renderPageControls(tablePath(table.schema), page),
128
+ ].join(''),
129
+ );
130
+ }
131
+
132
+ async row(tableName: string, token: string, query: QueryValues): Promise<WebResponse> {
133
+ assertQueryKeys(query, new Set());
134
+ const table = this.table(tableName);
135
+ const key = decodeKey(table, token);
136
+ const row = await this.oneRow(table, key);
137
+ if (row === undefined) {
138
+ throw new StudioRequestError(404, `no row matched the declared key for table "${table.schema.ir.table}"`);
139
+ }
140
+
141
+ const fields = table.visibleColumns
142
+ .map(column => `<dt>${escapeHtml(column.name)}</dt><dd>${escapeHtml(displayValue(rowValue(row, column)))}</dd>`)
143
+ .join('');
144
+ const relations = !hasBrowsableKey(table)
145
+ ? '<p>Relations are not linked because this table has no browser-safe declared key.</p>'
146
+ : table.schema.ir.relations.length === 0
147
+ ? '<p>No relations are declared.</p>'
148
+ : `<ul>${table.schema.ir.relations
149
+ .map(
150
+ relation =>
151
+ `<li><a href="${escapeHtml(relationPath(table.schema, token, relation.name))}">${escapeHtml(
152
+ relation.name,
153
+ )}</a> → ${escapeHtml(relation.target)}</li>`,
154
+ )
155
+ .join('')}</ul>`;
156
+
157
+ return htmlResponse(
158
+ `${table.schema.ir.table} row`,
159
+ [
160
+ breadcrumbs([
161
+ { href: '/', label: 'tables' },
162
+ { href: tablePath(table.schema), label: table.schema.ir.table },
163
+ { label: 'row' },
164
+ ]),
165
+ `<h1>${escapeHtml(table.schema.ir.table)} row</h1>`,
166
+ `<dl>${fields}</dl>`,
167
+ '<h2>Declared relations</h2>',
168
+ relations,
169
+ ].join(''),
170
+ );
171
+ }
172
+
173
+ async relation(tableName: string, token: string, relationName: string, query: QueryValues): Promise<WebResponse> {
174
+ const table = this.table(tableName);
175
+ const key = decodeKey(table, token);
176
+ const parent = await this.oneRow(table, key);
177
+ if (parent === undefined) {
178
+ throw new StudioRequestError(404, `no row matched the declared key for table "${table.schema.ir.table}"`);
179
+ }
180
+
181
+ let relation: ResolvedRelation;
182
+ try {
183
+ relation = resolveRelation(table.schema.ir, relationName);
184
+ } catch (error) {
185
+ throw new StudioRequestError(400, messageOf(error));
186
+ }
187
+ const target = this.table(relation.targetTable);
188
+ const predicates = relation.parentKey.map((parentKey, index) => {
189
+ const targetKey = relation.targetKey[index];
190
+ if (targetKey === undefined) {
191
+ throw new StudioRequestError(400, `${table.schema.table}.${relation.name} resolved mismatched key columns`);
192
+ }
193
+ const parentColumn = declaredColumn(table, parentKey);
194
+ return {
195
+ column: declaredColumn(target, targetKey),
196
+ value: rowValue(parent, parentColumn),
197
+ };
198
+ });
199
+ const options = pageOptions(target, query);
200
+ const page = await this.page(target, options, predicates);
201
+
202
+ return htmlResponse(
203
+ `${table.schema.ir.table}.${relation.name}`,
204
+ [
205
+ breadcrumbs([
206
+ { href: '/', label: 'tables' },
207
+ { href: tablePath(table.schema), label: table.schema.ir.table },
208
+ { href: rowPath(table.schema, token), label: 'row' },
209
+ { label: relation.name },
210
+ ]),
211
+ `<h1>${escapeHtml(relation.name)}</h1>`,
212
+ `<p>${String(page.total)} related ${escapeHtml(target.schema.ir.table)} rows.</p>`,
213
+ renderRows(target, page.rows),
214
+ renderPageControls(relationPath(table.schema, token, relation.name), page),
215
+ ].join(''),
216
+ );
217
+ }
218
+
219
+ private table(name: string): StudioTable {
220
+ const table = this.#tablesByName.get(name);
221
+ if (table === undefined) {
222
+ throw new StudioRequestError(400, `undeclared table "${name}" is not in the configured schema set`);
223
+ }
224
+ return table;
225
+ }
226
+
227
+ private async oneRow(table: StudioTable, key: readonly Predicate[]): Promise<Record<string, unknown> | undefined> {
228
+ let query = createQueryCompiler(this.#dialect)
229
+ .selectFrom(table.schema.ir.physicalTable)
230
+ .select(table.schema.ir.columns.map(column => column.physicalName));
231
+ query = applyPredicates(query, key);
232
+ return (await this.#driver.execute(query.limit(1).compile()))[0];
233
+ }
234
+
235
+ private async page(table: StudioTable, options: PageOptions, predicates: readonly Predicate[]): Promise<PageResult> {
236
+ const visible = unique(table.visibleColumns.map(column => column.physicalName));
237
+ const selected = visible.length === 0 ? [options.orderBy.physicalName] : visible;
238
+ let query = createQueryCompiler(this.#dialect)
239
+ .selectFrom(table.schema.ir.physicalTable)
240
+ .select(selected)
241
+ .orderBy(options.orderBy.physicalName, options.direction)
242
+ .limit(options.pageSize)
243
+ .offset(options.offset);
244
+ query = applyPredicates(query, predicates);
245
+
246
+ let count = aggregateSelectFrom(table.schema.ir.physicalTable, this.#dialect).count('*', 'count');
247
+ count = applyAggregatePredicates(count, predicates);
248
+ const [rows, countRows] = await Promise.all([
249
+ this.#driver.execute(query.compile()),
250
+ this.#driver.execute(count.compile()),
251
+ ]);
252
+
253
+ return {
254
+ rows,
255
+ total: countValue(countRows[0]?.['count']),
256
+ options,
257
+ };
258
+ }
259
+ }
260
+
261
+ @Controller()
262
+ class StudioController {
263
+ readonly #studio: Studio;
264
+
265
+ constructor(studio: Studio) {
266
+ this.#studio = studio;
267
+ }
268
+
269
+ @Get('/')
270
+ index(ctx: Ctx<Record<never, string>, unknown, QueryValues>): Promise<WebResponse> {
271
+ return this.answer(() => this.#studio.index(ctx.query));
272
+ }
273
+
274
+ @Get('/tables/:table')
275
+ table(ctx: Ctx<{ table: string }, unknown, QueryValues>): Promise<WebResponse> {
276
+ return this.answer(() => this.#studio.tablePage(decodePath(ctx.params.table), ctx.query));
277
+ }
278
+
279
+ @Get('/tables/:table/rows/:key')
280
+ row(ctx: Ctx<{ table: string; key: string }, unknown, QueryValues>): Promise<WebResponse> {
281
+ return this.answer(() => this.#studio.row(decodePath(ctx.params.table), ctx.params.key, ctx.query));
282
+ }
283
+
284
+ @Get('/tables/:table/rows/:key/relations/:relation')
285
+ relation(ctx: Ctx<{ table: string; key: string; relation: string }, unknown, QueryValues>): Promise<WebResponse> {
286
+ return this.answer(() =>
287
+ this.#studio.relation(decodePath(ctx.params.table), ctx.params.key, decodePath(ctx.params.relation), ctx.query),
288
+ );
289
+ }
290
+
291
+ private async answer(action: () => WebResponse | Promise<WebResponse>): Promise<WebResponse> {
292
+ try {
293
+ return await action();
294
+ } catch (error) {
295
+ if (error instanceof StudioRequestError) {
296
+ return htmlResponse(
297
+ error.status === 404 ? 'Not found' : 'Request refused',
298
+ `<h1>${error.status === 404 ? 'Not found' : 'Request refused'}</h1><p>${escapeHtml(error.message)}</p>`,
299
+ error.status,
300
+ );
301
+ }
302
+ return htmlResponse('Studio error', `<h1>Studio error</h1><p>${escapeHtml(messageOf(error))}</p>`, 500);
303
+ }
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Build the dependency-free server-rendered studio application.
309
+ *
310
+ * The router contains GET routes only. The Fetch boundary rejects every other
311
+ * verb before route matching or database access, so adding a handler cannot
312
+ * accidentally turn the studio into a write surface.
313
+ */
314
+ export function createStudioApp(input: StudioInput): StudioApp {
315
+ const router = createRouter();
316
+ router.register(new StudioController(new Studio(input)));
317
+ let disposed = false;
318
+
319
+ return {
320
+ async fetch(request: Request): Promise<Response> {
321
+ if (disposed) {
322
+ return new Response('studio is closed', {
323
+ status: 503,
324
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
325
+ });
326
+ }
327
+ if (request.method !== 'GET') {
328
+ return new Response('Method Not Allowed', {
329
+ status: 405,
330
+ headers: { allow: 'GET', 'content-type': 'text/plain; charset=utf-8' },
331
+ });
332
+ }
333
+
334
+ const url = new URL(request.url);
335
+ const response = await router.handle({
336
+ method: 'GET',
337
+ path: url.pathname,
338
+ headers: Object.fromEntries(request.headers),
339
+ query: queryValues(url.searchParams),
340
+ scheme: url.protocol.slice(0, -1),
341
+ });
342
+ return fetchResponse(response);
343
+ },
344
+ [Symbol.asyncDispose](): Promise<void> {
345
+ disposed = true;
346
+ return Promise.resolve();
347
+ },
348
+ };
349
+ }
350
+
351
+ function applyPredicates(builder: SelectBuilder, predicates: readonly Predicate[]): SelectBuilder {
352
+ let query = builder;
353
+ for (const predicate of predicates) {
354
+ query = query.where(predicate.column.physicalName, '=', predicate.value);
355
+ }
356
+ return query;
357
+ }
358
+
359
+ function applyAggregatePredicates(builder: AggregateSelect, predicates: readonly Predicate[]): AggregateSelect {
360
+ let query = builder;
361
+ for (const predicate of predicates) {
362
+ query = query.where(predicate.column.physicalName, '=', predicate.value);
363
+ }
364
+ return query;
365
+ }
366
+
367
+ function pageOptions(table: StudioTable, query: QueryValues): PageOptions {
368
+ assertQueryKeys(query, PAGE_QUERY_KEYS);
369
+ const page = positiveInteger(queryValue(query, 'page') ?? '1', 'page');
370
+ const requestedPageSize = positiveInteger(queryValue(query, 'pageSize') ?? String(DEFAULT_PAGE_SIZE), 'pageSize');
371
+ const pageSize = Math.min(requestedPageSize, MAX_PAGE_SIZE);
372
+ const namedOrder = queryValue(query, 'orderBy');
373
+ const defaultOrder =
374
+ table.schema.ir.primaryKey.find(name => isVisibleColumn(table, name)) ??
375
+ table.visibleColumns[0]?.name ??
376
+ table.schema.ir.columns[0]?.name;
377
+ if (defaultOrder === undefined) {
378
+ throw new StudioRequestError(400, `table "${table.schema.ir.table}" declares no columns`);
379
+ }
380
+ const orderBy =
381
+ namedOrder === undefined
382
+ ? declaredColumn(table, defaultOrder)
383
+ : (table.visibleColumns.find(column => column.name === namedOrder) ??
384
+ (() => {
385
+ throw new StudioRequestError(400, `undeclared column "${namedOrder}" on table "${table.schema.ir.table}"`);
386
+ })());
387
+ const namedDirection = queryValue(query, 'direction') ?? 'asc';
388
+ if (namedDirection !== 'asc' && namedDirection !== 'desc') {
389
+ throw new StudioRequestError(400, `direction must be "asc" or "desc", received "${namedDirection}"`);
390
+ }
391
+ const offset = (page - 1) * pageSize;
392
+ if (!Number.isSafeInteger(offset)) {
393
+ throw new StudioRequestError(400, `page ${String(page)} is too large`);
394
+ }
395
+ return {
396
+ page,
397
+ pageSize,
398
+ offset,
399
+ orderBy,
400
+ orderByParameter: isVisibleColumn(table, orderBy.name) ? orderBy.name : undefined,
401
+ direction: namedDirection,
402
+ };
403
+ }
404
+
405
+ function positiveInteger(value: string, name: string): number {
406
+ if (!/^[1-9]\d*$/.test(value)) {
407
+ throw new StudioRequestError(400, `${name} must be a positive integer`);
408
+ }
409
+ const parsed = Number(value);
410
+ if (!Number.isSafeInteger(parsed)) {
411
+ throw new StudioRequestError(400, `${name} is too large`);
412
+ }
413
+ return parsed;
414
+ }
415
+
416
+ function assertQueryKeys(query: QueryValues, allowed: ReadonlySet<string>): void {
417
+ for (const key of Object.keys(query)) {
418
+ if (!allowed.has(key)) {
419
+ const suffix = key.toLowerCase() === 'sql' ? '; studio never accepts SQL from the browser' : '';
420
+ throw new StudioRequestError(400, `query parameter "${key}" is not accepted${suffix}`);
421
+ }
422
+ }
423
+ }
424
+
425
+ function queryValue(query: QueryValues, name: string): string | undefined {
426
+ const value = query[name];
427
+ if (value === undefined || typeof value === 'string') {
428
+ return value;
429
+ }
430
+ if (value.length !== 1) {
431
+ throw new StudioRequestError(400, `query parameter "${name}" must appear once`);
432
+ }
433
+ return value[0];
434
+ }
435
+
436
+ function queryValues(search: URLSearchParams): QueryValues {
437
+ const values: Record<string, string | readonly string[]> = {};
438
+ for (const key of new Set(search.keys())) {
439
+ const all = search.getAll(key);
440
+ values[key] = all.length === 1 ? (all[0] ?? '') : all;
441
+ }
442
+ return values;
443
+ }
444
+
445
+ function declaredColumn(table: StudioTable, name: string): ColumnIR {
446
+ const column = table.schema.ir.columns.find(candidate => candidate.name === name);
447
+ if (column === undefined) {
448
+ throw new StudioRequestError(400, `undeclared column "${name}" on table "${table.schema.ir.table}"`);
449
+ }
450
+ return column;
451
+ }
452
+
453
+ function decodeKey(table: StudioTable, token: string): readonly Predicate[] {
454
+ if (table.schema.ir.primaryKey.length === 0) {
455
+ throw new StudioRequestError(400, `table "${table.schema.ir.table}" has no declared primary key`);
456
+ }
457
+ if (!hasBrowsableKey(table)) {
458
+ throw new StudioRequestError(400, `table "${table.schema.ir.table}" has no browser-safe declared key`);
459
+ }
460
+ let parsed: unknown;
461
+ try {
462
+ parsed = JSON.parse(decodePath(token));
463
+ } catch {
464
+ throw new StudioRequestError(400, `row key for table "${table.schema.ir.table}" is malformed`);
465
+ }
466
+ if (!isRecord(parsed)) {
467
+ throw new StudioRequestError(400, `row key for table "${table.schema.ir.table}" must be an object`);
468
+ }
469
+ const allowed = new Set(table.schema.ir.primaryKey);
470
+ for (const name of Object.keys(parsed)) {
471
+ if (!allowed.has(name)) {
472
+ throw new StudioRequestError(400, `row key names undeclared column "${name}"`);
473
+ }
474
+ }
475
+ return table.schema.ir.primaryKey.map(name => {
476
+ if (!Object.hasOwn(parsed, name)) {
477
+ throw new StudioRequestError(400, `row key is missing declared column "${name}"`);
478
+ }
479
+ const column = declaredColumn(table, name);
480
+ return { column, value: parsed[name] };
481
+ });
482
+ }
483
+
484
+ function isVisibleColumn(table: StudioTable, name: string): boolean {
485
+ return table.visibleColumns.some(column => column.name === name);
486
+ }
487
+
488
+ function encodeKey(table: StudioTable, row: Readonly<Record<string, unknown>>): string | undefined {
489
+ if (!hasBrowsableKey(table)) {
490
+ return undefined;
491
+ }
492
+ const key: Record<string, unknown> = {};
493
+ for (const name of table.schema.ir.primaryKey) {
494
+ const column = declaredColumn(table, name);
495
+ const value = rowValue(row, column);
496
+ if (value === undefined) {
497
+ return undefined;
498
+ }
499
+ key[name] = value;
500
+ }
501
+ return encodeURIComponent(
502
+ JSON.stringify(key, (_name, value: unknown) => (typeof value === 'bigint' ? value.toString() : value)),
503
+ );
504
+ }
505
+
506
+ function hasBrowsableKey(table: StudioTable): boolean {
507
+ return (
508
+ table.schema.ir.primaryKey.length > 0 && table.schema.ir.primaryKey.every(name => isVisibleColumn(table, name))
509
+ );
510
+ }
511
+
512
+ function rowValue(row: Readonly<Record<string, unknown>>, column: ColumnIR): unknown {
513
+ return Object.hasOwn(row, column.physicalName) ? row[column.physicalName] : row[column.name];
514
+ }
515
+
516
+ function countValue(value: unknown): number {
517
+ const count = typeof value === 'bigint' ? Number(value) : Number(value ?? 0);
518
+ if (!Number.isSafeInteger(count) || count < 0) {
519
+ throw new Error(`studio count query returned ${String(value)}`);
520
+ }
521
+ return count;
522
+ }
523
+
524
+ function unique(values: readonly string[]): readonly string[] {
525
+ return [...new Set(values)];
526
+ }
527
+
528
+ function pageCount(page: PageResult): number {
529
+ return Math.max(1, Math.ceil(page.total / page.options.pageSize));
530
+ }
531
+
532
+ function tablePath(schema: CoreSchema<string>): string {
533
+ return `/tables/${encodeURIComponent(schema.ir.table)}`;
534
+ }
535
+
536
+ function rowPath(schema: CoreSchema<string>, token: string): string {
537
+ return `${tablePath(schema)}/rows/${token}`;
538
+ }
539
+
540
+ function relationPath(schema: CoreSchema<string>, token: string, relation: string): string {
541
+ return `${rowPath(schema, token)}/relations/${encodeURIComponent(relation)}`;
542
+ }
543
+
544
+ function renderRows(table: StudioTable, rows: readonly Record<string, unknown>[]): string {
545
+ if (rows.length === 0) {
546
+ return '<p>No rows on this page.</p>';
547
+ }
548
+ const headers = table.visibleColumns.map(column => `<th scope="col">${escapeHtml(column.name)}</th>`).join('');
549
+ const body = rows
550
+ .map(row => {
551
+ const token = encodeKey(table, row);
552
+ const rowLink =
553
+ token === undefined ? '<span>no key</span>' : `<a href="${escapeHtml(rowPath(table.schema, token))}">view</a>`;
554
+ const cells = table.visibleColumns
555
+ .map(column => `<td>${escapeHtml(displayValue(rowValue(row, column)))}</td>`)
556
+ .join('');
557
+ return `<tr><td>${rowLink}</td>${cells}</tr>`;
558
+ })
559
+ .join('');
560
+ return `<div class="scroll"><table><thead><tr><th scope="col">row</th>${headers}</tr></thead><tbody>${body}</tbody></table></div>`;
561
+ }
562
+
563
+ function renderPageControls(base: string, page: PageResult): string {
564
+ const pages = pageCount(page);
565
+ const previous =
566
+ page.options.page > 1
567
+ ? `<a rel="prev" href="${escapeHtml(pageHref(base, page.options, page.options.page - 1))}">previous</a>`
568
+ : '<span>previous</span>';
569
+ const next =
570
+ page.options.page < pages
571
+ ? `<a rel="next" href="${escapeHtml(pageHref(base, page.options, page.options.page + 1))}">next</a>`
572
+ : '<span>next</span>';
573
+ return `<nav class="pages" aria-label="pagination">${previous}<span>page ${String(
574
+ page.options.page,
575
+ )} / ${String(pages)}</span>${next}</nav>`;
576
+ }
577
+
578
+ function pageHref(base: string, options: PageOptions, page: number): string {
579
+ const query = new URLSearchParams({
580
+ page: String(page),
581
+ pageSize: String(options.pageSize),
582
+ direction: options.direction,
583
+ });
584
+ if (options.orderByParameter !== undefined) {
585
+ query.set('orderBy', options.orderByParameter);
586
+ }
587
+ return `${base}?${query.toString()}`;
588
+ }
589
+
590
+ function breadcrumbs(items: readonly { readonly href?: string; readonly label: string }[]): string {
591
+ return `<nav class="crumbs" aria-label="breadcrumb">${items
592
+ .map(item =>
593
+ item.href === undefined
594
+ ? `<span>${escapeHtml(item.label)}</span>`
595
+ : `<a href="${escapeHtml(item.href)}">${escapeHtml(item.label)}</a>`,
596
+ )
597
+ .join('<span>/</span>')}</nav>`;
598
+ }
599
+
600
+ function displayValue(value: unknown): string {
601
+ if (value === null) return 'null';
602
+ if (value === undefined) return 'undefined';
603
+ if (value instanceof Date) return value.toISOString();
604
+ if (value instanceof Uint8Array) return `[${String(value.byteLength)} bytes]`;
605
+ if (typeof value === 'string') return value;
606
+ if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') {
607
+ return String(value);
608
+ }
609
+ try {
610
+ return JSON.stringify(value, (_name, item: unknown) => (typeof item === 'bigint' ? item.toString() : item));
611
+ } catch {
612
+ return String(value);
613
+ }
614
+ }
615
+
616
+ function htmlResponse(title: string, content: string, status = 200): WebResponse {
617
+ return respond({
618
+ status,
619
+ headers: HTML_HEADERS,
620
+ body:
621
+ '<!doctype html><html lang="en"><head><meta charset="utf-8">' +
622
+ '<meta name="viewport" content="width=device-width,initial-scale=1">' +
623
+ `<title>${escapeHtml(title)} · zmdb studio</title>` +
624
+ '<style>' +
625
+ ':root{color-scheme:light dark;font-family:ui-sans-serif,system-ui,sans-serif}' +
626
+ 'body{max-width:1100px;margin:0 auto;padding:2rem;line-height:1.5}' +
627
+ 'a{color:LinkText}.warning{border:1px solid #b36b00;padding:.75rem 1rem;border-radius:.4rem}' +
628
+ '.tables{padding:0;list-style:none}.tables li{display:flex;gap:1rem;padding:.5rem 0}' +
629
+ '.tables span{opacity:.7}.scroll{overflow:auto}table{border-collapse:collapse;width:100%}' +
630
+ 'th,td{border:1px solid GrayText;padding:.45rem;text-align:left;vertical-align:top}' +
631
+ 'dl{display:grid;grid-template-columns:minmax(8rem,15rem) 1fr;gap:.5rem}dt{font-weight:700}' +
632
+ '.crumbs,.pages{display:flex;gap:.65rem;align-items:center}.pages{justify-content:space-between;margin-top:1rem}' +
633
+ 'code{font-family:ui-monospace,monospace}' +
634
+ '</style></head><body>' +
635
+ '<p class="warning"><strong>Local raw-data viewer.</strong> Apart from columns declared ' +
636
+ '<code>Sensitive</code>, values are shown without masking. Do not point this at a production ' +
637
+ 'database expecting automatic redaction.</p>' +
638
+ `<main>${content}</main></body></html>`,
639
+ });
640
+ }
641
+
642
+ function escapeHtml(value: string): string {
643
+ return value.replace(/[&<>"']/g, character => {
644
+ switch (character) {
645
+ case '&':
646
+ return '&amp;';
647
+ case '<':
648
+ return '&lt;';
649
+ case '>':
650
+ return '&gt;';
651
+ case '"':
652
+ return '&quot;';
653
+ default:
654
+ return '&#39;';
655
+ }
656
+ });
657
+ }
658
+
659
+ function decodePath(value: string): string {
660
+ try {
661
+ return decodeURIComponent(value);
662
+ } catch {
663
+ throw new StudioRequestError(400, `malformed URL component "${value}"`);
664
+ }
665
+ }
666
+
667
+ function fetchResponse(response: WebResponse): Response {
668
+ const headers = new Headers(response.headers);
669
+ return new Response(response.body.value, { status: response.status, headers });
670
+ }
671
+
672
+ function messageOf(error: unknown): string {
673
+ return error instanceof Error ? error.message : String(error);
674
+ }