elm-ssr 0.6.1 → 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 CHANGED
@@ -3,10 +3,11 @@
3
3
  import { readFile, stat } from "node:fs/promises";
4
4
  import { watch } from "node:fs";
5
5
  import { dirname, resolve } from "node:path";
6
- import { createAppScaffold } from "../lib/scaffold.mjs";
6
+ import { createAppScaffold, createRouteScaffold } from "../lib/scaffold.mjs";
7
7
  import { readWorkspaceConfig } from "../lib/workspace.mjs";
8
8
  import { build } from "../lib/build.mjs";
9
9
  import { migrate } from "../lib/migrate.mjs";
10
+ import { generateQueries } from "../lib/query.mjs";
10
11
 
11
12
  const defaultRootPath = process.cwd();
12
13
  const args = process.argv.slice(2);
@@ -36,7 +37,14 @@ const findWorkspaceRoot = async (startPath) => {
36
37
  };
37
38
 
38
39
  const userRoot = findFlagValue("--root");
39
- const rootPath = resolve(userRoot ?? await findWorkspaceRoot(defaultRootPath));
40
+ let rootPath;
41
+ if (userRoot) {
42
+ rootPath = resolve(userRoot);
43
+ } else if (["new", "init", "migrate", "help"].includes(command)) {
44
+ rootPath = defaultRootPath;
45
+ } else {
46
+ rootPath = resolve(await findWorkspaceRoot(defaultRootPath));
47
+ }
40
48
  const packageJsonPath = resolve(rootPath, "package.json");
41
49
 
42
50
  let packageJson = { name: "unknown" };
@@ -72,6 +80,8 @@ const printHelp = () => {
72
80
  new <name> Create a new app at <workspace>/<name>/ (or <workspace>/<subdir>/<name>/
73
81
  with --in <subdir>) and register it in elm-ssr.config.json
74
82
  routes Print configured apps and their public modules
83
+ route <path> Scaffold a new route (standard HTML, --api JSON, --ws WebSocket, or --sse EventSource)
84
+ query Generate type-safe Elm Db modules from SQL table definitions
75
85
  info Print current workspace package and configured app names
76
86
  migrate ... Apply / revert / inspect SQL migrations (see: elm-ssr migrate --help)
77
87
  `);
@@ -128,7 +138,7 @@ switch (command) {
128
138
  const srcDir = resolve(rootPath, app.root, "src");
129
139
  try {
130
140
  const watcher = watch(srcDir, { recursive: true }, (eventType, filename) => {
131
- if (filename && filename.endsWith(".elm")) {
141
+ if (filename && (filename.endsWith(".elm") || filename.endsWith(".css"))) {
132
142
  triggerBuild();
133
143
  }
134
144
  });
@@ -218,12 +228,82 @@ switch (command) {
218
228
  }
219
229
  break;
220
230
 
231
+ case "route": {
232
+ requireConfig();
233
+ const routePath = args[1];
234
+ if (!routePath) {
235
+ console.error("Usage: elm-ssr route <path> [--app <app-name>] [--api] [--ws] [--sse]");
236
+ process.exit(1);
237
+ }
238
+
239
+ const appName = findFlagValue("--app");
240
+ let appConfig;
241
+ if (appName) {
242
+ appConfig = config.apps.find(a => a.name === appName);
243
+ if (!appConfig) {
244
+ console.error(`Error: App "${appName}" not found in elm-ssr.config.json`);
245
+ process.exit(1);
246
+ }
247
+ } else {
248
+ if (config.apps.length === 1) {
249
+ appConfig = config.apps[0];
250
+ } else {
251
+ console.error("Error: Multiple apps found in workspace config. Please specify which app to add the route to using --app <app-name>");
252
+ console.error("Available apps:", config.apps.map(a => a.name).join(", "));
253
+ process.exit(1);
254
+ }
255
+ }
256
+
257
+ const isApi = args.includes("--api");
258
+ const isWs = args.includes("--ws") || args.includes("--websocket");
259
+ const isSse = args.includes("--sse");
260
+
261
+ const result = await createRouteScaffold(rootPath, appConfig, routePath, { isApi, isWs, isSse });
262
+ console.log(`Scaffolded ${result.type} route at ${result.path}`);
263
+ if (result.instructions) {
264
+ console.log("\nInstructions to wire it up:\n" + result.instructions);
265
+ }
266
+ break;
267
+ }
268
+
221
269
  case "info":
222
270
  requireConfig();
223
271
  console.log(`workspace: ${packageJson.name}`);
224
272
  console.log(`apps: ${config.apps.map((app) => app.name).join(", ")}`);
225
273
  break;
226
274
 
275
+ case "query": {
276
+ requireConfig();
277
+ const appName = findFlagValue("--app");
278
+ let appConfig;
279
+ if (appName) {
280
+ appConfig = config.apps.find(a => a.name === appName);
281
+ if (!appConfig) {
282
+ console.error(`Error: App "${appName}" not found in elm-ssr.config.json`);
283
+ process.exit(1);
284
+ }
285
+ } else {
286
+ if (config.apps.length === 1) {
287
+ appConfig = config.apps[0];
288
+ } else {
289
+ console.error("Error: Multiple apps found in workspace config. Please specify which app to query using --app <app-name>");
290
+ console.error("Available apps:", config.apps.map(a => a.name).join(", "));
291
+ process.exit(1);
292
+ }
293
+ }
294
+
295
+ const migrationsDir = findFlagValue("--dir");
296
+ const outputDir = findFlagValue("--output");
297
+
298
+ try {
299
+ await generateQueries({ rootPath, appConfig, migrationsDir, outputDir });
300
+ } catch (err) {
301
+ console.error(`Error generating query modules: ${err.message}`);
302
+ process.exit(1);
303
+ }
304
+ break;
305
+ }
306
+
227
307
  case "migrate":
228
308
  await migrate(args.slice(1));
229
309
  break;
@@ -1,6 +1,6 @@
1
1
  module ElmSsr.Action exposing
2
2
  ( Action
3
- , succeed, fail, redirect, json
3
+ , succeed, fail, redirect, json, requireUser
4
4
  , map, andThen, fromLoader
5
5
  , Cookie, SameSite(..), setCookie, clearCookie, defaultCookie, sessionCookie
6
6
  , Effect, Step(..), step, collectCookies, encodeStep, encodeCookies
@@ -225,6 +225,21 @@ json =
225
225
  JsonResult
226
226
 
227
227
 
228
+ {-| Require a user session for an action. If missing, redirect to the login path. -}
229
+ requireUser : Decode.Decoder user -> String -> (user -> Action a) -> Action a
230
+ requireUser userDecoder loginPath toAction =
231
+ fromLoader (Loader.session userDecoder)
232
+ |> andThen
233
+ (\maybeUser ->
234
+ case maybeUser of
235
+ Just user ->
236
+ toAction user
237
+
238
+ Nothing ->
239
+ redirect loginPath
240
+ )
241
+
242
+
228
243
  {-| Transform the value an action resolves to. -}
229
244
  map : (a -> b) -> Action a -> Action b
230
245
  map fn action =
@@ -284,6 +299,9 @@ fromLoader loader =
284
299
  Loader.Errored status message ->
285
300
  Failed status message
286
301
 
302
+ Loader.Moved url ->
303
+ Redirect url
304
+
287
305
  Loader.Await effect continue ->
288
306
  Pending effect (\value -> fromLoader (continue value))
289
307
 
@@ -0,0 +1,223 @@
1
+ module ElmSsr.Db.Dsl exposing
2
+ ( Table, table
3
+ , Column, column
4
+ , AnyColumn, col
5
+ , Expression
6
+ , Query
7
+ , select, selectAll
8
+ , where_, limit
9
+ , eq, neq, gt, gte, lt, lte, like, inList
10
+ , isNull, isNotNull
11
+ , and, or
12
+ , toLoader, toLoaderOne
13
+ , compileQuery
14
+ )
15
+
16
+ import Json.Decode as Decode exposing (Decoder)
17
+ import Json.Encode as Encode
18
+ import ElmSsr.Loader as Loader exposing (Loader)
19
+
20
+
21
+ type Table table
22
+ = Table String
23
+
24
+
25
+ table : String -> Table table
26
+ table name =
27
+ Table name
28
+
29
+
30
+ type Column table val
31
+ = Column String (val -> Encode.Value)
32
+
33
+
34
+ column : String -> (val -> Encode.Value) -> Column table val
35
+ column name encoder =
36
+ Column name encoder
37
+
38
+
39
+ type AnyColumn table
40
+ = AnyColumn String
41
+
42
+
43
+ col : Column table val -> AnyColumn table
44
+ col (Column name _) =
45
+ AnyColumn name
46
+
47
+
48
+ type Expression table
49
+ = Expression String (List Encode.Value)
50
+
51
+
52
+ type Query table a
53
+ = Query
54
+ { tableName : String
55
+ , selection : List String
56
+ , conditions : List (Expression table)
57
+ , limitVal : Maybe Int
58
+ , decoder : Decoder a
59
+ }
60
+
61
+
62
+ eq : val -> Column table val -> Expression table
63
+ eq val (Column colName encoder) =
64
+ Expression (colName ++ " = ?") [ encoder val ]
65
+
66
+
67
+ neq : val -> Column table val -> Expression table
68
+ neq val (Column colName encoder) =
69
+ Expression (colName ++ " != ?") [ encoder val ]
70
+
71
+
72
+ gt : val -> Column table val -> Expression table
73
+ gt val (Column colName encoder) =
74
+ Expression (colName ++ " > ?") [ encoder val ]
75
+
76
+
77
+ gte : val -> Column table val -> Expression table
78
+ gte val (Column colName encoder) =
79
+ Expression (colName ++ " >= ?") [ encoder val ]
80
+
81
+
82
+ lt : val -> Column table val -> Expression table
83
+ lt val (Column colName encoder) =
84
+ Expression (colName ++ " < ?") [ encoder val ]
85
+
86
+
87
+ lte : val -> Column table val -> Expression table
88
+ lte val (Column colName encoder) =
89
+ Expression (colName ++ " <= ?") [ encoder val ]
90
+
91
+
92
+ like : String -> Column table val -> Expression table
93
+ like search (Column colName _) =
94
+ Expression (colName ++ " LIKE ?") [ Encode.string search ]
95
+
96
+
97
+ inList : List val -> Column table val -> Expression table
98
+ inList values (Column colName encoder) =
99
+ let
100
+ placeholders =
101
+ List.map (\_ -> "?") values |> String.join ", "
102
+ in
103
+ Expression (colName ++ " IN (" ++ placeholders ++ ")") (List.map encoder values)
104
+
105
+
106
+ isNull : Column table val -> Expression table
107
+ isNull (Column colName _) =
108
+ Expression (colName ++ " IS NULL") []
109
+
110
+
111
+ isNotNull : Column table val -> Expression table
112
+ isNotNull (Column colName _) =
113
+ Expression (colName ++ " IS NOT NULL") []
114
+
115
+
116
+ and : Expression table -> Expression table -> Expression table
117
+ and (Expression sql1 params1) (Expression sql2 params2) =
118
+ Expression ("(" ++ sql1 ++ " AND " ++ sql2 ++ ")") (params1 ++ params2)
119
+
120
+
121
+ or : Expression table -> Expression table -> Expression table
122
+ or (Expression sql1 params1) (Expression sql2 params2) =
123
+ Expression ("(" ++ sql1 ++ " OR " ++ sql2 ++ ")") (params1 ++ params2)
124
+
125
+
126
+ selectAll : Table table -> Decoder a -> Query table a
127
+ selectAll (Table tableName) dec =
128
+ Query
129
+ { tableName = tableName
130
+ , selection = []
131
+ , conditions = []
132
+ , limitVal = Nothing
133
+ , decoder = dec
134
+ }
135
+
136
+
137
+ select : Table table -> List (AnyColumn table) -> Decoder a -> Query table a
138
+ select (Table tableName) columns dec =
139
+ let
140
+ selects =
141
+ List.map (\(AnyColumn colName) -> colName) columns
142
+ in
143
+ Query
144
+ { tableName = tableName
145
+ , selection = selects
146
+ , conditions = []
147
+ , limitVal = Nothing
148
+ , decoder = dec
149
+ }
150
+
151
+
152
+ where_ : Expression table -> Query table a -> Query table a
153
+ where_ cond (Query q) =
154
+ Query { q | conditions = q.conditions ++ [ cond ] }
155
+
156
+
157
+ limit : Int -> Query table a -> Query table a
158
+ limit val (Query q) =
159
+ Query { q | limitVal = Just val }
160
+
161
+
162
+ compileQuery : Query table a -> { sql : String, params : List Encode.Value, decoder : Decoder a }
163
+ compileQuery (Query q) =
164
+ let
165
+ selectsStr =
166
+ if List.isEmpty q.selection then
167
+ "*"
168
+
169
+ else
170
+ String.join ", " q.selection
171
+
172
+ baseSql =
173
+ "SELECT " ++ selectsStr ++ " FROM " ++ q.tableName
174
+
175
+ ( whereSql, params ) =
176
+ if List.isEmpty q.conditions then
177
+ ( "", [] )
178
+
179
+ else
180
+ let
181
+ sqls =
182
+ List.map (\(Expression sql _) -> sql) q.conditions
183
+ |> String.join " AND"
184
+ -- Note: we join multi-where conditions with AND
185
+ -- and wrap the expressions in parentheses if needed,
186
+ -- but a simple join with AND works since operators have higher precedence.
187
+
188
+ vals =
189
+ List.map (\(Expression _ valsList) -> valsList) q.conditions
190
+ |> List.concat
191
+ in
192
+ ( " WHERE " ++ sqls, vals )
193
+
194
+ limitSql =
195
+ case q.limitVal of
196
+ Just l ->
197
+ " LIMIT " ++ String.fromInt l
198
+
199
+ Nothing ->
200
+ ""
201
+ in
202
+ { sql = baseSql ++ whereSql ++ limitSql
203
+ , params = params
204
+ , decoder = q.decoder
205
+ }
206
+
207
+
208
+ toLoader : Query table a -> Loader (List a)
209
+ toLoader q =
210
+ let
211
+ c =
212
+ compileQuery q
213
+ in
214
+ Loader.query { sql = c.sql, params = c.params, decoder = c.decoder }
215
+
216
+
217
+ toLoaderOne : Query table a -> Loader (Maybe a)
218
+ toLoaderOne q =
219
+ let
220
+ c =
221
+ compileQuery q
222
+ in
223
+ Loader.queryOne { sql = c.sql, params = c.params, decoder = c.decoder }
@@ -1,6 +1,6 @@
1
1
  module ElmSsr.Loader exposing
2
2
  ( Loader
3
- , succeed, fail
3
+ , succeed, fail, redirect, requireUser
4
4
  , map, map2, andThen
5
5
  , fetchJson
6
6
  , cacheGet, cachePut
@@ -89,6 +89,7 @@ import Json.Encode as Encode
89
89
  type Loader a
90
90
  = Done a
91
91
  | Failed Int String
92
+ | Redirect String
92
93
  | Pending Effect (Decode.Value -> Loader a)
93
94
 
94
95
 
@@ -113,6 +114,27 @@ fail status message =
113
114
  Failed status message
114
115
 
115
116
 
117
+ {-| Redirect the browser progressively to a same-origin or external URL. -}
118
+ redirect : String -> Loader a
119
+ redirect url =
120
+ Redirect url
121
+
122
+
123
+ {-| Require a user session. If missing, redirect to the login path. -}
124
+ requireUser : Decoder user -> String -> (user -> Loader a) -> Loader a
125
+ requireUser userDecoder loginPath toLoader =
126
+ session userDecoder
127
+ |> andThen
128
+ (\maybeUser ->
129
+ case maybeUser of
130
+ Just user ->
131
+ toLoader user
132
+
133
+ Nothing ->
134
+ redirect loginPath
135
+ )
136
+
137
+
116
138
  {-| Transform the value a loader resolves to. -}
117
139
  map : (a -> b) -> Loader a -> Loader b
118
140
  map fn loader =
@@ -123,6 +145,9 @@ map fn loader =
123
145
  Failed status message ->
124
146
  Failed status message
125
147
 
148
+ Redirect url ->
149
+ Redirect url
150
+
126
151
  Pending effect continue ->
127
152
  Pending effect (\value -> map fn (continue value))
128
153
 
@@ -139,6 +164,9 @@ andThen fn loader =
139
164
  Failed status message ->
140
165
  Failed status message
141
166
 
167
+ Redirect url ->
168
+ Redirect url
169
+
142
170
  Pending effect continue ->
143
171
  Pending effect (\value -> andThen fn (continue value))
144
172
 
@@ -537,6 +565,7 @@ the effect result.
537
565
  type Step a
538
566
  = Resolved a
539
567
  | Errored Int String
568
+ | Moved String
540
569
  | Await Effect (Decode.Value -> Loader a)
541
570
 
542
571
 
@@ -550,6 +579,9 @@ step loader =
550
579
  Failed status message ->
551
580
  Errored status message
552
581
 
582
+ Redirect url ->
583
+ Moved url
584
+
553
585
  Pending effect continue ->
554
586
  Await effect continue
555
587