elm-ssr 0.6.2 → 0.90.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/bin/elm-ssr.mjs +75 -2
- package/elm-src/ElmSsr/Action.elm +19 -1
- package/elm-src/ElmSsr/Db/Dsl.elm +223 -0
- package/elm-src/ElmSsr/Loader.elm +33 -1
- package/elm-src/ElmSsr/Request/Decode.elm +478 -0
- package/elm-src/ElmSsr/Runtime.elm +3 -0
- package/lib/build.mjs +66 -1
- package/lib/query.mjs +464 -0
- package/lib/scaffold.mjs +156 -0
- package/package.json +1 -1
- package/src/app.ts +8 -2
- package/src/client-runtime/islands.ts +71 -4
- package/src/debugger.ts +535 -0
- package/src/effects.ts +8 -0
- package/src/request-handler.ts +38 -8
package/lib/query.mjs
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import { readdir, readFile, writeFile, mkdir, stat } from "node:fs/promises";
|
|
2
|
+
import { resolve, dirname, basename } from "node:path";
|
|
3
|
+
|
|
4
|
+
const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);
|
|
5
|
+
|
|
6
|
+
const snakeToCamel = (str) => {
|
|
7
|
+
return str.replace(/_([a-z])/g, (g) => g[1].toUpperCase());
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const singularize = (str) => {
|
|
11
|
+
if (str.endsWith("ies")) return str.slice(0, -3) + "y";
|
|
12
|
+
if (str.endsWith("es") && (str.endsWith("shes") || str.endsWith("ches") || str.endsWith("xes"))) return str.slice(0, -2);
|
|
13
|
+
if (str.endsWith("s") && !str.endsWith("ss")) return str.slice(0, -1);
|
|
14
|
+
return str;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const scanCreateTables = (sqlText) => {
|
|
18
|
+
const tables = [];
|
|
19
|
+
// Case-insensitive match for CREATE TABLE table_name (
|
|
20
|
+
const regex = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)\s*\(/gi;
|
|
21
|
+
let match;
|
|
22
|
+
|
|
23
|
+
while ((match = regex.exec(sqlText)) !== null) {
|
|
24
|
+
const tableName = match[1];
|
|
25
|
+
const startIndex = regex.lastIndex; // index after the opening '('
|
|
26
|
+
let parenDepth = 1;
|
|
27
|
+
let endIndex = -1;
|
|
28
|
+
|
|
29
|
+
for (let i = startIndex; i < sqlText.length; i++) {
|
|
30
|
+
const char = sqlText[i];
|
|
31
|
+
if (char === "(") parenDepth++;
|
|
32
|
+
if (char === ")") parenDepth--;
|
|
33
|
+
if (parenDepth === 0) {
|
|
34
|
+
endIndex = i;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (endIndex !== -1) {
|
|
40
|
+
const body = sqlText.slice(startIndex, endIndex);
|
|
41
|
+
tables.push({ name: tableName, body });
|
|
42
|
+
regex.lastIndex = endIndex + 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return tables;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const splitColumns = (body) => {
|
|
49
|
+
const cols = [];
|
|
50
|
+
let current = "";
|
|
51
|
+
let parenDepth = 0;
|
|
52
|
+
for (let i = 0; i < body.length; i++) {
|
|
53
|
+
const char = body[i];
|
|
54
|
+
if (char === "(") parenDepth++;
|
|
55
|
+
if (char === ")") parenDepth--;
|
|
56
|
+
if (char === "," && parenDepth === 0) {
|
|
57
|
+
cols.push(current.trim());
|
|
58
|
+
current = "";
|
|
59
|
+
} else {
|
|
60
|
+
current += char;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (current.trim()) {
|
|
64
|
+
cols.push(current.trim());
|
|
65
|
+
}
|
|
66
|
+
return cols;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const mapSqlTypeToElm = (sqlType) => {
|
|
70
|
+
switch (sqlType) {
|
|
71
|
+
case "INT":
|
|
72
|
+
case "INTEGER":
|
|
73
|
+
case "SERIAL":
|
|
74
|
+
case "BIGINT":
|
|
75
|
+
case "SMALLINT":
|
|
76
|
+
case "TINYINT":
|
|
77
|
+
return "Int";
|
|
78
|
+
case "REAL":
|
|
79
|
+
case "FLOAT":
|
|
80
|
+
case "DOUBLE":
|
|
81
|
+
case "DECIMAL":
|
|
82
|
+
case "NUMERIC":
|
|
83
|
+
return "Float";
|
|
84
|
+
case "BOOLEAN":
|
|
85
|
+
case "BOOL":
|
|
86
|
+
return "Bool";
|
|
87
|
+
case "TEXT":
|
|
88
|
+
case "VARCHAR":
|
|
89
|
+
case "CHAR":
|
|
90
|
+
case "UUID":
|
|
91
|
+
case "TIMESTAMP":
|
|
92
|
+
case "DATE":
|
|
93
|
+
case "TIME":
|
|
94
|
+
return "String";
|
|
95
|
+
default:
|
|
96
|
+
console.warn(`[elm-ssr query] Unknown SQL type "${sqlType}", falling back to String.`);
|
|
97
|
+
return "String";
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const parseColumnDefinition = (colStr) => {
|
|
102
|
+
const trimmed = colStr.replace(/\s+/g, " ").trim();
|
|
103
|
+
|
|
104
|
+
// Skip constraints
|
|
105
|
+
if (/^(?:PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CONSTRAINT|CHECK)\b/i.test(trimmed)) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const tokens = trimmed.split(" ");
|
|
110
|
+
if (tokens.length < 2) return null;
|
|
111
|
+
|
|
112
|
+
const rawName = tokens[0];
|
|
113
|
+
const dbName = rawName.replace(/[`"]/g, "");
|
|
114
|
+
const elmName = snakeToCamel(dbName);
|
|
115
|
+
|
|
116
|
+
const rawType = tokens[1].replace(/\([\s\S]*?\)/g, "").toUpperCase();
|
|
117
|
+
const elmType = mapSqlTypeToElm(rawType);
|
|
118
|
+
|
|
119
|
+
const isPrimaryKey = /\bPRIMARY\s+KEY\b/i.test(trimmed);
|
|
120
|
+
const isNullable = !/\bNOT\s+NULL\b/i.test(trimmed) && !isPrimaryKey;
|
|
121
|
+
const hasDefault = /\bDEFAULT\b/i.test(trimmed);
|
|
122
|
+
|
|
123
|
+
const isAutoIncrement = /\bAUTOINCREMENT\b/i.test(trimmed) ||
|
|
124
|
+
(isPrimaryKey && (rawType === "INTEGER" || rawType === "INT")) ||
|
|
125
|
+
rawType === "SERIAL";
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
dbName,
|
|
129
|
+
elmName,
|
|
130
|
+
elmType,
|
|
131
|
+
isNullable,
|
|
132
|
+
isPrimaryKey,
|
|
133
|
+
hasDefault,
|
|
134
|
+
isAutoIncrement
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const getFieldDecoder = (c) => {
|
|
139
|
+
let baseDec = "";
|
|
140
|
+
if (c.elmType === "Int") baseDec = "Decode.int";
|
|
141
|
+
else if (c.elmType === "Float") baseDec = "Decode.float";
|
|
142
|
+
else if (c.elmType === "Bool") baseDec = "boolDecoder";
|
|
143
|
+
else baseDec = "Decode.string";
|
|
144
|
+
|
|
145
|
+
if (c.isNullable) {
|
|
146
|
+
return `(Decode.field "${c.dbName}" (Decode.nullable ${baseDec}))`;
|
|
147
|
+
} else {
|
|
148
|
+
return `(Decode.field "${c.dbName}" ${baseDec})`;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const getEncoderExpr = (c, valName) => {
|
|
153
|
+
let baseEnc = "";
|
|
154
|
+
if (c.elmType === "Int") baseEnc = "Encode.int";
|
|
155
|
+
else if (c.elmType === "Float") baseEnc = "Encode.float";
|
|
156
|
+
else if (c.elmType === "Bool") baseEnc = "Encode.bool";
|
|
157
|
+
else baseEnc = "Encode.string";
|
|
158
|
+
|
|
159
|
+
if (c.isNullable) {
|
|
160
|
+
return `encodeNullable ${baseEnc} ${valName}`;
|
|
161
|
+
} else {
|
|
162
|
+
return `${baseEnc} ${valName}`;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const generatePipelineDecoder = (recordName, columns) => {
|
|
167
|
+
let code = "";
|
|
168
|
+
let indent = "";
|
|
169
|
+
for (let i = 0; i < columns.length; i++) {
|
|
170
|
+
const c = columns[i];
|
|
171
|
+
const dec = getFieldDecoder(c);
|
|
172
|
+
code += `${indent}${dec}\n${indent} |> Decode.andThen (\\${c.elmName} ->\n`;
|
|
173
|
+
indent += " ";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const fieldsAssignment = columns.map(c => `${c.elmName} = ${c.elmName}`).join(", ");
|
|
177
|
+
code += `${indent}Decode.succeed { ${fieldsAssignment} }\n`;
|
|
178
|
+
|
|
179
|
+
for (let i = 0; i < columns.length; i++) {
|
|
180
|
+
code += `)`;
|
|
181
|
+
}
|
|
182
|
+
return code;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const getDslEncoder = (elmType) => {
|
|
186
|
+
if (elmType === "Int") return "Encode.int";
|
|
187
|
+
if (elmType === "Float") return "Encode.float";
|
|
188
|
+
if (elmType === "Bool") return "Encode.bool";
|
|
189
|
+
return "Encode.string";
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const generateElmModule = (namespace, table) => {
|
|
193
|
+
const { moduleName, recordName, name: tableName, columns } = table;
|
|
194
|
+
const pk = columns.find(c => c.isPrimaryKey);
|
|
195
|
+
const hasBool = columns.some(c => c.elmType === "Bool");
|
|
196
|
+
const hasNullable = columns.some(c => c.isNullable);
|
|
197
|
+
|
|
198
|
+
let exports = [recordName, moduleName + "Table", "table", ...columns.map(c => c.elmName), "decoder", "all", "insert"];
|
|
199
|
+
if (pk) {
|
|
200
|
+
exports.push("byId", "delete", "update");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let code = `module ${namespace}.Db.${moduleName} exposing (${exports.join(", ")})
|
|
204
|
+
|
|
205
|
+
-- This module was automatically generated by elm-ssr query.
|
|
206
|
+
-- Do not edit this file manually.
|
|
207
|
+
|
|
208
|
+
import ElmSsr.Db.Dsl as Dsl exposing (Table, Column)
|
|
209
|
+
import Json.Decode as Decode exposing (Decoder)
|
|
210
|
+
import Json.Encode as Encode
|
|
211
|
+
import ElmSsr.Loader as Loader exposing (Loader)
|
|
212
|
+
|
|
213
|
+
type ${moduleName}Table
|
|
214
|
+
= ${moduleName}Table
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
table : Table ${moduleName}Table
|
|
218
|
+
table =
|
|
219
|
+
Dsl.table "${tableName}"
|
|
220
|
+
|
|
221
|
+
`;
|
|
222
|
+
|
|
223
|
+
// Column descriptors
|
|
224
|
+
for (const c of columns) {
|
|
225
|
+
code += `
|
|
226
|
+
${c.elmName} : Column ${moduleName}Table ${c.elmType}
|
|
227
|
+
${c.elmName} =
|
|
228
|
+
Dsl.column "${c.dbName}" ${getDslEncoder(c.elmType)}
|
|
229
|
+
|
|
230
|
+
`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
code += `\n`;
|
|
234
|
+
|
|
235
|
+
// Type alias
|
|
236
|
+
code += `type alias ${recordName} =\n { `;
|
|
237
|
+
const recordFields = columns.map(c => {
|
|
238
|
+
const typeStr = c.isNullable ? `Maybe ${c.elmType}` : c.elmType;
|
|
239
|
+
return `${c.elmName} : ${typeStr}`;
|
|
240
|
+
});
|
|
241
|
+
code += recordFields.join("\n , ") + "\n }\n\n";
|
|
242
|
+
|
|
243
|
+
// Local helpers
|
|
244
|
+
if (hasBool) {
|
|
245
|
+
code += `boolDecoder : Decoder Bool
|
|
246
|
+
boolDecoder =
|
|
247
|
+
Decode.oneOf
|
|
248
|
+
[ Decode.bool
|
|
249
|
+
, Decode.int |> Decode.map (\\val -> val /= 0)
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (hasNullable) {
|
|
257
|
+
code += `encodeNullable : (a -> Encode.Value) -> Maybe a -> Encode.Value
|
|
258
|
+
encodeNullable encoder maybeVal =
|
|
259
|
+
case maybeVal of
|
|
260
|
+
Just val ->
|
|
261
|
+
encoder val
|
|
262
|
+
|
|
263
|
+
Nothing ->
|
|
264
|
+
Encode.null
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Decoder
|
|
271
|
+
code += `decoder : Decoder ${recordName}
|
|
272
|
+
decoder =
|
|
273
|
+
`;
|
|
274
|
+
|
|
275
|
+
if (columns.length === 1) {
|
|
276
|
+
const c = columns[0];
|
|
277
|
+
const fieldDec = getFieldDecoder(c);
|
|
278
|
+
code += `Decode.map ${recordName}\n (${fieldDec})\n\n`;
|
|
279
|
+
} else if (columns.length <= 8) {
|
|
280
|
+
code += `Decode.map${columns.length} ${recordName}\n `;
|
|
281
|
+
const fields = columns.map(c => getFieldDecoder(c));
|
|
282
|
+
code += fields.join("\n ") + "\n\n";
|
|
283
|
+
} else {
|
|
284
|
+
code += generatePipelineDecoder(recordName, columns) + "\n\n";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Helpers: all
|
|
288
|
+
const selectFields = columns.map(c => c.dbName).join(", ");
|
|
289
|
+
code += `all : Loader (List ${recordName})
|
|
290
|
+
all =
|
|
291
|
+
Loader.query
|
|
292
|
+
{ sql = "SELECT ${selectFields} FROM ${tableName}"
|
|
293
|
+
, params = []
|
|
294
|
+
, decoder = decoder
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
`;
|
|
299
|
+
|
|
300
|
+
// Helpers: byId
|
|
301
|
+
if (pk) {
|
|
302
|
+
const pkEncoder = getEncoderExpr(pk, pk.elmName + "Val");
|
|
303
|
+
code += `byId : ${pk.elmType} -> Loader (Maybe ${recordName})
|
|
304
|
+
byId ${pk.elmName}Val =
|
|
305
|
+
Loader.queryOne
|
|
306
|
+
{ sql = "SELECT ${selectFields} FROM ${tableName} WHERE ${pk.dbName} = ?"
|
|
307
|
+
, params = [ ${pkEncoder} ]
|
|
308
|
+
, decoder = decoder
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Helpers: insert
|
|
316
|
+
const insertParams = columns.filter(c => !c.isAutoIncrement && !c.hasDefault);
|
|
317
|
+
const insertSqlFields = insertParams.map(c => c.dbName).join(", ");
|
|
318
|
+
const insertPlaceholders = insertParams.map(() => "?").join(", ");
|
|
319
|
+
|
|
320
|
+
if (insertParams.length > 0) {
|
|
321
|
+
const paramTypes = insertParams.map(c => {
|
|
322
|
+
const typeStr = c.isNullable ? `Maybe ${c.elmType}` : c.elmType;
|
|
323
|
+
return `${c.elmName} : ${typeStr}`;
|
|
324
|
+
});
|
|
325
|
+
const paramEncoders = insertParams.map(c => getEncoderExpr(c, `params.${c.elmName}`));
|
|
326
|
+
const encoderLines = paramEncoders.map((e, idx) => {
|
|
327
|
+
const prefix = idx === 0 ? "[ " : ", ";
|
|
328
|
+
return ` ${prefix}${e}`;
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
code += `insert : { ${paramTypes.join(", ")} } -> Loader { rowsAffected : Int }
|
|
332
|
+
insert params =
|
|
333
|
+
Loader.execute
|
|
334
|
+
{ sql = "INSERT INTO ${tableName} (${insertSqlFields}) VALUES (${insertPlaceholders})"
|
|
335
|
+
, params =
|
|
336
|
+
${encoderLines.join("\n")}
|
|
337
|
+
]
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
`;
|
|
342
|
+
} else {
|
|
343
|
+
code += `insert : Loader { rowsAffected : Int }
|
|
344
|
+
insert =
|
|
345
|
+
Loader.execute
|
|
346
|
+
{ sql = "INSERT INTO ${tableName} DEFAULT VALUES"
|
|
347
|
+
, params = []
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Helpers: delete
|
|
355
|
+
if (pk) {
|
|
356
|
+
const pkEncoder = getEncoderExpr(pk, pk.elmName + "Val");
|
|
357
|
+
code += `delete : ${pk.elmType} -> Loader { rowsAffected : Int }
|
|
358
|
+
delete ${pk.elmName}Val =
|
|
359
|
+
Loader.execute
|
|
360
|
+
{ sql = "DELETE FROM ${tableName} WHERE ${pk.dbName} = ?"
|
|
361
|
+
, params = [ ${pkEncoder} ]
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Helpers: update
|
|
369
|
+
if (pk) {
|
|
370
|
+
const updateParams = columns.filter(c => !c.isPrimaryKey);
|
|
371
|
+
if (updateParams.length > 0) {
|
|
372
|
+
const updateTypes = updateParams.map(c => {
|
|
373
|
+
const typeStr = c.isNullable ? `Maybe ${c.elmType}` : c.elmType;
|
|
374
|
+
return `${c.elmName} : ${typeStr}`;
|
|
375
|
+
});
|
|
376
|
+
const updateSets = updateParams.map(c => `${c.dbName} = ?`).join(", ");
|
|
377
|
+
const updateEncoders = updateParams.map(c => getEncoderExpr(c, `params.${c.elmName}`));
|
|
378
|
+
const pkEncoder = getEncoderExpr(pk, pk.elmName + "Val");
|
|
379
|
+
|
|
380
|
+
const encoderLines = [
|
|
381
|
+
...updateEncoders,
|
|
382
|
+
pkEncoder
|
|
383
|
+
].map((e, idx) => {
|
|
384
|
+
const prefix = idx === 0 ? "[ " : ", ";
|
|
385
|
+
return ` ${prefix}${e}`;
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
code += `update : ${pk.elmType} -> { ${updateTypes.join(", ")} } -> Loader { rowsAffected : Int }
|
|
389
|
+
update ${pk.elmName}Val params =
|
|
390
|
+
Loader.execute
|
|
391
|
+
{ sql = "UPDATE ${tableName} SET ${updateSets} WHERE ${pk.dbName} = ?"
|
|
392
|
+
, params =
|
|
393
|
+
${encoderLines.join("\n")}
|
|
394
|
+
]
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
`;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return code;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
export const generateQueries = async ({ rootPath, appConfig, migrationsDir, outputDir }) => {
|
|
406
|
+
const resolvedMigrationsDir = migrationsDir ? resolve(rootPath, migrationsDir) : resolve(rootPath, appConfig.root, "migrations");
|
|
407
|
+
const resolvedOutputDir = outputDir ? resolve(rootPath, outputDir) : resolve(rootPath, appConfig.root, "src", appConfig.module.split(".").join("/"), "Db");
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
await stat(resolvedMigrationsDir);
|
|
411
|
+
} catch {
|
|
412
|
+
throw new Error(`Migrations directory not found: ${resolvedMigrationsDir}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const files = await readdir(resolvedMigrationsDir);
|
|
416
|
+
const sqlFiles = files
|
|
417
|
+
.filter(f => f.endsWith(".sql") && !f.endsWith(".down.sql"))
|
|
418
|
+
.sort();
|
|
419
|
+
|
|
420
|
+
if (sqlFiles.length === 0) {
|
|
421
|
+
console.log(`[elm-ssr query] No SQL migration files found in ${resolvedMigrationsDir}`);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Map to store tables to prevent duplicates and keep the latest definition
|
|
426
|
+
const tablesMap = new Map();
|
|
427
|
+
|
|
428
|
+
for (const file of sqlFiles) {
|
|
429
|
+
const content = await readFile(resolve(resolvedMigrationsDir, file), "utf8");
|
|
430
|
+
const parsedTables = scanCreateTables(content);
|
|
431
|
+
|
|
432
|
+
for (const table of parsedTables) {
|
|
433
|
+
const columns = splitColumns(table.body)
|
|
434
|
+
.map(parseColumnDefinition)
|
|
435
|
+
.filter(Boolean);
|
|
436
|
+
|
|
437
|
+
if (columns.length === 0) continue;
|
|
438
|
+
|
|
439
|
+
const moduleName = capitalize(snakeToCamel(table.name));
|
|
440
|
+
const recordName = capitalize(singularize(snakeToCamel(table.name)));
|
|
441
|
+
|
|
442
|
+
tablesMap.set(table.name, {
|
|
443
|
+
name: table.name,
|
|
444
|
+
moduleName,
|
|
445
|
+
recordName,
|
|
446
|
+
columns
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (tablesMap.size === 0) {
|
|
452
|
+
console.log(`[elm-ssr query] No valid CREATE TABLE statements found in migrations.`);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
await mkdir(resolvedOutputDir, { recursive: true });
|
|
457
|
+
|
|
458
|
+
for (const table of tablesMap.values()) {
|
|
459
|
+
const fileContent = generateElmModule(appConfig.module, table);
|
|
460
|
+
const targetFile = resolve(resolvedOutputDir, `${table.moduleName}.elm`);
|
|
461
|
+
await writeFile(targetFile, fileContent, "utf8");
|
|
462
|
+
console.log(`[elm-ssr query] Generated Db helper at ${targetFile}`);
|
|
463
|
+
}
|
|
464
|
+
};
|
package/lib/scaffold.mjs
CHANGED
|
@@ -541,3 +541,159 @@ export const createAppScaffold = async (rootPath, name, options = {}) => {
|
|
|
541
541
|
|
|
542
542
|
/** @deprecated Use `createAppScaffold`. */
|
|
543
543
|
export const createExampleScaffold = createAppScaffold;
|
|
544
|
+
|
|
545
|
+
const parseRoutePath = (routePath) => {
|
|
546
|
+
const parts = routePath.split("/").map(p => p.trim()).filter(Boolean);
|
|
547
|
+
if (parts.length === 0) {
|
|
548
|
+
throw new Error(`Invalid route path: "${routePath}"`);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return parts.map(part => {
|
|
552
|
+
const clean = part.replace(/_+$/, "");
|
|
553
|
+
const underscores = part.slice(clean.length);
|
|
554
|
+
return toPascalCase(clean) + underscores;
|
|
555
|
+
});
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
export const createRouteScaffold = async (rootPath, appConfig, routePath, options = {}) => {
|
|
559
|
+
const parts = parseRoutePath(routePath);
|
|
560
|
+
const namespace = appConfig.module;
|
|
561
|
+
|
|
562
|
+
if (options.isWs || options.isSse) {
|
|
563
|
+
const endpointName = parts.join("");
|
|
564
|
+
const fileSubpath = `src/Endpoints/${parts.join("/")}.ts`;
|
|
565
|
+
const filePath = resolve(rootPath, appConfig.root, fileSubpath);
|
|
566
|
+
|
|
567
|
+
let content = "";
|
|
568
|
+
let type = "";
|
|
569
|
+
let instructions = "";
|
|
570
|
+
|
|
571
|
+
if (options.isWs) {
|
|
572
|
+
type = "WebSocket";
|
|
573
|
+
content = `export const handleWebSocket = (request: Request): Response => {
|
|
574
|
+
const upgradeHeader = request.headers.get("Upgrade");
|
|
575
|
+
if (!upgradeHeader || upgradeHeader !== "websocket") {
|
|
576
|
+
return new Response("Expected Upgrade: websocket", { status: 426 });
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const webSocketPair = new WebSocketPair();
|
|
580
|
+
const [client, server] = Object.values(webSocketPair);
|
|
581
|
+
|
|
582
|
+
server.accept();
|
|
583
|
+
server.addEventListener("message", (event) => {
|
|
584
|
+
console.log("WS received:", event.data);
|
|
585
|
+
server.send(JSON.stringify({ echo: event.data, time: new Date().toISOString() }));
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
server.addEventListener("close", () => {
|
|
589
|
+
console.log("WS connection closed");
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
return new Response(null, {
|
|
593
|
+
status: 101,
|
|
594
|
+
webSocket: client
|
|
595
|
+
});
|
|
596
|
+
};
|
|
597
|
+
`;
|
|
598
|
+
instructions = `1. Import this handler in your worker entrypoint (${appConfig.root}/worker.ts or runtime.ts):
|
|
599
|
+
import { handleWebSocket } from "./src/Endpoints/${parts.join("/")}";
|
|
600
|
+
|
|
601
|
+
2. Intercept the request in your fetch handler:
|
|
602
|
+
if (url.pathname === "/${routePath}") {
|
|
603
|
+
return handleWebSocket(request);
|
|
604
|
+
}`;
|
|
605
|
+
} else {
|
|
606
|
+
type = "Server-Sent Events (SSE)";
|
|
607
|
+
content = `import { createSseStream } from "elm-ssr/sse";
|
|
608
|
+
|
|
609
|
+
export const handleSse = (request: Request): Response => {
|
|
610
|
+
return createSseStream(request, async (send, signal) => {
|
|
611
|
+
let count = 0;
|
|
612
|
+
while (!signal.aborted && count < 100) {
|
|
613
|
+
count += 1;
|
|
614
|
+
send(JSON.stringify({ event: "tick", count, time: new Date().toISOString() }));
|
|
615
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
};
|
|
619
|
+
`;
|
|
620
|
+
instructions = `1. Import this handler in your worker entrypoint (${appConfig.root}/worker.ts or runtime.ts):
|
|
621
|
+
import { handleSse } from "./src/Endpoints/${parts.join("/")}";
|
|
622
|
+
|
|
623
|
+
2. Intercept the request in your fetch handler:
|
|
624
|
+
if (url.pathname === "/${routePath}") {
|
|
625
|
+
return handleSse(request);
|
|
626
|
+
}`;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
630
|
+
await writeFile(filePath, content, "utf8");
|
|
631
|
+
|
|
632
|
+
return { type, path: fileSubpath, instructions };
|
|
633
|
+
} else {
|
|
634
|
+
const moduleName = parts.join(".");
|
|
635
|
+
const fileSubpath = `src/${namespace.split(".").join("/")}/Routes/${parts.join("/")}.elm`;
|
|
636
|
+
const filePath = resolve(rootPath, appConfig.root, fileSubpath);
|
|
637
|
+
|
|
638
|
+
let content = "";
|
|
639
|
+
let type = "";
|
|
640
|
+
|
|
641
|
+
if (options.isApi) {
|
|
642
|
+
type = "Elm JSON API";
|
|
643
|
+
content = `module ${namespace}.Routes.${moduleName} exposing (page, action)
|
|
644
|
+
|
|
645
|
+
import ElmSsr.Action as Action exposing (Action)
|
|
646
|
+
import ElmSsr.Document exposing (Document)
|
|
647
|
+
import ElmSsr.Loader as Loader exposing (Loader)
|
|
648
|
+
import ElmSsr.Route exposing (Request)
|
|
649
|
+
import Json.Encode as Encode
|
|
650
|
+
|
|
651
|
+
page : Request -> Loader (Document Never)
|
|
652
|
+
page _ =
|
|
653
|
+
Loader.fail 405 "GET not allowed on this API route"
|
|
654
|
+
|
|
655
|
+
action : Request -> Action (Document Never)
|
|
656
|
+
action request =
|
|
657
|
+
-- Process request and return JSON response
|
|
658
|
+
Action.json <|
|
|
659
|
+
Encode.object
|
|
660
|
+
[ ( "ok", Encode.bool True )
|
|
661
|
+
, ( "message", Encode.string "Hello from ${routePath} API route!" )
|
|
662
|
+
]
|
|
663
|
+
`;
|
|
664
|
+
} else {
|
|
665
|
+
type = "Elm Page";
|
|
666
|
+
content = `module ${namespace}.Routes.${moduleName} exposing (page, action)
|
|
667
|
+
|
|
668
|
+
import ElmSsr.Action as Action exposing (Action)
|
|
669
|
+
import ElmSsr.Document exposing (Document)
|
|
670
|
+
import ElmSsr.Html exposing (div, text)
|
|
671
|
+
import ElmSsr.Loader as Loader exposing (Loader)
|
|
672
|
+
import ElmSsr.Page as Page
|
|
673
|
+
import ElmSsr.Route exposing (Request)
|
|
674
|
+
import ${namespace}.View.Shared as Shared
|
|
675
|
+
|
|
676
|
+
page : Request -> Loader (Document Never)
|
|
677
|
+
page _ =
|
|
678
|
+
Loader.succeed view
|
|
679
|
+
|
|
680
|
+
action : Request -> Action (Document Never)
|
|
681
|
+
action _ =
|
|
682
|
+
Action.fail 405 "Method not allowed"
|
|
683
|
+
|
|
684
|
+
view : Document Never
|
|
685
|
+
view =
|
|
686
|
+
Page.page
|
|
687
|
+
{ title = "${parts[parts.length - 1]}"
|
|
688
|
+
, head = Shared.head
|
|
689
|
+
, body = [ div [] [ text "Hello from ${routePath}!" ] ]
|
|
690
|
+
}
|
|
691
|
+
`;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
695
|
+
await writeFile(filePath, content, "utf8");
|
|
696
|
+
|
|
697
|
+
return { type, path: fileSubpath };
|
|
698
|
+
}
|
|
699
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "elm-ssr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.90.0",
|
|
4
4
|
"description": "Elm-first SSR library and framework for Cloudflare Workers (and Bun): file-based routes/islands, backend-neutral effect adapters (KV/D1/Redis/Postgres), background tasks (waitUntil/Queues), SQL-file migrations, CLI scaffold + build.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Michał Majchrzak <michmajchrzak@gmail.com>",
|
package/src/app.ts
CHANGED
|
@@ -50,6 +50,7 @@ export interface WorkerAppOptions {
|
|
|
50
50
|
* skip paths.
|
|
51
51
|
*/
|
|
52
52
|
csrf?: CsrfMiddlewareOptions | boolean;
|
|
53
|
+
debug?: boolean;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
export const createWorkerApp = ({
|
|
@@ -62,8 +63,12 @@ export const createWorkerApp = ({
|
|
|
62
63
|
effects,
|
|
63
64
|
log,
|
|
64
65
|
sessions,
|
|
65
|
-
csrf
|
|
66
|
+
csrf,
|
|
67
|
+
debug
|
|
66
68
|
}: WorkerAppOptions): WorkerHandler => {
|
|
69
|
+
const isDev = typeof process !== "undefined" && process.env ? (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test") : false;
|
|
70
|
+
const enableDebug = debug ?? isDev;
|
|
71
|
+
|
|
67
72
|
const runner: EffectRunner = effects ?? defaultEffectRunner;
|
|
68
73
|
const effectsWithSessions = sessions ? sessionEffects(runner) : runner;
|
|
69
74
|
|
|
@@ -74,7 +79,8 @@ export const createWorkerApp = ({
|
|
|
74
79
|
stylesheet,
|
|
75
80
|
routes,
|
|
76
81
|
createFlags,
|
|
77
|
-
effects: effectsWithSessions
|
|
82
|
+
effects: effectsWithSessions,
|
|
83
|
+
debug: enableDebug
|
|
78
84
|
});
|
|
79
85
|
|
|
80
86
|
const middlewares = [
|