@everystack/model 0.4.21 → 0.4.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ All notable changes to `@everystack/model` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.22] - 2026-10-16
8
+
9
+ ### Added
10
+
11
+ - check() takes an explicit constraint name; db:pull keeps a live name
12
+
13
+ ### Fixed
14
+
15
+ - db:apply runs a rename-only plan
16
+
7
17
  ## [0.4.21] - 2026-10-14
8
18
 
9
19
  ### Added
@@ -20,12 +30,6 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
20
30
  - rowOwnership exceptRoles lifts the backstop for manage roles
21
31
  - only a plain manage role lifts the rowOwnership backstop
22
32
 
23
- ## [Unreleased]
24
-
25
- ### Added
26
-
27
- - apply state prerequisites atomically
28
-
29
33
  ## [0.4.20] - 2026-09-26
30
34
 
31
35
  ### BREAKING CHANGES
package/README.md CHANGED
@@ -87,6 +87,8 @@ The table-level surface, composed in `constraints: [...]`, for what a column-sco
87
87
  - `check(sql\`a >= b\`)` — a multi-column CHECK.
88
88
  - `foreignKey(['a', 'b'], () => Other, ['x', 'y'], { onDelete })` — a composite foreign key.
89
89
 
90
+ Each can carry the name the database calls it: `index('slug').name('posts_slug_lookup')`, `foreignKey([...], () => Other, [...], { name: 'posts_owner_fk' })`, and `check(sql\`ends_at >= starts_at\`, { name: 'events_window_check' })`. Undeclared, the compiler generates the name. A check is then named by its position among the table's unnamed checks (`events_check_0`, `events_check_1`), so inserting or reordering one renames the others; an explicit name is used verbatim and is stable under reordering. A name must be a bare lowercase identifier of at most 63 bytes, and two checks in one table cannot share a name or take one an unnamed check gets by position. Declaring a name on an existing constraint plans a `RENAME CONSTRAINT` (or `ALTER INDEX … RENAME`), never a drop and re-add. `db:apply` applies a rename like any other change, also when it is the only change in the plan. `db:pull` declares a live name only when it is not the one the compiler would generate.
91
+
90
92
  ## Authorization
91
93
 
92
94
  `can(action, condition)` declares one rule (default-deny: no matching `can()` means denied). Conditions:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/model",
3
- "version": "0.4.21",
3
+ "version": "0.4.22",
4
4
  "description": "Declare a table's schema, validation, and authorization once — compiled to migrations, RLS, and handler config",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
@@ -16,8 +16,11 @@ import type { ReferentialAction } from './field';
16
16
  export type TableConstraint =
17
17
  /** `UNIQUE (a, b)` over two or more field keys — the composite the field builder can't carry. */
18
18
  | { kind: 'unique'; columns: string[] }
19
- /** `CHECK (<predicate>)` — a raw SQL predicate, typically spanning columns. */
20
- | { kind: 'check'; predicate: string }
19
+ /**
20
+ * `CHECK (<predicate>)` — a raw SQL predicate, typically spanning columns. `name` is present
21
+ * only when declared; absent, the compiler names it by position (see {@link positionalCheckName}).
22
+ */
23
+ | { kind: 'check'; predicate: string; name?: string }
21
24
  /** A standalone index — `CREATE [UNIQUE] INDEX [USING <method>] … (keys) [INCLUDE (cols)] [WHERE <predicate>]`. */
22
25
  | { kind: 'index'; columns: string[]; isUnique: boolean; predicate?: string; method?: string; includeColumns?: string[]; rawPositions?: boolean[]; indexName?: string }
23
26
  /**
@@ -35,9 +38,60 @@ export function unique(...columns: string[]): TableConstraint {
35
38
  return { kind: 'unique', columns };
36
39
  }
37
40
 
38
- /** A table CHECK from a raw predicate: `check(sql\`published_at >= created_at\`)`. */
39
- export function check(predicate: RawSql): TableConstraint {
40
- return { kind: 'check', predicate: predicate.sql };
41
+ /**
42
+ * A table CHECK from a raw predicate: `check(sql\`published_at >= created_at\`)`.
43
+ *
44
+ * `{ name }` declares the name the database calls it: `check(sql\`…\`, { name: 'contents_state_check' })`.
45
+ * Undeclared, the compiler names it by POSITION among the table's unnamed checks
46
+ * (`<table>_check_<n>`), which is fine for a greenfield model and fragile for everything that
47
+ * names a constraint: inserting or reordering an unnamed check renames its siblings. A declared
48
+ * name is used verbatim, does not take a position, and is stable under reordering. Same rule as
49
+ * `index().name()` and `foreignKey({ name })`: the generated name is the fallback.
50
+ */
51
+ export function check(predicate: RawSql, opts?: { name?: string }): TableConstraint {
52
+ return {
53
+ kind: 'check',
54
+ predicate: predicate.sql,
55
+ ...(opts?.name !== undefined ? { name: assertConstraintName(opts.name, `check(sql\`${predicate.sql}\`)`, MAX_IDENTIFIER_BYTES) } : {}),
56
+ };
57
+ }
58
+
59
+ /**
60
+ * The name an UNNAMED table check gets: `<table>_check_<position>`, where position counts only
61
+ * the unnamed checks before it — a named check never takes a position, so naming one check
62
+ * cannot rename the others. ONE implementation, shared by the compiler, `db:pull` (which declares
63
+ * a live name exactly when it is not this) and {@link assertCheckNames}.
64
+ */
65
+ export function positionalCheckName(table: string, position: number): string {
66
+ return `${table}_check_${position}`;
67
+ }
68
+
69
+ /**
70
+ * Refuse declared check names that cannot coexist in one table: the same name twice, or a name
71
+ * an unnamed check in the same table gets by position. Run by `defineModel`, the first point
72
+ * that knows the table — the identifier itself was already validated at {@link check}.
73
+ */
74
+ export function assertCheckNames(table: string, constraints: readonly TableConstraint[]): void {
75
+ const positional = new Set<string>();
76
+ let position = 0;
77
+ for (const c of constraints) {
78
+ if (c.kind === 'check' && c.name === undefined) positional.add(positionalCheckName(table, position++));
79
+ }
80
+ const declared = new Set<string>();
81
+ for (const c of constraints) {
82
+ if (c.kind !== 'check' || c.name === undefined) continue;
83
+ const label = `check(sql\`${c.predicate}\`)`;
84
+ if (declared.has(c.name)) {
85
+ throw new Error(`model '${table}': two checks are named '${c.name}' (the second is ${label}). A constraint name is unique within its table.`);
86
+ }
87
+ if (positional.has(c.name)) {
88
+ throw new Error(
89
+ `model '${table}': ${label} is named '${c.name}', which an unnamed check in this table gets by position. `
90
+ + 'Name that check as well, or choose another name.',
91
+ );
92
+ }
93
+ declared.add(c.name);
94
+ }
41
95
  }
42
96
 
43
97
  /**
@@ -184,15 +238,27 @@ const INDEX_NAME = /^[a-z_][a-z0-9_$]*$/;
184
238
  * surface that lets a model name a database object — an index, a foreign key constraint —
185
239
  * because two copies of a validation rule is how one of them ends up laxer than the other.
186
240
  */
187
- export function assertConstraintName(name: string, what = 'constraint'): string {
241
+ export function assertConstraintName(name: string, what = 'constraint', maxBytes?: number): string {
188
242
  if (!INDEX_NAME.test(name)) {
189
243
  throw new Error(
190
244
  `${what} name '${name}' is not a bare lowercase identifier (${INDEX_NAME}). It is interpolated into DDL that takes no parameters, so it is refused here rather than built into SQL.`,
191
245
  );
192
246
  }
247
+ // PostgreSQL truncates a longer identifier with only a NOTICE, so the database would hold a
248
+ // name the model does not declare, and every later plan would see the difference.
249
+ // Opt-in: index and foreign key names loaded without this limit before check names had it.
250
+ const bytes = new TextEncoder().encode(name).length;
251
+ if (maxBytes !== undefined && bytes > maxBytes) {
252
+ throw new Error(
253
+ `${what} name '${name}' is ${bytes} bytes. PostgreSQL identifiers are at most ${maxBytes} bytes and a longer one is truncated silently, so it is refused here.`,
254
+ );
255
+ }
193
256
  return name;
194
257
  }
195
258
 
259
+ /** NAMEDATALEN - 1 on a stock PostgreSQL build. */
260
+ const MAX_IDENTIFIER_BYTES = 63;
261
+
196
262
  export function index(...entries: Array<string | RawSql>): IndexBuilder {
197
263
  return new IndexBuilder(entries);
198
264
  }
package/src/index.ts CHANGED
@@ -15,7 +15,7 @@ export { canonicalColumnType, fieldFactoryCall, pgTypeRefusal } from './pg-type'
15
15
  export { parseQualified } from './qualified';
16
16
  export { linkModels, ModelLinkError } from './link';
17
17
 
18
- export { unique, check, index, foreignKey, IndexBuilder } from './constraints';
18
+ export { unique, check, index, foreignKey, IndexBuilder, positionalCheckName } from './constraints';
19
19
  export type { TableConstraint } from './constraints';
20
20
 
21
21
  export { sql } from './raw-sql';
package/src/model.ts CHANGED
@@ -16,7 +16,7 @@ import { referenceLocation } from './link';
16
16
  import type { Ability } from './can';
17
17
  import { isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete, sessionlessAudience } from './can';
18
18
  import type { Relation } from './relations';
19
- import type { TableConstraint } from './constraints';
19
+ import { assertCheckNames, type TableConstraint } from './constraints';
20
20
  import type { RawSql } from './raw-sql';
21
21
  import { validateTriggersFor, type TriggerSpec, type DependsOnRef } from './derived';
22
22
  import { TABLE_PRIVILEGES, type TablePrivilege } from './privileges';
@@ -450,6 +450,8 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
450
450
  );
451
451
  }
452
452
 
453
+ assertCheckNames(table, def.constraints ?? []);
454
+
453
455
  return {
454
456
  table,
455
457
  fields: def.fields,