@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.
Files changed (74) hide show
  1. package/dist/bin/palbase-backend.cjs +695 -61
  2. package/dist/bin/palbase-backend.cjs.map +1 -1
  3. package/dist/bin/palbase-backend.js +4 -5
  4. package/dist/bin/palbase-backend.js.map +1 -1
  5. package/dist/{chunk-FSGSB42K.js → chunk-7Z6MGMXQ.js} +64 -4
  6. package/dist/chunk-7Z6MGMXQ.js.map +1 -0
  7. package/dist/{chunk-OMRTHM4X.js → chunk-H3JAISUY.js} +136 -1
  8. package/dist/chunk-H3JAISUY.js.map +1 -0
  9. package/dist/{chunk-REZU6UKT.js → chunk-NXDH6VQJ.js} +549 -42
  10. package/dist/chunk-NXDH6VQJ.js.map +1 -0
  11. package/dist/{chunk-W5ODXPY3.js → chunk-P2Q27SGP.js} +32 -3
  12. package/dist/chunk-P2Q27SGP.js.map +1 -0
  13. package/dist/{chunk-ZC6Q2BRD.js → chunk-T5IOSOE5.js} +7 -2
  14. package/dist/chunk-T5IOSOE5.js.map +1 -0
  15. package/dist/{chunk-HAF67F2H.js → chunk-ZUGY7RGS.js} +86 -3
  16. package/dist/chunk-ZUGY7RGS.js.map +1 -0
  17. package/dist/db/index.cjs +115 -3
  18. package/dist/db/index.cjs.map +1 -1
  19. package/dist/db/index.d.cts +2 -2
  20. package/dist/db/index.d.ts +2 -2
  21. package/dist/db/index.js +2 -2
  22. package/dist/{endpoint-BavvbW4P.d.ts → endpoint-0_DGBajf.d.ts} +168 -9
  23. package/dist/{endpoint-i8TTCohk.d.cts → endpoint-CcQ1a36a.d.cts} +168 -9
  24. package/dist/engine/index.cjs +684 -48
  25. package/dist/engine/index.cjs.map +1 -1
  26. package/dist/engine/index.d.cts +4 -4
  27. package/dist/engine/index.d.ts +4 -4
  28. package/dist/engine/index.js +4 -4
  29. package/dist/{index-B3jmmItD.d.ts → index-CJiJU9ux.d.ts} +209 -36
  30. package/dist/{index-Bmvx1EvJ.d.cts → index-D-4-PNuQ.d.cts} +209 -36
  31. package/dist/{index-B7YBEG5w.d.ts → index-D17r-MKb.d.ts} +177 -7
  32. package/dist/{index-E7OscPJT.d.cts → index-DRFxf07H.d.cts} +177 -7
  33. package/dist/index.cjs +269 -7
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.cts +54 -12
  36. package/dist/index.d.ts +54 -12
  37. package/dist/index.js +54 -11
  38. package/dist/index.js.map +1 -1
  39. package/dist/openapi/index.cjs +45 -5
  40. package/dist/openapi/index.cjs.map +1 -1
  41. package/dist/openapi/index.d.cts +9 -4
  42. package/dist/openapi/index.d.ts +9 -4
  43. package/dist/openapi/index.js +35 -11
  44. package/dist/openapi/index.js.map +1 -1
  45. package/dist/{registry-C3H2uPeZ.d.cts → registry-1X-skBNu.d.cts} +101 -7
  46. package/dist/{registry-DY3d9l1k.d.ts → registry-CEod_5sz.d.ts} +101 -7
  47. package/dist/test/index.cjs +509 -9
  48. package/dist/test/index.cjs.map +1 -1
  49. package/dist/test/index.d.cts +35 -3
  50. package/dist/test/index.d.ts +35 -3
  51. package/dist/test/index.js +507 -8
  52. package/dist/test/index.js.map +1 -1
  53. package/docs/README.md +4 -4
  54. package/docs/database.md +115 -11
  55. package/docs/getting-started.md +5 -4
  56. package/docs/llms-full.txt +385 -89
  57. package/docs/migrations.md +81 -59
  58. package/docs/schema.md +82 -2
  59. package/docs/services.md +98 -9
  60. package/package.json +3 -2
  61. package/stager/return_types.js +23 -0
  62. package/template/AGENTS.md +121 -41
  63. package/template/controllers/notes.controller.ts +64 -0
  64. package/template/package.json +1 -1
  65. package/template/services/note.service.ts +74 -0
  66. package/template/tsconfig.json +11 -1
  67. package/dist/chunk-FSGSB42K.js.map +0 -1
  68. package/dist/chunk-HAF67F2H.js.map +0 -1
  69. package/dist/chunk-OMRTHM4X.js.map +0 -1
  70. package/dist/chunk-REZU6UKT.js.map +0 -1
  71. package/dist/chunk-W5ODXPY3.js.map +0 -1
  72. package/dist/chunk-Y5HXVUMP.js +0 -90
  73. package/dist/chunk-Y5HXVUMP.js.map +0 -1
  74. package/dist/chunk-ZC6Q2BRD.js.map +0 -1
@@ -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
- - `palbase db types` regenerate `palbase-env.d.ts` from `db/schema.ts` so
200
- `Database.tables.*` is typed (no import, no generic). Run it after editing the
201
- schema.
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
- - `palbase db types` — regenerate `palbase-env.d.ts` from `db/schema.ts` so
342
- `Database.tables.*` is typed (no import, no generic). Run it after editing the
343
- schema. See [migrations.md](./migrations.md) for the schema/migration side.
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 dynamic table names or read-only SQL, the string-keyed ops are still
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.update(table, id, data)` | the updated row |
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, query?)` | matching rows (array) |
877
- | `Database.query(sql, params?)` | rows from a read-only SQL query (runs in a READ ONLY transaction) |
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
- `findMany`'s `query` is an equality filter: keys are ANDed together. For
881
- anything richer (ranges, ordering, joins) use `Database.query`.
886
+ ### Filters
882
887
 
883
- `Database.query` is **read-only** use it for selects/joins the helpers don't
884
- cover. Writes must go through `insert`/`update`/`delete` or a transaction.
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 id, title FROM todos WHERE done = $1 ORDER BY created_at DESC LIMIT $2",
889
- [false, 20],
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 `{ columns, rls?, policies? }` `columns` is required; `rls` and
1075
- `policies` enable [Row-Level Security](#row-level-security-rls).
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, then generating a **migration** from the diff
1307
- with `palbase db diff`. Every schema change additive or destructive flows
1308
- through a reviewable migration file committed to git. The deploy applies the
1309
- migrations in `db/migrations/`; nothing is auto-applied behind your back.
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. Generate the migration from the diff (declared schema vs the selected Environment)
1317
- palbase db diff -f add_priority
1318
- # writes db/migrations/<timestamp>_add_priority.sql
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. Review the generated SQL (especially destructive changes see below),
1321
- # then commit + push. git push deploys; the migration runs on deploy.
1322
- git add db/migrations && git commit -m "add priority column" && git push
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 diff` introspects the selected Environment's live database (there is
1326
- no local database each Environment runs on the server), diffs it against
1327
- `db/schema.ts`, and writes one migration SQL file. If the schema is already in
1328
- sync it writes nothing and tells you so.
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 generated SQL labels what it does. An additive change (new table, new column)
1333
- is plain DDL:
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
- A **destructive** change (dropping a column or table losing data) is generated
1343
- with a clear warning comment, and `palbase db diff` prints a warning. Review it
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
- A **column type change** is emitted as a commented stub auto-migration never
1352
- alters types, so you write the real `ALTER ... TYPE` with whatever `USING` cast
1353
- and backfill your data needs:
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
- -- TYPE CHANGE: todos.priority text -> integer (review; auto-migrate does not ALTER types)
1357
- -- ALTER TABLE todos ALTER COLUMN priority TYPE integer;
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
- You can't push a schema change without its migration:
1363
-
1364
- - **`palbase db check`** exits non-zero when `db/schema.ts` declares something the
1365
- database lacks (i.e. you edited the schema but didn't run `palbase db diff`).
1366
- - The scaffold installs a **git pre-push hook** that runs `palbase db check`, so a
1367
- plain `git push` is **blocked** until you generate + commit the migration.
1368
- (Bypass with `git push --no-verify` but the deploy-time gate still rejects it.)
1369
- - On deploy, after migrations run, Palbase asserts `db/schema.ts` matches the live
1370
- database. Any unresolved drift **fails the deploy** and keeps the previous
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 until you deploy it
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
- There is no local database. `Database` always talks to the selected deployed
1376
- Environment, so a change in `db/schema.ts` does not exist in that database until
1377
- you generate the migration and deploy it the types can be ahead of the tables.
1378
- Run `palbase db types` after editing the schema to refresh `palbase-env.d.ts` so
1379
- `Database.tables.<name>` is fully typed in your services, and deploy to a dev
1380
- Environment to exercise it against real data.
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). `palbase db diff`
1386
- generates them for you, but you can also hand-write one for anything the diff
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
- check` stays clean.
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 check` (the
1435
- read-only client warning) will print a `- table <name>` would-be-drop line for
1436
- it; that's expected and the deploy-time gate does not block on it.
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, a branch, `palbase db reset`
1464
- fails on the second run. Drop first:
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
- # Services
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
- Import service singletons from `@palbase/backend` in every handler type —
1489
- endpoints, workers, jobs, hooks, and webhooks all use the same imports. Only
1490
- **middleware** uses a `ctx` argument (`ctx.db`, `ctx.log`, etc.).
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
- Available singletons: `Database`, `Documents`, `Storage`, `Cache`, `Queue`,
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, no API key, and no
1565
- `config/notifications.ts`. You configure your own provider only to send from your
1566
- own domain see [Config](./config.md).
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", // defined in config/notifications.ts
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 ===== -->