@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
@@ -1,8 +1,8 @@
1
- export { A as App, h as AuthVerifier, B as BootRefused, i as CreateAppOptions, E as EgressPolicy, j as EngineConfig, M as ModuleClients, k as RateLimiter, l as RequestDatabase, m as RouteEntry, n as RuntimeHooks, o as ScrubResult, p as SqlDriver, q as SqlTx, r as buildRouteTable, s as createApp, t as createLazyTransaction, u as createOps, v as createRequestDatabase, w as effectiveAuth, x as hostAllowed, y as installEgressFence, z as loadConfig, G as makeMemoryCache, H as matchRoute, I as quoteIdent, J as scrubSecrets, K as withTables } from '../index-E7OscPJT.cjs';
2
- import '../endpoint-i8TTCohk.cjs';
1
+ export { A as App, m as AuthVerifier, B as BootRefused, n as CreateAppOptions, E as EgressPolicy, p as EngineConfig, M as ModuleClients, q as RateLimiter, r as RequestDatabase, s as RouteEntry, t as RuntimeHooks, u as ScrubResult, v as SqlDriver, w as SqlTx, x as buildRouteTable, y as createApp, z as createLazyTransaction, G as createOps, H as createRequestDatabase, I as effectiveAuth, J as hostAllowed, K as installEgressFence, O as loadConfig, P as makeMemoryCache, Q as matchRoute, T as quoteIdent, U as scrubSecrets, V as withTables } from '../index-DRFxf07H.cjs';
2
+ import '../endpoint-CcQ1a36a.cjs';
3
3
  import '../stack.cjs';
4
4
  import 'node:async_hooks';
5
- import '../index-Bmvx1EvJ.cjs';
5
+ import '../index-D-4-PNuQ.cjs';
6
6
  import '../db/env.cjs';
7
- import '../registry-C3H2uPeZ.cjs';
7
+ import '../registry-1X-skBNu.cjs';
8
8
  import 'zod';
@@ -1,8 +1,8 @@
1
- export { A as App, h as AuthVerifier, B as BootRefused, i as CreateAppOptions, E as EgressPolicy, j as EngineConfig, M as ModuleClients, k as RateLimiter, l as RequestDatabase, m as RouteEntry, n as RuntimeHooks, o as ScrubResult, p as SqlDriver, q as SqlTx, r as buildRouteTable, s as createApp, t as createLazyTransaction, u as createOps, v as createRequestDatabase, w as effectiveAuth, x as hostAllowed, y as installEgressFence, z as loadConfig, G as makeMemoryCache, H as matchRoute, I as quoteIdent, J as scrubSecrets, K as withTables } from '../index-B7YBEG5w.js';
2
- import '../endpoint-BavvbW4P.js';
1
+ export { A as App, m as AuthVerifier, B as BootRefused, n as CreateAppOptions, E as EgressPolicy, p as EngineConfig, M as ModuleClients, q as RateLimiter, r as RequestDatabase, s as RouteEntry, t as RuntimeHooks, u as ScrubResult, v as SqlDriver, w as SqlTx, x as buildRouteTable, y as createApp, z as createLazyTransaction, G as createOps, H as createRequestDatabase, I as effectiveAuth, J as hostAllowed, K as installEgressFence, O as loadConfig, P as makeMemoryCache, Q as matchRoute, T as quoteIdent, U as scrubSecrets, V as withTables } from '../index-D17r-MKb.js';
2
+ import '../endpoint-0_DGBajf.js';
3
3
  import '../stack.js';
4
4
  import 'node:async_hooks';
5
- import '../index-B3jmmItD.js';
5
+ import '../index-CJiJU9ux.js';
6
6
  import '../db/env.js';
7
- import '../registry-DY3d9l1k.js';
7
+ import '../registry-CEod_5sz.js';
8
8
  import 'zod';
@@ -16,10 +16,10 @@ import {
16
16
  quoteIdent,
17
17
  scrubSecrets,
18
18
  withTables
19
- } from "../chunk-REZU6UKT.js";
20
- import "../chunk-FSGSB42K.js";
21
- import "../chunk-W5ODXPY3.js";
22
- import "../chunk-OMRTHM4X.js";
19
+ } from "../chunk-NXDH6VQJ.js";
20
+ import "../chunk-7Z6MGMXQ.js";
21
+ import "../chunk-P2Q27SGP.js";
22
+ import "../chunk-H3JAISUY.js";
23
23
  import "../chunk-7D4SUZUM.js";
24
24
  export {
25
25
  AuthVerifier,
@@ -1,5 +1,5 @@
1
1
  import { Tables, TableTypes } from './db/env.js';
2
- import { D as DBClient, b8 as TxPlanHandle, bh as TxTable, M as Materialized } from './endpoint-BavvbW4P.js';
2
+ import { D as DBClient, b8 as TxPlanHandle, bh as TxTable, M as Materialized } from './endpoint-0_DGBajf.js';
3
3
 
4
4
  /** On delete action for foreign key references. */
5
5
  type OnDeleteAction = 'cascade' | 'set null' | 'restrict' | 'no action';
@@ -36,15 +36,47 @@ interface ColumnDef {
36
36
  enumName?: string;
37
37
  enumValues?: string[];
38
38
  unique?: boolean;
39
+ /**
40
+ * The value is written by the DATABASE — a trigger, a rule, an identity — not by
41
+ * the author and not by a DEFAULT this schema declares. It makes the column
42
+ * optional on INSERT without putting a DEFAULT in the DDL.
43
+ *
44
+ * Before this existed the only way to keep a trigger-filled column off the
45
+ * INSERT type was to give it a fake `default()`: a value the schema claimed to
46
+ * write and the trigger immediately overwrote. That made the schema lie about
47
+ * its own data.
48
+ */
49
+ dbAssigned?: boolean;
39
50
  /** vector(n): the declared dimension count — part of the TYPE (typmod), read
40
51
  * by the wire serializer and the deploy's auto-index (FR-001). */
41
52
  dimensions?: number;
53
+ /**
54
+ * How the stored value is projected in and out of this process (FR-009).
55
+ *
56
+ * NOT part of the DDL: the column's Postgres type is unchanged and this pair
57
+ * is never serialized into a migration. It exists so the row surface can hand
58
+ * back the type the application actually works with.
59
+ */
60
+ transform?: ColumnTransform;
61
+ }
62
+ /**
63
+ * The read/write pair a column may declare (FR-009).
64
+ *
65
+ * `fromDb` takes whatever the driver produced for this column and returns the
66
+ * value the application sees; `toDb` is its inverse on the way out. Kept
67
+ * deliberately unexported — a column declares one inline, nobody needs to name
68
+ * the shape.
69
+ */
70
+ interface ColumnTransform<T = unknown> {
71
+ fromDb: (value: unknown) => T;
72
+ toDb: (value: T) => unknown;
42
73
  }
43
74
  declare const __colKind: unique symbol;
44
75
  declare const __colNullable: unique symbol;
45
76
  declare const __colHasDefault: unique symbol;
46
77
  declare const __colEnumValues: unique symbol;
47
78
  declare const __colPayload: unique symbol;
79
+ declare const __colTransform: unique symbol;
48
80
  /**
49
81
  * Fluent column builder with phantom type params:
50
82
  * K — ColumnType literal (e.g. "text", "integer")
@@ -52,34 +84,50 @@ declare const __colPayload: unique symbol;
52
84
  * D — boolean: true when a default has been set
53
85
  * E — enum value union (never for non-enum columns)
54
86
  * P — jsonb payload shape (unknown unless jsonb<T>() supplied one)
87
+ * T — transform target type (`never` when the column declares no transform;
88
+ * `never` is the sentinel because it is the only type that survives
89
+ * `[T] extends [never]` and never collides with a real target type)
55
90
  *
56
- * All five params have defaults so bare `ColumnBuilder` (no args) still
91
+ * All six params have defaults so bare `ColumnBuilder` (no args) still
57
92
  * satisfies `Record<string, ColumnBuilder>` in schema.ts without modification.
58
93
  *
59
- * The five `declare readonly` brand fields carry the phantom types into the
94
+ * The six `declare readonly` brand fields carry the phantom types into the
60
95
  * structural shape so that conditional types like ColValue<C> can discriminate
61
96
  * on K without requiring runtime values on those fields.
62
97
  */
63
- declare class ColumnBuilder<K extends ColumnType = ColumnType, N extends boolean = boolean, D extends boolean = boolean, E = unknown, P = unknown> {
98
+ declare class ColumnBuilder<K extends ColumnType = ColumnType, N extends boolean = boolean, D extends boolean = boolean, E = unknown, P = unknown, T = never> {
64
99
  readonly [__colKind]: K;
65
100
  readonly [__colNullable]: N;
66
101
  readonly [__colHasDefault]: D;
67
102
  readonly [__colEnumValues]: E;
68
103
  readonly [__colPayload]: P;
104
+ readonly [__colTransform]: T;
69
105
  readonly _def: ColumnDef;
70
106
  constructor(type: K, existingDef?: ColumnDef);
71
107
  /** Mark this column as the primary key. */
72
- primaryKey(): ColumnBuilder<K, N, D, E, P>;
108
+ primaryKey(): ColumnBuilder<K, N, D, E, P, T>;
73
109
  /** Mark this column as NOT NULL (default). */
74
- notNull(): ColumnBuilder<K, false, D, E, P>;
110
+ notNull(): ColumnBuilder<K, false, D, E, P, T>;
75
111
  /** Allow NULL values. */
76
- nullable(): ColumnBuilder<K, true, D, E, P>;
112
+ nullable(): ColumnBuilder<K, true, D, E, P, T>;
77
113
  /** Set a default value. */
78
- default(value: unknown): ColumnBuilder<K, N, true, E, P>;
114
+ default(value: unknown): ColumnBuilder<K, N, true, E, P, T>;
79
115
  /** UUID: generate a random default (gen_random_uuid()). */
80
- defaultRandom(): ColumnBuilder<K, N, true, E, P>;
116
+ defaultRandom(): ColumnBuilder<K, N, true, E, P, T>;
81
117
  /** Timestamp: default to now(). */
82
- defaultNow(): ColumnBuilder<K, N, true, E, P>;
118
+ defaultNow(): ColumnBuilder<K, N, true, E, P, T>;
119
+ /**
120
+ * The DATABASE assigns this column's value — a trigger, a rule, an identity.
121
+ *
122
+ * The column becomes optional on INSERT (the author has nothing to send) while
123
+ * the DDL stays free of a DEFAULT this schema would not honour. It is NOT
124
+ * `default()`: that declares a value the schema promises to write.
125
+ *
126
+ * Naming: deliberately not `generated()`. Postgres has GENERATED columns and
127
+ * they are a different thing; borrowing the word would send a reader — or a
128
+ * model writing a schema — to the wrong feature.
129
+ */
130
+ dbAssigned(): ColumnBuilder<K, N, true, E, P, T>;
83
131
  /** Add a foreign key reference. */
84
132
  /**
85
133
  * Declares that this column used to be called `previous`.
@@ -92,8 +140,8 @@ declare class ColumnBuilder<K extends ColumnType = ColumnType, N extends boolean
92
140
  * Once the rename has been applied the annotation is inert (the old name is no
93
141
  * longer there to rename), so it can be deleted at your leisure.
94
142
  */
95
- renamedFrom(previous: string): ColumnBuilder<K, N, D, E, P>;
96
- references(table: string, column: string): ColumnBuilder<K, N, D, E, P>;
143
+ renamedFrom(previous: string): ColumnBuilder<K, N, D, E, P, T>;
144
+ references(table: string, column: string): ColumnBuilder<K, N, D, E, P, T>;
97
145
  /**
98
146
  * Add a real DB-level foreign key to the built-in auth users
99
147
  * (`REFERENCES auth.users(id)`), so a column like `user_id` gets true
@@ -114,7 +162,7 @@ declare class ColumnBuilder<K extends ColumnType = ColumnType, N extends boolean
114
162
  * express (referencing column is text, `set null` needs a nullable column) —
115
163
  * as the real boundary; this signature is the compile-time DX mirror.
116
164
  */
117
- referencesAuthUser(onDelete: AuthUserOnDelete): ColumnBuilder<K, N, D, E, P>;
165
+ referencesAuthUser(onDelete: AuthUserOnDelete): ColumnBuilder<K, N, D, E, P, T>;
118
166
  /**
119
167
  * Add a real DB-level foreign key to the canonical, server-minted installation
120
168
  * anchor (`REFERENCES auth.installations(id)`) — the app-scoped verified-device
@@ -133,11 +181,30 @@ declare class ColumnBuilder<K extends ColumnType = ColumnType, N extends boolean
133
181
  * blocked by a lingering FK. The server (validateAuthAnchorFK) is the real
134
182
  * boundary; this signature is the compile-time DX mirror.
135
183
  */
136
- referencesInstallation(onDelete: AuthUserOnDelete): ColumnBuilder<K, N, D, E, P>;
184
+ referencesInstallation(onDelete: AuthUserOnDelete): ColumnBuilder<K, N, D, E, P, T>;
137
185
  /** Set the ON DELETE action for a foreign key reference. */
138
- onDelete(action: OnDeleteAction): ColumnBuilder<K, N, D, E, P>;
186
+ onDelete(action: OnDeleteAction): ColumnBuilder<K, N, D, E, P, T>;
139
187
  /** Add a single-column UNIQUE constraint. */
140
- unique(): ColumnBuilder<K, N, D, E, P>;
188
+ unique(): ColumnBuilder<K, N, D, E, P, T>;
189
+ /**
190
+ * Declare how this column's value is projected in and out of the process.
191
+ *
192
+ * The DDL does not move: `numeric` stays `numeric`, and the driver still hands
193
+ * back what Postgres sent. What changes is the type the row surface exposes —
194
+ * it becomes `Target`:
195
+ *
196
+ * amount: numeric().transform<number>({ fromDb: Number, toDb: String })
197
+ *
198
+ * `numeric` surfacing as `string` is CORRECT (a JS number cannot hold
199
+ * arbitrary precision), and that is exactly why this exists: application code
200
+ * that does arithmetic on the column otherwise rewrites the same
201
+ * `Number(row.amount)` / `String(x)` pair in every controller that touches it,
202
+ * and each rewrite is a place the two directions can drift apart.
203
+ *
204
+ * A transform is a PROJECTION, never a constraint: it lives only in this
205
+ * process, so it can neither validate nor migrate what is stored.
206
+ */
207
+ transform<Target>(fns: ColumnTransform<Target>): ColumnBuilder<K, N, D, E, P, Target>;
141
208
  }
142
209
  /**
143
210
  * Extracts the TypeScript value type for a column, respecting nullability.
@@ -149,14 +216,22 @@ declare class ColumnBuilder<K extends ColumnType = ColumnType, N extends boolean
149
216
  * - "boolean" → boolean
150
217
  * - "jsonb" → P (the dev-supplied payload shape from jsonb<T>(), else unknown)
151
218
  * - "enum" → E (the union of literal values)
219
+ *
220
+ * A declared `.transform<T>()` OVERRIDES the table above: the column then
221
+ * surfaces as T (or T | null when nullable), because that is the value the
222
+ * application is handed. Nullability is still the column's, not the
223
+ * transform's — `fromDb` is not called for a NULL.
152
224
  */
153
- type ColValue<C> = C extends ColumnBuilder<'uuid' | 'text' | 'timestamp' | 'bigint' | 'numeric', infer N, infer _D, infer _E, infer _P> ? N extends true ? string | null : string : C extends ColumnBuilder<'integer', infer N, infer _D, infer _E, infer _P> ? N extends true ? number | null : number : C extends ColumnBuilder<'boolean', infer N, infer _D, infer _E, infer _P> ? N extends true ? boolean | null : boolean : C extends ColumnBuilder<'jsonb', infer N, infer _D, infer _E, infer P> ? N extends true ? P | null : P : C extends ColumnBuilder<'vector', infer N, infer _D, infer _E, infer _P> ? N extends true ? number[] | null : number[] : C extends ColumnBuilder<'enum', infer N, infer _D, infer E, infer _P> ? N extends true ? E | null : E : never;
225
+ type ColValue<C> = C extends ColumnBuilder<ColumnType, infer N, boolean, unknown, unknown, infer T> ? [T] extends [never] ? ColStoredValue<C> : N extends true ? T | null : T : never;
226
+ /** The value as the DATABASE hands it over — the branch table above, before any
227
+ * transform. This is what a column's `fromDb` receives. */
228
+ type ColStoredValue<C> = C extends ColumnBuilder<'uuid' | 'text' | 'timestamp' | 'bigint' | 'numeric', infer N, infer _D, infer _E, infer _P> ? N extends true ? string | null : string : C extends ColumnBuilder<'integer', infer N, infer _D, infer _E, infer _P> ? N extends true ? number | null : number : C extends ColumnBuilder<'boolean', infer N, infer _D, infer _E, infer _P> ? N extends true ? boolean | null : boolean : C extends ColumnBuilder<'jsonb', infer N, infer _D, infer _E, infer P> ? N extends true ? P | null : P : C extends ColumnBuilder<'vector', infer N, infer _D, infer _E, infer _P> ? N extends true ? number[] | null : number[] : C extends ColumnBuilder<'enum', infer N, infer _D, infer E, infer _P> ? N extends true ? E | null : E : never;
154
229
  /**
155
230
  * True when a column is optional on INSERT:
156
231
  * - nullable columns (N = true) — the DB allows NULL so the field may be omitted
157
232
  * - columns with a default (D = true) — the DB fills in the value when absent
158
233
  */
159
- type ColIsOptionalOnInsert<C> = C extends ColumnBuilder<infer _K, true, infer _D, infer _E> ? true : C extends ColumnBuilder<infer _K, infer _N, true, infer _E> ? true : false;
234
+ type ColIsOptionalOnInsert<C> = C extends ColumnBuilder<ColumnType, true, boolean, unknown, unknown, unknown> ? true : C extends ColumnBuilder<ColumnType, boolean, true, unknown, unknown, unknown> ? true : false;
160
235
  /** Create a UUID column. */
161
236
  declare function uuid(): ColumnBuilder<'uuid', false, false, never>;
162
237
  /** Create a TEXT column. */
@@ -247,22 +322,6 @@ interface PolicyDef {
247
322
  withCheck: string | null;
248
323
  permissive: boolean;
249
324
  }
250
- /**
251
- * Fluent RLS policy builder.
252
- *
253
- * Defaults (documented, applied at construction):
254
- * - `command`: `"all"` — applies to every SQL command unless `.for(...)` narrows it.
255
- * - `roles`: `["authenticated"]` — the common case is "rule applies to signed-in
256
- * users". Call `.to(...)` to override; pass `.to()` with no roles (or never
257
- * call it after a reset) to target PUBLIC.
258
- * - `using` / `withCheck`: `null` — no row filter / write check until set.
259
- * - `permissive`: `true` — `AS PERMISSIVE` (policies OR together).
260
- *
261
- * Each method mutates `_def` in place and returns `this`, so the chain is a
262
- * single builder instance (no per-call allocation, like a tagged-template
263
- * compile target). The terminal `PolicyDef` is read directly off `_def` by
264
- * `schema_extract.js`.
265
- */
266
325
  declare class PolicyBuilder {
267
326
  readonly _def: PolicyDef;
268
327
  constructor(name: string);
@@ -280,6 +339,34 @@ declare class PolicyBuilder {
280
339
  to(...roles: string[]): this;
281
340
  /** Set the `USING (...)` row-visibility expression (raw SQL). */
282
341
  using(sqlExpr: string): this;
342
+ /**
343
+ * "Rows of THIS table whose owner the caller is a member of" — the membership
344
+ * pattern, written so it cannot recurse.
345
+ *
346
+ * THE TRAP IT EXISTS FOR. Written by hand, membership policies point at each
347
+ * other: `channels` is visible to members, so its policy reads
348
+ * `channel_members`; `channel_members` is visible to members, so its policy
349
+ * reads `channels`. Postgres refuses the pair at query time with `infinite
350
+ * recursion detected in policy for relation ...`, and the error names the
351
+ * relation but not the cycle. The way out is asymmetry — the MEMBERSHIP table
352
+ * is protected by `user_id = auth.uid()` and nothing else, and every other
353
+ * table subqueries INTO it. That shape was in the platform's own schema and
354
+ * written down nowhere; a customer recovered it by reading that schema.
355
+ *
356
+ * `(select auth.uid())` rather than a bare call: the scalar subquery is
357
+ * evaluated ONCE per statement instead of per row.
358
+ *
359
+ * @example
360
+ * // channels: visible to members. The membership table gets the simple one.
361
+ * policy("member_read").for("select").to("authenticated")
362
+ * .memberOf("channel_members", "channel_id")
363
+ * // → id IN (SELECT "channel_id" FROM "channel_members"
364
+ * // WHERE "user_id" = (select auth.uid()))
365
+ */
366
+ memberOf(membershipTable: string, foreignKey: string, options?: {
367
+ column?: string;
368
+ userColumn?: string;
369
+ }): this;
283
370
  /** Set the `WITH CHECK (...)` write-validation expression (raw SQL). */
284
371
  withCheck(sqlExpr: string): this;
285
372
  /** Set the policy mode: `"permissive"` (default, OR-combined) or
@@ -647,13 +734,24 @@ type RowShape<T extends TableDef> = {
647
734
  /** A typed table accessor that mirrors the runtime DBClient surface. */
648
735
  interface TypedTable<T extends TableDef> {
649
736
  insert(data: InsertShape<T>): Promise<RowShape<T>>;
737
+ upsert(data: InsertShape<T>, opts: {
738
+ onConflict: readonly string[];
739
+ }): Promise<RowShape<T>>;
650
740
  /** Update the row by id; resolves to the updated row, or `null` if no row
651
741
  * matched (absent or RLS-hidden) — an idempotent outcome, mirroring
652
742
  * `findById`. The runtime returns a null row rather than throwing. */
653
743
  update(id: string, data: Partial<InsertShape<T>>): Promise<RowShape<T> | null>;
654
744
  delete(id: string): Promise<void>;
655
745
  findById(id: string): Promise<RowShape<T> | null>;
656
- findMany(query?: Partial<RowShape<T>>): Promise<RowShape<T>[]>;
746
+ /** Rows matching the filter. Operators, ordering and paging are the ENGINE's
747
+ * surface — this declaration is what makes them callable. */
748
+ findMany(query?: WhereFilter<RowShape<T>>, opts?: FindManyOpts<RowShape<T>>): Promise<RowShape<T>[]>;
749
+ /** Update every matching row in one statement; an empty filter is refused. */
750
+ updateMany(where: WhereFilter<RowShape<T>>, set: Partial<InsertShape<T>>): Promise<RowShape<T>[]>;
751
+ /** Delete every matching row; resolves to how many. Empty filter refused. */
752
+ deleteMany(where: WhereFilter<RowShape<T>>): Promise<number>;
753
+ /** How many rows match. An empty filter is legitimate: counting is a read. */
754
+ count(where?: WhereFilter<RowShape<T>>): Promise<number>;
657
755
  }
658
756
  /** A typed DB facade covering all tables declared in schema `S`. */
659
757
  interface TypedDB<S extends SchemaDef> {
@@ -692,6 +790,30 @@ type WhereOp<V> = V | {
692
790
  neq?: V;
693
791
  in?: V[];
694
792
  };
793
+ /**
794
+ * A filter over a row: every field optional, each one a plain value (equality)
795
+ * or an operator object. THE filter language — `findMany`, `updateMany`,
796
+ * `deleteMany` and `count` all take this one, because two spellings of a filter
797
+ * is how the two come to disagree.
798
+ */
799
+ type WhereFilter<Row> = {
800
+ [K in keyof Row]?: WhereOp<Row[K]>;
801
+ };
802
+ /**
803
+ * Ordering and paging for a read.
804
+ *
805
+ * `column` is `keyof Row`, not `string`: a mistyped column name is a compile
806
+ * error here rather than a runtime rejection three layers down. `offset`
807
+ * without `limit` is refused by the engine — a page with no size is not a page.
808
+ */
809
+ type FindManyOpts<Row> = {
810
+ orderBy?: {
811
+ column: Extract<keyof Row, string>;
812
+ direction?: "asc" | "desc";
813
+ };
814
+ limit?: number;
815
+ offset?: number;
816
+ };
695
817
  /** search() parametreleri, satır tipiyle koşullanmış (FR-013). `offset` BİLEREK yok (UD-013). */
696
818
  interface SearchParamsTyped<T extends TableTypes> {
697
819
  /** Metin sorgusu: FTS kolunu besler; embed beyanlıysa sorgu vektörü de bundan üretilir. */
@@ -755,13 +877,31 @@ type RecommendParamsTyped<T extends TableTypes> = SimilarParamsTyped<T> & {
755
877
  /** Temel tablo erişimcisi — search'süz beş op. */
756
878
  interface EnvTypedTableBase<T extends TableTypes> {
757
879
  insert(data: T["insert"]): Promise<T["row"]>;
880
+ /**
881
+ * Insert the row, or update it when it collides on `onConflict`.
882
+ *
883
+ * The conflict columns must carry a unique constraint or index — that is what
884
+ * Postgres matches on — and they are excluded from the update, since they are
885
+ * what matched.
886
+ */
887
+ upsert(data: T["insert"], opts: {
888
+ onConflict: readonly Extract<keyof T["row"], string>[];
889
+ }): Promise<T["row"]>;
758
890
  /** Update the row by id; resolves to the updated row, or `null` if no row
759
891
  * matched (absent or RLS-hidden) — an idempotent outcome, mirroring
760
892
  * `findById`. The runtime returns a null row rather than throwing. */
761
893
  update(id: string, data: Partial<T["insert"]>): Promise<T["row"] | null>;
762
894
  delete(id: string): Promise<void>;
763
895
  findById(id: string): Promise<T["row"] | null>;
764
- findMany(query?: Partial<T["row"]>): Promise<T["row"][]>;
896
+ /** Rows matching the filter. See {@link WhereFilter} / {@link FindManyOpts} —
897
+ * this declaration is what makes the engine's operators callable. */
898
+ findMany(query?: WhereFilter<T["row"]>, opts?: FindManyOpts<T["row"]>): Promise<T["row"][]>;
899
+ /** Update every matching row in one statement; an empty filter is refused. */
900
+ updateMany(where: WhereFilter<T["row"]>, set: Partial<T["insert"]>): Promise<T["row"][]>;
901
+ /** Delete every matching row; resolves to how many. Empty filter refused. */
902
+ deleteMany(where: WhereFilter<T["row"]>): Promise<number>;
903
+ /** How many rows match. An empty filter is legitimate: counting is a read. */
904
+ count(where?: WhereFilter<T["row"]>): Promise<number>;
765
905
  /** Validity'li tabloda satırın yeni versiyonu (FR-029, C-9): eski satır
766
906
  * kapanır (valid_to/superseded_by), yenisi TEK savepoint'te eklenir; dönüş
767
907
  * yeni satır. Validity beyanı olmayan tabloda adlandırılmış çalışma-zamanı
@@ -784,6 +924,18 @@ type EnvTypedTable<T extends TableTypes> = EnvTypedTableBase<T> & (T extends {
784
924
  similar(id: string, params?: SimilarParamsTyped<T>): Promise<Array<T["row"] & {
785
925
  _score: number;
786
926
  }>>;
927
+ /** D-021: sayaçlar bağımsız dönüşle — search'ün dizi-üstü _facets'i
928
+ * JSON.stringify'da kaybolur; ciddi sözleşme budur. */
929
+ facets(params: {
930
+ facets: Array<keyof T["row"] & string>;
931
+ where?: Partial<T["row"]>;
932
+ validity?: "all" | {
933
+ asOf: string;
934
+ };
935
+ }): Promise<Record<string, {
936
+ value: string | null;
937
+ count: number;
938
+ }[]>>;
787
939
  /** positive/negative beğenilerden öneri (FR-023): hedef vektör DB-içi
788
940
  * avg CTE'leriyle; kaynak id'ler sonuçta yoktur. */
789
941
  recommend(params: RecommendParamsTyped<T>): Promise<Array<T["row"] & {
@@ -856,6 +1008,27 @@ interface EnvTypedDatabase extends Omit<DBClient, "txPlan" | "asService"> {
856
1008
  * });
857
1009
  */
858
1010
  transaction<T>(fn: (tx: TxPlan) => T extends Promise<unknown> ? never : T): Promise<Materialized<T>>;
1011
+ /**
1012
+ * Run `fn` inside a SAVEPOINT, so a write that fails in it does not poison the
1013
+ * rest of the request.
1014
+ *
1015
+ * A request is ONE Postgres transaction: a failed statement aborts it and
1016
+ * every later one answers `current transaction is aborted`. That is why
1017
+ * "insert, catch the unique violation, update instead" cannot be written
1018
+ * directly — and why {@link EnvTypedTableBase.upsert} exists for the common
1019
+ * case. Reach for `attempt` when the recovery is not an upsert.
1020
+ *
1021
+ * The handle is a parameter, not the ambient `Database`: only what `tx` writes
1022
+ * is inside the boundary, so a concurrent branch of the same request cannot be
1023
+ * rolled back by someone else's failure.
1024
+ *
1025
+ * @example
1026
+ * const claimed = await Database.attempt(async (tx) => {
1027
+ * await tx.insert("seats", { row: 4, seat: 12, user_id: user.id });
1028
+ * return true;
1029
+ * }).catch(() => false);
1030
+ */
1031
+ attempt<T>(fn: (tx: Omit<DBClient, "attempt" | "txPlan" | "asService">) => Promise<T>): Promise<T>;
859
1032
  /**
860
1033
  * Return a sibling that bypasses RLS by running as the `service_role`. Use
861
1034
  * sparingly and explicitly — the default `Database.*` path is RLS-enforced.