@rebasepro/types 0.19.1 → 0.19.2-canary.g09316f6

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.
@@ -6,6 +6,7 @@ import type { Relation } from "./relations.js";
6
6
  import type { SecurityRule } from "./security_rules.js";
7
7
  import type { SearchConfig } from "./search.js";
8
8
  import type { CollectionIndex } from "./indexes.js";
9
+ import type { CollectionTenantConfig } from "./tenancy.js";
9
10
  /**
10
11
  * Base interface containing all driver-agnostic collection properties.
11
12
  * Use {@link PostgresCollectionConfig} or {@link FirebaseCollectionConfig} for
@@ -149,6 +150,12 @@ export interface BaseCollectionConfig<M extends Record<string, unknown> = Record
149
150
  /**
150
151
  * User id of the owner of this collection. This is used only by plugins, or if you
151
152
  * are writing custom code
153
+ *
154
+ * **Admin form only — not enforced by the API or the database.** The
155
+ * collection editor stamps it on a collection it creates and shows it
156
+ * beside the name; nothing on the request path consults it. It is not an
157
+ * ownership check, and a collection with somebody else's id here is served
158
+ * to exactly the same callers as one with none.
152
159
  */
153
160
  ownerId?: string;
154
161
  /**
@@ -290,6 +297,58 @@ export interface PostgresCollectionConfig<M extends Record<string, unknown> = Re
290
297
  * rather than silently ignored.
291
298
  */
292
299
  indexes?: readonly CollectionIndex<Extract<keyof M, string>>[];
300
+ /**
301
+ * Turn `delete` into "stamp a timestamp", and hide stamped rows from reads.
302
+ *
303
+ * With this on, a delete — single, bulk or through a nested path — sets the
304
+ * field to `now()` instead of issuing a `DELETE`, and every read filters
305
+ * `<field> IS NULL` by default: `find`, `findById`, `count`, aggregates, the
306
+ * realtime refetch, and the loading of this collection through a relation.
307
+ * A restore is an ordinary update setting the field back to `null`. A real
308
+ * `DELETE` is still available as `delete(…, { hard: true })` / `?hard=true`,
309
+ * and needs exactly the same permission an ordinary delete does — it is the
310
+ * same operation, and gating it separately would be a second access-control
311
+ * surface for one verb.
312
+ *
313
+ * `true` uses `deletedAt` (column `deleted_at`). The object form renames the
314
+ * field. **Either way the collection must declare that property itself**, as
315
+ * a `date` — this flag says what a column *means*, it does not conjure the
316
+ * column into existence. A config that turns it on without the property is
317
+ * refused at boot rather than at the first delete, because the failure would
318
+ * otherwise land on a caller trying to remove a row.
319
+ *
320
+ * The hooks do not change: `beforeDelete` can still veto and `afterDelete`
321
+ * still fires. From the application's point of view the row was deleted;
322
+ * how the table records that is this flag's business.
323
+ *
324
+ * Postgres-only, like {@link SearchConfig}.
325
+ */
326
+ softDelete?: boolean | {
327
+ /**
328
+ * The `date` property that records the deletion. Defaults to
329
+ * `deletedAt`.
330
+ */
331
+ field?: string;
332
+ };
333
+ /**
334
+ * Scope every row of this collection to a tenant.
335
+ *
336
+ * One declaration replaces the four hand-written pieces a tenant-scoped
337
+ * table used to need — the `NOT NULL` column, the RLS rule, the value
338
+ * stamped on insert, and the index — and keeps them in agreement, because
339
+ * they are all derived from this.
340
+ *
341
+ * ```ts
342
+ * tenant: { field: "orgId", from: { claim: "org_id" } }
343
+ * ```
344
+ *
345
+ * The property must already be declared: this says what a column *means*,
346
+ * it does not create one. Postgres-only, like {@link SearchConfig} — RLS is
347
+ * what enforces the boundary.
348
+ *
349
+ * @see CollectionTenantConfig
350
+ */
351
+ tenant?: CollectionTenantConfig<M>;
293
352
  }
294
353
  /**
295
354
  * A collection backed by Firebase / Firestore.
@@ -37,16 +37,36 @@
37
37
  * @module
38
38
  */
39
39
  /**
40
- * Canonical sort representation: `[fieldName, direction]`.
40
+ * Where NULLs sort relative to real values on one key.
41
+ *
42
+ * Absent means the convention Postgres itself applies and the driver writes
43
+ * out: `NULLS LAST` ascending, `NULLS FIRST` descending. That convention was
44
+ * hardcoded and unstateable — a "newest first" list put every row with no date
45
+ * at the very top, and the only way out was to add a `is-not-null` filter and
46
+ * lose those rows entirely.
47
+ *
48
+ * The keyset comparison honours whatever is chosen here, so a cursor over a
49
+ * nullable key stays correct under either placement.
50
+ *
51
+ * @group Models
52
+ */
53
+ export type NullsPlacement = "first" | "last";
54
+ /**
55
+ * Canonical sort representation: `[fieldName, direction]`, optionally with a
56
+ * {@link NullsPlacement}.
41
57
  *
42
58
  * Used in `FindParams.orderBy`, `collection.sort`, and `FilterPreset.sort`.
43
- * The colon-string form (`"field:direction"`) exists only at the HTTP wire
44
- * boundary, handled by `serializeOrderBy` / `deserializeOrderBy` in
45
- * `@rebasepro/common`.
59
+ * The colon-string form (`"field:direction"`, or `"field:direction:nulls"`)
60
+ * exists only at the HTTP wire boundary, handled by `serializeOrderBy` /
61
+ * `deserializeOrderBy` in `@rebasepro/common`.
62
+ *
63
+ * The third slot is optional so every `[field, direction]` written before it
64
+ * existed is still exactly this type, and every `const [field, direction] =`
65
+ * destructure still reads what it always read.
46
66
  *
47
67
  * @group Models
48
68
  */
49
- export type OrderByTuple<Key extends string = string> = [Key, "asc" | "desc"];
69
+ export type OrderByTuple<Key extends string = string> = [Key, "asc" | "desc", NullsPlacement?];
50
70
  /**
51
71
  * One sort key, or several applied in order of significance.
52
72
  *
@@ -83,7 +103,7 @@ export type SortKey<Key extends string = string> = Key | RelationAggregateSort;
83
103
  *
84
104
  * @group Models
85
105
  */
86
- export type OrderBySortTuple<Key extends string = string> = [SortKey<Key>, "asc" | "desc"];
106
+ export type OrderBySortTuple<Key extends string = string> = [SortKey<Key>, "asc" | "desc", NullsPlacement?];
87
107
  /**
88
108
  * The aggregate functions a relation sort can apply.
89
109
  *
@@ -307,6 +327,17 @@ export declare const REST_TO_CANONICAL: Readonly<Record<RestFilterOp, WhereFilte
307
327
  * Codecs normalize the value of these conditions to `null`.
308
328
  */
309
329
  export declare const NULL_OPS: ReadonlySet<WhereFilterOp>;
330
+ /**
331
+ * Operators whose operand is a **list** of values rather than one value.
332
+ *
333
+ * On the wire that list is always parenthesised — `in.(draft,review)` — which
334
+ * is what lets the REST codec tell `?status=in.(a,b)` (the operator) from
335
+ * `?status=in.progress` (a value that happens to start with an operator's
336
+ * name). See `deserializeSingle` in `@rebasepro/common`.
337
+ *
338
+ * @group Models
339
+ */
340
+ export declare const LIST_OPS: ReadonlySet<WhereFilterOp>;
310
341
  /**
311
342
  * Every canonical operator, in a stable order. Useful for engine capability
312
343
  * declarations ({@link DataSourceCapabilities.filterOperators}) and for
@@ -10,6 +10,7 @@ export * from "./relations.js";
10
10
  export * from "./policy.js";
11
11
  export * from "./rls-functions.js";
12
12
  export * from "./security_rules.js";
13
+ export * from "./tenancy.js";
13
14
  export * from "./entity_callbacks.js";
14
15
  export * from "./websockets.js";
15
16
  export * from "./backend.js";
@@ -236,7 +236,7 @@ export interface RawPolicyExpression {
236
236
  * An operand referenced by a {@link ComparePolicyExpression}.
237
237
  * @group Models
238
238
  */
239
- export type PolicyOperand = FieldPolicyOperand | OuterFieldPolicyOperand | LiteralPolicyOperand | AuthUidPolicyOperand | AuthRolesPolicyOperand;
239
+ export type PolicyOperand = FieldPolicyOperand | OuterFieldPolicyOperand | LiteralPolicyOperand | AuthUidPolicyOperand | AuthRolesPolicyOperand | AuthClaimPolicyOperand;
240
240
  /** A column value on the row being evaluated. @group Models */
241
241
  export interface FieldPolicyOperand {
242
242
  kind: "field";
@@ -271,6 +271,43 @@ export interface AuthUidPolicyOperand {
271
271
  export interface AuthRolesPolicyOperand {
272
272
  kind: "authRoles";
273
273
  }
274
+ /**
275
+ * A named claim on the caller's session token — compiles to
276
+ * `NULLIF(rebase.jwt() ->> '<name>', '')`.
277
+ *
278
+ * The operand multi-tenancy is built on, and the reason it is an operand rather
279
+ * than a {@link RawPolicyExpression}: a claim arrives as **text**, and the
280
+ * column it is compared against usually is not. `org_id = rebase.jwt() ->>
281
+ * 'org_id'` on a `uuid` column is not a policy that denies — it is
282
+ * `CREATE POLICY` failing with "operator does not exist: uuid = text", and a
283
+ * table left with RLS enabled and no policy denies every row. Casting the
284
+ * *column* to text instead compiles, but takes the index off the one predicate
285
+ * that is ANDed into every read of the table.
286
+ *
287
+ * As an operand the compiler can see both sides: it casts the claim to the
288
+ * column's type, guarded so a malformed claim denies rather than raising
289
+ * `invalid input syntax` on every query, and the column keeps its index.
290
+ *
291
+ * An absent claim, and a claim set to the empty string, are both NULL — and a
292
+ * comparison against NULL is never true, so a caller carrying no claim sees no
293
+ * rows rather than all of them.
294
+ *
295
+ * Only *custom* claims are reachable. `uid`, `roles`, `aal` and `isAnonymous`
296
+ * are identity claims written after the custom ones when a token is minted,
297
+ * precisely so a claims hook cannot assert them; they have their own operands
298
+ * ({@link AuthUidPolicyOperand}, {@link AuthRolesPolicyOperand}) and naming one
299
+ * here is refused.
300
+ *
301
+ * Postgres-authoritative: the JavaScript evaluator reports *unknown* rather
302
+ * than reproducing Postgres's cast semantics (uuid case folding, numeric
303
+ * widening) a second time and getting them subtly wrong.
304
+ * @group Models
305
+ */
306
+ export interface AuthClaimPolicyOperand {
307
+ kind: "authClaim";
308
+ /** The claim's name on the token, e.g. `"org_id"`. */
309
+ name: string;
310
+ }
274
311
  /** @group Models */
275
312
  export declare const policy: {
276
313
  true: () => TruePolicyExpression;
@@ -294,4 +331,5 @@ export declare const policy: {
294
331
  literal: (value: string | number | boolean | null) => LiteralPolicyOperand;
295
332
  authUid: () => AuthUidPolicyOperand;
296
333
  authRoles: () => AuthRolesPolicyOperand;
334
+ authClaim: (name: string) => AuthClaimPolicyOperand;
297
335
  };
@@ -250,6 +250,28 @@ export type InferEntityType<P extends Properties> = {
250
250
  } & {
251
251
  -readonly [K in OptionalPropertyKeys<P>]?: InferPropertyType<P[K]>;
252
252
  };
253
+ /**
254
+ * Per-field read and write permission, by application role.
255
+ *
256
+ * An omitted list is not an empty one, and the difference is the whole type:
257
+ * *omitted* delegates to the row — everyone the collection's security rules let
258
+ * read (or write) the row gets the field; *`[]`* is nobody, through the API, at
259
+ * any privilege. `["editor"]` is everyone holding `editor`, plus `admin`.
260
+ *
261
+ * @see BaseProperty.access
262
+ */
263
+ export interface FieldAccess {
264
+ /**
265
+ * Roles that may read the field. Omitted = everyone the collection's RLS
266
+ * lets read the row. `[]` = nobody through the API.
267
+ */
268
+ read?: readonly string[];
269
+ /**
270
+ * Roles that may write the field. Omitted = everyone the collection's RLS
271
+ * lets write the row. `[]` = nobody through the API.
272
+ */
273
+ write?: readonly string[];
274
+ }
253
275
  export interface BaseProperty<CustomProps = unknown> {
254
276
  /**
255
277
  * The label the admin panel shows for this field — a column header, a form
@@ -311,13 +333,56 @@ export interface BaseProperty<CustomProps = unknown> {
311
333
  * This is a server-side guarantee, unlike `admin.hideFromCollection`, which
312
334
  * only stops the admin panel from *rendering* a field and leaves it in the
313
335
  * JSON payload.
336
+ *
337
+ * Sugar for `access: { read: [], write: [] }` — the two are one mechanism,
338
+ * not two, and declaring both on the same property is refused at boot. Write
339
+ * whichever reads better: the flag says "this is the server's column", the
340
+ * empty lists say the same thing in the vocabulary of {@link FieldAccess}.
314
341
  */
315
342
  excludeFromApi?: boolean;
343
+ /**
344
+ * Who may read and who may write this one field.
345
+ *
346
+ * Row access is the collection's `securityRules`; this is the field inside
347
+ * the row. A caller the row's policies let through still does not receive a
348
+ * field their roles cannot read — it is *absent* from the response rather
349
+ * than `null`, so a client cannot tell a withheld value from a stored one by
350
+ * its shape — and a write naming a field their roles cannot write is a 400
351
+ * (`FIELD_NOT_WRITABLE`), never a silently dropped key.
352
+ *
353
+ * Roles are Rebase application roles, the same ones `policy.rolesOverlap`
354
+ * compiles against and the same list `rebase.roles()` reads inside a policy:
355
+ * whatever the call context carries as `user.roles`. `admin` satisfies any
356
+ * non-empty list, mirroring the `rolesOverlap(['admin'])` arm every baseline
357
+ * policy carries — a field-level rule must not lock an administrator out of
358
+ * their own data, and `rebase.dataAsAdmin` holds that role.
359
+ *
360
+ * The enforcement point is the API boundary. In-process writes through
361
+ * `rebase.data` / `rebase.dataAsAdmin` and the framework's own auth paths do
362
+ * not pass through it — the same exemption `excludeFromApi` has always had,
363
+ * and the reason it is possible to store a password hash at all.
364
+ *
365
+ * @example
366
+ * ```ts
367
+ * salary: {
368
+ * type: "number",
369
+ * // Readable by HR and by admins; writable by nobody through the API.
370
+ * access: { read: ["hr"], write: [] }
371
+ * }
372
+ * ```
373
+ */
374
+ access?: FieldAccess;
316
375
  /**
317
376
  * Use this to define dynamic properties that change based on certain conditions
318
377
  * or on the entity's values. For example, you can make a field read-only if
319
378
  * another field has a certain value.
320
379
  * This function receives the same props as a `PropertyBuilder` and should return a partial `Property` object.
380
+ *
381
+ * **Admin form only — not enforced by the API or the database.** The
382
+ * function is bundled into the panel and called while a form renders. A
383
+ * write that never goes through a form never goes through it, so a rule
384
+ * that must hold for every caller belongs in {@link validation}, which the
385
+ * server checks, or in a security rule, which the database enforces.
321
386
  */
322
387
  dynamicProps?: (props: PropertyBuilderProps) => Partial<Property>;
323
388
  /**
@@ -328,6 +393,11 @@ export interface BaseProperty<CustomProps = unknown> {
328
393
  * - Edited via the collection editor UI
329
394
  * - Evaluated at runtime like property builders
330
395
  *
396
+ * **Admin form only — not enforced by the API or the database.** Like
397
+ * {@link dynamicProps}, these are evaluated while a form renders and shape
398
+ * what the panel offers. `conditions.required` does not make a column
399
+ * `NOT NULL` and does not make a write fail.
400
+ *
331
401
  * @see PropertyConditions for available condition options
332
402
  * @see https://jsonlogic.com/ for JSON Logic syntax
333
403
  */
@@ -367,12 +437,21 @@ export interface StringProperty extends BaseProperty {
367
437
  *
368
438
  * You can set this to `"manual"` for a user-defined ID, or specify a generation strategy:
369
439
  * 'uuid' -> Drizzle `.defaultRandom()` (Postgres gen_random_uuid())
370
- * 'cuid' -> Drizzle `.default(sql\`cuid()\`)`
371
- * Or any other random string to act as a raw SQL default expression: e.g. `nanoid()`
440
+ * Or any other string to act as a raw SQL default expression, written either
441
+ * bare (`gen_random_uuid()::text`) or in template form
442
+ * (``isId: "sql`my_id()`"``) — the wrapper is markup and is stripped before
443
+ * the SQL is written. The function has to exist: nothing here creates it.
444
+ *
445
+ * `"cuid"` is **refused on Postgres**, at config load. It emitted
446
+ * `DEFAULT cuid()` against a function Rebase has never created — not by a
447
+ * generator, not at boot, not in a migration — so the column has never had a
448
+ * working default and the first insert relying on it failed with
449
+ * `function cuid() does not exist`. Use `"uuid"`, or supply your own SQL
450
+ * expression above and create the function in a migration.
372
451
  *
373
452
  * On the UI side, the field automatically gets disabled on new entities if a string strategy is provided.
374
453
  */
375
- isId?: boolean | "manual" | "uuid" | "cuid" | string;
454
+ isId?: boolean | "manual" | "uuid" | string;
376
455
  /**
377
456
  * You can use the enum values providing a map of possible
378
457
  * exclusive values the property can take, mapped to the label that it is
@@ -382,6 +461,10 @@ export interface StringProperty extends BaseProperty {
382
461
  * colors). If you need to ensure the order of the elements, you can pass
383
462
  * a `Map` instead of a plain object.
384
463
  *
464
+ * On a SQL store this compiles to a Postgres enum type named
465
+ * `<table>_<column>`, so the ids are the type's labels. An empty list is
466
+ * refused at config load: `CREATE TYPE … AS ENUM ()` is not valid SQL, and
467
+ * the column would reference a type nothing creates.
385
468
  */
386
469
  enum?: EnumValues;
387
470
  /**
@@ -396,6 +479,11 @@ export interface StringProperty extends BaseProperty {
396
479
  * provider (e.g. the ID in your `users` table).
397
480
  * You can also use a property builder to specify the user path dynamically
398
481
  * based on other values of the entity.
482
+ *
483
+ * **Admin form only — not enforced by the API or the database.** The column
484
+ * is an ordinary string; there is no foreign key to the users table and
485
+ * nothing resolves or checks the id on the way in. To make the link real,
486
+ * declare a `relation` to the auth collection instead.
399
487
  */
400
488
  userSelect?: boolean;
401
489
  /**
@@ -411,6 +499,29 @@ export interface StringProperty extends BaseProperty {
411
499
  * renders it — as a link, an image, a video — is `admin.urlPreview`.
412
500
  */
413
501
  url?: boolean;
502
+ /**
503
+ * Stamp this column with the **uid of the acting user**, on creation only
504
+ * (`user_on_create`) or on every write including creation
505
+ * (`user_on_update`). The `created_by` / `updated_by` twin of
506
+ * {@link DateProperty.autoValue}, which has always done the same for
507
+ * timestamps.
508
+ *
509
+ * The value is taken from the call context, not from the request body: a
510
+ * caller cannot claim to be somebody else by sending the field, because the
511
+ * driver overwrites whatever arrived. That is the whole point — a column
512
+ * whose value the caller supplies is not an audit column.
513
+ *
514
+ * With no acting user (an anonymous request, a service token, a seed
515
+ * script) the column is set to `null`. If it is also
516
+ * `validation: { required: true }`, that is a 400 rather than a null: a
517
+ * collection that demands to know who wrote a row is a collection that
518
+ * cannot accept an anonymous write.
519
+ *
520
+ * There is no foreign key here. The uid is a string, the user store may be
521
+ * another database entirely, and a deleted user must not take their audit
522
+ * trail with them — declare a `relation` if you want the join.
523
+ */
524
+ autoValue?: "user_on_create" | "user_on_update";
414
525
  }
415
526
  export interface NumberProperty extends BaseProperty {
416
527
  type: "number";
@@ -423,6 +534,27 @@ export interface NumberProperty extends BaseProperty {
423
534
  * If not provided, integer fields (where validation.integer is true or isId is true) default to `integer`, others to `numeric`.
424
535
  */
425
536
  columnType?: "integer" | "real" | "double precision" | "numeric" | "bigint" | "serial" | "bigserial";
537
+ /**
538
+ * Total significant digits for a `numeric` column — `NUMERIC(precision, scale)`.
539
+ *
540
+ * Money is the case this exists for. Without it a price lands as an
541
+ * *unbounded* `NUMERIC`, which stores `19.999999999999998` as faithfully as
542
+ * `19.99` and cannot be tightened afterwards without rewriting the column,
543
+ * so the rounding rule ends up living in whichever caller last touched the
544
+ * value. `precision: 10, scale: 2` makes the database the one that decides.
545
+ *
546
+ * Read only when the column is `numeric` — either declared
547
+ * ({@link NumberProperty.columnType}) or arrived at by default, which is
548
+ * what a non-integer `number` gets. Ignored on `integer`, `real`,
549
+ * `double precision` and the serial types, which have no modifier.
550
+ *
551
+ * Changing it on a live column is an `ALTER COLUMN … TYPE`, so `db push`
552
+ * plans it and the boot-time ensure reports it rather than applying it —
553
+ * the same treatment every other type change gets.
554
+ */
555
+ precision?: number;
556
+ /** Digits after the decimal point. Requires {@link NumberProperty.precision}. */
557
+ scale?: number;
426
558
  /**
427
559
  * Rules for validating this property
428
560
  */
@@ -434,14 +566,27 @@ export interface NumberProperty extends BaseProperty {
434
566
  * UI behavior: Field value cannot be changed after creation.
435
567
  *
436
568
  * You can set this to `"manual"` for a user-defined ID, or specify a generation strategy:
437
- * 'increment' -> PostgreSQL `GENERATED BY DEFAULT AS IDENTITY` or auto-incrementing integer.
438
- * Or any other random string to act as a raw SQL default expression.
569
+ * 'increment' -> PostgreSQL `INTEGER GENERATED BY DEFAULT AS IDENTITY`.
570
+ * Or any other string to act as a raw SQL default expression, bare or in
571
+ * template form (``isId: "sql`nextval('s')`"``).
572
+ *
573
+ * {@link NumberProperty.columnType} is **not read** beside
574
+ * `isId: "increment"`: an increment key is always INTEGER, because every
575
+ * foreign key and junction column that points at a numeric primary key is
576
+ * INTEGER, and a wider key would be referenced by narrower columns. Config
577
+ * validation warns when the two are written together.
439
578
  */
440
579
  isId?: boolean | "manual" | "increment" | string;
441
580
  /**
442
581
  * You can use the enum values providing a map of possible
443
582
  * exclusive values the property can take, mapped to the label that it is
444
583
  * displayed in the dropdown.
584
+ *
585
+ * Unlike a string enum, this creates **no Postgres enum type**: the column
586
+ * stays NUMERIC or INTEGER, so the values are offered by the panel and are
587
+ * not enforced by the database. (One used to be created, referenced by
588
+ * nothing, and every `db push` planned a DROP for it.) An empty list is
589
+ * refused at config load.
445
590
  */
446
591
  enum?: EnumValues;
447
592
  }
@@ -567,10 +712,18 @@ export interface DateProperty extends BaseProperty {
567
712
  * Set the granularity of the field to a date or date + time.
568
713
  * Defaults to `date_time`.
569
714
  *
715
+ * **Admin form only — not enforced by the API or the database.** It picks
716
+ * the picker. The column is whatever {@link columnType} says, and the API
717
+ * accepts a full timestamp either way — narrowing the widget does not
718
+ * narrow the value.
570
719
  */
571
720
  mode?: "date" | "date_time";
572
721
  /**
573
722
  * Timezone string to evaluate the date in.
723
+ *
724
+ * **Admin form only — not enforced by the API or the database.** It is the
725
+ * zone the panel reads and writes the value in; what is stored is a
726
+ * `timestamptz`, which has no zone of its own.
574
727
  */
575
728
  timezone?: string;
576
729
  /**
@@ -875,7 +1028,11 @@ export interface PropertyValidationSchema {
875
1028
  */
876
1029
  required?: boolean;
877
1030
  /**
878
- * Customize the required message when the property is not set
1031
+ * Customize the required message when the property is not set.
1032
+ *
1033
+ * **Admin form only — not enforced by the API or the database.** It is the
1034
+ * sentence the panel shows under the field; an API rejection carries its
1035
+ * own error code and message.
879
1036
  */
880
1037
  requiredMessage?: string;
881
1038
  /**
@@ -888,6 +1045,11 @@ export interface PropertyValidationSchema {
888
1045
  * once per entry in the parent `ArrayProperty`. It has no effect if this
889
1046
  * property is not a child of an `ArrayProperty`. It works on direct
890
1047
  * children of an `ArrayProperty` or first level children of `MapProperty`
1048
+ *
1049
+ * **Admin form only — not enforced by the API or the database.** Unlike
1050
+ * {@link unique}, which compiles to a constraint, this one is checked as
1051
+ * the form is filled in: the column holds a JSON array, and nothing in
1052
+ * Postgres is looking inside it.
891
1053
  */
892
1054
  uniqueInArray?: boolean;
893
1055
  }
@@ -947,11 +1109,24 @@ export interface StringPropertyValidationSchema extends PropertyValidationSchema
947
1109
  *
948
1110
  * A transform, not a check: it changes the value that is written, which is
949
1111
  * what makes it the fix for "the same tag twice, one with a trailing space".
1112
+ *
1113
+ * **Admin form only — not enforced by the API or the database.** The panel
1114
+ * applies it on its way to the API; a value written any other way arrives
1115
+ * as it was sent. A transform that must always happen is a `beforeSave`
1116
+ * callback, which runs on the server for every write.
950
1117
  */
951
1118
  trim?: boolean;
952
- /** Lowercase the value before saving. A transform, like {@link trim}. */
1119
+ /**
1120
+ * Lowercase the value before saving. A transform, like {@link trim}.
1121
+ *
1122
+ * **Admin form only — not enforced by the API or the database.**
1123
+ */
953
1124
  lowercase?: boolean;
954
- /** Uppercase the value before saving. A transform, like {@link trim}. */
1125
+ /**
1126
+ * Uppercase the value before saving. A transform, like {@link trim}.
1127
+ *
1128
+ * **Admin form only — not enforced by the API or the database.**
1129
+ */
955
1130
  uppercase?: boolean;
956
1131
  }
957
1132
  /**
@@ -1008,6 +1183,10 @@ export type StorageConfig = {
1008
1183
  * Advanced image resizing and cropping configuration.
1009
1184
  * Applied before upload to optimize storage and bandwidth.
1010
1185
  * Only applies to image MIME types: image/jpeg, image/png, image/webp
1186
+ *
1187
+ * **Admin form only — not enforced by the API or the database.** The
1188
+ * resizing happens in the browser, before the bytes are sent. An upload
1189
+ * that does not go through the panel is stored at its original size.
1011
1190
  */
1012
1191
  imageResize?: ImageResize;
1013
1192
  /**
@@ -1028,6 +1207,9 @@ export type StorageConfig = {
1028
1207
  * - `{propertyKey}` - ID of this property
1029
1208
  * - `{path}` - Path of this entity
1030
1209
  *
1210
+ * **Admin form only — not enforced by the API or the database.** The panel's
1211
+ * uploader resolves it; `client.storage.upload()` names its own `key`.
1212
+ *
1031
1213
  * @param context
1032
1214
  */
1033
1215
  fileName?: string | ((context: UploadedFileContext) => string | Promise<string>);
@@ -1043,6 +1225,12 @@ export type StorageConfig = {
1043
1225
  * - `{entityId}` - ID of the entity
1044
1226
  * - `{propertyKey}` - ID of this property
1045
1227
  * - `{path}` - Path of this entity
1228
+ *
1229
+ * **Admin form only — not enforced by the API or the database.** Required
1230
+ * on the type because a file field in the panel has to put the object
1231
+ * somewhere, but it is the panel's uploader that resolves it. Nothing on
1232
+ * the server confines an upload to this prefix — that is what a storage
1233
+ * authorization rule is for.
1046
1234
  */
1047
1235
  storagePath: string | ((context: UploadedFileContext) => string);
1048
1236
  /**
@@ -1072,17 +1260,26 @@ export type StorageConfig = {
1072
1260
  /**
1073
1261
  * Use this callback to process the file before uploading it to the storage.
1074
1262
  * If nothing is returned, the file is uploaded as it is.
1263
+ *
1264
+ * **Admin form only — not enforced by the API or the database.** It runs in
1265
+ * the browser, on the file the reader picked.
1266
+ *
1075
1267
  * @param file
1076
1268
  */
1077
1269
  processFile?: (file: File) => Promise<File> | undefined;
1078
1270
  /**
1079
1271
  * Postprocess the saved value (storage path or URL)
1080
1272
  * after it has been resolved.
1273
+ *
1274
+ * **Admin form only — not enforced by the API or the database.**
1081
1275
  */
1082
1276
  postProcess?: (pathOrUrl: string) => Promise<string>;
1083
1277
  /**
1084
1278
  * You can use this prop in order to provide a custom preview URL.
1085
1279
  * Useful when the file's path is different from the original field value
1280
+ *
1281
+ * **Admin form only — not enforced by the API or the database.** It changes
1282
+ * what the panel renders, never what is stored.
1086
1283
  */
1087
1284
  previewUrl?: (fileName: string) => string;
1088
1285
  };
@@ -1,8 +1,27 @@
1
1
  import type { AnyCollectionConfig } from "./collections.js";
2
+ import type { Properties } from "./properties.js";
2
3
  /**
3
4
  * @group Models
4
5
  */
5
6
  export type OnAction = "cascade" | "restrict" | "no action" | "set null" | "set default";
7
+ /**
8
+ * The key a junction row's own columns are carried under, in both directions.
9
+ *
10
+ * A read that includes a `manyToMany` relation serves each related row with its
11
+ * link's columns nested here — `{ id: 5, name: "ts", _pivot: { role: "owner" } }`
12
+ * — and a membership write may name the same key on an element to state what
13
+ * the link should hold. One constant because the two have to be the same word:
14
+ * a wire name that differs between the read and the write it round-trips
15
+ * through is a shape no client can echo back.
16
+ *
17
+ * Leading underscore, like `_matches`: it reads as metadata about the row
18
+ * rather than as one of its columns. A payload property may not be named
19
+ * `_pivot` either — `checkJunctionPayload` refuses it — so the key means one
20
+ * thing wherever it appears.
21
+ *
22
+ * @group Models
23
+ */
24
+ export declare const JUNCTION_PIVOT_KEY = "_pivot";
6
25
  /**
7
26
  * What kind of link a relation is.
8
27
  *
@@ -183,6 +202,52 @@ export interface ManyToManyRelation extends RelationBase {
183
202
  sourceColumn?: string;
184
203
  /** Junction column holding the **target's** key. */
185
204
  targetColumn?: string;
205
+ /**
206
+ * Extra columns the junction row carries, declared exactly like a
207
+ * collection's properties.
208
+ *
209
+ * A membership is often not only a membership. "This user is in that
210
+ * organisation" is really "…as an `owner`, since March"; "this tag is
211
+ * on that post" is really "…in third place". Until this existed the
212
+ * junction was two key columns and nothing else, so the role and the
213
+ * position had to become a collection of their own — which is a
214
+ * different data model, a different set of policies and a different
215
+ * URL, for what is still one link.
216
+ *
217
+ * The properties are read by the same planner that reads a
218
+ * collection's, so a payload column gets the type, `NOT NULL`,
219
+ * `DEFAULT`, `UNIQUE` and enum type it would get on a table. What it
220
+ * does **not** get is `indexes` (declared per collection, and no
221
+ * collection declares a junction), `search`, `vector`, or anything a
222
+ * relation would put on it — a payload property may not be a
223
+ * `relation`, a `reference` or a `vector`, and config validation
224
+ * refuses one that is.
225
+ *
226
+ * On the wire the values travel under {@link JUNCTION_PIVOT_KEY}: a
227
+ * read serves `{ …target, _pivot: { role } }`, and a membership write
228
+ * accepts `{ id, _pivot: { role } }` beside the bare ids.
229
+ *
230
+ * ```ts
231
+ * members: {
232
+ * kind: "manyToMany",
233
+ * target: () => users,
234
+ * through: {
235
+ * table: "org_members",
236
+ * properties: {
237
+ * role: { type: "string", enum: ["owner", "admin", "member"],
238
+ * defaultValue: "member", validation: { required: true } },
239
+ * joinedAt: { type: "date", autoValue: "on_create" }
240
+ * }
241
+ * }
242
+ * }
243
+ * ```
244
+ *
245
+ * Both sides of the same junction may declare it, and both must agree:
246
+ * `resolveJunctionSpecs` refuses two declarations of the same payload
247
+ * key that do not describe the same column, because only one of them
248
+ * could ever be created.
249
+ */
250
+ properties?: Properties;
186
251
  };
187
252
  }
188
253
  /**
@@ -351,6 +416,12 @@ export interface ResolvedManyToMany extends ResolvedRelationBase {
351
416
  table: string;
352
417
  sourceColumn: string;
353
418
  targetColumn: string;
419
+ /**
420
+ * The payload columns as authored, or `{}` when there are none —
421
+ * never `undefined`, so a consumer reads one shape.
422
+ * See {@link ManyToManyRelation.through}.
423
+ */
424
+ properties: Properties;
354
425
  };
355
426
  }
356
427
  /** @group Models */