bonescript-compiler 0.5.8 → 0.6.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.
- package/dist/ast.d.ts +2 -0
- package/dist/cli.js +52 -8
- package/dist/cli.js.map +1 -1
- package/dist/emit_admin.d.ts +5 -0
- package/dist/emit_admin.js +340 -35
- package/dist/emit_admin.js.map +1 -1
- package/dist/emit_audit.js +38 -4
- package/dist/emit_audit.js.map +1 -1
- package/dist/emit_capability.js +14 -0
- package/dist/emit_capability.js.map +1 -1
- package/dist/emit_full.js +10 -2
- package/dist/emit_full.js.map +1 -1
- package/dist/emit_maintenance.js +35 -3
- package/dist/emit_maintenance.js.map +1 -1
- package/dist/emit_nakama.js +36 -36
- package/dist/emit_notify.js +30 -2
- package/dist/emit_notify.js.map +1 -1
- package/dist/emit_runtime.d.ts +18 -1
- package/dist/emit_runtime.js +265 -85
- package/dist/emit_runtime.js.map +1 -1
- package/dist/emit_websocket.js +22 -2
- package/dist/emit_websocket.js.map +1 -1
- package/dist/emit_zod.js +12 -1
- package/dist/emit_zod.js.map +1 -1
- package/dist/formatter.d.ts +1 -0
- package/dist/formatter.js +10 -2
- package/dist/formatter.js.map +1 -1
- package/dist/ir.d.ts +2 -0
- package/dist/lexer.d.ts +1 -0
- package/dist/lexer.js +4 -0
- package/dist/lexer.js.map +1 -1
- package/dist/lowering.js +2 -0
- package/dist/lowering.js.map +1 -1
- package/dist/parse_decls.js +36 -1
- package/dist/parse_decls.js.map +1 -1
- package/dist/typechecker.js +9 -0
- package/dist/typechecker.js.map +1 -1
- package/package.json +1 -1
- package/src/ast.ts +2 -0
- package/src/cli.ts +58 -10
- package/src/emit_admin.ts +342 -35
- package/src/emit_audit.ts +40 -4
- package/src/emit_capability.ts +13 -0
- package/src/emit_full.ts +9 -2
- package/src/emit_maintenance.ts +35 -3
- package/src/emit_nakama.ts +576 -576
- package/src/emit_notify.ts +30 -2
- package/src/emit_runtime.ts +955 -763
- package/src/emit_websocket.ts +22 -2
- package/src/emit_zod.ts +11 -1
- package/src/formatter.ts +9 -2
- package/src/ir.ts +2 -0
- package/src/lexer.ts +2 -0
- package/src/lowering.ts +5 -3
- package/src/parse_decls.ts +31 -1
- package/src/typechecker.ts +10 -0
package/src/emit_runtime.ts
CHANGED
|
@@ -1,763 +1,955 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* BoneScript Runtime Code Emitter
|
|
3
|
-
* Generates runnable service code from IR.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import * as IR from "./ir";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
"@types/
|
|
68
|
-
"@types/
|
|
69
|
-
"@types/
|
|
70
|
-
"@types/
|
|
71
|
-
|
|
72
|
-
"
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
"
|
|
113
|
-
"
|
|
114
|
-
"
|
|
115
|
-
|
|
116
|
-
"
|
|
117
|
-
"
|
|
118
|
-
"
|
|
119
|
-
"}",
|
|
120
|
-
"
|
|
121
|
-
"",
|
|
122
|
-
"
|
|
123
|
-
"
|
|
124
|
-
"
|
|
125
|
-
"
|
|
126
|
-
"export async function
|
|
127
|
-
" try { const
|
|
128
|
-
" catch (e: any) { throw new Error(`DB query failed: ${e.message}`); }",
|
|
129
|
-
"}",
|
|
130
|
-
"export async function
|
|
131
|
-
" try { const
|
|
132
|
-
" catch (e: any) { throw new Error(`DB
|
|
133
|
-
"}",
|
|
134
|
-
"export async function
|
|
135
|
-
" const
|
|
136
|
-
"
|
|
137
|
-
"
|
|
138
|
-
"
|
|
139
|
-
"
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
lines.push(`
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
//
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
lines.push(
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
lines.push(`
|
|
296
|
-
|
|
297
|
-
lines.push(
|
|
298
|
-
lines.push(`
|
|
299
|
-
lines.push(` }
|
|
300
|
-
lines.push(`
|
|
301
|
-
lines.push(
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
lines.push(
|
|
306
|
-
lines.push(
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
lines.push(`
|
|
328
|
-
lines.push(
|
|
329
|
-
lines.push(`
|
|
330
|
-
lines.push(`
|
|
331
|
-
lines.push(`});`);
|
|
332
|
-
lines.push(
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
lines.push(
|
|
336
|
-
lines.push(
|
|
337
|
-
lines.push(
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
lines.push(`
|
|
363
|
-
lines.push(`
|
|
364
|
-
lines.push(`
|
|
365
|
-
lines.push(` res.status(
|
|
366
|
-
lines.push(` }
|
|
367
|
-
lines.push(`
|
|
368
|
-
lines.push(
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
}
|
|
430
|
-
lines.push(
|
|
431
|
-
lines.push(`
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
lines.push(`
|
|
502
|
-
lines.push(`
|
|
503
|
-
lines.push(`
|
|
504
|
-
lines.push(`
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
lines.push(`
|
|
509
|
-
lines.push(`
|
|
510
|
-
lines.push(`
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
//
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
lines.push(
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
);
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
lines.push(
|
|
601
|
-
lines.push(
|
|
602
|
-
|
|
603
|
-
lines.push(`
|
|
604
|
-
lines.push(`
|
|
605
|
-
lines.push(`
|
|
606
|
-
lines.push(`
|
|
607
|
-
lines.push(`
|
|
608
|
-
lines.push(`
|
|
609
|
-
lines.push(`}
|
|
610
|
-
lines.push(`
|
|
611
|
-
lines.push(
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
lines.push(
|
|
624
|
-
lines.push(
|
|
625
|
-
lines.push(`
|
|
626
|
-
lines.push(`
|
|
627
|
-
lines.push(`
|
|
628
|
-
lines.push(`
|
|
629
|
-
lines.push(`
|
|
630
|
-
lines.push(
|
|
631
|
-
|
|
632
|
-
lines.push(
|
|
633
|
-
lines.push(`
|
|
634
|
-
lines.push(
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
if (hasWebSocket) {
|
|
646
|
-
lines.push(
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
lines.push(
|
|
663
|
-
lines.push(
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
lines.push(
|
|
673
|
-
lines.push(`
|
|
674
|
-
lines.push(
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
lines.push(
|
|
678
|
-
lines.push(`
|
|
679
|
-
lines.push(`
|
|
680
|
-
lines.push(`
|
|
681
|
-
lines.push(`
|
|
682
|
-
lines.push(`
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
lines.push(`
|
|
687
|
-
lines.push(
|
|
688
|
-
|
|
689
|
-
lines.push(
|
|
690
|
-
lines.push(`
|
|
691
|
-
lines.push(`
|
|
692
|
-
lines.push(`
|
|
693
|
-
lines.push(`
|
|
694
|
-
lines.push(
|
|
695
|
-
lines.push(`
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
lines.push(
|
|
705
|
-
|
|
706
|
-
lines.push(`
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
lines.push(`
|
|
722
|
-
lines.push(`
|
|
723
|
-
lines.push(`}
|
|
724
|
-
lines.push(
|
|
725
|
-
lines.push(`
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
1
|
+
/**
|
|
2
|
+
* BoneScript Runtime Code Emitter
|
|
3
|
+
* Generates runnable service code from IR.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as IR from "./ir";
|
|
7
|
+
import { createHash } from "crypto";
|
|
8
|
+
import { emitCapabilityBody } from "./emit_capability";
|
|
9
|
+
|
|
10
|
+
function toSnakeCase(s: string): string {
|
|
11
|
+
return s.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function toCamelCase(s: string): string {
|
|
15
|
+
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function toPascalCase(s: string): string {
|
|
19
|
+
const c = toCamelCase(s);
|
|
20
|
+
return c.charAt(0).toUpperCase() + c.slice(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const TS_TYPE_MAP: Record<string, string> = {
|
|
24
|
+
string: "string", uint: "number", int: "number", float: "number",
|
|
25
|
+
bool: "boolean", timestamp: "Date", uuid: "string", bytes: "Buffer", json: "unknown",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function toTsType(irType: string): string {
|
|
29
|
+
if (TS_TYPE_MAP[irType]) return TS_TYPE_MAP[irType];
|
|
30
|
+
const m = irType.match(/^(list|set)<(.+)>$/);
|
|
31
|
+
if (m) return `${toTsType(m[2])}[]`;
|
|
32
|
+
const om = irType.match(/^optional<(.+)>$/);
|
|
33
|
+
if (om) return `${toTsType(om[1])} | null`;
|
|
34
|
+
return irType;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── Package.json ─────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export function emitPackageJson(system: IR.IRSystem): string {
|
|
40
|
+
const pkg = {
|
|
41
|
+
name: toSnakeCase(system.name),
|
|
42
|
+
version: system.version,
|
|
43
|
+
private: true,
|
|
44
|
+
scripts: {
|
|
45
|
+
build: "tsc",
|
|
46
|
+
start: "node dist/index.js",
|
|
47
|
+
dev: "ts-node src/index.ts",
|
|
48
|
+
migrate: "ts-node src/migrate.ts",
|
|
49
|
+
},
|
|
50
|
+
dependencies: {
|
|
51
|
+
// Pinned to specific patch versions. Update consciously — a bump should
|
|
52
|
+
// be paired with a quick read of the release notes / advisories.
|
|
53
|
+
express: "4.22.2", // 4.22.x patches qs / path-to-regexp DoS
|
|
54
|
+
pg: "8.13.1",
|
|
55
|
+
ioredis: "5.4.1",
|
|
56
|
+
ws: "8.18.0", // 8.17.1 patched CVE-2024-37890 (DoS)
|
|
57
|
+
uuid: "10.0.0",
|
|
58
|
+
cors: "2.8.5",
|
|
59
|
+
helmet: "8.0.0",
|
|
60
|
+
"express-rate-limit": "7.5.0",
|
|
61
|
+
jsonwebtoken: "9.0.2",
|
|
62
|
+
dotenv: "16.4.7",
|
|
63
|
+
"node-cron": "3.0.3",
|
|
64
|
+
zod: "3.23.8",
|
|
65
|
+
},
|
|
66
|
+
devDependencies: {
|
|
67
|
+
"@types/express": "4.17.21",
|
|
68
|
+
"@types/node": "20.14.0",
|
|
69
|
+
"@types/pg": "8.11.10",
|
|
70
|
+
"@types/ws": "8.5.13",
|
|
71
|
+
"@types/cors": "2.8.17",
|
|
72
|
+
"@types/jsonwebtoken": "9.0.7",
|
|
73
|
+
"@types/uuid": "10.0.0",
|
|
74
|
+
"@types/node-cron": "3.0.11",
|
|
75
|
+
typescript: "5.6.3",
|
|
76
|
+
"ts-node": "10.9.2",
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
return JSON.stringify(pkg, null, 2);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── tsconfig.json ────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export function emitTsConfig(): string {
|
|
85
|
+
const cfg = {
|
|
86
|
+
compilerOptions: {
|
|
87
|
+
target: "ES2020",
|
|
88
|
+
module: "commonjs",
|
|
89
|
+
// ES2020 + DOM gives us the built-in fetch types (Node 18+ ships a
|
|
90
|
+
// WHATWG-compatible fetch; DOM provides the matching type declarations).
|
|
91
|
+
lib: ["ES2020", "DOM"],
|
|
92
|
+
outDir: "./dist",
|
|
93
|
+
rootDir: "./src",
|
|
94
|
+
strict: true,
|
|
95
|
+
esModuleInterop: true,
|
|
96
|
+
skipLibCheck: true,
|
|
97
|
+
forceConsistentCasingInFileNames: true,
|
|
98
|
+
declaration: true,
|
|
99
|
+
sourceMap: true,
|
|
100
|
+
},
|
|
101
|
+
include: ["src/**/*"],
|
|
102
|
+
exclude: ["node_modules", "dist"],
|
|
103
|
+
};
|
|
104
|
+
return JSON.stringify(cfg, null, 2);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Database Client ──────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
export function emitDbClient(system: IR.IRSystem): string {
|
|
110
|
+
const name = toSnakeCase(system.name);
|
|
111
|
+
return [
|
|
112
|
+
"// Generated by BoneScript compiler. DO NOT EDIT.",
|
|
113
|
+
"import { Pool, PoolClient } from \"pg\";",
|
|
114
|
+
"",
|
|
115
|
+
"// Lazy pool — created on first use so DATABASE_URL is read after dotenv loads",
|
|
116
|
+
"let _pool: Pool | null = null;",
|
|
117
|
+
"function getPool(): Pool {",
|
|
118
|
+
" if (!_pool) {",
|
|
119
|
+
` _pool = new Pool({ connectionString: process.env.DATABASE_URL || "postgresql://localhost:5432/${name}", max: 20 });`,
|
|
120
|
+
" _pool.on(\"error\", (err: Error) => console.error(\"[DB] Pool error (non-fatal):\", err.message));",
|
|
121
|
+
" }",
|
|
122
|
+
" return _pool;",
|
|
123
|
+
"}",
|
|
124
|
+
"export const pool = new Proxy({} as Pool, { get(_t: any, p: any) { return (getPool() as any)[p]; } });",
|
|
125
|
+
"",
|
|
126
|
+
"export async function query<T = any>(text: string, params?: any[]): Promise<T[]> {",
|
|
127
|
+
" try { const result = await pool.query(text, params); return result.rows as T[]; }",
|
|
128
|
+
" catch (e: any) { throw new Error(`DB query failed: ${e.message}`); }",
|
|
129
|
+
"}",
|
|
130
|
+
"export async function queryOne<T = any>(text: string, params?: any[]): Promise<T | null> {",
|
|
131
|
+
" try { const rows = await query<T>(text, params); return rows[0] || null; }",
|
|
132
|
+
" catch (e: any) { throw new Error(`DB query failed: ${e.message}`); }",
|
|
133
|
+
"}",
|
|
134
|
+
"export async function execute(text: string, params?: any[]): Promise<number> {",
|
|
135
|
+
" try { const result = await pool.query(text, params); return result.rowCount || 0; }",
|
|
136
|
+
" catch (e: any) { throw new Error(`DB execute failed: ${e.message}`); }",
|
|
137
|
+
"}",
|
|
138
|
+
"export async function transaction<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {",
|
|
139
|
+
" const client = await pool.connect();",
|
|
140
|
+
" try { await client.query(\"BEGIN\"); const result = await fn(client); await client.query(\"COMMIT\"); return result; }",
|
|
141
|
+
" catch (e) { await client.query(\"ROLLBACK\"); throw e; }",
|
|
142
|
+
" finally { client.release(); }",
|
|
143
|
+
"}",
|
|
144
|
+
].join("\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
export function emitAuthMiddleware(system: IR.IRSystem): string {
|
|
149
|
+
return `// Generated by BoneScript compiler. DO NOT EDIT.
|
|
150
|
+
import { Request, Response, NextFunction } from "express";
|
|
151
|
+
import jwt from "jsonwebtoken";
|
|
152
|
+
import { v4 as uuid } from "uuid";
|
|
153
|
+
|
|
154
|
+
// JWT_SECRET must be set in production. The server will refuse to start without it
|
|
155
|
+
// when NODE_ENV is "production" to prevent accidental use of a weak fallback.
|
|
156
|
+
const JWT_SECRET = (() => {
|
|
157
|
+
const secret = process.env.JWT_SECRET;
|
|
158
|
+
if (!secret) {
|
|
159
|
+
if (process.env.NODE_ENV === "production") {
|
|
160
|
+
console.error("[FATAL] JWT_SECRET environment variable is not set. Refusing to start in production.");
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
console.warn("[WARN] JWT_SECRET is not set. Using insecure default — do not use in production.");
|
|
164
|
+
return "bonescript-dev-secret-do-not-use-in-production";
|
|
165
|
+
}
|
|
166
|
+
if (secret.length < 32) {
|
|
167
|
+
console.warn("[WARN] JWT_SECRET is shorter than 32 characters. Use a longer secret in production.");
|
|
168
|
+
}
|
|
169
|
+
return secret;
|
|
170
|
+
})();
|
|
171
|
+
|
|
172
|
+
export interface AuthContext {
|
|
173
|
+
authenticated: boolean;
|
|
174
|
+
actor_id: string | null;
|
|
175
|
+
trace_id: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Trace IDs are server-generated. We accept a client-supplied X-Trace-Id only
|
|
179
|
+
// if it parses as a UUID, so callers can correlate their own logs without
|
|
180
|
+
// being able to forge correlation IDs in audit / event records.
|
|
181
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
182
|
+
function resolveTraceId(req: Request): string {
|
|
183
|
+
const supplied = req.headers["x-trace-id"];
|
|
184
|
+
if (typeof supplied === "string" && UUID_RE.test(supplied)) return supplied;
|
|
185
|
+
return uuid();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
|
189
|
+
const header = req.headers.authorization;
|
|
190
|
+
const traceId = resolveTraceId(req);
|
|
191
|
+
if (!header || !header.startsWith("Bearer ")) {
|
|
192
|
+
(req as any).auth = { authenticated: false, actor_id: null, trace_id: traceId };
|
|
193
|
+
next();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const token = header.slice(7);
|
|
198
|
+
// Pin algorithms to HS256 and require an expiration. Without pinning,
|
|
199
|
+
// jsonwebtoken would accept any algorithm including RS/HS confusion attacks.
|
|
200
|
+
// maxAge is a safety net even if exp is set far in the future.
|
|
201
|
+
const decoded = jwt.verify(token, JWT_SECRET, {
|
|
202
|
+
algorithms: ["HS256"],
|
|
203
|
+
maxAge: process.env.JWT_MAX_AGE || "1h",
|
|
204
|
+
}) as { sub?: unknown };
|
|
205
|
+
if (typeof decoded.sub !== "string" || decoded.sub.length === 0) {
|
|
206
|
+
(req as any).auth = { authenticated: false, actor_id: null, trace_id: traceId };
|
|
207
|
+
next();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
(req as any).auth = {
|
|
211
|
+
authenticated: true,
|
|
212
|
+
actor_id: decoded.sub,
|
|
213
|
+
trace_id: traceId,
|
|
214
|
+
};
|
|
215
|
+
} catch {
|
|
216
|
+
(req as any).auth = { authenticated: false, actor_id: null, trace_id: traceId };
|
|
217
|
+
}
|
|
218
|
+
next();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
|
222
|
+
const auth: AuthContext = (req as any).auth;
|
|
223
|
+
if (!auth || !auth.authenticated) {
|
|
224
|
+
res.status(401).json({ error: { code: "UNAUTHORIZED", message: "Authentication required" } });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
next();
|
|
228
|
+
}
|
|
229
|
+
`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─── Entity Router ────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string {
|
|
235
|
+
const entityModel = mod.models[0];
|
|
236
|
+
if (!entityModel) return "";
|
|
237
|
+
|
|
238
|
+
const tableName = toSnakeCase(entityModel.name) + "s";
|
|
239
|
+
const routeBase = toSnakeCase(entityModel.name) + "s";
|
|
240
|
+
const lines: string[] = [];
|
|
241
|
+
|
|
242
|
+
lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
|
|
243
|
+
lines.push(`// Service: ${mod.name}`);
|
|
244
|
+
lines.push(`import { Router, Request, Response } from "express";`);
|
|
245
|
+
lines.push(`import { v4 as uuid } from "uuid";`);
|
|
246
|
+
lines.push(`import { query, queryOne, execute, pool } from "../db";`);
|
|
247
|
+
lines.push(`import { eventBus } from "../events";`);
|
|
248
|
+
lines.push(`import { requireAuth, AuthContext } from "../auth";`);
|
|
249
|
+
lines.push(`import rateLimit from "express-rate-limit";`);
|
|
250
|
+
// Only import audit if module has audit: true
|
|
251
|
+
if (mod.config["audit"]) {
|
|
252
|
+
lines.push(`import { auditLog } from "../audit";`);
|
|
253
|
+
}
|
|
254
|
+
lines.push(`import { logger } from "../logger";`);
|
|
255
|
+
lines.push(`import { counter } from "../metrics";`);
|
|
256
|
+
// Zod schemas for input validation. The Create / Update derivatives are
|
|
257
|
+
// generated alongside the full model schema in src/schemas.ts.
|
|
258
|
+
lines.push(`import { ${toPascalCase(entityModel.name)}CreateSchema, ${toPascalCase(entityModel.name)}UpdateSchema } from "../schemas";`);
|
|
259
|
+
lines.push(`import * as __algorithms from "../algorithms";`);
|
|
260
|
+
lines.push(`const { shortestPath, topologicalSort, binarySearch, bipartiteMatching, roundRobin, weightedAverage, percentile, rankBy, consistentHash } = __algorithms as any;`);
|
|
261
|
+
lines.push(``);
|
|
262
|
+
|
|
263
|
+
// Collect unknown function calls from capability effects and emit stubs
|
|
264
|
+
const unknownFunctions = collectUnknownFunctions(mod);
|
|
265
|
+
if (unknownFunctions.size > 0) {
|
|
266
|
+
lines.push(`// User-defined functions referenced in effects — implement these or use extension_point`);
|
|
267
|
+
for (const fn of unknownFunctions) {
|
|
268
|
+
lines.push(`declare function ${fn}(...args: any[]): any;`);
|
|
269
|
+
}
|
|
270
|
+
lines.push(``);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (mod.state_machines.length > 0) {
|
|
274
|
+
const sm = mod.state_machines[0];
|
|
275
|
+
lines.push(`import { transition${sm.entity}, ${sm.entity.toUpperCase()}_INITIAL } from "../state_machines/${toSnakeCase(sm.entity)}";`);
|
|
276
|
+
lines.push(``);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
lines.push(`export const ${toCamelCase(routeBase)}Router = Router();`);
|
|
280
|
+
lines.push(``);
|
|
281
|
+
|
|
282
|
+
// Per-module rate limiter (from policy declaration)
|
|
283
|
+
const modRateLimit = typeof mod.config["rate_limit"] === "number" && (mod.config["rate_limit"] as number) > 0
|
|
284
|
+
? mod.config["rate_limit"] as number : 0;
|
|
285
|
+
const modRateLimitWindowMs = typeof mod.config["rate_limit_window_ms"] === "number"
|
|
286
|
+
? mod.config["rate_limit_window_ms"] as number : 60000;
|
|
287
|
+
if (modRateLimit > 0) {
|
|
288
|
+
lines.push(`// Rate limiter from policy declaration`);
|
|
289
|
+
lines.push(`const __routeRateLimit = rateLimit({ windowMs: ${modRateLimitWindowMs}, max: ${modRateLimit}, standardHeaders: true, legacyHeaders: false });`);
|
|
290
|
+
lines.push(``);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// CREATE
|
|
294
|
+
const insertFields = entityModel.fields.filter(f => f.name !== "id" && f.name !== "created_at" && f.name !== "updated_at");
|
|
295
|
+
lines.push(`// CREATE`);
|
|
296
|
+
const __crudMiddlewares = modRateLimit > 0 ? "__routeRateLimit, requireAuth" : "requireAuth";
|
|
297
|
+
lines.push(`${toCamelCase(routeBase)}Router.post("/", ${__crudMiddlewares}, async (req: Request, res: Response) => {`);
|
|
298
|
+
lines.push(` // Validate the request body against the generated Zod schema.`);
|
|
299
|
+
lines.push(` const __parsed = ${toPascalCase(entityModel.name)}CreateSchema.safeParse(req.body);`);
|
|
300
|
+
lines.push(` if (!__parsed.success) {`);
|
|
301
|
+
lines.push(` return res.status(400).json({ error: { code: "VALIDATION_FAILED", message: "Invalid request body", issues: __parsed.error.flatten() } });`);
|
|
302
|
+
lines.push(` }`);
|
|
303
|
+
lines.push(` const __body = __parsed.data;`);
|
|
304
|
+
lines.push(` try {`);
|
|
305
|
+
lines.push(` const id = uuid();`);
|
|
306
|
+
lines.push(` const { ${insertFields.map(f => f.name).join(", ")} } = __body as any;`);
|
|
307
|
+
if (mod.state_machines.length > 0) {
|
|
308
|
+
lines.push(` const state = ${mod.state_machines[0].entity.toUpperCase()}_INITIAL;`);
|
|
309
|
+
}
|
|
310
|
+
const allInsertFields = ["id", ...insertFields.map(f => f.name)];
|
|
311
|
+
if (mod.state_machines.length > 0 && !insertFields.find(f => f.name === "state")) {
|
|
312
|
+
allInsertFields.push("state");
|
|
313
|
+
}
|
|
314
|
+
const placeholders = allInsertFields.map((_, i) => `$${i + 1}`).join(", ");
|
|
315
|
+
const values = allInsertFields.map(f => f === "id" ? "id" : f === "state" ? "state" : f).join(", ");
|
|
316
|
+
lines.push(` const sql = \`INSERT INTO ${tableName} (${allInsertFields.join(", ")}) VALUES (${placeholders}) RETURNING *\`;`);
|
|
317
|
+
lines.push(` const rows = await query(sql, [${values}]);`);
|
|
318
|
+
lines.push(` counter("entity.created", { entity: "${entityModel.name}" });`);
|
|
319
|
+
lines.push(` res.status(201).json(rows[0]);`);
|
|
320
|
+
lines.push(` } catch (e: any) {`);
|
|
321
|
+
lines.push(` res.status(400).json({ error: { code: "CREATE_FAILED", message: e.message } });`);
|
|
322
|
+
lines.push(` }`);
|
|
323
|
+
lines.push(`});`);
|
|
324
|
+
lines.push(``);
|
|
325
|
+
|
|
326
|
+
// READ
|
|
327
|
+
lines.push(`// READ`);
|
|
328
|
+
lines.push(`${toCamelCase(routeBase)}Router.get("/:id", ${__crudMiddlewares}, async (req: Request, res: Response) => {`);
|
|
329
|
+
lines.push(` try {`);
|
|
330
|
+
lines.push(` const row = await queryOne(\`SELECT * FROM ${tableName} WHERE id = $1\`, [req.params.id]);`);
|
|
331
|
+
lines.push(` if (!row) return res.status(404).json({ error: { code: "NOT_FOUND", message: "Not found" } });`);
|
|
332
|
+
lines.push(` res.json(row);`);
|
|
333
|
+
lines.push(` } catch (e: any) {`);
|
|
334
|
+
lines.push(` res.status(500).json({ error: { code: "DB_ERROR", message: e.message } });`);
|
|
335
|
+
lines.push(` }`);
|
|
336
|
+
lines.push(`});`);
|
|
337
|
+
lines.push(``);
|
|
338
|
+
|
|
339
|
+
// LIST — with optional JOINs for has_one/belongs_to relations
|
|
340
|
+
const joinRelations = mod.relations.filter(r => r.kind === "has_one" || r.kind === "belongs_to");
|
|
341
|
+
lines.push(`// LIST`);
|
|
342
|
+
lines.push(`${toCamelCase(routeBase)}Router.get("/", ${__crudMiddlewares}, async (req: Request, res: Response) => {`);
|
|
343
|
+
lines.push(` try {`);
|
|
344
|
+
lines.push(` const page = parseInt(req.query.page as string) || 1;`);
|
|
345
|
+
lines.push(` const pageSize = Math.min(parseInt(req.query.page_size as string) || 50, 100);`);
|
|
346
|
+
lines.push(` const offset = (page - 1) * pageSize;`);
|
|
347
|
+
|
|
348
|
+
if (joinRelations.length > 0) {
|
|
349
|
+
const joinClauses = joinRelations.map(r => {
|
|
350
|
+
const alias = r.to_table.slice(0, -1);
|
|
351
|
+
if (r.kind === "belongs_to") {
|
|
352
|
+
return `LEFT JOIN ${r.to_table} ${alias} ON ${tableName}.${r.foreign_key} = ${alias}.id`;
|
|
353
|
+
} else {
|
|
354
|
+
return `LEFT JOIN ${r.to_table} ${alias} ON ${alias}.${r.foreign_key} = ${tableName}.id`;
|
|
355
|
+
}
|
|
356
|
+
}).join(" ");
|
|
357
|
+
lines.push(` const rows = await query(\`SELECT ${tableName}.*, ${joinRelations.map(r => `row_to_json(${r.to_table.slice(0, -1)}.*) as ${r.name}`).join(", ")} FROM ${tableName} ${joinClauses} ORDER BY ${tableName}.created_at DESC LIMIT $1 OFFSET $2\`, [pageSize, offset]);`);
|
|
358
|
+
} else {
|
|
359
|
+
lines.push(` const rows = await query(\`SELECT * FROM ${tableName} ORDER BY created_at DESC LIMIT $1 OFFSET $2\`, [pageSize, offset]);`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
lines.push(` const [{ count }] = await query(\`SELECT COUNT(*) as count FROM ${tableName}\`);`);
|
|
363
|
+
lines.push(` res.json({ items: rows, total: parseInt(count), page, page_size: pageSize });`);
|
|
364
|
+
lines.push(` } catch (e: any) {`);
|
|
365
|
+
lines.push(` res.status(500).json({ error: { code: "DB_ERROR", message: e.message } });`);
|
|
366
|
+
lines.push(` }`);
|
|
367
|
+
lines.push(`});`);
|
|
368
|
+
lines.push(``);
|
|
369
|
+
|
|
370
|
+
// UPDATE — with state machine enforcement and column allow-list
|
|
371
|
+
// The allow-list is derived from the IR model so generated SQL never
|
|
372
|
+
// interpolates user-supplied keys as identifiers.
|
|
373
|
+
const updatableColumns = entityModel.fields
|
|
374
|
+
.filter(f => !["id", "created_at", "updated_at"].includes(f.name))
|
|
375
|
+
.map(f => f.name);
|
|
376
|
+
// Entities with a state machine carry a `state` column not present in the
|
|
377
|
+
// IR field list. Include it so PUT can drive transitions.
|
|
378
|
+
if (mod.state_machines.length > 0 && !updatableColumns.includes("state")) {
|
|
379
|
+
updatableColumns.push("state");
|
|
380
|
+
}
|
|
381
|
+
lines.push(`// UPDATE`);
|
|
382
|
+
lines.push(`const __${toCamelCase(routeBase)}Updatable = new Set<string>(${JSON.stringify(updatableColumns)});`);
|
|
383
|
+
lines.push(`${toCamelCase(routeBase)}Router.put("/:id", ${__crudMiddlewares}, async (req: Request, res: Response) => {`);
|
|
384
|
+
lines.push(` // 1. Validate body shape and types via Zod (UpdateSchema is partial).`);
|
|
385
|
+
lines.push(` const __parsed = ${toPascalCase(entityModel.name)}UpdateSchema.safeParse(req.body);`);
|
|
386
|
+
lines.push(` if (!__parsed.success) {`);
|
|
387
|
+
lines.push(` return res.status(400).json({ error: { code: "VALIDATION_FAILED", message: "Invalid request body", issues: __parsed.error.flatten() } });`);
|
|
388
|
+
lines.push(` }`);
|
|
389
|
+
lines.push(` // 2. Reject unknown columns (defense in depth — Zod would already strip them).`);
|
|
390
|
+
lines.push(` const __unknown = Object.keys(req.body || {}).filter(k => !__${toCamelCase(routeBase)}Updatable.has(k));`);
|
|
391
|
+
lines.push(` if (__unknown.length > 0) {`);
|
|
392
|
+
lines.push(` return res.status(400).json({ error: { code: "UNKNOWN_FIELDS", message: \`Unknown fields: \${__unknown.join(", ")}\`, fields: __unknown } });`);
|
|
393
|
+
lines.push(` }`);
|
|
394
|
+
lines.push(` // 3. Use the Zod-parsed object as the update set.`);
|
|
395
|
+
lines.push(` const fields: Record<string, unknown> = __parsed.data as Record<string, unknown>;`);
|
|
396
|
+
lines.push(` if (Object.keys(fields).length === 0) {`);
|
|
397
|
+
lines.push(` return res.status(400).json({ error: { code: "NO_FIELDS", message: "No updatable fields supplied" } });`);
|
|
398
|
+
lines.push(` }`);
|
|
399
|
+
if (mod.state_machines.length > 0) {
|
|
400
|
+
const sm = mod.state_machines[0];
|
|
401
|
+
lines.push(` // State machine enforcement`);
|
|
402
|
+
lines.push(` if (fields.state !== undefined) {`);
|
|
403
|
+
lines.push(` const current = await queryOne<{ state: string }>(\`SELECT state FROM ${tableName} WHERE id = $1\`, [req.params.id]);`);
|
|
404
|
+
lines.push(` if (!current) return res.status(404).json({ error: { code: "NOT_FOUND", message: "Not found" } });`);
|
|
405
|
+
lines.push(` // Find the trigger for this state transition`);
|
|
406
|
+
lines.push(` const trigger = \`\${current.state}_to_\${fields.state}\`;`);
|
|
407
|
+
lines.push(` const tr = transition${sm.entity}(current.state as any, trigger);`);
|
|
408
|
+
lines.push(` if (!tr.ok) return res.status(422).json({ error: { code: "INVALID_TRANSITION", message: \`Cannot transition from \${current.state} to \${fields.state}\` } });`);
|
|
409
|
+
lines.push(` }`);
|
|
410
|
+
}
|
|
411
|
+
lines.push(` const keys = Object.keys(fields);`);
|
|
412
|
+
lines.push(` const sets = keys.map((k, i) => \`\${k} = $\${i + 2}\`).join(", ");`);
|
|
413
|
+
lines.push(` const values = keys.map(k => fields[k]);`);
|
|
414
|
+
lines.push(` const sql = \`UPDATE ${tableName} SET \${sets}, updated_at = NOW() WHERE id = $1 RETURNING *\`;`);
|
|
415
|
+
lines.push(` const rows = await query(sql, [req.params.id, ...values]);`);
|
|
416
|
+
lines.push(` if (rows.length === 0) return res.status(404).json({ error: { code: "NOT_FOUND", message: "Not found" } });`);
|
|
417
|
+
lines.push(` res.json(rows[0]);`);
|
|
418
|
+
lines.push(`});`);
|
|
419
|
+
lines.push(``);
|
|
420
|
+
|
|
421
|
+
// DELETE
|
|
422
|
+
lines.push(`// DELETE`);
|
|
423
|
+
lines.push(`${toCamelCase(routeBase)}Router.delete("/:id", ${__crudMiddlewares}, async (req: Request, res: Response) => {`);
|
|
424
|
+
lines.push(` try {`);
|
|
425
|
+
lines.push(` const count = await execute(\`DELETE FROM ${tableName} WHERE id = $1\`, [req.params.id]);`);
|
|
426
|
+
lines.push(` if (count === 0) return res.status(404).json({ error: { code: "NOT_FOUND", message: "Not found" } });`);
|
|
427
|
+
lines.push(` res.status(204).send();`);
|
|
428
|
+
lines.push(` } catch (e: any) {`);
|
|
429
|
+
lines.push(` res.status(500).json({ error: { code: "DB_ERROR", message: e.message } });`);
|
|
430
|
+
lines.push(` }`);
|
|
431
|
+
lines.push(`});`);
|
|
432
|
+
lines.push(``);
|
|
433
|
+
|
|
434
|
+
// Capability endpoints
|
|
435
|
+
for (const iface of mod.interfaces) {
|
|
436
|
+
for (const method of iface.methods) {
|
|
437
|
+
if (["create", "read", "update", "delete", "list"].includes(method.name)) continue;
|
|
438
|
+
lines.push(emitCapabilityEndpoint(method, mod, tableName, system));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// has_many relation endpoints: GET /:id/relation-name
|
|
443
|
+
for (const rel of mod.relations) {
|
|
444
|
+
if (rel.kind === "has_many") {
|
|
445
|
+
lines.push(`// RELATION: ${rel.name} (has_many ${rel.to_entity})`);
|
|
446
|
+
lines.push(`${toCamelCase(routeBase)}Router.get("/:id/${rel.name}", requireAuth, async (req: Request, res: Response) => {`);
|
|
447
|
+
lines.push(` try {`);
|
|
448
|
+
lines.push(` const page = parseInt(req.query.page as string) || 1;`);
|
|
449
|
+
lines.push(` const pageSize = Math.min(parseInt(req.query.page_size as string) || 50, 100);`);
|
|
450
|
+
lines.push(` const offset = (page - 1) * pageSize;`);
|
|
451
|
+
lines.push(` const rows = await query(\`SELECT * FROM ${rel.to_table} WHERE ${rel.foreign_key} = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3\`, [req.params.id, pageSize, offset]);`);
|
|
452
|
+
lines.push(` const [{ count }] = await query(\`SELECT COUNT(*) as count FROM ${rel.to_table} WHERE ${rel.foreign_key} = $1\`, [req.params.id]);`);
|
|
453
|
+
lines.push(` res.json({ items: rows, total: parseInt(count), page, page_size: pageSize });`);
|
|
454
|
+
lines.push(` } catch (e: any) {`);
|
|
455
|
+
lines.push(` res.status(500).json({ error: { code: "DB_ERROR", message: e.message } });`);
|
|
456
|
+
lines.push(` }`);
|
|
457
|
+
lines.push(`});`);
|
|
458
|
+
lines.push(``);
|
|
459
|
+
}
|
|
460
|
+
if (rel.kind === "many_to_many" && rel.junction_table) {
|
|
461
|
+
lines.push(`// RELATION: ${rel.name} (many_to_many ${rel.to_entity})`);
|
|
462
|
+
lines.push(`${toCamelCase(routeBase)}Router.get("/:id/${rel.name}", requireAuth, async (req: Request, res: Response) => {`);
|
|
463
|
+
lines.push(` try {`);
|
|
464
|
+
lines.push(` const rows = await query(\`SELECT ${rel.to_table}.* FROM ${rel.to_table} INNER JOIN ${rel.junction_table} ON ${rel.junction_table}.${rel.to_table.slice(0, -1)}_id = ${rel.to_table}.id WHERE ${rel.junction_table}.${rel.from_table.slice(0, -1)}_id = $1\`, [req.params.id]);`);
|
|
465
|
+
lines.push(` res.json({ items: rows, total: rows.length });`);
|
|
466
|
+
lines.push(` } catch (e: any) {`);
|
|
467
|
+
lines.push(` res.status(500).json({ error: { code: "DB_ERROR", message: e.message } });`);
|
|
468
|
+
lines.push(` }`);
|
|
469
|
+
lines.push(`});`);
|
|
470
|
+
lines.push(``);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return lines.join("\n");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function emitCapabilityEndpoint(method: IR.IRMethod, mod: IR.IRModule, tableName: string, system: IR.IRSystem): string {
|
|
478
|
+
const lines: string[] = [];
|
|
479
|
+
const routerName = toCamelCase(toSnakeCase(mod.models[0]?.name || mod.name) + "s") + "Router";
|
|
480
|
+
const endpoint = `/${method.name.replace(/_/g, "-")}`;
|
|
481
|
+
const isTransactional = method.sync === "transactional";
|
|
482
|
+
|
|
483
|
+
lines.push(`// CAPABILITY: ${method.name}${isTransactional ? " [transactional]" : ""}${method.retry ? ` [retry: ${method.retry.max_attempts}x ${method.retry.backoff}]` : ""}`);
|
|
484
|
+
// Build middleware chain: optional rate limiter + requireAuth + optional audit
|
|
485
|
+
const capMiddlewares: string[] = ["requireAuth"];
|
|
486
|
+
if (typeof mod.config["rate_limit"] === "number" && (mod.config["rate_limit"] as number) > 0) {
|
|
487
|
+
capMiddlewares.unshift("__routeRateLimit");
|
|
488
|
+
}
|
|
489
|
+
if (mod.config["audit"]) {
|
|
490
|
+
capMiddlewares.push(`auditLog("${method.name}", "${mod.models[0]?.name ?? ""}")`);
|
|
491
|
+
}
|
|
492
|
+
lines.push(`${routerName}.post("${endpoint}", ${capMiddlewares.join(", ")}, async (req: Request, res: Response) => {`);
|
|
493
|
+
lines.push(` const auth: AuthContext = (req as any).auth;`);
|
|
494
|
+
|
|
495
|
+
// Wrap in retry logic if declared
|
|
496
|
+
if (method.retry) {
|
|
497
|
+
const { max_attempts, backoff, interval_ms } = method.retry;
|
|
498
|
+
lines.push(` let __attempt = 0;`);
|
|
499
|
+
lines.push(` const __maxAttempts = ${max_attempts};`);
|
|
500
|
+
lines.push(` const __intervalMs = ${interval_ms};`);
|
|
501
|
+
lines.push(` const __backoff = "${backoff}";`);
|
|
502
|
+
lines.push(` while (__attempt < __maxAttempts) {`);
|
|
503
|
+
lines.push(` __attempt++;`);
|
|
504
|
+
lines.push(` try {`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (isTransactional) {
|
|
508
|
+
lines.push(` const __client = await pool.connect();`);
|
|
509
|
+
lines.push(` try {`);
|
|
510
|
+
lines.push(` await __client.query("BEGIN");`);
|
|
511
|
+
} else if (method.sync === "realtime") {
|
|
512
|
+
lines.push(` try {`);
|
|
513
|
+
lines.push(` // sync: realtime — execute then broadcast to WebSocket channel`);
|
|
514
|
+
} else if (method.sync === "eventual") {
|
|
515
|
+
lines.push(` try {`);
|
|
516
|
+
lines.push(` // sync: eventual — effects applied, events queued via outbox`);
|
|
517
|
+
} else if (method.sync === "batch") {
|
|
518
|
+
lines.push(` try {`);
|
|
519
|
+
lines.push(` // sync: batch — enqueued for batch processing`);
|
|
520
|
+
lines.push(` const { enqueueBatch } = require("../batch");`);
|
|
521
|
+
lines.push(` const result = await enqueueBatch("${mod.name}.${method.name}", req.body);`);
|
|
522
|
+
lines.push(` res.json({ ok: true, action: "${method.name}", queued: true, result });`);
|
|
523
|
+
lines.push(` return;`);
|
|
524
|
+
} else {
|
|
525
|
+
lines.push(` try {`);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (method.pipeline) {
|
|
529
|
+
const { emitPipelineBody } = require("./emit_composition");
|
|
530
|
+
lines.push(emitPipelineBody(method, " "));
|
|
531
|
+
} else if (method.algorithm) {
|
|
532
|
+
const { emitAlgorithmBody } = require("./emit_composition");
|
|
533
|
+
lines.push(emitAlgorithmBody(method, " "));
|
|
534
|
+
} else {
|
|
535
|
+
// Real capability body: compiled preconditions + effects + emissions
|
|
536
|
+
lines.push(emitCapabilityBody(method, mod, system, " "));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (isTransactional) {
|
|
540
|
+
lines.push(` await __client.query("COMMIT");`);
|
|
541
|
+
lines.push(` } catch (e: any) {`);
|
|
542
|
+
lines.push(` await __client.query("ROLLBACK");`);
|
|
543
|
+
lines.push(` res.status(400).json({ error: { code: "CAPABILITY_FAILED", message: e.message } });`);
|
|
544
|
+
lines.push(` } finally {`);
|
|
545
|
+
lines.push(` __client.release();`);
|
|
546
|
+
lines.push(` }`);
|
|
547
|
+
} else if (method.sync === "realtime") {
|
|
548
|
+
lines.push(` // Broadcast to WebSocket subscribers after successful execution`);
|
|
549
|
+
lines.push(` if (typeof broadcastToChannel === "function") {`);
|
|
550
|
+
lines.push(` broadcastToChannel("${mod.name}", { type: "${method.name}", payload: req.body, actor: auth.actor_id });`);
|
|
551
|
+
lines.push(` }`);
|
|
552
|
+
lines.push(` } catch (e: any) {`);
|
|
553
|
+
lines.push(` res.status(400).json({ error: { code: "CAPABILITY_FAILED", message: e.message } });`);
|
|
554
|
+
lines.push(` }`);
|
|
555
|
+
} else {
|
|
556
|
+
lines.push(` } catch (e: any) {`);
|
|
557
|
+
lines.push(` res.status(400).json({ error: { code: "CAPABILITY_FAILED", message: e.message } });`);
|
|
558
|
+
lines.push(` }`);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (method.retry) {
|
|
562
|
+
// Close the retry while loop
|
|
563
|
+
lines.push(` } catch (e: any) {`);
|
|
564
|
+
lines.push(` if (__attempt >= __maxAttempts) {`);
|
|
565
|
+
lines.push(` res.status(503).json({ error: { code: "MAX_RETRIES_EXCEEDED", message: e.message, attempts: __attempt } });`);
|
|
566
|
+
lines.push(` return;`);
|
|
567
|
+
lines.push(` }`);
|
|
568
|
+
lines.push(` const delay = __backoff === "exponential" ? __intervalMs * Math.pow(2, __attempt - 1)`);
|
|
569
|
+
lines.push(` : __backoff === "linear" ? __intervalMs * __attempt`);
|
|
570
|
+
lines.push(` : __intervalMs;`);
|
|
571
|
+
lines.push(` await new Promise(r => setTimeout(r, delay));`);
|
|
572
|
+
lines.push(` }`);
|
|
573
|
+
lines.push(` } // end retry loop`);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
lines.push(`});`);
|
|
577
|
+
lines.push(``);
|
|
578
|
+
return lines.join("\n");
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ─── State Machine Runtime ────────────────────────────────────────────────────
|
|
582
|
+
|
|
583
|
+
export function emitStateMachineRuntime(sm: IR.IRStateMachine): string {
|
|
584
|
+
const lines: string[] = [];
|
|
585
|
+
const stateUnion = sm.states.map(s => `"${s}"`).join(" | ");
|
|
586
|
+
|
|
587
|
+
lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
|
|
588
|
+
lines.push(``);
|
|
589
|
+
lines.push(`export type ${sm.entity}State = ${stateUnion};`);
|
|
590
|
+
lines.push(``);
|
|
591
|
+
lines.push(`export const ${sm.entity.toUpperCase()}_INITIAL: ${sm.entity}State = "${sm.initial}";`);
|
|
592
|
+
lines.push(``);
|
|
593
|
+
|
|
594
|
+
lines.push(`const TRANSITIONS: Record<${sm.entity}State, Record<string, ${sm.entity}State>> = {`);
|
|
595
|
+
for (const state of sm.states) {
|
|
596
|
+
const trans = sm.transitions.filter(t => t.from === state);
|
|
597
|
+
const entries = trans.map(t => `"${t.trigger}": "${t.to}"`).join(", ");
|
|
598
|
+
lines.push(` "${state}": { ${entries} },`);
|
|
599
|
+
}
|
|
600
|
+
lines.push(`};`);
|
|
601
|
+
lines.push(``);
|
|
602
|
+
|
|
603
|
+
lines.push(`export function transition${sm.entity}(`);
|
|
604
|
+
lines.push(` current: ${sm.entity}State,`);
|
|
605
|
+
lines.push(` trigger: string`);
|
|
606
|
+
lines.push(`): { ok: true; next: ${sm.entity}State } | { ok: false; error: string } {`);
|
|
607
|
+
lines.push(` const next = TRANSITIONS[current]?.[trigger];`);
|
|
608
|
+
lines.push(` if (!next) return { ok: false, error: \`Invalid transition: \${current} --[\${trigger}]--> ?\` };`);
|
|
609
|
+
lines.push(` return { ok: true, next };`);
|
|
610
|
+
lines.push(`}`);
|
|
611
|
+
lines.push(``);
|
|
612
|
+
|
|
613
|
+
return lines.join("\n");
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ─── Main Entry Point ─────────────────────────────────────────────────────────
|
|
617
|
+
|
|
618
|
+
export function emitIndex(system: IR.IRSystem): string {
|
|
619
|
+
const apiModules = system.modules.filter(m => m.kind === "api_service");
|
|
620
|
+
const hasWebSocket = system.modules.some(m => m.kind === "realtime_service");
|
|
621
|
+
const lines: string[] = [];
|
|
622
|
+
|
|
623
|
+
lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
|
|
624
|
+
lines.push(`// System: ${system.name}`);
|
|
625
|
+
lines.push(`require("dotenv").config();`);
|
|
626
|
+
lines.push(`import express from "express";`);
|
|
627
|
+
lines.push(`import { createServer } from "http";`);
|
|
628
|
+
lines.push(`import cors from "cors";`);
|
|
629
|
+
lines.push(`import helmet from "helmet";`);
|
|
630
|
+
lines.push(`import rateLimit from "express-rate-limit";`);
|
|
631
|
+
lines.push(`import { authMiddleware } from "./auth";`);
|
|
632
|
+
lines.push(`import { healthRouter } from "./health";`);
|
|
633
|
+
lines.push(`import { logger } from "./logger";`);
|
|
634
|
+
lines.push(`import { eventBus } from "./events";`);
|
|
635
|
+
lines.push(`import { pool } from "./db";`);
|
|
636
|
+
|
|
637
|
+
// Import batch worker if any batch capabilities exist
|
|
638
|
+
const hasBatch = system.modules.some(m =>
|
|
639
|
+
m.interfaces.some(i => i.methods.some(mth => mth.sync === "batch"))
|
|
640
|
+
);
|
|
641
|
+
if (hasBatch) {
|
|
642
|
+
lines.push(`import { startBatchWorker } from "./batch";`);
|
|
643
|
+
lines.push(`import { startCronJobs } from "./cron";`);
|
|
644
|
+
}
|
|
645
|
+
if (hasWebSocket) {
|
|
646
|
+
lines.push(`import { setupWebSocketServer } from "./websocket";`);
|
|
647
|
+
}
|
|
648
|
+
lines.push(``);
|
|
649
|
+
|
|
650
|
+
for (const mod of apiModules) {
|
|
651
|
+
const model = mod.models[0];
|
|
652
|
+
if (!model) continue;
|
|
653
|
+
const routerName = toCamelCase(toSnakeCase(model.name) + "s") + "Router";
|
|
654
|
+
lines.push(`import { ${routerName} } from "./routes/${toSnakeCase(model.name)}";`);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
lines.push(``);
|
|
658
|
+
lines.push(`const app = express();`);
|
|
659
|
+
lines.push(`const httpServer = createServer(app);`);
|
|
660
|
+
lines.push(`const PORT = parseInt(process.env.PORT || "3000");`);
|
|
661
|
+
lines.push(``);
|
|
662
|
+
lines.push(`// Trust the immediate hop's X-Forwarded-* headers when running behind a`);
|
|
663
|
+
lines.push(`// load balancer / k8s ingress. Override with TRUST_PROXY=<n> for multi-hop`);
|
|
664
|
+
lines.push(`// (e.g. "2" for ingress + service mesh). Set to "false" to disable.`);
|
|
665
|
+
lines.push(`const __trust = process.env.TRUST_PROXY ?? "loopback, linklocal, uniquelocal";`);
|
|
666
|
+
lines.push(`if (__trust === "false") app.set("trust proxy", false);`);
|
|
667
|
+
lines.push(`else if (/^\\d+$/.test(__trust)) app.set("trust proxy", Number(__trust));`);
|
|
668
|
+
lines.push(`else app.set("trust proxy", __trust);`);
|
|
669
|
+
lines.push(``);
|
|
670
|
+
lines.push(`// Middleware`);
|
|
671
|
+
lines.push(`app.use(helmet());`);
|
|
672
|
+
lines.push(`// CORS: restrict to ALLOWED_ORIGINS env var (comma-separated). Defaults to same-origin only.`);
|
|
673
|
+
lines.push(`const __allowedOrigins = (process.env.ALLOWED_ORIGINS || "").split(",").map(s => s.trim()).filter(Boolean);`);
|
|
674
|
+
lines.push(`app.use(cors({`);
|
|
675
|
+
lines.push(` origin: __allowedOrigins.length > 0`);
|
|
676
|
+
lines.push(` ? (origin, cb) => { if (!origin || __allowedOrigins.includes(origin)) cb(null, true); else cb(new Error("Not allowed by CORS")); }`);
|
|
677
|
+
lines.push(` : false,`);
|
|
678
|
+
lines.push(` credentials: true,`);
|
|
679
|
+
lines.push(`}));`);
|
|
680
|
+
lines.push(`app.use(express.json({ limit: "1mb" }));`);
|
|
681
|
+
lines.push(`app.use(express.urlencoded({ extended: false, limit: "1mb" }));`);
|
|
682
|
+
lines.push(`app.use(authMiddleware);`);
|
|
683
|
+
|
|
684
|
+
const gw = system.modules.find(m => m.kind === "gateway");
|
|
685
|
+
const rateVal = gw?.config["rate_limit"] || 1000;
|
|
686
|
+
lines.push(`app.use(rateLimit({ windowMs: 60000, max: ${rateVal} }));`);
|
|
687
|
+
lines.push(``);
|
|
688
|
+
// Request timeout middleware
|
|
689
|
+
lines.push(`// Request timeout (default 30s, override per-route)`);
|
|
690
|
+
lines.push(`app.use((req: any, res: any, next: any) => {`);
|
|
691
|
+
lines.push(` const timeout = parseInt(process.env.REQUEST_TIMEOUT_MS || "30000");`);
|
|
692
|
+
lines.push(` const timer = setTimeout(() => {`);
|
|
693
|
+
lines.push(` if (!res.headersSent) {`);
|
|
694
|
+
lines.push(` res.status(503).json({ error: { code: "REQUEST_TIMEOUT", message: "Request timed out" } });`);
|
|
695
|
+
lines.push(` }`);
|
|
696
|
+
lines.push(` }, timeout);`);
|
|
697
|
+
lines.push(` res.on("finish", () => clearTimeout(timer));`);
|
|
698
|
+
lines.push(` next();`);
|
|
699
|
+
lines.push(`});`);
|
|
700
|
+
lines.push(``);
|
|
701
|
+
|
|
702
|
+
lines.push(`// Health & metrics`);
|
|
703
|
+
lines.push(`app.use("/health", healthRouter);`);
|
|
704
|
+
lines.push(``);
|
|
705
|
+
|
|
706
|
+
lines.push(`// Routes`);
|
|
707
|
+
for (const mod of apiModules) {
|
|
708
|
+
const model = mod.models[0];
|
|
709
|
+
if (!model) continue;
|
|
710
|
+
const routerName = toCamelCase(toSnakeCase(model.name) + "s") + "Router";
|
|
711
|
+
lines.push(`app.use("/${toSnakeCase(model.name)}s", ${routerName});`);
|
|
712
|
+
}
|
|
713
|
+
lines.push(``);
|
|
714
|
+
|
|
715
|
+
if (hasWebSocket) {
|
|
716
|
+
lines.push(`// WebSocket`);
|
|
717
|
+
lines.push(`setupWebSocketServer(httpServer);`);
|
|
718
|
+
lines.push(``);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
lines.push(`// Start`);
|
|
722
|
+
lines.push(`httpServer.listen(PORT, () => {`);
|
|
723
|
+
lines.push(` logger.info("server_started", { event: "startup", metadata: { port: PORT } });`);
|
|
724
|
+
lines.push(` // Start durable event worker (no-op in in_process mode)`);
|
|
725
|
+
lines.push(` eventBus.startWorker(parseInt(process.env.EVENT_WORKER_INTERVAL_MS || "1000"));`);
|
|
726
|
+
if (hasBatch) {
|
|
727
|
+
lines.push(` // Start batch executor`);
|
|
728
|
+
lines.push(` startBatchWorker();`);
|
|
729
|
+
lines.push(` // Start cron jobs`);
|
|
730
|
+
lines.push(` startCronJobs();`);
|
|
731
|
+
}
|
|
732
|
+
lines.push(` console.log(\`[${system.name}] Running on port \${PORT}\`);`);
|
|
733
|
+
lines.push(` console.log(\` HTTP routes:\`);`);
|
|
734
|
+
for (const mod of apiModules) {
|
|
735
|
+
const model = mod.models[0];
|
|
736
|
+
if (!model) continue;
|
|
737
|
+
lines.push(` console.log(\` /${toSnakeCase(model.name)}s\`);`);
|
|
738
|
+
}
|
|
739
|
+
if (hasWebSocket) {
|
|
740
|
+
lines.push(` console.log(\` WebSocket: /ws?channel=<name>&token=<jwt>\`);`);
|
|
741
|
+
}
|
|
742
|
+
lines.push(` console.log(\` Health: /health/live, /health/ready, /health/metrics\`);`);
|
|
743
|
+
lines.push(`});`);
|
|
744
|
+
lines.push(``);
|
|
745
|
+
|
|
746
|
+
// Graceful shutdown
|
|
747
|
+
lines.push(`// Graceful shutdown`);
|
|
748
|
+
lines.push(`const shutdown = async (signal: string) => {`);
|
|
749
|
+
lines.push(` logger.info("server_stopping", { event: "shutdown", metadata: { signal } });`);
|
|
750
|
+
lines.push(` httpServer.close(async () => {`);
|
|
751
|
+
lines.push(` try {`);
|
|
752
|
+
lines.push(` await pool.end();`);
|
|
753
|
+
lines.push(` logger.info("server_stopped", { event: "shutdown", status: "success" });`);
|
|
754
|
+
lines.push(` } catch (e: any) {`);
|
|
755
|
+
lines.push(` logger.error("shutdown_error", { event: "shutdown", metadata: { error: e.message } });`);
|
|
756
|
+
lines.push(` }`);
|
|
757
|
+
lines.push(` process.exit(0);`);
|
|
758
|
+
lines.push(` });`);
|
|
759
|
+
lines.push(` // Force exit after 10s if connections don't drain`);
|
|
760
|
+
lines.push(` setTimeout(() => { logger.error("shutdown_timeout", { event: "shutdown" }); process.exit(1); }, 10000);`);
|
|
761
|
+
lines.push(`};`);
|
|
762
|
+
lines.push(`process.on("SIGTERM", () => shutdown("SIGTERM"));`);
|
|
763
|
+
lines.push(`process.on("SIGINT", () => shutdown("SIGINT"));`);
|
|
764
|
+
lines.push(``);
|
|
765
|
+
lines.push(`export { app, httpServer };`);
|
|
766
|
+
|
|
767
|
+
return lines.join("\n");
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ─── Migration Script ─────────────────────────────────────────────────────────
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* A migration block paired with a stable identifier and content checksum.
|
|
774
|
+
* - `id` is a human-readable, ordered name (e.g. "0001_create_sellers").
|
|
775
|
+
* - `checksum` is a sha256 of the SQL body — used by the ledger to detect
|
|
776
|
+
* tampering with already-applied migrations.
|
|
777
|
+
*/
|
|
778
|
+
export interface MigrationBlock {
|
|
779
|
+
id: string;
|
|
780
|
+
checksum: string;
|
|
781
|
+
sql: string;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Build deterministic migration blocks from raw schema strings.
|
|
786
|
+
* The order of `schemas` is preserved; each block gets a zero-padded prefix
|
|
787
|
+
* so lexicographic sort matches insertion order.
|
|
788
|
+
*/
|
|
789
|
+
export function buildMigrationBlocks(schemas: string[]): MigrationBlock[] {
|
|
790
|
+
const blocks: MigrationBlock[] = [];
|
|
791
|
+
schemas.forEach((sql, i) => {
|
|
792
|
+
const checksum = createHash("sha256").update(sql).digest("hex");
|
|
793
|
+
// Try to extract the module name from the schema header for a friendlier id.
|
|
794
|
+
const moduleMatch = sql.match(/^--\s*Module:\s*(\S+)/m);
|
|
795
|
+
const tableMatch = sql.match(/CREATE TABLE IF NOT EXISTS\s+(\w+)/i);
|
|
796
|
+
const slug = (tableMatch?.[1] || moduleMatch?.[1] || `block_${i}`)
|
|
797
|
+
.replace(/[^a-zA-Z0-9_]/g, "_")
|
|
798
|
+
.toLowerCase();
|
|
799
|
+
const id = `${String(i).padStart(4, "0")}_${slug}`;
|
|
800
|
+
blocks.push({ id, checksum, sql });
|
|
801
|
+
});
|
|
802
|
+
return blocks;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
export function emitMigration(_system: IR.IRSystem, schemas: string[]): string {
|
|
806
|
+
const blocks = buildMigrationBlocks(schemas);
|
|
807
|
+
|
|
808
|
+
const lines: string[] = [];
|
|
809
|
+
lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
|
|
810
|
+
lines.push(`// Migration runner with a schema_migrations ledger.`);
|
|
811
|
+
lines.push(`// Each block is hashed at compile time; runs are skipped if the same`);
|
|
812
|
+
lines.push(`// (id, checksum) pair has already been recorded as applied.`);
|
|
813
|
+
lines.push(`require("dotenv").config();`);
|
|
814
|
+
lines.push(`import * as fs from "fs";`);
|
|
815
|
+
lines.push(`import * as path from "path";`);
|
|
816
|
+
lines.push(`import { createHash } from "crypto";`);
|
|
817
|
+
lines.push(`import { pool } from "./db";`);
|
|
818
|
+
lines.push(``);
|
|
819
|
+
lines.push(`interface Block { id: string; checksum: string; sql: string; }`);
|
|
820
|
+
lines.push(``);
|
|
821
|
+
lines.push(`const GENERATED_BLOCKS: Block[] = [`);
|
|
822
|
+
for (const b of blocks) {
|
|
823
|
+
const escaped = b.sql.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$/g, "\\$");
|
|
824
|
+
lines.push(` {`);
|
|
825
|
+
lines.push(` id: ${JSON.stringify(b.id)},`);
|
|
826
|
+
lines.push(` checksum: ${JSON.stringify(b.checksum)},`);
|
|
827
|
+
lines.push(` sql: \`${escaped}\`,`);
|
|
828
|
+
lines.push(` },`);
|
|
829
|
+
}
|
|
830
|
+
lines.push(`];`);
|
|
831
|
+
lines.push(``);
|
|
832
|
+
lines.push(`/**`);
|
|
833
|
+
lines.push(` * Pick up any hand-authored migrations dropped into migrations/_manual/.`);
|
|
834
|
+
lines.push(` * Files are picked up in lexicographic order; \`bonec diff --write\` writes`);
|
|
835
|
+
lines.push(` * them with a numeric prefix so insertion order is stable.`);
|
|
836
|
+
lines.push(` */`);
|
|
837
|
+
lines.push(`function loadManualBlocks(): Block[] {`);
|
|
838
|
+
lines.push(` const dir = path.resolve(__dirname, "..", "migrations", "_manual");`);
|
|
839
|
+
lines.push(` if (!fs.existsSync(dir)) return [];`);
|
|
840
|
+
lines.push(` return fs.readdirSync(dir)`);
|
|
841
|
+
lines.push(` .filter(f => f.endsWith(".sql"))`);
|
|
842
|
+
lines.push(` .sort()`);
|
|
843
|
+
lines.push(` .map(f => {`);
|
|
844
|
+
lines.push(` const sql = fs.readFileSync(path.join(dir, f), "utf-8");`);
|
|
845
|
+
lines.push(` return {`);
|
|
846
|
+
lines.push(` id: "manual_" + f.replace(/\\.sql$/, ""),`);
|
|
847
|
+
lines.push(` checksum: createHash("sha256").update(sql).digest("hex"),`);
|
|
848
|
+
lines.push(` sql,`);
|
|
849
|
+
lines.push(` };`);
|
|
850
|
+
lines.push(` });`);
|
|
851
|
+
lines.push(`}`);
|
|
852
|
+
lines.push(``);
|
|
853
|
+
lines.push(`const LEDGER_DDL = \`CREATE TABLE IF NOT EXISTS schema_migrations (`);
|
|
854
|
+
lines.push(` id VARCHAR PRIMARY KEY,`);
|
|
855
|
+
lines.push(` checksum VARCHAR NOT NULL,`);
|
|
856
|
+
lines.push(` applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),`);
|
|
857
|
+
lines.push(` applied_by VARCHAR NOT NULL DEFAULT current_user,`);
|
|
858
|
+
lines.push(` duration_ms INTEGER NOT NULL DEFAULT 0`);
|
|
859
|
+
lines.push(`);\`;`);
|
|
860
|
+
lines.push(``);
|
|
861
|
+
lines.push(`async function migrate() {`);
|
|
862
|
+
lines.push(` const client = await pool.connect();`);
|
|
863
|
+
lines.push(` let applied = 0, skipped = 0;`);
|
|
864
|
+
lines.push(` try {`);
|
|
865
|
+
lines.push(` await client.query(LEDGER_DDL);`);
|
|
866
|
+
lines.push(``);
|
|
867
|
+
lines.push(` // Generated blocks come first (compile-time schema), then manual diffs.`);
|
|
868
|
+
lines.push(` const allBlocks: Block[] = [...GENERATED_BLOCKS, ...loadManualBlocks()];`);
|
|
869
|
+
lines.push(``);
|
|
870
|
+
lines.push(` // Load already-applied entries.`);
|
|
871
|
+
lines.push(` const existing = await client.query(`);
|
|
872
|
+
lines.push(` "SELECT id, checksum FROM schema_migrations"`);
|
|
873
|
+
lines.push(` );`);
|
|
874
|
+
lines.push(` const rows = existing.rows as Array<{ id: string; checksum: string }>;`);
|
|
875
|
+
lines.push(` const appliedById = new Map(rows.map(r => [r.id, r.checksum]));`);
|
|
876
|
+
lines.push(``);
|
|
877
|
+
lines.push(` for (const block of allBlocks) {`);
|
|
878
|
+
lines.push(` const prior = appliedById.get(block.id);`);
|
|
879
|
+
lines.push(` if (prior === block.checksum) {`);
|
|
880
|
+
lines.push(` skipped++;`);
|
|
881
|
+
lines.push(` continue;`);
|
|
882
|
+
lines.push(` }`);
|
|
883
|
+
lines.push(` if (prior && prior !== block.checksum) {`);
|
|
884
|
+
lines.push(` // Someone changed an already-applied migration. Refuse to proceed —`);
|
|
885
|
+
lines.push(` // an explicit migration file should be authored instead.`);
|
|
886
|
+
lines.push(` throw new Error(`);
|
|
887
|
+
lines.push(" `Migration ${block.id} was previously applied with a different ` +");
|
|
888
|
+
lines.push(" `checksum. Generate a new migration via 'bonec diff --write' ` +");
|
|
889
|
+
lines.push(" `instead of editing existing schemas.`");
|
|
890
|
+
lines.push(` );`);
|
|
891
|
+
lines.push(` }`);
|
|
892
|
+
lines.push(``);
|
|
893
|
+
lines.push(` const start = Date.now();`);
|
|
894
|
+
lines.push(` try {`);
|
|
895
|
+
lines.push(` await client.query("BEGIN");`);
|
|
896
|
+
lines.push(` await client.query(block.sql);`);
|
|
897
|
+
lines.push(` await client.query(`);
|
|
898
|
+
lines.push(` "INSERT INTO schema_migrations (id, checksum, duration_ms) VALUES ($1, $2, $3)",`);
|
|
899
|
+
lines.push(` [block.id, block.checksum, Date.now() - start]`);
|
|
900
|
+
lines.push(` );`);
|
|
901
|
+
lines.push(` await client.query("COMMIT");`);
|
|
902
|
+
lines.push(" console.log(` applied ${block.id} (${Date.now() - start}ms)`);");
|
|
903
|
+
lines.push(` applied++;`);
|
|
904
|
+
lines.push(` } catch (e) {`);
|
|
905
|
+
lines.push(` await client.query("ROLLBACK").catch(() => {});`);
|
|
906
|
+
lines.push(` throw e;`);
|
|
907
|
+
lines.push(` }`);
|
|
908
|
+
lines.push(` }`);
|
|
909
|
+
lines.push(``);
|
|
910
|
+
lines.push(" console.log(`Migrations complete: ${applied} applied, ${skipped} already up to date.`);");
|
|
911
|
+
lines.push(` } finally {`);
|
|
912
|
+
lines.push(` client.release();`);
|
|
913
|
+
lines.push(` await pool.end();`);
|
|
914
|
+
lines.push(` }`);
|
|
915
|
+
lines.push(`}`);
|
|
916
|
+
lines.push(``);
|
|
917
|
+
lines.push(`migrate().catch(e => { console.error(e); process.exit(1); });`);
|
|
918
|
+
|
|
919
|
+
return lines.join("\n");
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// ─── Unknown Function Collector ───────────────────────────────────────────────
|
|
923
|
+
// Finds function calls in effect expressions that aren't built-ins or known names.
|
|
924
|
+
|
|
925
|
+
const KNOWN_FUNCTIONS = new Set([
|
|
926
|
+
"now", "count", "sum", "min", "max", "avg", "length", "size",
|
|
927
|
+
"shortestPath", "topologicalSort", "binarySearch", "bipartiteMatching",
|
|
928
|
+
"roundRobin", "weightedAverage", "percentile", "rankBy", "consistentHash",
|
|
929
|
+
]);
|
|
930
|
+
|
|
931
|
+
function collectUnknownFunctions(mod: IR.IRModule): Set<string> {
|
|
932
|
+
const found = new Set<string>();
|
|
933
|
+
for (const iface of mod.interfaces) {
|
|
934
|
+
for (const method of iface.methods) {
|
|
935
|
+
for (const effect of method.effects) {
|
|
936
|
+
extractFunctionCalls(effect.value, found);
|
|
937
|
+
}
|
|
938
|
+
for (const pre of method.preconditions) {
|
|
939
|
+
extractFunctionCalls(pre.expression, found);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
return found;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function extractFunctionCalls(expr: string, found: Set<string>): void {
|
|
947
|
+
const pattern = /\b([a-zA-Z_]\w*)\s*\(/g;
|
|
948
|
+
let match: RegExpExecArray | null;
|
|
949
|
+
while ((match = pattern.exec(expr)) !== null) {
|
|
950
|
+
const name = match[1];
|
|
951
|
+
if (!KNOWN_FUNCTIONS.has(name)) {
|
|
952
|
+
found.add(name);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|