@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 +10 -6
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/constraints.ts +72 -6
- package/src/index.ts +1 -1
- package/src/model.ts +3 -1
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
package/src/constraints.ts
CHANGED
|
@@ -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
|
-
/**
|
|
20
|
-
|
|
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
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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,
|