@palbase/backend 25.1.0 → 27.0.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.
Files changed (104) hide show
  1. package/dist/bin/palbase-backend.cjs +2401 -1039
  2. package/dist/bin/palbase-backend.cjs.map +1 -1
  3. package/dist/bin/palbase-backend.js +87 -51
  4. package/dist/bin/palbase-backend.js.map +1 -1
  5. package/dist/chunk-CGNN2PUH.js +213 -0
  6. package/dist/chunk-CGNN2PUH.js.map +1 -0
  7. package/dist/chunk-DRZFQRJI.js +370 -0
  8. package/dist/chunk-DRZFQRJI.js.map +1 -0
  9. package/dist/chunk-GYK6QYS4.js +708 -0
  10. package/dist/chunk-GYK6QYS4.js.map +1 -0
  11. package/dist/chunk-I3C4PFIW.js +930 -0
  12. package/dist/chunk-I3C4PFIW.js.map +1 -0
  13. package/dist/{chunk-VDF2T4AS.js → chunk-OO7R25AI.js} +1213 -591
  14. package/dist/chunk-OO7R25AI.js.map +1 -0
  15. package/dist/chunk-TS4U7NBD.js +885 -0
  16. package/dist/chunk-TS4U7NBD.js.map +1 -0
  17. package/dist/{chunk-YOY5DFQS.js → chunk-TWX6JTGJ.js} +76 -34
  18. package/dist/{chunk-YOY5DFQS.js.map → chunk-TWX6JTGJ.js.map} +1 -1
  19. package/dist/{chunk-35PNTIRN.js → chunk-VVMJEVQP.js} +63 -162
  20. package/dist/chunk-VVMJEVQP.js.map +1 -0
  21. package/dist/{chunk-7D4SUZUM.js → chunk-VXPNPVAG.js} +3 -1
  22. package/dist/db/env.cjs.map +1 -1
  23. package/dist/db/env.d.cts +2 -2
  24. package/dist/db/env.d.ts +2 -2
  25. package/dist/db/index.cjs +754 -344
  26. package/dist/db/index.cjs.map +1 -1
  27. package/dist/db/index.d.cts +2 -2
  28. package/dist/db/index.d.ts +2 -2
  29. package/dist/db/index.js +7 -4
  30. package/dist/engine/index.cjs +2339 -1006
  31. package/dist/engine/index.cjs.map +1 -1
  32. package/dist/engine/index.d.cts +6 -6
  33. package/dist/engine/index.d.ts +6 -6
  34. package/dist/engine/index.js +7 -6
  35. package/dist/index-BrvvxSpn.d.ts +4844 -0
  36. package/dist/index-Bve7BBTL.d.cts +4844 -0
  37. package/dist/{index-CUomTA3e.d.ts → index-NuzRCuxe.d.ts} +171 -296
  38. package/dist/{index-ClpDeSos.d.cts → index-VtToZmUm.d.cts} +171 -296
  39. package/dist/index.cjs +2694 -1169
  40. package/dist/index.cjs.map +1 -1
  41. package/dist/index.d.cts +165 -19
  42. package/dist/index.d.ts +165 -19
  43. package/dist/index.js +738 -477
  44. package/dist/index.js.map +1 -1
  45. package/dist/module-Dl1KFVtc.d.cts +54 -0
  46. package/dist/module-Dl1KFVtc.d.ts +54 -0
  47. package/dist/openapi/index.cjs +1330 -484
  48. package/dist/openapi/index.cjs.map +1 -1
  49. package/dist/openapi/index.d.cts +4 -2
  50. package/dist/openapi/index.d.ts +4 -2
  51. package/dist/openapi/index.js +1264 -474
  52. package/dist/openapi/index.js.map +1 -1
  53. package/dist/{registry-dZZ5JKYg.d.ts → registry-B0eyOF9x.d.ts} +1 -1
  54. package/dist/{registry-CC0WBQq6.d.cts → registry-Bk9_rbNd.d.cts} +1 -1
  55. package/dist/stack.cjs.map +1 -1
  56. package/dist/test/index.cjs +705 -141
  57. package/dist/test/index.cjs.map +1 -1
  58. package/dist/test/index.d.cts +30 -4
  59. package/dist/test/index.d.ts +30 -4
  60. package/dist/test/index.js +472 -124
  61. package/dist/test/index.js.map +1 -1
  62. package/docs/README.md +33 -18
  63. package/docs/auth.md +1 -1
  64. package/docs/background.md +2 -2
  65. package/docs/database.md +221 -50
  66. package/docs/endpoints.md +3 -4
  67. package/docs/events.md +3 -3
  68. package/docs/getting-started.md +1 -1
  69. package/docs/llms-full.txt +401 -117
  70. package/docs/migrations.md +2 -2
  71. package/docs/schema.md +19 -10
  72. package/docs/services.md +116 -26
  73. package/package.json +8 -4
  74. package/stager/generics.js +205 -0
  75. package/stager/stage.js +39 -3
  76. package/template/AGENTS.md +110 -72
  77. package/template/db/public.ts +1 -1
  78. package/template/{controllers → modules/health}/health.controller.ts +1 -1
  79. package/template/modules/health/health.module.ts +24 -0
  80. package/template/modules/notes/note.service.test.ts +49 -0
  81. package/template/modules/notes/note.service.ts +108 -0
  82. package/template/{controllers → modules/notes}/notes.controller.ts +17 -11
  83. package/template/modules/notes/notes.module.ts +37 -0
  84. package/template/package.json +5 -3
  85. package/template/scripts/test.sh +33 -0
  86. package/template/tsconfig.json +29 -30
  87. package/dist/chunk-35PNTIRN.js.map +0 -1
  88. package/dist/chunk-CJSKYY76.js +0 -627
  89. package/dist/chunk-CJSKYY76.js.map +0 -1
  90. package/dist/chunk-CRQKCRGF.js +0 -276
  91. package/dist/chunk-CRQKCRGF.js.map +0 -1
  92. package/dist/chunk-G4R6BTLV.js +0 -662
  93. package/dist/chunk-G4R6BTLV.js.map +0 -1
  94. package/dist/chunk-VDF2T4AS.js.map +0 -1
  95. package/dist/chunk-XABBC7JP.js +0 -55
  96. package/dist/chunk-XABBC7JP.js.map +0 -1
  97. package/dist/endpoint-CTEHhb7A.d.ts +0 -2386
  98. package/dist/endpoint-DYHMo6cC.d.cts +0 -2386
  99. package/dist/index-CW21M9Z3.d.ts +0 -1222
  100. package/dist/index-CmBK76nx.d.cts +0 -1222
  101. package/template/services/note.service.test.ts +0 -45
  102. package/template/services/note.service.ts +0 -76
  103. /package/dist/{chunk-7D4SUZUM.js.map → chunk-VXPNPVAG.js.map} +0 -0
  104. /package/template/{models/notes → modules/notes/dto}/create.ts +0 -0
@@ -95,7 +95,7 @@ one, `palbase push` is what moves an Environment's. Until then the declaration i
95
95
  ahead of the tables.
96
96
 
97
97
  The TYPES move separately again. `palbase build` regenerates `palbase-env.d.ts`
98
- from `db/*.ts`, which is what types `Database.tables.<name>` in your
98
+ from `db/*.ts`, which is what types `Database.public.<name>` in your
99
99
  services — so after a schema edit, run it. (There is no second command for this:
100
100
  `build` regenerates everything derived, because one verb to remember is one verb
101
101
  to forget.) Typed and applied are independent, and knowing which one you are
@@ -168,7 +168,7 @@ existing data gets there.
168
168
  Add `rls: true` + `policies: [policy(...)]` to a table in `db/*.ts`; the
169
169
  generated migration emits the `ENABLE ROW LEVEL SECURITY` + `CREATE POLICY` DDL.
170
170
  See [schema.md](./schema.md) for the column builders, the policy DSL, and typed
171
- `Database.tables.*` access.
171
+ `Database.public.*` access.
172
172
 
173
173
  ### Hand-writing a policy
174
174
 
package/docs/schema.md CHANGED
@@ -4,7 +4,7 @@ Declare your tables under `db/`, **one file per schema**: `db/public.ts` is the
4
4
  schema Palbase expects to find, `db/billing.ts` declares a second one. Each file
5
5
  default-exports a `defineSchema("<name>", { tables })` call. That drives
6
6
  [migrations](./migrations.md) (additive changes auto-apply on deploy; type
7
- changes need an explicit migration) and makes `Database.tables.*` typed
7
+ changes need an explicit migration) and makes `Database.public.*` typed
8
8
  everywhere — by default, with no import and no generic.
9
9
 
10
10
  > Coming from a single `db/schema.ts` with tables declared inline? That layout is
@@ -411,14 +411,14 @@ and an index is not one.
411
411
  ## Typed DB access — by default
412
412
 
413
413
  You do **not** wire anything per endpoint. Saving a file under `db/` regenerates
414
- `palbase-env.d.ts`, which types `Database.tables.<name>` everywhere — no import
414
+ `palbase-env.d.ts`, which types `Database.public.<name>` everywhere — no import
415
415
  of the schema, no generic, no cast:
416
416
 
417
417
  ```ts
418
418
  // services/room.service.ts — the layer that touches the database.
419
419
  import { Database } from "@palbase/backend";
420
420
 
421
- type RoomsTable = typeof Database.tables.rooms; // typed from your db/*.ts
421
+ type RoomsTable = typeof Database.public.rooms; // typed from your db/*.ts
422
422
 
423
423
  export class RoomService {
424
424
  private readonly rooms: RoomsTable;
@@ -431,13 +431,22 @@ export class RoomService {
431
431
  }
432
432
  }
433
433
 
434
- export const roomService = new RoomService(Database.tables.rooms);
434
+ ```
435
+
436
+ ```ts
437
+ // rooms.module.ts
438
+ import { Module, type Token } from "@palbase/backend";
439
+ import { RoomsController } from "./controllers/rooms.controller.ts";
440
+ import { RoomService } from "./services/room.service.ts";
441
+
442
+ @Module({ controllers: [RoomsController as Token], providers: [RoomService as Token] })
443
+ export class RoomsModule {}
435
444
  ```
436
445
 
437
446
  ```ts
438
447
  // controllers/rooms.controller.ts — HTTP only; no `Database` import here.
439
448
  import { Controller, Post, Body, z } from "@palbase/backend";
440
- import { roomService } from "../services/room.service.js";
449
+ import { RoomService } from "../services/room.service.js";
441
450
 
442
451
  const CreateRoomBody = z.object({ name: z.string() });
443
452
  const RoomOut = z.object({ id: z.string(), name: z.string() });
@@ -453,12 +462,12 @@ export default class RoomsController {
453
462
  }
454
463
  ```
455
464
 
456
- `Database.tables.<name>` exposes `insert`, `update(id, data)`, `delete(id)`,
457
- `findById(id)`, `findMany(query?)`. `Database.transaction(fn)` yields a `tx`
465
+ `Database.public.<name>` exposes `insert`, `update(id, data)`, `delete(id)`,
466
+ `findById(id)`, `findMany(query?)`. `Database.$transaction(fn)` yields a `tx`
458
467
  whose `tx.tables.<name>` is typed from the same schema, but carries plan
459
468
  operations (`insert`/`insertMany`/`updateWhere`/`deleteWhere`/`select`) rather
460
469
  than awaited calls — see [database.md](./database.md#transactions). The raw
461
- string-keyed ops (`Database.insert("rooms", …)`, `Database.query(…)`) are still
470
+ string-keyed ops (`Database.$insert("rooms", …)`, `Database.$query(…)`) are still
462
471
  available for dynamic table names and read-only SQL.
463
472
 
464
473
  If you want a row type explicitly, import it from the generated env module:
@@ -579,10 +588,10 @@ export const todos = defineTable("todos", {
579
588
  export default defineSchema("public", { tables: [todos] });
580
589
  ```
581
590
 
582
- With this in place, `await Database.tables.todos.findMany({})` returns only the
591
+ With this in place, `await Database.public.todos.findMany({})` returns only the
583
592
  calling user's rows — no `WHERE owner = …` needed in the handler. To read or
584
593
  write across all users (e.g. an admin job), use the explicit bypass:
585
- `Database.asService()` (see [database.md](./database.md#bypassing-rls--databaseasservice)).
594
+ `Database.$asService()` (see [database.md](./database.md#bypassing-rls--databaseasservice)).
586
595
 
587
596
  ### How policies are applied
588
597
 
package/docs/services.md CHANGED
@@ -45,7 +45,7 @@ await Cache.del("k");
45
45
  // Stampede-safe read-through: concurrent callers wait on ONE execution of fn
46
46
  // and share its result, however many of them there are.
47
47
  const profile = await Cache.getOrSet("user:42", 300, async () => {
48
- return Database.findById("users", "42");
48
+ return Database.$findById("users", "42");
49
49
  });
50
50
  ```
51
51
 
@@ -153,7 +153,7 @@ export default class CheckoutController {
153
153
 
154
154
  ### Overriding a flag — current user vs cross-user
155
155
 
156
- Writes mirror the `Database` / `Database.asService()` model:
156
+ Writes mirror the `Database` / `Database.$asService()` model:
157
157
 
158
158
  - `Flags.setOverride(key, value)` (default) writes an override for the **current
159
159
  request user** — no `userId` argument, no admin power. It errors on an
@@ -161,7 +161,7 @@ Writes mirror the `Database` / `Database.asService()` model:
161
161
  - `Flags.asService()` returns the cross-user admin surface
162
162
  (`setOverrideForUser`, `setOverridesForUser`, `clearOverrideForUser`,
163
163
  `clearAllOverridesForUser`, `batchSetOverrides`) for writing overrides for an
164
- **arbitrary** user. Explicit and greppable, just like `Database.asService()`.
164
+ **arbitrary** user. Explicit and greppable, just like `Database.$asService()`.
165
165
 
166
166
  ```ts
167
167
  // Current request user — no userId needed:
@@ -230,47 +230,137 @@ A controller's job is HTTP — validate a body through a named schema, name the
230
230
  whose, in what order is the service's. A controller that reaches for `Database`
231
231
  has moved the logic into the layer that is hardest to test.
232
232
 
233
- **2. The dependency arrives through the CONSTRUCTOR, and the module exports one
234
- wired instance.** There is no DI container and no decorator the last line of
235
- the file is the wiring:
233
+ **2. A dependency arrives through the CONSTRUCTOR, and the container supplies
234
+ it.** Mark the class `@Injectable()` and name what it needs as ordinary
235
+ constructor parameters:
236
236
 
237
237
  ```ts
238
238
  // services/note.service.ts
239
- import { Database } from "@palbase/backend";
239
+ import { Database, Injectable } from "@palbase/backend";
240
240
 
241
- type NotesTable = typeof Database.tables.notes;
241
+ type NotesTable = typeof Database.public.notes;
242
242
 
243
+ @Injectable()
243
244
  export class NoteService {
244
- private readonly notes: NotesTable;
245
-
246
- // Assigned in the BODY. A parameter property (`constructor(private notes: …)`)
247
- // is refused by Node's type-stripping test runner, for the whole file.
248
- constructor(notes: NotesTable) {
249
- this.notes = notes;
250
- }
245
+ private readonly notes: NotesTable = Database.public.notes;
251
246
 
252
247
  list(userId: string) {
253
- return this.notes.findMany({ user_id: userId });
248
+ return this.notes.findMany({ where: { user_id: userId } });
254
249
  }
255
250
  }
251
+ ```
256
252
 
257
- export const noteService = new NoteService(Database.tables.notes);
253
+ ```ts
254
+ // services/report.service.ts — a service that depends on another service
255
+ import { Injectable } from "@palbase/backend";
256
+ import { NoteService } from "./note.service.ts";
257
+
258
+ @Injectable()
259
+ export class ReportService {
260
+ constructor(private readonly notes: NoteService) {}
261
+
262
+ async count(userId: string): Promise<number> {
263
+ return (await this.notes.list(userId)).length;
264
+ }
265
+ }
258
266
  ```
259
267
 
268
+ A controller asks the same way, and nothing wires it by hand:
269
+
260
270
  ```ts
261
271
  // controllers/notes.controller.ts
262
- import { noteService } from "../services/note.service";
272
+ import { Controller, Get } from "@palbase/backend";
273
+ import { NoteService } from "../services/note.service.ts";
274
+
275
+ @Controller("/notes")
276
+ export class NotesController {
277
+ constructor(private readonly notes: NoteService) {}
278
+
279
+ @Get("") list(): Promise<Note[]> { return this.notes.list(currentUserId()); }
280
+ }
281
+ ```
282
+
283
+ There is **no `inject()`, no `@Inject`, and no token registry**. A dependency is
284
+ named by its parameter's TYPE and by nothing else — which is also why an
285
+ `interface` cannot be one: an interface does not exist at runtime, so there is
286
+ nothing to hand over. Use a class, or an `abstract class` when you want the
287
+ abstraction:
288
+
289
+ ```ts
290
+ export abstract class Clock {
291
+ abstract now(): number;
292
+ }
293
+
294
+ @Injectable()
295
+ export class SystemClock extends Clock {
296
+ now(): number { return Date.now(); }
297
+ }
263
298
  ```
264
299
 
265
- **3. That module-level singleton is the only way to hold a dependency.**
266
- Controllers, hooks, jobs and webhooks are all constructed by the runtime with
267
- **no arguments**. A class that declares a constructor parameter is refused at
268
- build with the class named there is no injector to fill it, so the field would
269
- otherwise be `undefined` in production. Import the singleton instead.
300
+ The abstraction is the TOKEN; the implementation is the PROVIDER. `SystemClock`
301
+ goes in a module's `providers` and `Clock` goes in nobody's: the container
302
+ resolves an abstraction to the single owned class that `extends` it. Listing
303
+ `Clock` itself is refused`new Clock()` succeeds in JavaScript and hands back
304
+ an object missing every abstract member, and that object would be injected.
305
+
306
+ **3. ONE module says what exists, who owns it, and what it may reach.** A class
307
+ that no module lists does not exist: it is refused at build, by name, and it
308
+ never reaches the route table, the dispatcher or the OpenAPI document.
309
+
310
+ ```ts
311
+ // notes.module.ts — beside the domain it owns, not in a directory we name
312
+ import { Module, type Token } from "@palbase/backend";
313
+ import { NotesController } from "./controllers/notes.controller.ts";
314
+ import { NoteService } from "./services/note.service.ts";
315
+ import { ReportService } from "./services/report.service.ts";
316
+
317
+ @Module({
318
+ controllers: [NotesController as Token], // the entry points this module owns
319
+ providers: [NoteService as Token, ReportService as Token], // what it owns
320
+ exports: [NoteService as Token], // what OTHER modules may reach
321
+ imports: [], // whose exports THIS module may reach
322
+ })
323
+ export class NotesModule {}
324
+ ```
325
+
326
+ The four lists answer four different questions, and nothing else answers them:
327
+
328
+ | list | question |
329
+ |---|---|
330
+ | `providers` | which classes does this module OWN |
331
+ | `controllers` | which entry points does it own |
332
+ | `exports` | which of its own classes may another module reach |
333
+ | `imports` | whose exports may this module reach |
334
+
335
+ `ReportService` is NOT exported above, so no other module can depend on it —
336
+ and the build says so by name if one tries. That is the whole boundary: a
337
+ module's internals stay internal until it says otherwise.
338
+
339
+ There is no root module and nothing to mount one into. A small project writes
340
+ one module file; a large one writes a module per domain and the boundaries are
341
+ enforced at build rather than by convention.
342
+
343
+ **A constructor stays pure wiring.** It runs while the app is coming up, it is
344
+ synchronous, and it must not do I/O. Real work at startup belongs in `onStart`,
345
+ which is awaited before the first request is served — a connection opened in a
346
+ constructor fails somewhere nobody is watching, and one opened in `onStart`
347
+ fails at boot with the error in front of the operator.
348
+
349
+ Rule 2 is also what makes rule 1 pay off. A test can build the graph with a
350
+ stand-in in place of any part of it, however deep:
351
+
352
+ ```ts
353
+ import { isolated } from "@palbase/backend/test";
354
+
355
+ const frozen = new (class extends Clock { now() { return 0; } })();
356
+ const report = isolated().with(Clock, frozen).get(ReportService);
357
+ ```
270
358
 
271
- Rule 2 is also what makes rule 1 pay off: because the service is handed its
272
- table rather than reaching for the singleton, a test constructs it with a
273
- stand-in and never needs a database.
359
+ `isolated()` rebuilds the graph with the overrides in place and touches no
360
+ process-wide state, so the next test in the same file does not meet whatever
361
+ this one substituted. Or construct the class yourself — `new ReportService(new
362
+ NoteService())` is ordinary TypeScript, and the container is not required for it
363
+ to work.
274
364
 
275
365
  ```ts
276
366
  // services/note.service.test.ts — `npm test`, no database
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palbase/backend",
3
- "version": "25.1.0",
3
+ "version": "27.0.0",
4
4
  "description": "Palbase Backend SDK — class controllers (@Controller/@Get/@Post + @Body/@QueryParams/@Param), error classes, schema DSL",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -103,21 +103,25 @@
103
103
  "emit:openapi-fixture": "tsx scripts/emit-openapi-fixture.mjs",
104
104
  "check:api": "node scripts/api-surface.mjs",
105
105
  "api:update": "node scripts/api-surface.mjs --update",
106
- "build:stager": "mkdir -p stager && cp src/stager/*.js src/stager/package.json stager/"
106
+ "build:stager": "mkdir -p stager && for f in src/stager/*.js; do case \"$f\" in *.test.js) ;; *) cp \"$f\" stager/ ;; esac; done && cp src/stager/package.json stager/"
107
107
  },
108
108
  "dependencies": {
109
109
  "@asteasolutions/zod-to-openapi": "^7.3.4",
110
+ "reflect-metadata": "^0.2.2",
110
111
  "zod": "^3.24.0"
111
112
  },
112
113
  "devDependencies": {
114
+ "@palbase/core": "workspace:^",
115
+ "@swc/core": "^1.16.1",
113
116
  "@types/node": "^25.5.2",
117
+ "bun-types": "^1.4.0",
114
118
  "fast-glob": "^3.3.3",
115
119
  "openapi3-ts": "^4.5.0",
116
120
  "tsup": "^8.0.0",
117
121
  "tsx": "^4.22.4",
118
122
  "typescript": "^5.7.0",
119
- "vitest": "^3.0.0",
120
- "@palbase/core": "workspace:^"
123
+ "unplugin-swc": "^1.5.11",
124
+ "vitest": "^3.0.0"
121
125
  },
122
126
  "publishConfig": {
123
127
  "access": "public"
@@ -0,0 +1,205 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Refuses constructor parameters the container cannot resolve HONESTLY, at the
5
+ * only place they are still visible: the source.
6
+ *
7
+ * Two shapes qualify, and both were MEASURED rather than assumed:
8
+ *
9
+ * Repo<A> `emitDecoratorMetadata` erases the type arguments — `Repo<A>` and
10
+ * `Repo<B>` both arrive as `Repo`. The container would hand the same
11
+ * instance to both and nothing at runtime could tell that was wrong.
12
+ *
13
+ * A | null The two transpilers this project depends on DISAGREE. Bun narrows
14
+ * it to `A`, SWC emits `Object`. The container accepts the first and
15
+ * refuses the second — so the SDK's own tests would certify a
16
+ * behaviour the tenant does not get. Cut at the root instead: reject
17
+ * the shape, and the disagreement can never be reached.
18
+ *
19
+ * WHY A TREE WALK AND NOT THE STAGING LOOP. The stager is handed
20
+ * `<project>/controllers` (stack_bundle.go) and stages that directory alone.
21
+ * Bolting this check to that loop would leave every service unexamined — and a
22
+ * service is where an injectable class normally lives. So the check reads every
23
+ * source file.
24
+ *
25
+ * WHICH CLASSES IT SPEAKS ABOUT — and this was narrowed after a real project
26
+ * was refused for a class nothing injects. `GooglePlacesError extends Error`,
27
+ * constructed by hand with `new GooglePlacesError("HTTP", …)`, was rejected for
28
+ * a union parameter it is entitled to have. The rule is not "every class": it is
29
+ * every class the container can CONSTRUCT, and that set is decided by a
30
+ * mechanism, not by a naming convention. `emitDecoratorMetadata` writes
31
+ * `design:paramtypes` onto a class only when the class carries a class
32
+ * decorator. Without it there are no emitted parameter types, so the container
33
+ * cannot inject the class and the Bun/SWC disagreement is unreachable. A
34
+ * decorated class is therefore exactly the set at risk — and reading the
35
+ * decorator's PRESENCE rather than its NAME keeps the check honest when
36
+ * `import { Injectable as Inj }` renames it.
37
+ *
38
+ * A class listed in a module's `providers` without a decorator is not a hole:
39
+ * the container refuses it on its own, naming the missing metadata.
40
+ */
41
+
42
+ const fs = require('node:fs');
43
+ const path = require('node:path');
44
+
45
+ let tsapi = null;
46
+
47
+ function ts() {
48
+ if (tsapi) return tsapi;
49
+ let mod;
50
+ try {
51
+ mod = require('typescript');
52
+ } catch (e) {
53
+ throw new Error(
54
+ 'generics: the `typescript` package could not be loaded (' +
55
+ e.message +
56
+ '). It is the parser this check reads your constructors with.',
57
+ );
58
+ }
59
+ // Same guard return_types.js carries, for the same reason: TypeScript 7 is the
60
+ // Go-native compiler and its CommonJS entry exports version metadata only, so
61
+ // `ts.ScriptTarget.Latest` would throw "Cannot read properties of undefined"
62
+ // and tell the reader nothing.
63
+ if (typeof mod.createSourceFile !== 'function' || !mod.ScriptTarget) {
64
+ throw new Error(
65
+ 'generics: the resolved `typescript` (v' +
66
+ (mod.version || 'unknown') +
67
+ ') has no compiler API — TypeScript 7 ships the Go-native compiler, whose ' +
68
+ 'CommonJS build exposes version metadata only.',
69
+ );
70
+ }
71
+ tsapi = mod;
72
+ return tsapi;
73
+ }
74
+
75
+ function messageFor(relPath, owner, index, typeText, kind) {
76
+ const why =
77
+ kind === 'generic'
78
+ ? 'Generic dependencies cannot be injected: decorator metadata erases the type ' +
79
+ 'arguments, so Repo<A> and Repo<B> resolve to the SAME instance and nothing at ' +
80
+ 'runtime can tell that apart.'
81
+ : 'Union and intersection dependencies cannot be injected: transpilers DISAGREE on ' +
82
+ 'them. Measured — Bun emits the class for `A | null`, SWC emits Object. The same ' +
83
+ 'code would behave one way in tests and another in production.';
84
+
85
+ const fixes =
86
+ kind === 'generic'
87
+ ? ' - depend on a concrete non-generic class\n' +
88
+ ' - or name the specialisation: class UserRepo extends Repo<User> {}'
89
+ : ' - depend on exactly one class\n' +
90
+ ' - if the dependency is genuinely optional, give it a null-object implementation';
91
+
92
+ return (
93
+ relPath +
94
+ ': ' +
95
+ owner +
96
+ ' constructor, parameter ' +
97
+ index +
98
+ ' is typed `' +
99
+ typeText +
100
+ '`.\n' +
101
+ why +
102
+ '\n\nPotential solutions:\n' +
103
+ fixes
104
+ );
105
+ }
106
+
107
+ /** Checks ONE file's source. Throws on the first violation, naming it. */
108
+ function assertNoGenericDeps(source, relPath) {
109
+ const t = ts();
110
+ const sf = t.createSourceFile(relPath, source, t.ScriptTarget.Latest, true);
111
+
112
+ /**
113
+ * Does this class carry a class decorator? That is the emitter's own
114
+ * condition for writing `design:paramtypes`, so it is the condition for the
115
+ * container being able to construct the class at all.
116
+ */
117
+ const isDecorated = (cls) => {
118
+ if (!cls) return false;
119
+ const decorators =
120
+ typeof t.canHaveDecorators === 'function' && t.canHaveDecorators(cls)
121
+ ? t.getDecorators(cls)
122
+ : cls.decorators;
123
+ return Array.isArray(decorators) && decorators.length > 0;
124
+ };
125
+
126
+ const visit = (node) => {
127
+ if (t.isConstructorDeclaration(node) && isDecorated(node.parent)) {
128
+ const owner =
129
+ node.parent && node.parent.name && node.parent.name.text
130
+ ? node.parent.name.text
131
+ : '<anonymous>';
132
+ node.parameters.forEach((p, i) => {
133
+ const ty = p.type;
134
+ if (!ty) return;
135
+ const isGeneric =
136
+ t.isTypeReferenceNode(ty) && ty.typeArguments && ty.typeArguments.length > 0;
137
+ const isUnion = t.isUnionTypeNode(ty) || t.isIntersectionTypeNode(ty);
138
+ if (isGeneric || isUnion) {
139
+ throw new Error(
140
+ messageFor(relPath, owner, i, ty.getText(sf), isGeneric ? 'generic' : 'union'),
141
+ );
142
+ }
143
+ });
144
+ }
145
+ t.forEachChild(node, visit);
146
+ };
147
+
148
+ t.forEachChild(sf, visit);
149
+ }
150
+
151
+ /**
152
+ * Directories that are never a tenant's source.
153
+ *
154
+ * `.palbase-staged-controllers` and `.palbase` hold machine-written copies of
155
+ * code that was already checked; walking them would report the same violation
156
+ * twice, from a path the author does not recognise.
157
+ */
158
+ const SKIP = new Set([
159
+ 'node_modules',
160
+ 'dist',
161
+ 'build',
162
+ '.git',
163
+ '.palbase',
164
+ '.palbase-staged-controllers',
165
+ '.palbase-build-controllers',
166
+ ]);
167
+
168
+ function sources(dir, root, out) {
169
+ let entries;
170
+ try {
171
+ entries = fs.readdirSync(dir, { withFileTypes: true });
172
+ } catch {
173
+ return out;
174
+ }
175
+ for (const entry of entries) {
176
+ if (entry.name.startsWith('.') && entry.name !== '.') {
177
+ if (SKIP.has(entry.name)) continue;
178
+ }
179
+ const full = path.join(dir, entry.name);
180
+ if (entry.isDirectory()) {
181
+ if (SKIP.has(entry.name)) continue;
182
+ sources(full, root, out);
183
+ } else if (/\.(c?ts|tsx)$/i.test(entry.name) && !/\.d\.ts$/i.test(entry.name)) {
184
+ out.push(full);
185
+ }
186
+ }
187
+ return out;
188
+ }
189
+
190
+ /**
191
+ * Checks every source file under `projectRoot`.
192
+ *
193
+ * Returns HOW MANY files were examined, deliberately. A walk whose filter is
194
+ * wrong finds nothing and reports success — the count is what separates
195
+ * "checked and clean" from "never looked", and the caller prints it.
196
+ */
197
+ function assertNoGenericDepsInTree(projectRoot) {
198
+ const files = sources(projectRoot, projectRoot, []);
199
+ for (const file of files) {
200
+ assertNoGenericDeps(fs.readFileSync(file, 'utf8'), path.relative(projectRoot, file));
201
+ }
202
+ return files.length;
203
+ }
204
+
205
+ module.exports = { assertNoGenericDeps, assertNoGenericDepsInTree };
package/stager/stage.js CHANGED
@@ -19,12 +19,33 @@
19
19
  const fs = require("node:fs");
20
20
  const path = require("node:path");
21
21
 
22
+ const generics = require("./generics.js");
22
23
  const returnTypes = require("./return_types.js");
23
24
  const throwAnalysis = require("./throw_analysis.js");
24
25
 
26
+ /**
27
+ * Directories a staged copy must never contain.
28
+ *
29
+ * The stager used to be handed `controllers/` and nothing else. It is now handed
30
+ * the PROJECT ROOT, because a module file imports its controllers by relative
31
+ * path — staging only `controllers/` produced a tree whose module files still
32
+ * pointed at the UN-staged sources, so the injected return types were bypassed
33
+ * and every response schema went back to `{}`.
34
+ *
35
+ * Copying the root means skipping what is not source: node_modules is large and
36
+ * resolves from above the staged tree anyway, and `.palbase*` holds build output
37
+ * including previous staging runs.
38
+ */
39
+ const SKIP = new Set(["node_modules", "dist", "build", ".git", ".palbase"]);
40
+
25
41
  function walk(dir) {
26
42
  const out = [];
27
43
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
44
+ // Skipped by NAME, not by type: `node_modules` is often a SYMLINK (pnpm, and
45
+ // any workspace that links a local package), and `isDirectory()` is false
46
+ // for one — so a type-gated check walked straight into it and tried to
47
+ // `copyFileSync` a symlinked directory. Measured: ENOTSUP, copyfile.
48
+ if (SKIP.has(entry.name) || entry.name.startsWith(".palbase")) continue;
28
49
  const full = path.join(dir, entry.name);
29
50
  if (entry.isDirectory()) out.push(...walk(full));
30
51
  else out.push(full);
@@ -38,6 +59,16 @@ function walk(dir) {
38
59
  * tree and must still resolve.
39
60
  */
40
61
  function stageControllers(srcDir, stageDir, projectRoot) {
62
+ // Refuse unresolvable constructor shapes BEFORE staging, and over the whole
63
+ // project — not inside the loop below.
64
+ //
65
+ // The loop only ever sees `controllers/`: stack_bundle.go invokes this script
66
+ // as `stage.js <dir>/controllers <stageDir> <dir>`. A check bolted to it would
67
+ // leave every service unexamined, and a service is where an injectable class
68
+ // normally lives. `projectRoot` is the third argument precisely so a check
69
+ // can be wider than the staging copy.
70
+ const examined = generics.assertNoGenericDepsInTree(projectRoot);
71
+
41
72
  let injected = 0;
42
73
  for (const file of walk(srcDir)) {
43
74
  const rel = path.relative(srcDir, file);
@@ -62,7 +93,7 @@ function stageControllers(srcDir, stageDir, projectRoot) {
62
93
  fs.copyFileSync(file, dest);
63
94
  }
64
95
  }
65
- return injected;
96
+ return { injected, examined };
66
97
  }
67
98
 
68
99
  module.exports = { stageControllers };
@@ -73,6 +104,11 @@ if (require.main === module) {
73
104
  console.error("usage: stage.js <controllers-dir> <staging-dir> <project-root>");
74
105
  process.exit(2);
75
106
  }
76
- const injected = stageControllers(srcDir, stageDir, projectRoot);
77
- console.log(`typed ${injected} controller file(s) from their return types`);
107
+ const { injected, examined } = stageControllers(srcDir, stageDir, projectRoot);
108
+ // The examined count is printed on purpose: a walk whose filter is wrong finds
109
+ // nothing and reports success. `checked 0 source file(s)` is a visible fault.
110
+ console.log(
111
+ `checked ${examined} source file(s) for unresolvable constructor shapes; ` +
112
+ `typed ${injected} controller file(s) from their return types`,
113
+ );
78
114
  }