@palbase/backend 23.0.0 → 24.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 +695 -61
- 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-FSGSB42K.js → chunk-7Z6MGMXQ.js} +64 -4
- package/dist/chunk-7Z6MGMXQ.js.map +1 -0
- package/dist/{chunk-OMRTHM4X.js → chunk-H3JAISUY.js} +136 -1
- package/dist/chunk-H3JAISUY.js.map +1 -0
- package/dist/{chunk-REZU6UKT.js → chunk-NXDH6VQJ.js} +549 -42
- package/dist/chunk-NXDH6VQJ.js.map +1 -0
- package/dist/{chunk-W5ODXPY3.js → chunk-P2Q27SGP.js} +32 -3
- package/dist/chunk-P2Q27SGP.js.map +1 -0
- package/dist/{chunk-ZC6Q2BRD.js → chunk-T5IOSOE5.js} +7 -2
- package/dist/chunk-T5IOSOE5.js.map +1 -0
- package/dist/{chunk-HAF67F2H.js → chunk-ZUGY7RGS.js} +86 -3
- package/dist/chunk-ZUGY7RGS.js.map +1 -0
- package/dist/db/index.cjs +115 -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-BavvbW4P.d.ts → endpoint-0_DGBajf.d.ts} +168 -9
- package/dist/{endpoint-i8TTCohk.d.cts → endpoint-CcQ1a36a.d.cts} +168 -9
- package/dist/engine/index.cjs +684 -48
- 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-B3jmmItD.d.ts → index-CJiJU9ux.d.ts} +209 -36
- package/dist/{index-Bmvx1EvJ.d.cts → index-D-4-PNuQ.d.cts} +209 -36
- package/dist/{index-B7YBEG5w.d.ts → index-D17r-MKb.d.ts} +177 -7
- package/dist/{index-E7OscPJT.d.cts → index-DRFxf07H.d.cts} +177 -7
- package/dist/index.cjs +269 -7
- 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 +54 -11
- package/dist/index.js.map +1 -1
- package/dist/openapi/index.cjs +45 -5
- package/dist/openapi/index.cjs.map +1 -1
- package/dist/openapi/index.d.cts +9 -4
- package/dist/openapi/index.d.ts +9 -4
- package/dist/openapi/index.js +35 -11
- package/dist/openapi/index.js.map +1 -1
- package/dist/{registry-C3H2uPeZ.d.cts → registry-1X-skBNu.d.cts} +101 -7
- package/dist/{registry-DY3d9l1k.d.ts → registry-CEod_5sz.d.ts} +101 -7
- package/dist/test/index.cjs +509 -9
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.d.cts +35 -3
- package/dist/test/index.d.ts +35 -3
- package/dist/test/index.js +507 -8
- 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 +3 -2
- package/stager/return_types.js +23 -0
- 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-FSGSB42K.js.map +0 -1
- package/dist/chunk-HAF67F2H.js.map +0 -1
- package/dist/chunk-OMRTHM4X.js.map +0 -1
- package/dist/chunk-REZU6UKT.js.map +0 -1
- package/dist/chunk-W5ODXPY3.js.map +0 -1
- package/dist/chunk-Y5HXVUMP.js +0 -90
- package/dist/chunk-Y5HXVUMP.js.map +0 -1
- package/dist/chunk-ZC6Q2BRD.js.map +0 -1
package/docs/llms-full.txt
CHANGED
|
@@ -195,10 +195,10 @@ generated client surface) changes; the verb/path do not affect it.
|
|
|
195
195
|
- `palbase build` — validate the tree locally exactly the way a deploy would
|
|
196
196
|
(stage, bundle, extract controller metadata). Exits non-zero on a decorator,
|
|
197
197
|
return-type or version-skew error, so a push that would deploy zero endpoints
|
|
198
|
-
fails on your machine instead.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
198
|
+
fails on your machine instead. It also regenerates `palbase-env.d.ts` from
|
|
199
|
+
`db/schema.ts`, which is what types `Database.tables.*` (no import, no
|
|
200
|
+
generic) — so run it after editing the schema. There is no separate command
|
|
201
|
+
for that: `build` regenerates everything derived.
|
|
202
202
|
- `palbase push` — deploy the current backend to the selected Environment. For a
|
|
203
203
|
GitHub-backed project it runs `git push`; the pushed source Git branch deploys
|
|
204
204
|
to the Environment configured for that branch.
|
|
@@ -337,10 +337,11 @@ start. The loop is: edit, validate, push to a dev Environment.
|
|
|
337
337
|
bundles your `controllers/` exactly as the deploy does and runs the deploy's
|
|
338
338
|
own metadata extractor over the result, so a bad decorator, an illegal return
|
|
339
339
|
type or an SDK major skew fails here rather than shipping a deploy that
|
|
340
|
-
serves zero endpoints. It is wired into a `pre-push` git hook for you.
|
|
341
|
-
|
|
342
|
-
`Database.tables.*`
|
|
343
|
-
schema
|
|
340
|
+
serves zero endpoints. It is wired into a `pre-push` git hook for you. It also
|
|
341
|
+
regenerates `palbase-env.d.ts` from `db/schema.ts`, which is what types
|
|
342
|
+
`Database.tables.*` (no import, no generic), so run it after editing the
|
|
343
|
+
schema — there is no separate command for that. See
|
|
344
|
+
[migrations.md](./migrations.md) for the schema side.
|
|
344
345
|
- `palbase push` deploys the current backend to the selected Environment. For a
|
|
345
346
|
GitHub-backed project it runs `git push`; Studio maps the pushed source Git
|
|
346
347
|
branch to its configured Environment.
|
|
@@ -864,32 +865,136 @@ See [schema.md](./schema.md) for the full typed-table surface.
|
|
|
864
865
|
|
|
865
866
|
## Raw string-keyed operations
|
|
866
867
|
|
|
867
|
-
For
|
|
868
|
-
available:
|
|
868
|
+
For a table name computed at runtime, or for SQL the typed helpers do not
|
|
869
|
+
cover, the string-keyed ops are still available:
|
|
869
870
|
|
|
870
871
|
| Method | Returns |
|
|
871
872
|
|--------|---------|
|
|
872
873
|
| `Database.insert(table, data)` | the inserted row (`Record<string, unknown>`) |
|
|
873
|
-
| `Database.
|
|
874
|
+
| `Database.upsert(table, data, { onConflict })` | the inserted-or-updated row |
|
|
875
|
+
| `Database.update(table, id, data)` | the updated row, or `null` if none matched |
|
|
874
876
|
| `Database.delete(table, id)` | `void` |
|
|
877
|
+
| `Database.updateMany(table, where, set)` | every row the filter matched, updated |
|
|
878
|
+
| `Database.deleteMany(table, where)` | how many rows went |
|
|
875
879
|
| `Database.findById(table, id)` | the row or `null` |
|
|
876
|
-
| `Database.findMany(table,
|
|
877
|
-
| `Database.
|
|
880
|
+
| `Database.findMany(table, where?, opts?)` | matching rows (array) |
|
|
881
|
+
| `Database.count(table, where?)` | how many rows match |
|
|
882
|
+
| `Database.query(sql, params?)` | rows from a SQL query, in the request's own transaction |
|
|
878
883
|
| `Database.transaction(fn)` | runs a whole transaction plan in one request |
|
|
884
|
+
| `Database.attempt(fn)` | a savepoint: a failure inside rolls back only its own writes |
|
|
879
885
|
|
|
880
|
-
|
|
881
|
-
anything richer (ranges, ordering, joins) use `Database.query`.
|
|
886
|
+
### Filters
|
|
882
887
|
|
|
883
|
-
|
|
884
|
-
|
|
888
|
+
A filter's keys are ANDed. Each value is either a plain value (equality) or an
|
|
889
|
+
operator object:
|
|
890
|
+
|
|
891
|
+
```ts
|
|
892
|
+
await Database.tables.entries.findMany({
|
|
893
|
+
household_id: hid, // equality
|
|
894
|
+
amount_kurus: { gte: 1000 }, // >=
|
|
895
|
+
category: { in: ["food", "rent"] },
|
|
896
|
+
status: { neq: "void" },
|
|
897
|
+
});
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
Operators: `gt` · `gte` · `lt` · `lte` · `neq` · `in`. The same filter language
|
|
901
|
+
works for `updateMany`, `deleteMany` and `count` — one language, so two spellings
|
|
902
|
+
cannot drift apart. An empty `in` list means NO rows (written into the SQL as
|
|
903
|
+
`false`), never a silent full scan.
|
|
904
|
+
|
|
905
|
+
### Ordering and paging
|
|
906
|
+
|
|
907
|
+
```ts
|
|
908
|
+
await Database.tables.entries.findMany(
|
|
909
|
+
{ household_id: hid },
|
|
910
|
+
{ orderBy: { column: "created_at", direction: "desc" }, limit: 20, offset: 40 },
|
|
911
|
+
);
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
`orderBy.column` is checked against the row type: a mistyped column name is a
|
|
915
|
+
compile error, not a runtime rejection. `offset` without `limit` is refused —
|
|
916
|
+
"skip 40 of an unbounded result" is not a page. Pair `count(where)` with the same
|
|
917
|
+
filter to get the total; fetching everything to read `.length` is the shape this
|
|
918
|
+
surface exists to remove.
|
|
919
|
+
|
|
920
|
+
### Bulk writes refuse an empty filter
|
|
921
|
+
|
|
922
|
+
`updateMany({}, …)` and `deleteMany({})` throw with the table named. `UPDATE …
|
|
923
|
+
WHERE true` is a whole-table write, and the way to reach it by accident — a
|
|
924
|
+
filter object built from request input that happened to come back empty — is
|
|
925
|
+
exactly the shape that must not silently succeed. `count()` with no filter IS
|
|
926
|
+
allowed: counting is a read.
|
|
927
|
+
|
|
928
|
+
### `Database.query` — raw SQL
|
|
929
|
+
|
|
930
|
+
Use it for shapes the helpers do not cover (joins, aggregates, window functions).
|
|
931
|
+
It runs in **the request's own transaction**, not a read-only one: a write inside
|
|
932
|
+
it commits with the rest of the request.
|
|
885
933
|
|
|
886
934
|
```ts
|
|
887
935
|
const rows = await Database.query(
|
|
888
|
-
"SELECT
|
|
889
|
-
|
|
936
|
+
"SELECT c.name, count(*) FROM entries e JOIN categories c ON c.id = e.category_id" +
|
|
937
|
+
" WHERE e.household_id = $1 GROUP BY c.name",
|
|
938
|
+
[hid],
|
|
890
939
|
);
|
|
891
940
|
```
|
|
892
941
|
|
|
942
|
+
A JavaScript array parameter is encoded as a Postgres array literal, so
|
|
943
|
+
`= ANY($1::uuid[])` works.
|
|
944
|
+
|
|
945
|
+
### Bulk writes with a conflict rule
|
|
946
|
+
|
|
947
|
+
Inside a transaction, `insertMany` takes an optional conflict rule — which is how
|
|
948
|
+
"insert the ones that are new" becomes ONE statement instead of one round-trip
|
|
949
|
+
per row with a `23505` caught around each:
|
|
950
|
+
|
|
951
|
+
```ts
|
|
952
|
+
await Database.transaction((tx) => {
|
|
953
|
+
tx.tables.merchants.insertMany(rows, { onConflict: ["name"], action: "ignore" });
|
|
954
|
+
return null;
|
|
955
|
+
});
|
|
956
|
+
```
|
|
957
|
+
|
|
958
|
+
`action: "ignore"` emits `ON CONFLICT DO NOTHING`; `action: "update"` emits
|
|
959
|
+
`ON CONFLICT DO UPDATE`, setting every column that is not part of the conflict
|
|
960
|
+
key.
|
|
961
|
+
|
|
962
|
+
**With `"ignore"`, the returned rows are the ones actually INSERTED.** Postgres
|
|
963
|
+
does not return what it did not write, so a row that collided is absent from the
|
|
964
|
+
result — if you need the existing row's id too, use `"update"`, which writes
|
|
965
|
+
every row and therefore returns every row.
|
|
966
|
+
|
|
967
|
+
Without the second argument nothing changes: a collision aborts the transaction,
|
|
968
|
+
exactly as it did before the option existed.
|
|
969
|
+
|
|
970
|
+
### Unique violations are typed
|
|
971
|
+
|
|
972
|
+
A write that duplicates a row raises `UniqueViolation` (a `Conflict`, HTTP 409)
|
|
973
|
+
carrying the constraint Postgres named:
|
|
974
|
+
|
|
975
|
+
```ts
|
|
976
|
+
import { UniqueViolation, Conflict } from "@palbase/backend";
|
|
977
|
+
|
|
978
|
+
try {
|
|
979
|
+
return await Database.tables.users.insert({ email });
|
|
980
|
+
} catch (e) {
|
|
981
|
+
if (UniqueViolation.is(e) && e.constraint === "users_email_key") {
|
|
982
|
+
throw new Conflict("That email is taken", "email_taken");
|
|
983
|
+
}
|
|
984
|
+
throw e;
|
|
985
|
+
}
|
|
986
|
+
```
|
|
987
|
+
|
|
988
|
+
**Use `UniqueViolation.is(e)`, not `e instanceof UniqueViolation`.** A controller
|
|
989
|
+
bundle inlines its own copy of `@palbase/backend`, and the engine that raises this
|
|
990
|
+
error is the runtime's copy — two copies, two class identities, so `instanceof` is
|
|
991
|
+
false in the one place you would write it. `is()` matches on shape and works
|
|
992
|
+
across the boundary.
|
|
993
|
+
|
|
994
|
+
The constraint name is on the `constraint` field, for you. It is deliberately NOT
|
|
995
|
+
in the message that reaches the client — an uncaught error becomes an HTTP
|
|
996
|
+
response, and a constraint name there tells an end user how your schema is built.
|
|
997
|
+
|
|
893
998
|
## Transactions
|
|
894
999
|
|
|
895
1000
|
A transaction is a **plan**, not a conversation. The callback DESCRIBES the
|
|
@@ -1071,8 +1176,9 @@ everywhere — by default, with no import and no generic.
|
|
|
1071
1176
|
## Defining a schema
|
|
1072
1177
|
|
|
1073
1178
|
The table NAME comes from the object key under `tables`. Each table value is an
|
|
1074
|
-
object
|
|
1075
|
-
|
|
1179
|
+
object whose only required field is `columns`; `rls` and `policies` enable
|
|
1180
|
+
[Row-Level Security](#row-level-security-rls), and `indexes` declares plain
|
|
1181
|
+
btree [indexes](#indexes).
|
|
1076
1182
|
|
|
1077
1183
|
```ts
|
|
1078
1184
|
import {
|
|
@@ -1128,6 +1234,85 @@ Chainable modifiers: `.primaryKey()`, `.notNull()` (default), `.nullable()`,
|
|
|
1128
1234
|
`.defaultNow()` (timestamp → `now()`), `.references(table, column)`,
|
|
1129
1235
|
`.onDelete("cascade" | "set null" | "restrict" | "no action")`.
|
|
1130
1236
|
|
|
1237
|
+
## Indexes
|
|
1238
|
+
|
|
1239
|
+
`indexes` declares plain (non-unique) btree indexes over an ordered column list:
|
|
1240
|
+
|
|
1241
|
+
```ts
|
|
1242
|
+
export default defineSchema({
|
|
1243
|
+
tables: {
|
|
1244
|
+
sessions: {
|
|
1245
|
+
columns: {
|
|
1246
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
1247
|
+
room_id: uuid().notNull().references("rooms", "id"),
|
|
1248
|
+
started_at: timestamp().defaultNow(),
|
|
1249
|
+
},
|
|
1250
|
+
indexes: [
|
|
1251
|
+
{ name: "sessions_room_started_idx", columns: ["room_id", "started_at"] },
|
|
1252
|
+
],
|
|
1253
|
+
},
|
|
1254
|
+
},
|
|
1255
|
+
});
|
|
1256
|
+
```
|
|
1257
|
+
|
|
1258
|
+
On deploy each entry becomes a statement of its own — `CREATE INDEX IF NOT
|
|
1259
|
+
EXISTS <name> ON <table> (<columns>)` — not a clause of the `CREATE TABLE`. The
|
|
1260
|
+
name and every column are identifier-validated before any SQL is built.
|
|
1261
|
+
|
|
1262
|
+
**The diff key is the NAME, and the diff is additive.** Three consequences, worth
|
|
1263
|
+
knowing before you name an index:
|
|
1264
|
+
|
|
1265
|
+
- An index that exists in the database but is not in `indexes` is never dropped.
|
|
1266
|
+
`db/schema.ts` does not own the database's indexes; it only adds the ones it
|
|
1267
|
+
names.
|
|
1268
|
+
- Removing an entry from `indexes` therefore does **not** drop the index. Drop it
|
|
1269
|
+
in an explicit [migration](./migrations.md).
|
|
1270
|
+
- Changing `columns` under a name that is already live does **nothing** — the
|
|
1271
|
+
deploy matches the name, sees it, and skips. Give the new column list a NEW
|
|
1272
|
+
name (and drop the old index in a migration if you want it gone).
|
|
1273
|
+
|
|
1274
|
+
### Scope: plain btree over columns, nothing else
|
|
1275
|
+
|
|
1276
|
+
`indexes` models exactly one kind of index. It cannot express:
|
|
1277
|
+
|
|
1278
|
+
| what you want | declare it with |
|
|
1279
|
+
|---------------|-----------------|
|
|
1280
|
+
| a UNIQUE index | `unique: [{ name, columns }]` on the table |
|
|
1281
|
+
| a partial index (`WHERE …`) | `raw()` |
|
|
1282
|
+
| an expression index (`lower(email)`, …) | `raw()` |
|
|
1283
|
+
| any other method (`gin`, `gist`, `hnsw`, …) | `raw()` |
|
|
1284
|
+
|
|
1285
|
+
The limit is deliberate, not an oversight. Comparing a partial predicate or an
|
|
1286
|
+
expression against the live database needs the same round-trip through Postgres'
|
|
1287
|
+
own deparse that `checks` uses; without it an edited predicate would silently do
|
|
1288
|
+
nothing. Rather than ship a half-working partial-index path, the typed field
|
|
1289
|
+
stays columns-only and `raw()` carries the rest:
|
|
1290
|
+
|
|
1291
|
+
```ts
|
|
1292
|
+
import { defineSchema, raw, uuid, text, timestamp } from "@palbase/backend";
|
|
1293
|
+
|
|
1294
|
+
// …
|
|
1295
|
+
orders: {
|
|
1296
|
+
columns: {
|
|
1297
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
1298
|
+
status: text().notNull(),
|
|
1299
|
+
created_at: timestamp().defaultNow(),
|
|
1300
|
+
},
|
|
1301
|
+
raw: [
|
|
1302
|
+
raw(
|
|
1303
|
+
"orders_pending_idx",
|
|
1304
|
+
"CREATE INDEX IF NOT EXISTS orders_pending_idx ON orders (created_at) WHERE status = 'pending'",
|
|
1305
|
+
{ down: "DROP INDEX IF EXISTS orders_pending_idx" },
|
|
1306
|
+
),
|
|
1307
|
+
],
|
|
1308
|
+
},
|
|
1309
|
+
```
|
|
1310
|
+
|
|
1311
|
+
`raw()`'s `up` is emitted verbatim on the privileged DDL connection and, like
|
|
1312
|
+
`indexes`, is tracked by NAME only — a changed body needs a new name. Pass
|
|
1313
|
+
`down` yourself for an index: the default drop statement drops a *constraint*,
|
|
1314
|
+
and an index is not one.
|
|
1315
|
+
|
|
1131
1316
|
## Typed DB access — by default
|
|
1132
1317
|
|
|
1133
1318
|
You do **not** wire anything per endpoint. Saving `db/schema.ts` regenerates
|
|
@@ -1303,88 +1488,109 @@ so they apply without the `acceptDataLoss` confirmation that column drops need.
|
|
|
1303
1488
|
# Migrations
|
|
1304
1489
|
|
|
1305
1490
|
`db/schema.ts` is the single source of truth for your Postgres schema. You change
|
|
1306
|
-
the schema by editing that file
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1491
|
+
the schema by editing that file — and **nothing generates a migration from it**.
|
|
1492
|
+
There is no diff-to-file step: the change is computed against the database *as it
|
|
1493
|
+
is right now*, by the stack that owns it, which is what lets it cover type
|
|
1494
|
+
changes, policies and constraints that a text diff cannot see.
|
|
1495
|
+
|
|
1496
|
+
Two paths lead out of an edit, for two different moments:
|
|
1497
|
+
|
|
1498
|
+
- **On your machine** — `palbase db plan` and `palbase db apply` act on the local
|
|
1499
|
+
stack (`palbase start`). Try the change, look at it, keep going.
|
|
1500
|
+
- **Anywhere real** — `palbase push` carries the code AND the schema to the
|
|
1501
|
+
selected Environment together, because a deploy that lands a handler against a
|
|
1502
|
+
column that does not exist yet fails at the first request, in production.
|
|
1503
|
+
|
|
1504
|
+
`db/migrations/*.sql` still exists, and still runs on deploy, for the objects the
|
|
1505
|
+
declaration cannot express — see [Hand-written migrations](#hand-written-migrations).
|
|
1506
|
+
It is not where an ordinary column change goes any more.
|
|
1310
1507
|
|
|
1311
1508
|
## The workflow
|
|
1312
1509
|
|
|
1313
1510
|
```bash
|
|
1314
1511
|
# 1. Edit db/schema.ts (add a column, a table, a policy, …)
|
|
1315
1512
|
|
|
1316
|
-
# 2.
|
|
1317
|
-
palbase db
|
|
1318
|
-
#
|
|
1513
|
+
# 2. See what it would take. THIS APPLIES NOTHING.
|
|
1514
|
+
palbase db plan
|
|
1515
|
+
# ALTER TABLE todos ADD COLUMN priority text
|
|
1516
|
+
# ⚠ drop todos.notes — 41908 value(s) in 41908 row(s)
|
|
1517
|
+
#
|
|
1518
|
+
# the ⚠ changes take data away — `palbase db apply --approve` runs them too
|
|
1319
1519
|
|
|
1320
|
-
# 3.
|
|
1321
|
-
|
|
1322
|
-
|
|
1520
|
+
# 3. Do it — one transaction, against the LOCAL database.
|
|
1521
|
+
palbase db apply
|
|
1522
|
+
|
|
1523
|
+
# 4. Ship it. Code and schema travel together.
|
|
1524
|
+
palbase push
|
|
1323
1525
|
```
|
|
1324
1526
|
|
|
1325
|
-
`palbase db
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1527
|
+
`palbase db plan` hands `db/schema.ts` to the local stack, which computes the plan
|
|
1528
|
+
against its own database and answers with what it would change. When there is
|
|
1529
|
+
nothing to do it says `✓ the database matches db/schema.ts`. It writes nothing,
|
|
1530
|
+
ever — neither a file nor a row — so it is safe to run at any moment, including
|
|
1531
|
+
from a script (`--detailed-exitcode` exits 2 when the plan would change
|
|
1532
|
+
something, 0 when it is in sync).
|
|
1329
1533
|
|
|
1330
1534
|
## Additive vs destructive
|
|
1331
1535
|
|
|
1332
|
-
The
|
|
1333
|
-
is
|
|
1334
|
-
|
|
1335
|
-
```sql
|
|
1336
|
-
-- palbase db diff: add_priority
|
|
1337
|
-
-- generated 20260605T142233
|
|
1536
|
+
The plan separates the two, and a **destructive** change (dropping a column or a
|
|
1537
|
+
table) is REFUSED unless you say so again:
|
|
1338
1538
|
|
|
1339
|
-
ALTER TABLE todos ADD COLUMN IF NOT EXISTS priority text;
|
|
1340
1539
|
```
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
before committing:
|
|
1345
|
-
|
|
1346
|
-
```sql
|
|
1347
|
-
-- DESTRUCTIVE: dropping todos.notes loses its data
|
|
1348
|
-
ALTER TABLE todos DROP COLUMN notes;
|
|
1540
|
+
$ palbase db apply
|
|
1541
|
+
refused: this would take data away — run it again with --approve if that is
|
|
1542
|
+
what you mean
|
|
1349
1543
|
```
|
|
1350
1544
|
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1545
|
+
Destructive lines carry their **row counts**, and that is the whole design: "this
|
|
1546
|
+
drops a column" is a shrug, "this drops a column with 41,908 values in it" is a
|
|
1547
|
+
decision. `palbase db apply --approve` runs them; `palbase push --approve` is the
|
|
1548
|
+
same consent for an Environment.
|
|
1549
|
+
|
|
1550
|
+
A **column type change** is not applied by this rail at all. The plan lists it
|
|
1551
|
+
under `not applied by this rail — change these yourself:` and you write the real
|
|
1552
|
+
`ALTER ... TYPE` as a hand-written migration, with whatever `USING` cast and
|
|
1553
|
+
backfill your data needs:
|
|
1354
1554
|
|
|
1355
1555
|
```sql
|
|
1356
|
-
--
|
|
1357
|
-
|
|
1556
|
+
-- todos.priority text -> integer
|
|
1557
|
+
ALTER TABLE todos ALTER COLUMN priority TYPE integer USING priority::integer;
|
|
1358
1558
|
```
|
|
1359
1559
|
|
|
1360
1560
|
## The drift gate
|
|
1361
1561
|
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
- **`palbase db
|
|
1365
|
-
database
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1562
|
+
Code and schema move at different speeds, so three things keep them together:
|
|
1563
|
+
|
|
1564
|
+
- **`palbase db plan --detailed-exitcode`** exits 2 when the declaration and the
|
|
1565
|
+
database disagree and 0 when they match — the form to put in CI, because it
|
|
1566
|
+
answers with a status code and still writes nothing.
|
|
1567
|
+
- The CLI installs a **git pre-push hook** that runs `palbase build`, the same
|
|
1568
|
+
validation the deploy runs, so a push that would produce a failed deploy is
|
|
1569
|
+
caught on your machine first. (Bypass with `git push --no-verify` — the server
|
|
1570
|
+
still gates it.)
|
|
1571
|
+
- On deploy, the migrations run and Palbase then asserts `db/schema.ts` matches
|
|
1572
|
+
the live database. Unresolved drift **fails the deploy** and keeps the previous
|
|
1371
1573
|
version live — a broken schema never goes out silently.
|
|
1372
1574
|
|
|
1373
|
-
## Your schema change is not live
|
|
1575
|
+
## Your schema change is not live anywhere you have not applied it
|
|
1576
|
+
|
|
1577
|
+
The local stack and every Environment hold their own database, and editing
|
|
1578
|
+
`db/schema.ts` changes none of them: `palbase db apply` is what moves the local
|
|
1579
|
+
one, `palbase push` is what moves an Environment's. Until then the declaration is
|
|
1580
|
+
ahead of the tables.
|
|
1374
1581
|
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1582
|
+
The TYPES move separately again. `palbase build` regenerates `palbase-env.d.ts`
|
|
1583
|
+
from `db/schema.ts`, which is what types `Database.tables.<name>` in your
|
|
1584
|
+
services — so after a schema edit, run it. (There is no second command for this:
|
|
1585
|
+
`build` regenerates everything derived, because one verb to remember is one verb
|
|
1586
|
+
to forget.) Typed and applied are independent, and knowing which one you are
|
|
1587
|
+
missing is usually the whole debugging step.
|
|
1381
1588
|
|
|
1382
1589
|
## Hand-written migrations
|
|
1383
1590
|
|
|
1384
1591
|
`db/migrations/*.sql` is plain SQL applied in filename order and tracked in
|
|
1385
|
-
`schema_migrations` (idempotent — a migration runs once).
|
|
1386
|
-
|
|
1387
|
-
can't express: a data backfill, a complex type change with a `USING` cast, a
|
|
1592
|
+
`schema_migrations` (idempotent — a migration runs once). Nothing generates
|
|
1593
|
+
these; you write one for anything the declaration cannot express: a data backfill, a complex type change with a `USING` cast, a
|
|
1388
1594
|
trigger, a `CHECK` constraint, an `EXCLUDE` constraint, a composite/multi-column
|
|
1389
1595
|
`UNIQUE`, an index (`CREATE INDEX`), or `CREATE EXTENSION` for an extension that
|
|
1390
1596
|
isn't in the DSL allowlist (e.g. `btree_gist`). The DSL column builders cover
|
|
@@ -1428,12 +1634,12 @@ Two patterns follow from this:
|
|
|
1428
1634
|
- **Extra constraint/index on a DSL-expressible table** — declare the table in
|
|
1429
1635
|
`db/schema.ts` and add **only** the constraint/index via the migration. The
|
|
1430
1636
|
base table stays modeled, the gate ignores the constraint, and `palbase db
|
|
1431
|
-
|
|
1637
|
+
plan` stays clean.
|
|
1432
1638
|
- **A whole object the DSL can't model** (like `instructor_sessions` above — its
|
|
1433
1639
|
`tstzrange` column has no DSL builder) — the table lives entirely in raw SQL,
|
|
1434
|
-
not in `db/schema.ts`. The deploy still succeeds, but `palbase db
|
|
1435
|
-
|
|
1436
|
-
|
|
1640
|
+
not in `db/schema.ts`. The deploy still succeeds, but `palbase db plan` will
|
|
1641
|
+
list it as a ⚠ would-be-drop for the local database; that is expected, and the
|
|
1642
|
+
deploy-time gate does not block on it. Never approve that one.
|
|
1437
1643
|
|
|
1438
1644
|
## Adding a NOT NULL column to a table with rows
|
|
1439
1645
|
|
|
@@ -1460,8 +1666,9 @@ RLS on and no applicable policy, Postgres denies everything: empty reads, refuse
|
|
|
1460
1666
|
writes, no error anywhere that says why.
|
|
1461
1667
|
|
|
1462
1668
|
**Guard the CREATE.** Postgres has no `CREATE POLICY IF NOT EXISTS`, so a
|
|
1463
|
-
migration that replays — a fresh Environment,
|
|
1464
|
-
fails on the second
|
|
1669
|
+
migration that replays — a fresh Environment, or `palbase start --reset`, which
|
|
1670
|
+
throws the local database away and brings it back empty — fails on the second
|
|
1671
|
+
run. Drop first:
|
|
1465
1672
|
|
|
1466
1673
|
```sql
|
|
1467
1674
|
DROP POLICY IF EXISTS owner_all ON notes;
|
|
@@ -1483,15 +1690,30 @@ no such safety net — write it the first time.
|
|
|
1483
1690
|
|
|
1484
1691
|
<!-- ===== services.md ===== -->
|
|
1485
1692
|
|
|
1486
|
-
#
|
|
1693
|
+
# Service singletons — `Database`, `Storage`, `Cache`, …
|
|
1694
|
+
|
|
1695
|
+
**Two different things are called "services" here, and this page is the second
|
|
1696
|
+
one.** Read the first paragraph before searching this page for a layout rule.
|
|
1487
1697
|
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1698
|
+
- **YOUR service layer** — `services/<name>.service.ts`, the plain classes your
|
|
1699
|
+
own business logic lives in. The scaffold ships one, and the contract is at
|
|
1700
|
+
the bottom of this page: [Your own service layer](#your-own-service-layer).
|
|
1701
|
+
- **THE service singletons** — the platform objects you import from
|
|
1702
|
+
`@palbase/backend` (`Database`, `Cache`, `Storage`, …). Everything between
|
|
1703
|
+
here and that last section is about these.
|
|
1491
1704
|
|
|
1492
|
-
|
|
1705
|
+
Import them in every handler type — endpoints, workers, jobs, hooks, and
|
|
1706
|
+
webhooks all use the same imports. Only **middleware** uses a `ctx` argument
|
|
1707
|
+
(`ctx.db`, `ctx.log`, etc.).
|
|
1708
|
+
|
|
1709
|
+
Available singletons: `Database`, `Documents`, `Storage`, `Cache`,
|
|
1493
1710
|
`Log`, `Notifications`, `Flags`, `Realtime`.
|
|
1494
1711
|
|
|
1712
|
+
There is **no `Queue`**. It was listed here for months and has never been
|
|
1713
|
+
exported: background work is a cron `@Job` under `jobs/` (see
|
|
1714
|
+
[background.md](./background.md)). Importing it does not fail at a queue that
|
|
1715
|
+
turns out to be empty — it fails at the import.
|
|
1716
|
+
|
|
1495
1717
|
`Realtime` is **broadcast-only** on the backend — a handler can push an event to
|
|
1496
1718
|
a channel, but cannot subscribe (a stateless request can't hold a socket).
|
|
1497
1719
|
Subscription lives on the client SDK (`pb.realtime`).
|
|
@@ -1561,9 +1783,10 @@ const { data: doc } = await Documents.doc("rooms/abc").get();
|
|
|
1561
1783
|
## Notifications
|
|
1562
1784
|
|
|
1563
1785
|
**No provider setup is required.** Every project starts with a managed sender, so
|
|
1564
|
-
email and SMS deliver on a brand-new project with no provider
|
|
1565
|
-
|
|
1566
|
-
|
|
1786
|
+
email and SMS deliver on a brand-new project with no provider and no API key. You
|
|
1787
|
+
configure your own provider only to send from your own domain, and that is a CLI
|
|
1788
|
+
act against the stack, not a file in this repository:
|
|
1789
|
+
`palbase notifications providers`.
|
|
1567
1790
|
|
|
1568
1791
|
```ts
|
|
1569
1792
|
import { Notifications } from "@palbase/backend";
|
|
@@ -1576,7 +1799,7 @@ await Notifications.email.send({
|
|
|
1576
1799
|
});
|
|
1577
1800
|
await Notifications.email.send({
|
|
1578
1801
|
to: user.email,
|
|
1579
|
-
templateSlug: "receipt", //
|
|
1802
|
+
templateSlug: "receipt", // created with `palbase notifications templates`
|
|
1580
1803
|
locale: "tr", // without this the extra locale rows are unreachable
|
|
1581
1804
|
variables: { total: "₺240" },
|
|
1582
1805
|
});
|
|
@@ -1684,6 +1907,79 @@ and never blocks the handler waiting on subscribers.
|
|
|
1684
1907
|
Pair it with the client SDK's `pb.realtime.channel("room:42").on("message", …)`
|
|
1685
1908
|
to drive live chat, presence, dashboards, and other push features.
|
|
1686
1909
|
|
|
1910
|
+
## Your own service layer
|
|
1911
|
+
|
|
1912
|
+
The singletons above are what a service CALLS. This section is the layer that
|
|
1913
|
+
calls them — `services/<name>.service.ts`, which the scaffold ships a working
|
|
1914
|
+
example of (`services/note.service.ts` and the controller that uses it).
|
|
1915
|
+
|
|
1916
|
+
The contract is three rules, and the scaffold's own test enforces all three:
|
|
1917
|
+
|
|
1918
|
+
**1. The logic lives in a service; the controller does not touch the database.**
|
|
1919
|
+
A controller's job is HTTP — validate a body through a named schema, name the
|
|
1920
|
+
200 shape as the return type, turn a missing row into a status. Which rows,
|
|
1921
|
+
whose, in what order is the service's. A controller that reaches for `Database`
|
|
1922
|
+
has moved the logic into the layer that is hardest to test.
|
|
1923
|
+
|
|
1924
|
+
**2. The dependency arrives through the CONSTRUCTOR, and the module exports one
|
|
1925
|
+
wired instance.** There is no DI container and no decorator — the last line of
|
|
1926
|
+
the file is the wiring:
|
|
1927
|
+
|
|
1928
|
+
```ts
|
|
1929
|
+
// services/note.service.ts
|
|
1930
|
+
import { Database } from "@palbase/backend";
|
|
1931
|
+
|
|
1932
|
+
type NotesTable = typeof Database.tables.notes;
|
|
1933
|
+
|
|
1934
|
+
export class NoteService {
|
|
1935
|
+
private readonly notes: NotesTable;
|
|
1936
|
+
|
|
1937
|
+
// Assigned in the BODY. A parameter property (`constructor(private notes: …)`)
|
|
1938
|
+
// is refused by Node's type-stripping test runner, for the whole file.
|
|
1939
|
+
constructor(notes: NotesTable) {
|
|
1940
|
+
this.notes = notes;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
list(userId: string) {
|
|
1944
|
+
return this.notes.findMany({ user_id: userId });
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
export const noteService = new NoteService(Database.tables.notes);
|
|
1949
|
+
```
|
|
1950
|
+
|
|
1951
|
+
```ts
|
|
1952
|
+
// controllers/notes.controller.ts
|
|
1953
|
+
import { noteService } from "../services/note.service";
|
|
1954
|
+
```
|
|
1955
|
+
|
|
1956
|
+
**3. That module-level singleton is the only way to hold a dependency.**
|
|
1957
|
+
Controllers, hooks, jobs and webhooks are all constructed by the runtime with
|
|
1958
|
+
**no arguments**. A class that declares a constructor parameter is refused at
|
|
1959
|
+
build with the class named — there is no injector to fill it, so the field would
|
|
1960
|
+
otherwise be `undefined` in production. Import the singleton instead.
|
|
1961
|
+
|
|
1962
|
+
Rule 2 is also what makes rule 1 pay off: because the service is handed its
|
|
1963
|
+
table rather than reaching for the singleton, a test constructs it with a
|
|
1964
|
+
stand-in and never needs a database.
|
|
1965
|
+
|
|
1966
|
+
```ts
|
|
1967
|
+
// services/note.service.test.ts — `npm test`, no database
|
|
1968
|
+
import assert from "node:assert/strict";
|
|
1969
|
+
import { test } from "node:test";
|
|
1970
|
+
import { NoteService } from "./note.service.ts";
|
|
1971
|
+
|
|
1972
|
+
test("list filters by owner", async () => {
|
|
1973
|
+
const seen: unknown[] = [];
|
|
1974
|
+
const fake = { findMany: async (q: unknown) => (seen.push(q), []) };
|
|
1975
|
+
await new NoteService(fake as never).list("u_1");
|
|
1976
|
+
assert.deepEqual(seen, [{ user_id: "u_1" }]);
|
|
1977
|
+
});
|
|
1978
|
+
```
|
|
1979
|
+
|
|
1980
|
+
Node's ESM resolver wants the extension on a relative import inside a test
|
|
1981
|
+
(`./note.service.ts`); the scaffold's `tsconfig.json` allows it.
|
|
1982
|
+
|
|
1687
1983
|
|
|
1688
1984
|
|
|
1689
1985
|
<!-- ===== errors.md ===== -->
|