@palbase/backend 23.1.0 → 24.0.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/bin/palbase-backend.cjs +400 -47
- package/dist/bin/palbase-backend.cjs.map +1 -1
- package/dist/bin/palbase-backend.js +4 -5
- package/dist/bin/palbase-backend.js.map +1 -1
- package/dist/{chunk-OHALWEOG.js → chunk-7Z6MGMXQ.js} +57 -2
- package/dist/chunk-7Z6MGMXQ.js.map +1 -0
- package/dist/{chunk-RCLNBJCM.js → chunk-H3JAISUY.js} +136 -1
- package/dist/chunk-H3JAISUY.js.map +1 -0
- package/dist/{chunk-PY7YJDCT.js → chunk-LCL7TUAI.js} +32 -3
- package/dist/chunk-LCL7TUAI.js.map +1 -0
- package/dist/{chunk-NS5V43YQ.js → chunk-P2Q27SGP.js} +19 -3
- package/dist/chunk-P2Q27SGP.js.map +1 -0
- package/dist/{chunk-R3KN6RHD.js → chunk-T5IOSOE5.js} +7 -2
- package/dist/chunk-T5IOSOE5.js.map +1 -0
- package/dist/{chunk-M5MCBWJI.js → chunk-YSQBC2VL.js} +275 -31
- package/dist/chunk-YSQBC2VL.js.map +1 -0
- package/dist/db/index.cjs +48 -3
- 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 +2 -2
- package/dist/{endpoint-CVWXh6oG.d.ts → endpoint-0_DGBajf.d.ts} +100 -3
- package/dist/{endpoint-c9h5jriX.d.cts → endpoint-CcQ1a36a.d.cts} +100 -3
- package/dist/engine/index.cjs +389 -34
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.d.cts +4 -4
- package/dist/engine/index.d.ts +4 -4
- package/dist/engine/index.js +4 -4
- package/dist/{index-CwAJ7HEe.d.ts → index-BOS_rFBO.d.ts} +125 -21
- package/dist/{index-CxeQSfJP.d.cts → index-C84bLgeO.d.cts} +134 -9
- package/dist/{index-By8Dle5U.d.cts → index-MoQ31B6M.d.cts} +125 -21
- package/dist/{index-BZrJXnVh.d.ts → index-dJNhDZ7j.d.ts} +134 -9
- package/dist/index.cjs +166 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +54 -12
- package/dist/index.d.ts +54 -12
- package/dist/index.js +28 -11
- package/dist/index.js.map +1 -1
- package/dist/openapi/index.cjs +16 -1
- package/dist/openapi/index.cjs.map +1 -1
- package/dist/openapi/index.d.cts +6 -4
- package/dist/openapi/index.d.ts +6 -4
- package/dist/openapi/index.js +6 -7
- package/dist/openapi/index.js.map +1 -1
- package/dist/{registry-CqPK2Qby.d.cts → registry-1X-skBNu.d.cts} +1 -1
- package/dist/{registry-B3niOVYp.d.ts → registry-CEod_5sz.d.ts} +1 -1
- package/dist/test/index.cjs +27 -0
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.d.cts +1 -1
- package/dist/test/index.d.ts +1 -1
- package/dist/test/index.js +27 -0
- package/dist/test/index.js.map +1 -1
- package/docs/README.md +4 -4
- package/docs/database.md +115 -11
- package/docs/getting-started.md +5 -4
- package/docs/llms-full.txt +385 -89
- package/docs/migrations.md +81 -59
- package/docs/schema.md +82 -2
- package/docs/services.md +98 -9
- package/package.json +2 -2
- package/template/AGENTS.md +121 -41
- package/template/controllers/notes.controller.ts +64 -0
- package/template/package.json +1 -1
- package/template/services/note.service.ts +74 -0
- package/template/tsconfig.json +11 -1
- package/dist/chunk-HQRJDARQ.js +0 -90
- package/dist/chunk-HQRJDARQ.js.map +0 -1
- package/dist/chunk-M5MCBWJI.js.map +0 -1
- package/dist/chunk-NS5V43YQ.js.map +0 -1
- package/dist/chunk-OHALWEOG.js.map +0 -1
- package/dist/chunk-PY7YJDCT.js.map +0 -1
- package/dist/chunk-R3KN6RHD.js.map +0 -1
- package/dist/chunk-RCLNBJCM.js.map +0 -1
package/template/AGENTS.md
CHANGED
|
@@ -18,10 +18,19 @@ import { Controller, Get, Post, Patch, Delete, Body, QueryParams, Param, User, z
|
|
|
18
18
|
|
|
19
19
|
```
|
|
20
20
|
controllers/<name>.controller.ts a @Controller class; each method is a route
|
|
21
|
+
services/<name>.service.ts the logic, as a plain class
|
|
21
22
|
db/schema.ts the database: tables, columns, RLS policies
|
|
22
|
-
|
|
23
|
+
palbase-stack.d.ts GENERATED — the names your stack actually holds
|
|
24
|
+
palbase-env.d.ts GENERATED — your tables, as types
|
|
23
25
|
```
|
|
24
26
|
|
|
27
|
+
Both generated files are written by `palbase build` and **committed**. They are
|
|
28
|
+
what makes `Secrets.get("X")`, `Flags.isEnabled("y")`, `@Upload({ bucket })` and
|
|
29
|
+
`Database.tables.*` compile: the names come from the stack and the schema, so
|
|
30
|
+
there is no second list in this repo to drift out of date. A project that has
|
|
31
|
+
never run `palbase build` has no valid names and those calls will not compile —
|
|
32
|
+
that is the gate working, not a rough edge. Run it once and commit both files.
|
|
33
|
+
|
|
25
34
|
Two folders appear as the project grows, and both are conventions the tooling
|
|
26
35
|
already understands:
|
|
27
36
|
|
|
@@ -137,7 +146,18 @@ throw new TooManyRequests({ retryAfter: 30 }); // data-first
|
|
|
137
146
|
|
|
138
147
|
Every route requires a signed-in user by default. `@Controller("/x", { auth: false })`
|
|
139
148
|
opens all of a class's routes; `@Get("", { auth: true })` overrides it for one.
|
|
140
|
-
|
|
149
|
+
|
|
150
|
+
An application-wide default sits under those two:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
import { defineDefaultAuth } from "@palbase/backend";
|
|
154
|
+
defineDefaultAuth({ verifiedEmail: true }); // every route, unless it says otherwise
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Put that call in `auth.ts` at the project root — the bundler imports that file by
|
|
158
|
+
name, so the declaration cannot be silently left out of the build. Resolution is
|
|
159
|
+
route → controller → application → `true`. Writing the same auth setting on ten
|
|
160
|
+
controllers is how the eleventh one gets forgotten.
|
|
141
161
|
|
|
142
162
|
## Models — schemas, one file per endpoint
|
|
143
163
|
|
|
@@ -155,27 +175,53 @@ Shared response schemas go in `models/<controller>/shared.ts`.
|
|
|
155
175
|
|
|
156
176
|
## Services — business logic
|
|
157
177
|
|
|
158
|
-
Plain classes
|
|
178
|
+
Plain classes wired as a module-level singleton. No DI container, no decorators
|
|
179
|
+
— and this is the ONLY supported way to hold a dependency anywhere in this
|
|
180
|
+
runtime. A controller, hook, job or webhook is constructed with **no arguments**;
|
|
181
|
+
one that declares a constructor parameter is refused at build with the class
|
|
182
|
+
named, because there is no injector to fill it and the field would simply be
|
|
183
|
+
`undefined` in production.
|
|
159
184
|
|
|
160
|
-
|
|
161
|
-
// services/todo.service.ts
|
|
162
|
-
import { Database } from "@palbase/backend";
|
|
185
|
+
`services/note.service.ts` in this scaffold is the worked example. The shape:
|
|
163
186
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
187
|
+
```ts
|
|
188
|
+
export class NoteService {
|
|
189
|
+
private readonly notes: typeof Database.tables.notes;
|
|
190
|
+
// Assigned in the BODY — `constructor(private notes: …)` is a parameter
|
|
191
|
+
// property, and Node's type-stripping test runner refuses those per FILE.
|
|
192
|
+
constructor(notes: typeof Database.tables.notes) { this.notes = notes; }
|
|
193
|
+
list(userId: string) { return this.notes.findMany({ user_id: userId }); }
|
|
171
194
|
}
|
|
172
195
|
|
|
173
|
-
|
|
196
|
+
/** Controllers import THIS, never the class. */
|
|
197
|
+
export const noteService = new NoteService(Database.tables.notes);
|
|
174
198
|
```
|
|
175
199
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
200
|
+
The constructor is the seam: the class is handed the table rather than reaching
|
|
201
|
+
for the singleton, so a test constructs it with a stand-in and never needs a
|
|
202
|
+
database. `fakeDatabase()` from `@palbase/backend/test` is that stand-in when you
|
|
203
|
+
want the whole surface instead of one table.
|
|
204
|
+
|
|
205
|
+
`Database.tables.<name>` gives:
|
|
206
|
+
|
|
207
|
+
| | |
|
|
208
|
+
|---|---|
|
|
209
|
+
| `insert(row)` · `upsert(row, { onConflict })` | write one |
|
|
210
|
+
| `update(id, data)` · `delete(id)` | write one, by id |
|
|
211
|
+
| `updateMany(where, set)` · `deleteMany(where)` | write every row the filter matches, in ONE statement |
|
|
212
|
+
| `findById(id)` · `findMany(where?, opts?)` | read |
|
|
213
|
+
| `count(where?)` | how many match |
|
|
214
|
+
|
|
215
|
+
`where` takes a plain value for equality or an operator object —
|
|
216
|
+
`{ n: { gte: 3 } }`, `{ id: { in: [...] } }`, also `gt` / `lt` / `lte` / `neq`.
|
|
217
|
+
`opts` takes `{ orderBy: { column, direction }, limit, offset }`, so ordering and
|
|
218
|
+
paging happen in SQL rather than in JavaScript after fetching everything.
|
|
219
|
+
`updateMany`/`deleteMany` REFUSE an empty filter — a whole-table write is not
|
|
220
|
+
something to reach by accident.
|
|
221
|
+
|
|
222
|
+
`Database.transaction(fn)` runs a plan that commits or rolls back as one, and
|
|
223
|
+
`Database.attempt(fn)` opens a savepoint so one failed write does not poison the
|
|
224
|
+
rest of the request.
|
|
179
225
|
|
|
180
226
|
## The database — declared, not migrated
|
|
181
227
|
|
|
@@ -194,37 +240,62 @@ RLS is on by default and a table with no policies is deny-all. Add
|
|
|
194
240
|
`policies: [policy(...)]` so Postgres enforces ownership — a query that forgets
|
|
195
241
|
its `where user_id = …` still cannot read another user's rows.
|
|
196
242
|
|
|
197
|
-
After a schema change
|
|
198
|
-
|
|
243
|
+
After a schema change run `palbase build`: it regenerates BOTH generated files —
|
|
244
|
+
`palbase-env.d.ts` (your tables) and `palbase-stack.d.ts` (the stack's names) —
|
|
245
|
+
so `Database.tables.*` keeps matching what is actually there. Commit them.
|
|
199
246
|
|
|
200
|
-
##
|
|
247
|
+
## Secrets, flags, buckets — read from the stack
|
|
201
248
|
|
|
202
|
-
`config
|
|
203
|
-
|
|
249
|
+
There is no `config/` directory and nothing to declare in this repo. Settings are
|
|
250
|
+
written to the STACK with the CLI, and the code READS them:
|
|
204
251
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
252
|
+
```ts
|
|
253
|
+
import { Secrets, Flags } from "@palbase/backend";
|
|
254
|
+
const dsn = await Secrets.get("SENTRY_DSN"); // compiles only if the stack holds it
|
|
255
|
+
if (await Flags.isEnabled("newCheckout")) { … } // same
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
The name in the quotes is checked by the compiler against `palbase-stack.d.ts`,
|
|
259
|
+
which `palbase build` generates from the stack. A secret's VALUE never enters
|
|
260
|
+
this repository, and you never read `process.env` for one.
|
|
261
|
+
|
|
262
|
+
Writing a setting is a CLI job, not a code job — `palbase --help` names the
|
|
263
|
+
commands (`secret`, `flags`, `storage`, `notifications`, `auth`). Settings have
|
|
264
|
+
one door; a second one is how the two come to disagree.
|
|
209
265
|
|
|
210
|
-
|
|
266
|
+
## Lifecycle — for things that are expensive to build
|
|
211
267
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
268
|
+
Most handlers need nothing here: the SDK's singletons are already pooled. But a
|
|
269
|
+
long-lived object you build yourself needs somewhere to close, and a process that
|
|
270
|
+
never closes one leaks it per deploy:
|
|
215
271
|
|
|
216
272
|
```ts
|
|
217
|
-
import {
|
|
218
|
-
|
|
273
|
+
import { onStart, onShutdown } from "@palbase/backend";
|
|
274
|
+
|
|
275
|
+
let client: SomeClient | undefined;
|
|
276
|
+
onStart("graph", () => { client = SomeClient.create({ maxPoolSize: 8 }); });
|
|
277
|
+
onShutdown("graph", async () => { await client?.close(); });
|
|
219
278
|
```
|
|
220
279
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
280
|
+
The NAME is the first argument, and it is what a failure is reported by — a stack
|
|
281
|
+
trace through an anonymous closure names nothing. `onStart` runs ONCE as the app
|
|
282
|
+
comes up, and a hook that throws refuses the boot rather than letting the app
|
|
283
|
+
serve traffic with a half-built dependency. `onShutdown` hooks run in reverse
|
|
284
|
+
order and are best-effort: one that throws is reported and the rest still run.
|
|
224
285
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
286
|
+
**These hooks run OUTSIDE a request, so no Palbase service is reachable from
|
|
287
|
+
them** — `Secrets`, `Database`, `Log` and the rest all throw "accessed outside a
|
|
288
|
+
request scope". If your object needs a secret, build it lazily on first use
|
|
289
|
+
inside a handler instead:
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
let client: SomeClient | undefined;
|
|
293
|
+
async function graph() {
|
|
294
|
+
client ??= SomeClient.create({ url: await Secrets.get("GRAPH_URL") });
|
|
295
|
+
return client;
|
|
296
|
+
}
|
|
297
|
+
onShutdown("graph", async () => { await client?.close(); });
|
|
298
|
+
```
|
|
228
299
|
|
|
229
300
|
## Tests
|
|
230
301
|
|
|
@@ -233,9 +304,18 @@ install and nothing to configure — Node discovers the files and strips the typ
|
|
|
233
304
|
itself.
|
|
234
305
|
|
|
235
306
|
Test the **service** layer. Controllers are thin by construction, so the logic
|
|
236
|
-
that can be wrong lives in a service
|
|
237
|
-
|
|
238
|
-
|
|
307
|
+
that can be wrong lives in a service — a plain class you construct directly with
|
|
308
|
+
a stand-in, exactly as `services/note.service.ts` is written. For a stand-in
|
|
309
|
+
covering the whole database surface rather than one table:
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
import { fakeDatabase } from "@palbase/backend/test";
|
|
313
|
+
const { db, queries } = fakeDatabase();
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
It answers `tables.*` from memory and records the `query` calls made. What it
|
|
317
|
+
does NOT do is enforce RLS, constraints or unique violations — those are real
|
|
318
|
+
database behaviour, and the test that proves them runs against a real one.
|
|
239
319
|
|
|
240
320
|
Node **strips** types, it never emits code, so TypeScript features that require
|
|
241
321
|
emitted code are refused outright: parameter properties (`constructor(private x: T)`),
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Body, Controller, Delete, Get, NotFound, Param, Post, User, z } from "@palbase/backend";
|
|
2
|
+
import type { UserT } from "@palbase/backend";
|
|
3
|
+
|
|
4
|
+
import { noteService } from "../services/note.service";
|
|
5
|
+
|
|
6
|
+
// The other half of the vertical that starts in `services/note.service.ts`.
|
|
7
|
+
//
|
|
8
|
+
// Everything a controller does here is HTTP: validate the body through a named
|
|
9
|
+
// schema, name the 200 shape as the return type, turn a missing row into a
|
|
10
|
+
// status. The logic — which rows, whose, in what order — lives in the service,
|
|
11
|
+
// which is why the service is the thing worth testing and this class has
|
|
12
|
+
// nothing in it to get wrong.
|
|
13
|
+
//
|
|
14
|
+
// So there is one rule, and it is the whole reason the layer exists: a
|
|
15
|
+
// controller does not import `Database`. If a method here starts reaching for a
|
|
16
|
+
// table, the logic has leaked into the layer that is hardest to test.
|
|
17
|
+
//
|
|
18
|
+
// The dependency is a MODULE-LEVEL SINGLETON, imported. It is not a constructor
|
|
19
|
+
// parameter: the runtime constructs this class with no arguments, and a
|
|
20
|
+
// controller that declares one is refused at build with the class named rather
|
|
21
|
+
// than handed `undefined` in production.
|
|
22
|
+
|
|
23
|
+
export const NoteSchema = z.object({
|
|
24
|
+
id: z.string(),
|
|
25
|
+
user_id: z.string(),
|
|
26
|
+
body: z.string(),
|
|
27
|
+
created_at: z.string(),
|
|
28
|
+
});
|
|
29
|
+
export type NoteSchema = z.infer<typeof NoteSchema>;
|
|
30
|
+
|
|
31
|
+
export const CreateNoteBody = z.object({
|
|
32
|
+
body: z.string().min(1),
|
|
33
|
+
});
|
|
34
|
+
export type CreateNoteBody = z.infer<typeof CreateNoteBody>;
|
|
35
|
+
|
|
36
|
+
// Auth is required unless a route opts out, so `@User()` is non-null here and
|
|
37
|
+
// every method below acts as somebody.
|
|
38
|
+
@Controller("/notes")
|
|
39
|
+
class NotesController {
|
|
40
|
+
@Get("")
|
|
41
|
+
list(@User() user: UserT): Promise<NoteSchema[]> {
|
|
42
|
+
return noteService.list(user.id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@Post("")
|
|
46
|
+
create(@Body(CreateNoteBody) body: CreateNoteBody, @User() user: UserT): Promise<NoteSchema> {
|
|
47
|
+
return noteService.create(user.id, body.body);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@Get("/{id}")
|
|
51
|
+
async get(@Param("id") id: string): Promise<NoteSchema> {
|
|
52
|
+
// `null` covers both "no such note" and "not yours" — the second is not
|
|
53
|
+
// distinguishable from the outside on purpose, because telling a caller
|
|
54
|
+
// that a row they may not read exists is itself a leak.
|
|
55
|
+
const note = await noteService.get(id);
|
|
56
|
+
if (!note) throw new NotFound("no note with that id");
|
|
57
|
+
return note;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@Delete("/{id}")
|
|
61
|
+
remove(@Param("id") id: string): Promise<void> {
|
|
62
|
+
return noteService.remove(id);
|
|
63
|
+
}
|
|
64
|
+
}
|
package/template/package.json
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Database } from "@palbase/backend";
|
|
2
|
+
import type { Tables } from "@palbase/backend/env";
|
|
3
|
+
|
|
4
|
+
// A SERVICE is where the logic lives. It is a plain class — no decorator, no DI
|
|
5
|
+
// container, nothing to register. What makes the layer real is the two lines at
|
|
6
|
+
// the bottom of this file, not the folder name.
|
|
7
|
+
//
|
|
8
|
+
// THE SEAM IS THE CONSTRUCTOR. The class is handed the table it works on rather
|
|
9
|
+
// than reaching for the singleton itself, so a test constructs it with a
|
|
10
|
+
// stand-in and never needs a database:
|
|
11
|
+
//
|
|
12
|
+
// const svc = new NoteService(fakeNotesTable);
|
|
13
|
+
// assert.deepEqual(await svc.list("u_1"), []);
|
|
14
|
+
//
|
|
15
|
+
// The module then wires the real one ONCE, on the last line, and that instance
|
|
16
|
+
// is what a controller imports. That module-level singleton is also the
|
|
17
|
+
// supported way to hold a dependency ANYWHERE in this runtime: a controller,
|
|
18
|
+
// hook, job or webhook is constructed with no arguments, and one that declares
|
|
19
|
+
// a constructor parameter is refused at build with the class named — there is
|
|
20
|
+
// no injector to fill it, so the field would simply be `undefined` in
|
|
21
|
+
// production.
|
|
22
|
+
//
|
|
23
|
+
// `Database.tables.notes` is typed from `db/schema.ts` through the generated
|
|
24
|
+
// `palbase-env.d.ts`, which `palbase build` writes. Before the first build that
|
|
25
|
+
// file does not exist yet and the table is unknown to the type checker; build
|
|
26
|
+
// once and the whole surface below is typed with no import and no generic.
|
|
27
|
+
|
|
28
|
+
/** One row of `notes`, exactly as `db/schema.ts` declares it. */
|
|
29
|
+
export type Note = Tables["notes"]["row"];
|
|
30
|
+
|
|
31
|
+
/** The typed surface of one table: `insert`, `update`, `delete`, `findById`,
|
|
32
|
+
* `findMany`. Naming it here is what keeps the seam ONE table wide — a test
|
|
33
|
+
* fake implements five methods, not the whole `Database`. */
|
|
34
|
+
type NotesTable = typeof Database.tables.notes;
|
|
35
|
+
|
|
36
|
+
export class NoteService {
|
|
37
|
+
private readonly notes: NotesTable;
|
|
38
|
+
|
|
39
|
+
// Assigned in the BODY, never as a parameter property (`constructor(private
|
|
40
|
+
// notes: NotesTable)`). `npm test` runs Node's type-stripping test runner,
|
|
41
|
+
// which refuses parameter properties — and refuses them for the whole FILE,
|
|
42
|
+
// so one of them anywhere in a test's import graph reads like a dozen broken
|
|
43
|
+
// tests and is one keyword.
|
|
44
|
+
constructor(notes: NotesTable) {
|
|
45
|
+
this.notes = notes;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The caller's notes. The filter is written out even though the table's RLS
|
|
49
|
+
* policy already scopes the read to `auth.uid()`: the policy is the backstop
|
|
50
|
+
* that holds when a query forgets, not a reason to stop writing the query. */
|
|
51
|
+
list(userId: string): Promise<Note[]> {
|
|
52
|
+
return this.notes.findMany({ user_id: userId });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `user_id` is `notNull` with no default, so ownership is written here —
|
|
56
|
+
* it is not something the request body may carry. */
|
|
57
|
+
create(userId: string, body: string): Promise<Note> {
|
|
58
|
+
return this.notes.insert({ user_id: userId, body });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** No `userId` argument, and that is not an oversight: another user's note is
|
|
62
|
+
* invisible to this read, so it comes back `null` exactly as a missing id
|
|
63
|
+
* does. Postgres enforces it, not this method. */
|
|
64
|
+
get(id: string): Promise<Note | null> {
|
|
65
|
+
return this.notes.findById(id);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
remove(id: string): Promise<void> {
|
|
69
|
+
return this.notes.delete(id);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The wired instance. Controllers import THIS, never the class. */
|
|
74
|
+
export const noteService = new NoteService(Database.tables.notes);
|
package/template/tsconfig.json
CHANGED
|
@@ -19,12 +19,22 @@
|
|
|
19
19
|
// it, so imports elsewhere stay extensionless.
|
|
20
20
|
"allowImportingTsExtensions": true
|
|
21
21
|
},
|
|
22
|
+
// Every directory the deploy path compiles has to be in here, or a file in it
|
|
23
|
+
// is type-checked by nothing until it is already running: `jobs/`, `hooks/`
|
|
24
|
+
// and `webhooks/` are read straight off disk by the bundler (one default-
|
|
25
|
+
// exported class per file), and `middleware/` only reaches a build through
|
|
26
|
+
// whichever controller imports it. `config/` is gone — 23.0.0 removed the
|
|
27
|
+
// declarations it held, and the settings it used to declare are written to the
|
|
28
|
+
// stack with the CLI.
|
|
22
29
|
"include": [
|
|
23
30
|
"controllers/**/*.ts",
|
|
24
31
|
"models/**/*.ts",
|
|
25
32
|
"services/**/*.ts",
|
|
26
|
-
"config/**/*.ts",
|
|
27
33
|
"db/**/*.ts",
|
|
34
|
+
"jobs/**/*.ts",
|
|
35
|
+
"hooks/**/*.ts",
|
|
36
|
+
"webhooks/**/*.ts",
|
|
37
|
+
"middleware/**/*.ts",
|
|
28
38
|
"*.d.ts"
|
|
29
39
|
]
|
|
30
40
|
}
|
package/dist/chunk-HQRJDARQ.js
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
getRoutes
|
|
3
|
-
} from "./chunk-RCLNBJCM.js";
|
|
4
|
-
|
|
5
|
-
// src/decorators/controller.ts
|
|
6
|
-
var CONTROLLER_META = /* @__PURE__ */ Symbol.for("palbase.backend.controllerMeta");
|
|
7
|
-
var REGISTRY = /* @__PURE__ */ Symbol.for("palbase.backend.allControllers");
|
|
8
|
-
function registry() {
|
|
9
|
-
const g = globalThis;
|
|
10
|
-
const existing = g[REGISTRY];
|
|
11
|
-
if (existing) return existing;
|
|
12
|
-
const fresh = [];
|
|
13
|
-
g[REGISTRY] = fresh;
|
|
14
|
-
return fresh;
|
|
15
|
-
}
|
|
16
|
-
function getRegisteredControllers() {
|
|
17
|
-
return registry().slice();
|
|
18
|
-
}
|
|
19
|
-
function __resetRegisteredControllers() {
|
|
20
|
-
registry().length = 0;
|
|
21
|
-
}
|
|
22
|
-
var RESERVED_FIRST_SEGMENT = "webhooks";
|
|
23
|
-
function assertNotReserved(path, subject) {
|
|
24
|
-
const [first] = path.split("/").filter(Boolean);
|
|
25
|
-
if (first === RESERVED_FIRST_SEGMENT) {
|
|
26
|
-
throw new Error(
|
|
27
|
-
`${subject} resolves under the reserved /${RESERVED_FIRST_SEGMENT} path \u2014 inbound webhooks are served there and would shadow this route`
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
function Controller(basePath, options = {}) {
|
|
32
|
-
return function(ctor) {
|
|
33
|
-
assertNotReserved(basePath, `@Controller("${basePath}")`);
|
|
34
|
-
for (const route of getRoutes(ctor)) {
|
|
35
|
-
assertNotReserved(
|
|
36
|
-
`${basePath}${route.subpath}`,
|
|
37
|
-
`@${route.method}("${route.subpath}") in @Controller("${basePath}")`
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
const carrier = ctor;
|
|
41
|
-
const meta = {
|
|
42
|
-
__palbase: "controller",
|
|
43
|
-
basePath,
|
|
44
|
-
...options.auth !== void 0 ? { defaultAuth: options.auth } : {}
|
|
45
|
-
};
|
|
46
|
-
Object.defineProperty(carrier, CONTROLLER_META, {
|
|
47
|
-
value: meta,
|
|
48
|
-
enumerable: false,
|
|
49
|
-
configurable: true,
|
|
50
|
-
writable: false
|
|
51
|
-
});
|
|
52
|
-
Object.defineProperty(carrier, "__palbase", {
|
|
53
|
-
value: "controller",
|
|
54
|
-
enumerable: false,
|
|
55
|
-
configurable: true,
|
|
56
|
-
writable: false
|
|
57
|
-
});
|
|
58
|
-
const all = registry();
|
|
59
|
-
if (!all.includes(ctor)) all.push(ctor);
|
|
60
|
-
return ctor;
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
function isController(value) {
|
|
64
|
-
if (typeof value !== "function" && (typeof value !== "object" || value === null)) {
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
const carrier = value;
|
|
68
|
-
return carrier.__palbase === "controller" && carrier[CONTROLLER_META] !== void 0;
|
|
69
|
-
}
|
|
70
|
-
function resolveController(ctor) {
|
|
71
|
-
if (typeof ctor !== "function" && (typeof ctor !== "object" || ctor === null)) {
|
|
72
|
-
throw new TypeError("resolveController: value is not a class");
|
|
73
|
-
}
|
|
74
|
-
const meta = ctor[CONTROLLER_META];
|
|
75
|
-
if (!meta) {
|
|
76
|
-
throw new TypeError(
|
|
77
|
-
"resolveController: class is not a @Controller \u2014 every controller file must `export default` a @Controller-decorated class"
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
return meta;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export {
|
|
84
|
-
getRegisteredControllers,
|
|
85
|
-
__resetRegisteredControllers,
|
|
86
|
-
Controller,
|
|
87
|
-
isController,
|
|
88
|
-
resolveController
|
|
89
|
-
};
|
|
90
|
-
//# sourceMappingURL=chunk-HQRJDARQ.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/decorators/controller.ts"],"sourcesContent":["// `@Controller(basePath, options?)` — the class decorator that marks a class as\n// a Palbase backend controller. It stamps a non-enumerable `__palbase`\n// discriminant + the resolved controller metadata onto the class so the\n// deploy/dispatch pipeline (and `isController`/`resolveController`) can detect\n// and read it without `reflect-metadata`.\nimport type { AuthSpec } from \"../endpoint.js\";\nimport { getRoutes } from \"./registry.js\";\n\n/** The controller metadata stamped onto a `@Controller`-decorated class. The\n * default export of a `controllers/*.controller.ts` file resolves to this via\n * {@link resolveController}. */\nexport interface ControllerMeta {\n /** Discriminant the runtime + tooling read. */\n readonly __palbase: \"controller\";\n /** The base path every route in this controller mounts under (e.g. \"/todos\"). */\n basePath: string;\n /** Controller-level default auth, applied to routes that don't set their own\n * (`@Get(\"/x\", { auth })` overrides this). `undefined` ⇒ secure-by-default. */\n defaultAuth?: AuthSpec;\n}\n\n/** Options accepted by `@Controller`. */\nexport interface ControllerOptions {\n /** Default auth for ALL routes in this controller (route-level overrides). */\n auth?: AuthSpec;\n}\n\n/** Symbol the controller metadata is stamped under. Symbol-keyed (not a string\n * property) so it never collides with an authored member and stays off the\n * structural surface. */\nexport const CONTROLLER_META: unique symbol = Symbol.for(\"palbase.backend.controllerMeta\");\n\n/**\n * Every class `@Controller` has decorated, in decoration order.\n *\n * This is what lets a controller file need no export at all: importing the file\n * runs the decorator, the decorator records the class here, and the runtime\n * reads the list. Without it the only handle on a class is its export name, so\n * every controller had to be exported AND named in a generated entry — the\n * ceremony NestJS still charges (`export class` PLUS\n * `@Module({controllers:[…]})`).\n *\n * Keyed on a well-known Symbol against globalThis rather than held in a module\n * variable, because a deployed bundle inlines its own copy of this package: two\n * copies would keep two lists, and the runtime would read the empty one. The\n * same hazard `runtimeHooks` exists for, closed the same way — one shared slot.\n */\nconst REGISTRY: unique symbol = Symbol.for(\"palbase.backend.allControllers\") as never;\n\nfunction registry(): unknown[] {\n const g = globalThis as unknown as Record<symbol, unknown[] | undefined>;\n const existing = g[REGISTRY];\n if (existing) return existing;\n const fresh: unknown[] = [];\n g[REGISTRY] = fresh;\n return fresh;\n}\n\n/**\n * The controller classes this process has loaded, in decoration order.\n *\n * Decoration order is import order, which the bundler fixes by sorting the\n * files it emits imports for — so two builds of one tree produce the same\n * route table, and route precedence is not a function of module-resolution\n * accidents.\n */\nexport function getRegisteredControllers(): readonly unknown[] {\n return registry().slice();\n}\n\n/** Empty the registry. For tests, which load controllers repeatedly. */\nexport function __resetRegisteredControllers(): void {\n registry().length = 0;\n}\n\n/** A class carrying the stamped controller metadata + discriminant. */\ninterface ControllerCarrier {\n __palbase?: \"controller\";\n [CONTROLLER_META]?: ControllerMeta;\n}\n\n/** The one path segment the platform owns. The isolate matches\n * `^/webhooks/([^/]+)$` on the raw request path BEFORE controller dispatch, so\n * anything a controller resolves to under it answers `404 webhook_not_found`\n * and never runs. */\nconst RESERVED_FIRST_SEGMENT = \"webhooks\";\n\n/**\n * Throw if `path` resolves under the reserved segment. Segments are compared the\n * way the isolate compares them — `split(\"/\").filter(Boolean)` — NOT by string\n * prefix, because empty segments collapse there: `@Controller(\"/\")` +\n * `@Post(\"/webhooks/x\")` composes to `//webhooks/x`, which the isolate serves as\n * `/webhooks/x`. A prefix check reads that as safe; the segment check does not.\n * `/webhooksy` stays allowed for the same reason — it is a different segment.\n *\n * Every verb is refused, not just the POST the isolate currently intercepts: the\n * reservation is of the URL namespace, so a `@Get(\"/webhooks/x\")` that happens\n * to work today would be silently shadowed the moment the isolate's method gate\n * widens. Refusing at build is recoverable; discovering it as a 404 is not.\n */\nfunction assertNotReserved(path: string, subject: string): void {\n const [first] = path.split(\"/\").filter(Boolean);\n if (first === RESERVED_FIRST_SEGMENT) {\n throw new Error(\n `${subject} resolves under the reserved /${RESERVED_FIRST_SEGMENT} path — ` +\n \"inbound webhooks are served there and would shadow this route\",\n );\n }\n}\n\n/**\n * Mark a class as a Palbase backend controller. `basePath` is the mount path\n * for every route the class declares; `options.auth` sets the controller-level\n * default auth (a route's own `auth` overrides it; absent ⇒ secure-by-default).\n *\n * @example\n * \\@Controller(\"/todos\", { auth: false })\n * export class TodosController {\n * \\@Get(\"\") list(\\@QueryParams(ListTodosQuery) q: ListTodosQuery): TodoSchema[] { … }\n * }\n */\nexport function Controller(basePath: string, options: ControllerOptions = {}) {\n return function <T extends abstract new (...args: never[]) => object>(ctor: T): T {\n // /webhooks/* belongs to the platform: the isolate matches the inbound\n // webhook route before controller dispatch, so a controller mounted here\n // would never receive a request. Silent shadowing is the failure mode this\n // whole change exists to remove, so refuse it at build.\n //\n // The COMPOSED path is what gets shadowed, not the base path. `@Controller(\"\")`\n // and `@Controller(\"/\")` both pass a base-path-only check while a\n // `@Post(\"/webhooks/stripe\")` inside them resolves to exactly the path the\n // isolate intercepts. Method decorators run BEFORE the class decorator (TS\n // evaluates members first), so every route this class declares is already in\n // the registry here — which is why the composed check can live at this one\n // seam instead of on the dispatch read path. The `@Controller(\"\") +\n // @Post(\"/webhooks/stripe\")` test is the lock on that ordering: if it ever\n // stopped holding, that test goes red.\n assertNotReserved(basePath, `@Controller(\"${basePath}\")`);\n for (const route of getRoutes(ctor)) {\n assertNotReserved(\n `${basePath}${route.subpath}`,\n `@${route.method}(\"${route.subpath}\") in @Controller(\"${basePath}\")`,\n );\n }\n\n const carrier = ctor as unknown as ControllerCarrier;\n const meta: ControllerMeta = {\n __palbase: \"controller\",\n basePath,\n ...(options.auth !== undefined ? { defaultAuth: options.auth } : {}),\n };\n // Non-enumerable so it doesn't leak onto instances / structural checks.\n Object.defineProperty(carrier, CONTROLLER_META, {\n value: meta,\n enumerable: false,\n configurable: true,\n writable: false,\n });\n // The bare `__palbase` discriminant is the cheap detection marker the\n // runtime/extractor checks; keep it readable but non-enumerable.\n Object.defineProperty(carrier, \"__palbase\", {\n value: \"controller\",\n enumerable: false,\n configurable: true,\n writable: false,\n });\n // Record it, so importing the file is enough and exporting is optional.\n // Guarded against a double-decoration re-entering the same class twice.\n const all = registry();\n if (!all.includes(ctor)) all.push(ctor);\n return ctor;\n };\n}\n\n/** True when `value` is a `@Controller`-decorated class (cheap discriminant\n * check). Accepts the class constructor (the default export of a controller\n * file). */\nexport function isController(value: unknown): boolean {\n if (typeof value !== \"function\" && (typeof value !== \"object\" || value === null)) {\n return false;\n }\n const carrier = value as ControllerCarrier;\n return carrier.__palbase === \"controller\" && carrier[CONTROLLER_META] !== undefined;\n}\n\n/** Read the resolved controller metadata off a `@Controller`-decorated class.\n * Throws if the class was not decorated — callers should gate with\n * {@link isController} first (the loader does). */\nexport function resolveController(ctor: unknown): ControllerMeta {\n if (typeof ctor !== \"function\" && (typeof ctor !== \"object\" || ctor === null)) {\n throw new TypeError(\"resolveController: value is not a class\");\n }\n const meta = (ctor as ControllerCarrier)[CONTROLLER_META];\n if (!meta) {\n throw new TypeError(\n \"resolveController: class is not a @Controller — every controller file must `export default` a @Controller-decorated class\",\n );\n }\n return meta;\n}\n"],"mappings":";;;;;AA8BO,IAAM,kBAAiC,uBAAO,IAAI,gCAAgC;AAiBzF,IAAM,WAA0B,uBAAO,IAAI,gCAAgC;AAE3E,SAAS,WAAsB;AAC7B,QAAM,IAAI;AACV,QAAM,WAAW,EAAE,QAAQ;AAC3B,MAAI,SAAU,QAAO;AACrB,QAAM,QAAmB,CAAC;AAC1B,IAAE,QAAQ,IAAI;AACd,SAAO;AACT;AAUO,SAAS,2BAA+C;AAC7D,SAAO,SAAS,EAAE,MAAM;AAC1B;AAGO,SAAS,+BAAqC;AACnD,WAAS,EAAE,SAAS;AACtB;AAYA,IAAM,yBAAyB;AAe/B,SAAS,kBAAkB,MAAc,SAAuB;AAC9D,QAAM,CAAC,KAAK,IAAI,KAAK,MAAM,GAAG,EAAE,OAAO,OAAO;AAC9C,MAAI,UAAU,wBAAwB;AACpC,UAAM,IAAI;AAAA,MACR,GAAG,OAAO,iCAAiC,sBAAsB;AAAA,IAEnE;AAAA,EACF;AACF;AAaO,SAAS,WAAW,UAAkB,UAA6B,CAAC,GAAG;AAC5E,SAAO,SAA+D,MAAY;AAehF,sBAAkB,UAAU,gBAAgB,QAAQ,IAAI;AACxD,eAAW,SAAS,UAAU,IAAI,GAAG;AACnC;AAAA,QACE,GAAG,QAAQ,GAAG,MAAM,OAAO;AAAA,QAC3B,IAAI,MAAM,MAAM,KAAK,MAAM,OAAO,sBAAsB,QAAQ;AAAA,MAClE;AAAA,IACF;AAEA,UAAM,UAAU;AAChB,UAAM,OAAuB;AAAA,MAC3B,WAAW;AAAA,MACX;AAAA,MACA,GAAI,QAAQ,SAAS,SAAY,EAAE,aAAa,QAAQ,KAAK,IAAI,CAAC;AAAA,IACpE;AAEA,WAAO,eAAe,SAAS,iBAAiB;AAAA,MAC9C,OAAO;AAAA,MACP,YAAY;AAAA,MACZ,cAAc;AAAA,MACd,UAAU;AAAA,IACZ,CAAC;AAGD,WAAO,eAAe,SAAS,aAAa;AAAA,MAC1C,OAAO;AAAA,MACP,YAAY;AAAA,MACZ,cAAc;AAAA,MACd,UAAU;AAAA,IACZ,CAAC;AAGD,UAAM,MAAM,SAAS;AACrB,QAAI,CAAC,IAAI,SAAS,IAAI,EAAG,KAAI,KAAK,IAAI;AACtC,WAAO;AAAA,EACT;AACF;AAKO,SAAS,aAAa,OAAyB;AACpD,MAAI,OAAO,UAAU,eAAe,OAAO,UAAU,YAAY,UAAU,OAAO;AAChF,WAAO;AAAA,EACT;AACA,QAAM,UAAU;AAChB,SAAO,QAAQ,cAAc,gBAAgB,QAAQ,eAAe,MAAM;AAC5E;AAKO,SAAS,kBAAkB,MAA+B;AAC/D,MAAI,OAAO,SAAS,eAAe,OAAO,SAAS,YAAY,SAAS,OAAO;AAC7E,UAAM,IAAI,UAAU,yCAAyC;AAAA,EAC/D;AACA,QAAM,OAAQ,KAA2B,eAAe;AACxD,MAAI,CAAC,MAAM;AACT,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;","names":[]}
|