@palbase/backend 25.0.4 → 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.
- package/dist/bin/palbase-backend.cjs +2622 -1012
- package/dist/bin/palbase-backend.cjs.map +1 -1
- package/dist/bin/palbase-backend.js +87 -51
- package/dist/bin/palbase-backend.js.map +1 -1
- package/dist/chunk-CGNN2PUH.js +213 -0
- package/dist/chunk-CGNN2PUH.js.map +1 -0
- package/dist/chunk-DRZFQRJI.js +370 -0
- package/dist/chunk-DRZFQRJI.js.map +1 -0
- package/dist/chunk-GYK6QYS4.js +708 -0
- package/dist/chunk-GYK6QYS4.js.map +1 -0
- package/dist/chunk-I3C4PFIW.js +930 -0
- package/dist/chunk-I3C4PFIW.js.map +1 -0
- package/dist/{chunk-AILPKEK5.js → chunk-OO7R25AI.js} +1433 -563
- package/dist/chunk-OO7R25AI.js.map +1 -0
- package/dist/chunk-TS4U7NBD.js +885 -0
- package/dist/chunk-TS4U7NBD.js.map +1 -0
- package/dist/{chunk-YOY5DFQS.js → chunk-TWX6JTGJ.js} +76 -34
- package/dist/{chunk-YOY5DFQS.js.map → chunk-TWX6JTGJ.js.map} +1 -1
- package/dist/{chunk-35PNTIRN.js → chunk-VVMJEVQP.js} +63 -162
- package/dist/chunk-VVMJEVQP.js.map +1 -0
- package/dist/{chunk-7D4SUZUM.js → chunk-VXPNPVAG.js} +3 -1
- package/dist/db/env.cjs.map +1 -1
- package/dist/db/env.d.cts +2 -2
- package/dist/db/env.d.ts +2 -2
- package/dist/db/index.cjs +754 -344
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +2 -2
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.js +7 -4
- package/dist/engine/index.cjs +2562 -981
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.d.cts +6 -6
- package/dist/engine/index.d.ts +6 -6
- package/dist/engine/index.js +7 -6
- package/dist/index-BrvvxSpn.d.ts +4844 -0
- package/dist/index-Bve7BBTL.d.cts +4844 -0
- package/dist/{index-CUomTA3e.d.ts → index-NuzRCuxe.d.ts} +171 -296
- package/dist/{index-ClpDeSos.d.cts → index-VtToZmUm.d.cts} +171 -296
- package/dist/index.cjs +2694 -1169
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +165 -19
- package/dist/index.d.ts +165 -19
- package/dist/index.js +738 -477
- package/dist/index.js.map +1 -1
- package/dist/module-Dl1KFVtc.d.cts +54 -0
- package/dist/module-Dl1KFVtc.d.ts +54 -0
- package/dist/openapi/index.cjs +1330 -484
- package/dist/openapi/index.cjs.map +1 -1
- package/dist/openapi/index.d.cts +4 -2
- package/dist/openapi/index.d.ts +4 -2
- package/dist/openapi/index.js +1264 -474
- package/dist/openapi/index.js.map +1 -1
- package/dist/{registry-dZZ5JKYg.d.ts → registry-B0eyOF9x.d.ts} +1 -1
- package/dist/{registry-CC0WBQq6.d.cts → registry-Bk9_rbNd.d.cts} +1 -1
- package/dist/stack.cjs.map +1 -1
- package/dist/test/index.cjs +705 -141
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.d.cts +30 -4
- package/dist/test/index.d.ts +30 -4
- package/dist/test/index.js +472 -124
- package/dist/test/index.js.map +1 -1
- package/docs/README.md +34 -18
- package/docs/auth.md +1 -1
- package/docs/background.md +2 -2
- package/docs/database.md +221 -50
- package/docs/endpoints.md +3 -4
- package/docs/events.md +3 -3
- package/docs/getting-started.md +1 -1
- package/docs/llms-full.txt +402 -117
- package/docs/migrations.md +2 -2
- package/docs/schema.md +19 -10
- package/docs/services.md +116 -26
- package/package.json +8 -4
- package/stager/generics.js +205 -0
- package/stager/stage.js +39 -3
- package/template/AGENTS.md +110 -70
- package/template/db/public.ts +1 -1
- package/template/{controllers → modules/health}/health.controller.ts +1 -1
- package/template/modules/health/health.module.ts +24 -0
- package/template/modules/notes/note.service.test.ts +49 -0
- package/template/modules/notes/note.service.ts +108 -0
- package/template/{controllers → modules/notes}/notes.controller.ts +17 -11
- package/template/modules/notes/notes.module.ts +37 -0
- package/template/package.json +5 -3
- package/template/scripts/test.sh +33 -0
- package/template/tsconfig.json +29 -30
- package/dist/chunk-35PNTIRN.js.map +0 -1
- package/dist/chunk-AILPKEK5.js.map +0 -1
- package/dist/chunk-CJSKYY76.js +0 -627
- package/dist/chunk-CJSKYY76.js.map +0 -1
- package/dist/chunk-CRQKCRGF.js +0 -276
- package/dist/chunk-CRQKCRGF.js.map +0 -1
- package/dist/chunk-G4R6BTLV.js +0 -662
- package/dist/chunk-G4R6BTLV.js.map +0 -1
- package/dist/chunk-XABBC7JP.js +0 -55
- package/dist/chunk-XABBC7JP.js.map +0 -1
- package/dist/endpoint-CTEHhb7A.d.ts +0 -2386
- package/dist/endpoint-DYHMo6cC.d.cts +0 -2386
- package/dist/index-CW21M9Z3.d.ts +0 -1222
- package/dist/index-CmBK76nx.d.cts +0 -1222
- package/template/services/note.service.test.ts +0 -45
- package/template/services/note.service.ts +0 -74
- /package/dist/{chunk-7D4SUZUM.js.map → chunk-VXPNPVAG.js.map} +0 -0
- /package/template/{models/notes → modules/notes/dto}/create.ts +0 -0
package/docs/migrations.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
457
|
-
`findById(id)`, `findMany(query?)`. `Database
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
234
|
-
|
|
235
|
-
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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": "
|
|
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 &&
|
|
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
|
-
"
|
|
120
|
-
"
|
|
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
|
-
|
|
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
|
}
|