bazis 0.114.0 → 0.115.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.
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
232
232
  if (method === "account/updated") this.modelCache = undefined;
233
233
  }, () => { this.clearLogin(); this.modelCache = undefined; });
234
234
  const initialized = await client.request("initialize", {
235
- clientInfo: { name: "bazis", title: "Bazis", version: "0.114.0" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.115.0" }, capabilities: { experimentalApi: true },
236
236
  }, 15_000, signal);
237
237
  if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
238
238
  client.notify("initialized");
@@ -57,6 +57,10 @@ function snapshot<TContext extends DbContext>(context: ContextClass<TContext>, e
57
57
  const definition = isDefinedOrmOwnedStoreV1(source) ? source : defineOrmOwnedStoreV1(source as OrmOwnedStoreDefinitionV1);
58
58
  const expected = compileExpectedSchema(new OrmModel(entities));
59
59
  if (expected.tables.length === 0 || expected.tables.length > 512) fail("ORM_OWNED_STORE_IDENTITY_MISMATCH", `${storeName(definition)} needs 1 to 512 entities, got ${expected.tables.length}`);
60
+ for (const table of expected.tables) {
61
+ const partial = table.indexes.find((index) => index.predicate !== undefined);
62
+ if (partial) fail("ORM_OWNED_STORE_IDENTITY_MISMATCH", `${storeName(definition)}: index "${partial.name}" on "${table.table}" has a where condition; owned stores do not support partial indexes`);
63
+ }
60
64
  return Object.freeze({ identity: Object.freeze({}), context, entities: Object.freeze([...entities]), definition, expected, source });
61
65
  }
62
66
 
@@ -1,7 +1,7 @@
1
1
  import { ModelBuildError } from "../errors";
2
2
  import { readRawEntity, type RawProperty } from "./decorators";
3
3
  import { defaultTableName, isConventionalKey, resolveColumnType } from "./conventions";
4
- import { validateCheckAst } from "../Schema/CheckExpression";
4
+ import { validateCheckAst, type CheckAst } from "../Schema/CheckExpression";
5
5
  import type {
6
6
  EntityModel,
7
7
  ForeignKeyModel,
@@ -57,6 +57,7 @@ export class ModelBuilder {
57
57
  name: rawProp.index.name ?? `ix_${(raw.table ?? defaultTableName(ctor.name)).toLowerCase()}_${property.columnName}`,
58
58
  columns: [property.columnName],
59
59
  unique: rawProp.index.unique,
60
+ ...(rawProp.index.where === undefined ? {} : { where: rawProp.index.where }),
60
61
  });
61
62
  }
62
63
  if (rawProp.fkTarget) {
@@ -177,12 +178,13 @@ export class ModelBuilder {
177
178
  return property.columnName;
178
179
  });
179
180
  if (new Set(columns).size !== columns.length) throw new ModelBuildError(`Entity "${ctor.name}": index contains duplicate properties.`);
180
- indexes.push({ name: declared.name ?? `ix_${(raw.table ?? defaultTableName(ctor.name)).toLowerCase()}_${columns.join("_")}`, columns, unique: declared.unique });
181
+ indexes.push({ name: declared.name ?? `ix_${(raw.table ?? defaultTableName(ctor.name)).toLowerCase()}_${columns.join("_")}`, columns, unique: declared.unique, ...(declared.where === undefined ? {} : { where: declared.where }) });
181
182
  }
182
183
  const indexNames = new Set<string>();
183
184
  for (const index of indexes) {
184
185
  if (indexNames.has(index.name)) throw new ModelBuildError(`Entity "${ctor.name}": duplicate index name "${index.name}".`);
185
186
  indexNames.add(index.name);
187
+ if (index.where !== undefined) validateIndexCondition(ctor.name, index.name, index.where, properties);
186
188
  }
187
189
  // Encryption with a random nonce stores equal values differently: such a
188
190
  // column cannot identify a row or be unique.
@@ -346,3 +348,24 @@ function isCanonicalRfc3339(value: string): boolean {
346
348
  }
347
349
  return true;
348
350
  }
351
+
352
+ /**
353
+ * A partial index condition uses the `@Check` operators. A date literal is
354
+ * refused: PostgreSQL stores it in its own time zone form, so the declared
355
+ * and the stored conditions could never be compared.
356
+ */
357
+ function validateIndexCondition(entity: string, index: string, where: CheckAst, properties: readonly PropertyModel[]): void {
358
+ validateCheckAst(where, properties, `Entity "${entity}": index "${index}" condition`);
359
+ const byName = new Map(properties.map((property) => [property.propertyName, property]));
360
+ const visit = (node: CheckAst): void => {
361
+ switch (node.kind) {
362
+ case "and":
363
+ case "or": visit(node.left); visit(node.right); return;
364
+ case "not": visit(node.inner); return;
365
+ case "null": return;
366
+ }
367
+ if (byName.get(node.left)?.type !== "datetime") return;
368
+ if (node.kind === "in" || !(typeof node.right === "string" && node.right.startsWith("\0"))) throw new ModelBuildError(`Entity "${entity}": index "${index}" condition compares "${node.left}" with a date literal; use isNull()/isNotNull() or compare with another column.`);
369
+ };
370
+ visit(where);
371
+ }
@@ -62,10 +62,18 @@ export interface UUIDOptions {
62
62
  readonly name?: string;
63
63
  }
64
64
 
65
- /** `@Index` options (on a property). */
66
- export interface IndexOptions {
65
+ /** `@Index` options. */
66
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
67
+ export interface IndexOptions<T extends object = any> {
67
68
  readonly unique?: boolean;
68
69
  readonly name?: string;
70
+ /**
71
+ * Partial index: only rows that match the condition are indexed. With
72
+ * `@SoftDelete` a unique index keeps deleted rows out:
73
+ * `@Index<User>(["email"], { unique: true, where: (u) => u.deletedAt.isNull() })`.
74
+ * The condition uses the same operators as `@Check`.
75
+ */
76
+ readonly where?: CheckPredicate<T>;
69
77
  }
70
78
  export interface CompositeKeyOptions { readonly name?: string }
71
79
  export type ReferentialAction = "noAction" | "restrict" | "cascade" | "setNull";
@@ -87,7 +95,7 @@ export interface RawProperty {
87
95
  required?: boolean;
88
96
  nullable?: boolean;
89
97
  default?: ColumnDefaultValue;
90
- index?: { unique: boolean; name?: string };
98
+ index?: { unique: boolean; name?: string; where?: import("../Schema/CheckExpression").CheckAst };
91
99
  /** `@ForeignKey(() => Principal)` on a scalar column, for the DDL FK. */
92
100
  fkTarget?: () => EntityClass;
93
101
  /** `@HasConversion(...)`: conversion before/after the dialect. */
@@ -119,7 +127,7 @@ export interface RawEntity {
119
127
  /** Soft-delete property name (see `@SoftDelete` / `@Entity({ softDelete })`). */
120
128
  softDeleteProperty?: string;
121
129
  keyDeclaration?: { properties: readonly string[]; name?: string; anchor: string; composite: boolean };
122
- indexes?: Array<{ properties: readonly string[]; unique: boolean; name?: string }>;
130
+ indexes?: Array<{ properties: readonly string[]; unique: boolean; name?: string; where?: import("../Schema/CheckExpression").CheckAst }>;
123
131
  foreignKeys?: Array<{ properties: readonly string[]; target: () => EntityClass; name?: string; onDelete?: ReferentialAction; onUpdate?: ReferentialAction }>;
124
132
  checks?: Array<{ name: string; expression: import("../Schema/CheckExpression").CheckAst }>;
125
133
  }
@@ -292,17 +300,21 @@ export function Required() {
292
300
  }
293
301
 
294
302
  /** Index on the property's column. */
295
- export function Index(options?: IndexOptions): (value: undefined, context: ClassFieldDecoratorContext) => void;
296
- export function Index(properties: readonly [string, ...string[]], options?: IndexOptions): (value: abstract new (...args: never[]) => unknown, context: ClassDecoratorContext) => void;
303
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
304
+ export function Index<T extends object = any>(options?: IndexOptions<T>): (value: undefined, context: ClassFieldDecoratorContext) => void;
305
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
306
+ export function Index<T extends object = any>(properties: readonly [string, ...string[]], options?: IndexOptions<T>): (value: abstract new (...args: never[]) => unknown, context: ClassDecoratorContext) => void;
297
307
  export function Index(first: IndexOptions | readonly [string, ...string[]] = {}, options: IndexOptions = {}) {
298
308
  if (Array.isArray(first)) {
309
+ const where = options.where === undefined ? undefined : compileCheck(options.where);
299
310
  return (_value: abstract new (...args: never[]) => unknown, context: ClassDecoratorContext): void => {
300
- (ownRaw(context.metadata).indexes ??= []).push({ properties: [...first], unique: options.unique === true, name: options.name });
311
+ (ownRaw(context.metadata).indexes ??= []).push({ properties: [...first], unique: options.unique === true, name: options.name, ...(where === undefined ? {} : { where }) });
301
312
  };
302
313
  }
303
314
  const propertyOptions = first as IndexOptions;
315
+ const where = propertyOptions.where === undefined ? undefined : compileCheck(propertyOptions.where);
304
316
  return (_value: undefined, context: ClassFieldDecoratorContext): void => {
305
- ownProperty(context.metadata, fieldName(context)).index = { unique: propertyOptions.unique === true, name: propertyOptions.name };
317
+ ownProperty(context.metadata, fieldName(context)).index = { unique: propertyOptions.unique === true, name: propertyOptions.name, ...(where === undefined ? {} : { where }) };
306
318
  };
307
319
  }
308
320
 
@@ -72,6 +72,8 @@ export interface IndexModel {
72
72
  readonly name: string;
73
73
  readonly columns: readonly string[];
74
74
  readonly unique: boolean;
75
+ /** Partial index condition over property names (`@Index({ where })`). */
76
+ readonly where?: CheckAst;
75
77
  }
76
78
  export interface CheckModel { readonly name: string; readonly expression: CheckAst }
77
79
 
@@ -165,9 +165,10 @@ export class PostgresDialect implements SqlDialect {
165
165
  createIndexSqlOne(model: EntityModel, index: IndexModel): string {
166
166
  const unique = index.unique ? "UNIQUE " : "";
167
167
  const cols = index.columns.map((column) => this.quoteId(column)).join(", ");
168
+ const where = index.where === undefined ? "" : ` WHERE ${renderCheck(projectCheckAstIdentifiers(index.where, (name) => model.propertyByName(name)!.columnName), (name) => this.quoteId(name))}`;
168
169
  return `CREATE ${unique}INDEX IF NOT EXISTS ${this.quoteId(index.name)} ON ${this.qualifyTable(
169
170
  model,
170
- )} (${cols})`;
171
+ )} (${cols})${where}`;
171
172
  }
172
173
 
173
174
  addColumnSql(model: EntityModel, property: PropertyModel, foreignKey?: ForeignKeyConstraint): string {
@@ -476,7 +476,7 @@ export class PostgresProvider implements DatabaseProvider {
476
476
  const column = String(row.column_name);
477
477
  return [column, { name: column, notNull: row.not_null === true, isPrimaryKey: primaryColumns.has(column), ...normalized, physicalType: exact ? exactPostgresType(String(row.type_name), normalized.physicalType) : normalized.physicalType }];
478
478
  }));
479
- const indexRows = await executor.query(`SELECT ic.relname AS index_name, ix.indisunique AS is_unique, am.amname AS method, ix.indpred IS NOT NULL AS has_predicate, ix.indisvalid AS is_valid, ix.indisready AS is_ready, ix.indnkeyatts AS key_count, k.ord, a.attname AS column_name, k.attnum = 0 AS is_expression, k.ord > ix.indnkeyatts AS is_include,
479
+ const indexRows = await executor.query(`SELECT ic.relname AS index_name, ix.indisunique AS is_unique, am.amname AS method, ix.indpred IS NOT NULL AS has_predicate, pg_get_expr(ix.indpred, ix.indrelid, true) AS predicate, ix.indisvalid AS is_valid, ix.indisready AS is_ready, ix.indnkeyatts AS key_count, k.ord, a.attname AS column_name, k.attnum = 0 AS is_expression, k.ord > ix.indnkeyatts AS is_include,
480
480
  EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid = ix.indexrelid AND c.contype IN ('p', 'u')) AS backing_constraint
481
481
  FROM pg_index ix JOIN pg_class ic ON ic.oid = ix.indexrelid JOIN pg_class tc ON tc.oid = ix.indrelid JOIN pg_namespace n ON n.oid = tc.relnamespace JOIN pg_am am ON am.oid = ic.relam JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY k(attnum, ord) ON true LEFT JOIN pg_attribute a ON a.attrelid = tc.oid AND a.attnum = k.attnum
482
482
  WHERE n.nspname = $1 AND tc.relname = $2 ORDER BY ic.relname, k.ord`, [schema, name]);
@@ -1,6 +1,6 @@
1
1
  import type { Row } from "./types";
2
2
  import type { IntrospectedColumn, IntrospectedIndex, IntrospectedForeignKey } from "../Schema/introspection";
3
- import { parseRenderedCheck } from "../Schema/CheckExpression";
3
+ import { parsePostgresPredicate, type CheckAst } from "../Schema/CheckExpression";
4
4
 
5
5
  export function canonicalPostgresColumn(type: string, defaultExpression: string | null, identity: string): Pick<IntrospectedColumn, "physicalType" | "default" | "generation" | "unsupported"> {
6
6
  const physicalType = canonicalPostgresType(type);
@@ -78,10 +78,10 @@ function stripPostgresCasts(value: string): string { return value.replace(/::(?:
78
78
  function stripPostgresOuter(value: string): string { while (value.startsWith("(") && value.endsWith(")")) value = value.slice(1, -1).trim(); return value; }
79
79
 
80
80
  export function collectPostgresIndexes(rows: readonly Row[], exact = false): IntrospectedIndex[] {
81
- const byName = new Map<string, { unique: boolean; columns: string[]; method: "btree" | undefined; backingConstraint: boolean; unsupported: boolean; predicate?: string }>();
81
+ const byName = new Map<string, { unique: boolean; columns: string[]; method: "btree" | undefined; backingConstraint: boolean; unsupported: boolean; predicate?: CheckAst }>();
82
82
  for (const row of rows) {
83
83
  const name = String(row.index_name); let index = byName.get(name);
84
- if (!index) { index = { unique: row.is_unique === true, columns: [], method: String(row.method) === "btree" ? "btree" : undefined, backingConstraint: row.backing_constraint === true, unsupported: row.is_expression === true || row.is_valid !== true || row.is_ready !== true }; if (row.has_predicate === true) index.predicate = "unsupported"; byName.set(name, index); }
84
+ if (!index) { index = { unique: row.is_unique === true, columns: [], method: String(row.method) === "btree" ? "btree" : undefined, backingConstraint: row.backing_constraint === true, unsupported: row.is_expression === true || row.is_valid !== true || row.is_ready !== true }; if (row.has_predicate === true) { const predicate = typeof row.predicate === "string" ? parsePostgresPredicate(row.predicate) : undefined; if (predicate === undefined) index.unsupported = true; else index.predicate = predicate; } byName.set(name, index); }
85
85
  if (row.column_name == null || (exact && row.is_include === true)) index.unsupported = true; else index.columns.push(String(row.column_name));
86
86
  }
87
87
  return [...byName.entries()].map(([name, value]) => ({ name, unique: value.unique, columns: value.columns, method: value.method, backingConstraint: value.backingConstraint, predicate: value.predicate, unsupported: value.unsupported }));
@@ -100,8 +100,8 @@ export function collectPostgresForeignKeys(rows: readonly Row[], exact = false):
100
100
  function postgresAction(code: string): { readonly value: string; readonly unsupported: boolean } { switch (code) { case "a": return { value: "noAction", unsupported: false }; case "r": return { value: "restrict", unsupported: false }; case "c": return { value: "cascade", unsupported: false }; case "n": return { value: "setNull", unsupported: false }; default: return { value: "unknown", unsupported: true }; } }
101
101
 
102
102
  export function canonicalPostgresCheck(name: string, definition: string): { readonly name: string; readonly expression: unknown; readonly unsupported: boolean } {
103
- const body = definition.trim().match(/^CHECK\s*\((.*)\)$/isu)?.[1]?.replace(/::(?:[A-Za-z_][A-Za-z0-9_]*(?:\[\])?)/gu, "");
104
- const expression = body === undefined ? undefined : parseRenderedCheck(body);
103
+ const body = definition.trim().match(/^CHECK\s*\((.*)\)$/isu)?.[1];
104
+ const expression = body === undefined ? undefined : parsePostgresPredicate(body);
105
105
  return { name, expression: expression ?? { kind: "unsupported" }, unsupported: expression === undefined };
106
106
  }
107
107
 
@@ -125,17 +125,17 @@ export function projectCheckAstIdentifiers(ast: CheckAst, resolve: (propertyName
125
125
  }
126
126
  }
127
127
  /** Validates the complete closed AST against the compiled entity columns before DDL/snapshot rendering. */
128
- export function validateCheckAst(ast: CheckAst, properties: readonly PropertyModel[]): void {
128
+ export function validateCheckAst(ast: CheckAst, properties: readonly PropertyModel[], subject = "CHECK"): void {
129
129
  const byName = new Map(properties.map((property) => [property.propertyName, property]));
130
130
  const property = (name: string): PropertyModel => {
131
131
  const value = byName.get(name);
132
132
  if (!value)
133
- throw new ModelBuildError(`CHECK references unknown property "${name}".`);
133
+ throw new ModelBuildError(`${subject} references unknown property "${name}".`);
134
134
  return value;
135
135
  };
136
136
  const validateLiteral = (column: PropertyModel, value: CheckScalar): void => {
137
137
  if (value === null)
138
- throw new ModelBuildError(`CHECK comparison for "${column.propertyName}" must use isNull()/isNotNull().`);
138
+ throw new ModelBuildError(`${subject} comparison for "${column.propertyName}" must use isNull()/isNotNull().`);
139
139
  const compatible = (column.type === "integer" || column.type === "real")
140
140
  ? typeof value === "number"
141
141
  : (column.type === "text" || column.type === "datetime")
@@ -144,7 +144,7 @@ export function validateCheckAst(ast: CheckAst, properties: readonly PropertyMod
144
144
  ? typeof value === "boolean"
145
145
  : false;
146
146
  if (!compatible)
147
- throw new ModelBuildError(`CHECK literal is incompatible with property "${column.propertyName}".`);
147
+ throw new ModelBuildError(`${subject} literal is incompatible with property "${column.propertyName}".`);
148
148
  };
149
149
  const visit = (node: CheckAst): void => {
150
150
  switch (node.kind) {
@@ -162,7 +162,7 @@ export function validateCheckAst(ast: CheckAst, properties: readonly PropertyMod
162
162
  case "in": {
163
163
  const left = property(node.left);
164
164
  if (!node.values.length || node.values.length > MAX_CHECK_IN_VALUES)
165
- throw new ModelBuildError(`CHECK IN requires between 1 and ${MAX_CHECK_IN_VALUES} literals.`);
165
+ throw new ModelBuildError(`${subject} IN requires between 1 and ${MAX_CHECK_IN_VALUES} literals.`);
166
166
  for (const value of node.values)
167
167
  validateLiteral(left, value);
168
168
  return;
@@ -173,13 +173,13 @@ export function validateCheckAst(ast: CheckAst, properties: readonly PropertyMod
173
173
  if (rightName !== undefined) {
174
174
  const right = property(rightName);
175
175
  if (left.type !== right.type)
176
- throw new ModelBuildError(`CHECK column comparison requires identical column types ("${left.propertyName}", "${right.propertyName}").`);
176
+ throw new ModelBuildError(`${subject} column comparison requires identical column types ("${left.propertyName}", "${right.propertyName}").`);
177
177
  }
178
178
  else {
179
179
  validateLiteral(left, node.right as CheckScalar);
180
180
  }
181
181
  if ([">", ">=", "<", "<="].includes(node.op) && !["integer", "real", "text", "datetime"].includes(left.type)) {
182
- throw new ModelBuildError(`CHECK ordered comparison is not supported for property "${left.propertyName}".`);
182
+ throw new ModelBuildError(`${subject} ordered comparison is not supported for property "${left.propertyName}".`);
183
183
  }
184
184
  }
185
185
  }
@@ -215,6 +215,13 @@ export function parseRenderedCheck(value: string): CheckAst | undefined {
215
215
  const left = parseIdentifier(nullable[1]!);
216
216
  return left ? { kind: "null", left, not: Boolean(nullable[2]) } : undefined;
217
217
  }
218
+ // PostgreSQL stores IN (...) as = ANY (ARRAY[...]).
219
+ const anyMatch = source.match(/^(.+?)\s*=\s*ANY\s*\(\s*ARRAY\[(.*)\]\s*\)$/isu);
220
+ if (anyMatch) {
221
+ const left = parseIdentifier(anyMatch[1]!);
222
+ const values = splitLiterals(anyMatch[2]!);
223
+ return left && values ? { kind: "in", left, values } : undefined;
224
+ }
218
225
  const inMatch = source.match(/^(.+?)\s+IN\s*\((.*)\)$/isu);
219
226
  if (inMatch) {
220
227
  const left = parseIdentifier(inMatch[1]!);
@@ -228,6 +235,46 @@ export function parseRenderedCheck(value: string): CheckAst | undefined {
228
235
  const right = parseOperand(comparison[3]!);
229
236
  return left && right !== undefined ? { kind: "compare", left, op: comparison[2]! as "=" | "<>" | ">" | ">=" | "<" | "<=", right } : undefined;
230
237
  }
238
+ /**
239
+ * Reads an expression PostgreSQL deparsed (`pg_get_expr`, a partial index
240
+ * predicate): casts like `'x'::text` are dropped outside string literals.
241
+ * Unknown syntax returns undefined.
242
+ */
243
+ export function parsePostgresPredicate(value: string): CheckAst | undefined {
244
+ let result = "";
245
+ let quoted = false;
246
+ for (let i = 0; i < value.length; i++) {
247
+ const char = value[i]!;
248
+ if (char === "'") quoted = !quoted;
249
+ if (!quoted && char === ":" && value[i + 1] === ":") {
250
+ const cast = value.slice(i).match(/^::(?:timestamp with(?:out)? time zone|time with(?:out)? time zone|double precision|character varying|[A-Za-z_][A-Za-z0-9_]*)(?:\[\])?/u);
251
+ if (cast) { i += cast[0].length - 1; continue; }
252
+ }
253
+ result += char;
254
+ }
255
+ return parseRenderedCheck(result);
256
+ }
257
+ /**
258
+ * One form for equal conditions: `(a AND b) AND c` and `a AND (b AND c)` are
259
+ * the same chain, as PostgreSQL prints it. Used to compare a declared
260
+ * condition with the one read from the catalog.
261
+ */
262
+ export function normalizeCheckAst(ast: CheckAst): CheckAst {
263
+ switch (ast.kind) {
264
+ case "and":
265
+ case "or": {
266
+ const items: CheckAst[] = [];
267
+ const collect = (node: CheckAst): void => {
268
+ if (node.kind === ast.kind) { collect(node.left); collect(node.right); }
269
+ else items.push(normalizeCheckAst(node));
270
+ };
271
+ collect(ast);
272
+ return items.reduce((left, right) => ({ kind: ast.kind, left, right }));
273
+ }
274
+ case "not": return { kind: "not", inner: normalizeCheckAst(ast.inner) };
275
+ default: return ast;
276
+ }
277
+ }
231
278
  function stripOuter(value: string): string {
232
279
  while (value.startsWith("(") && value.endsWith(")") && balancedOuter(value))
233
280
  value = value.slice(1, -1).trim();
@@ -1,7 +1,8 @@
1
1
  import type { OrmExpectedSchema } from "./ExpectedSchema";
2
2
  import type { IntrospectedSchema, IntrospectedTable } from "./introspection";
3
3
  import { createHash } from "node:crypto";
4
- export type SchemaDifferenceCode = "table.missing" | "column.missing" | "column.unexpected" | "column.type" | "column.nullability" | "column.default" | "column.generation" | "primaryKey.missing" | "primaryKey.unexpected" | "primaryKey.name" | "primaryKey.columns" | "index.missing" | "index.unexpected" | "index.name" | "index.columns" | "index.uniqueness" | "index.method" | "index.unsupportedShape" | "foreignKey.missing" | "foreignKey.unexpected" | "foreignKey.name" | "foreignKey.columns" | "foreignKey.target" | "foreignKey.actions" | "foreignKey.deferrable" | "foreignKey.match" | "check.missing" | "check.unexpected" | "check.name" | "check.expression" | "catalog.unsupported";
4
+ import { normalizeCheckAst, type CheckAst } from "./CheckExpression";
5
+ export type SchemaDifferenceCode = "table.missing" | "column.missing" | "column.unexpected" | "column.type" | "column.nullability" | "column.default" | "column.generation" | "primaryKey.missing" | "primaryKey.unexpected" | "primaryKey.name" | "primaryKey.columns" | "index.missing" | "index.unexpected" | "index.name" | "index.columns" | "index.uniqueness" | "index.method" | "index.predicate" | "index.unsupportedShape" | "foreignKey.missing" | "foreignKey.unexpected" | "foreignKey.name" | "foreignKey.columns" | "foreignKey.target" | "foreignKey.actions" | "foreignKey.deferrable" | "foreignKey.match" | "check.missing" | "check.unexpected" | "check.name" | "check.expression" | "catalog.unsupported";
5
6
  export interface SafeSchemaDescriptor {
6
7
  readonly kind: "absent" | "present" | "canonicalType" | "canonicalDefault" | "canonicalGeneration" | "canonicalExpression" | "orderedColumns" | "referentialActions" | "unsupported";
7
8
  readonly value?: string;
@@ -85,7 +86,7 @@ export class ExactSchemaVerifier {
85
86
  const ordinary = actual.indexes.filter((index) => !index.backingConstraint);
86
87
  const expectedIndexes = new Map(expected.indexes.map((index) => [index.name, index]));
87
88
  for (const index of ordinary) {
88
- if (index.unsupported || index.method !== "btree" || index.predicate || index.columns.length === 0)
89
+ if (index.unsupported || index.method !== "btree" || index.columns.length === 0)
89
90
  add("index.unsupportedShape", table, index.name, undefined, unsupported);
90
91
  else if (!expectedIndexes.has(index.name))
91
92
  add("index.unexpected", table, index.name, absent, present);
@@ -98,7 +99,7 @@ export class ExactSchemaVerifier {
98
99
  }
99
100
  if (found.name !== name)
100
101
  add("index.name", table, name, value("present", name), value("present", found.name));
101
- if (found.unsupported || found.predicate || found.columns.length === 0) {
102
+ if (found.unsupported || found.columns.length === 0) {
102
103
  add("catalog.unsupported", table, name, undefined, unsupported);
103
104
  continue;
104
105
  }
@@ -108,6 +109,8 @@ export class ExactSchemaVerifier {
108
109
  add("index.columns", table, name, value("orderedColumns", index.columns), value("orderedColumns", found.columns));
109
110
  if (found.method !== "btree")
110
111
  add("index.method", table, name, value("present", "btree"), value("present", found.method ?? "unknown"));
112
+ if (!samePredicate(found.predicate, index.predicate))
113
+ add("index.predicate", table, name, value("canonicalExpression", index.predicate ?? null), value("canonicalExpression", found.predicate ?? null));
111
114
  }
112
115
  const foreignKeys = actual.foreignKeys ?? [];
113
116
  const expectedForeignKeys = new Map(expected.foreignKeys.map((foreignKey) => [foreignKey.name, foreignKey]));
@@ -148,12 +151,22 @@ export class ExactSchemaVerifier {
148
151
  else {
149
152
  if (found.name !== name)
150
153
  add("check.name", table, name, value("present", name), value("present", found.name));
151
- if (stable(found.expression) !== stable(check.expression))
154
+ if (!sameExpression(found.expression, check.expression))
152
155
  add("check.expression", table, name, value("canonicalExpression", check.expression), value("canonicalExpression", found.expression));
153
156
  }
154
157
  }
155
158
  }
156
159
  }
160
+ /** A CHECK read from the catalog against the declared one; AND/OR chains compare flat. */
161
+ function sameExpression(found: unknown, declared: unknown): boolean {
162
+ const known = (value: unknown): value is CheckAst => typeof value === "object" && value !== null && (value as { kind?: unknown }).kind !== "unsupported";
163
+ return known(found) && known(declared) ? stable(normalizeCheckAst(found)) === stable(normalizeCheckAst(declared)) : stable(found) === stable(declared);
164
+ }
165
+ /** @internal Two partial index conditions (or their absence) are the same. */
166
+ export function samePredicate(a: CheckAst | undefined, b: CheckAst | undefined): boolean {
167
+ if (a === undefined || b === undefined) return a === b;
168
+ return stable(normalizeCheckAst(a)) === stable(normalizeCheckAst(b));
169
+ }
157
170
  function same<T>(a: readonly T[], b: readonly T[]): boolean {
158
171
  return a.length === b.length && a.every((entry, index) => stable(entry) === stable(b[index]));
159
172
  }
@@ -1,13 +1,13 @@
1
1
  import type { EntityModel } from "../Metadata/types";
2
2
  import type { OrmModel } from "../Metadata/OrmModel";
3
3
  import type { CanonicalDefault, GenerationStrategy } from "./introspection";
4
- import { projectCheckAstIdentifiers } from "./CheckExpression";
4
+ import { projectCheckAstIdentifiers, type CheckAst } from "./CheckExpression";
5
5
  import { crossUnitForeignKey, physicalColumnTypes } from "./physicalColumnTypes";
6
6
  import { SchemaAdmissionError } from "../errors";
7
7
  import { physicalTableIdentity } from "./tableKey";
8
8
 
9
9
  export interface ExpectedColumn { readonly property: string; readonly column: string; readonly physicalType: string; readonly nullable: boolean; readonly default: CanonicalDefault; readonly generation: GenerationStrategy }
10
- export interface ExpectedTable { readonly schema: string; readonly table: string; readonly columns: readonly ExpectedColumn[]; readonly primaryKey: { readonly name: string; readonly columns: readonly string[] }; readonly indexes: readonly { readonly name: string; readonly columns: readonly string[]; readonly unique: boolean; readonly method: "btree" }[]; readonly foreignKeys: readonly { readonly name: string; readonly columns: readonly string[]; readonly target: { readonly schema: string; readonly table: string }; readonly targetColumns: readonly string[]; readonly onDelete: string; readonly onUpdate: string }[]; readonly checks: readonly { readonly name: string; readonly expression: unknown }[] }
10
+ export interface ExpectedTable { readonly schema: string; readonly table: string; readonly columns: readonly ExpectedColumn[]; readonly primaryKey: { readonly name: string; readonly columns: readonly string[] }; readonly indexes: readonly { readonly name: string; readonly columns: readonly string[]; readonly unique: boolean; readonly method: "btree"; readonly predicate?: CheckAst }[]; readonly foreignKeys: readonly { readonly name: string; readonly columns: readonly string[]; readonly target: { readonly schema: string; readonly table: string }; readonly targetColumns: readonly string[]; readonly onDelete: string; readonly onUpdate: string }[]; readonly checks: readonly { readonly name: string; readonly expression: unknown }[] }
11
11
  export interface OrmExpectedSchema { readonly tables: readonly ExpectedTable[] }
12
12
 
13
13
  const schemaOf = (model: EntityModel) => physicalTableIdentity(model).schema?.trim() || "public";
@@ -41,5 +41,5 @@ function compileTable(model: EntityModel, byCtor: ReadonlyMap<EntityModel["ctor"
41
41
  if (!target) throw crossUnitForeignKey(model, foreignKey.properties, foreignKey.target());
42
42
  return { name: foreignKey.name ?? `fk_${table}_${foreignKey.properties.join("_")}`, columns: foreignKey.properties.map((name) => model.propertyByName(name)!.columnName), target: { schema: schemaOf(target), table: physicalTableIdentity(target).table }, targetColumns: target.key.map((property) => property.columnName), onDelete: foreignKey.onDelete, onUpdate: foreignKey.onUpdate };
43
43
  });
44
- return Object.freeze({ schema, table, columns: Object.freeze(columns), primaryKey: Object.freeze({ name: model.keyName ?? `pk_${table}`, columns: Object.freeze(model.key.map((property) => property.columnName)) }), indexes: Object.freeze(model.indexes.map((index) => Object.freeze({ ...index, columns: Object.freeze([...index.columns]), method: "btree" as const }))), foreignKeys: Object.freeze(foreignKeys), checks: Object.freeze(model.checks.map((check) => Object.freeze({ name: check.name, expression: projectCheckAstIdentifiers(check.expression, (name) => model.propertyByName(name)!.columnName) }))) });
44
+ return Object.freeze({ schema, table, columns: Object.freeze(columns), primaryKey: Object.freeze({ name: model.keyName ?? `pk_${table}`, columns: Object.freeze(model.key.map((property) => property.columnName)) }), indexes: Object.freeze(model.indexes.map((index) => Object.freeze({ name: index.name, columns: Object.freeze([...index.columns]), unique: index.unique, method: "btree" as const, ...(index.where === undefined ? {} : { predicate: projectCheckAstIdentifiers(index.where, (name) => model.propertyByName(name)!.columnName) }) }))), foreignKeys: Object.freeze(foreignKeys), checks: Object.freeze(model.checks.map((check) => Object.freeze({ name: check.name, expression: projectCheckAstIdentifiers(check.expression, (name) => model.propertyByName(name)!.columnName) }))) });
45
45
  }
@@ -70,7 +70,7 @@ export function renderSafeAdditivePostgres(operation: SafeAdditiveSchemaOperatio
70
70
  case "addColumn": return `ALTER TABLE ${tableName(operation.table)} ADD COLUMN ${column(operation.column)}`;
71
71
  case "addCheck": return `ALTER TABLE ${tableName(operation.table)} ADD CONSTRAINT ${q(operation.check.name)} CHECK (${renderCheck(operation.check.expression as never, q)})`;
72
72
  case "addForeignKey": return `ALTER TABLE ${tableName(operation.table)} ADD CONSTRAINT ${q(operation.foreignKey.name)} FOREIGN KEY (${operation.foreignKey.columns.map(q).join(", ")}) REFERENCES ${q(operation.foreignKey.target.schema)}.${q(operation.foreignKey.target.table)} (${operation.foreignKey.targetColumns.map(q).join(", ")}) ON DELETE ${action(operation.foreignKey.onDelete)} ON UPDATE ${action(operation.foreignKey.onUpdate)}`;
73
- case "createIndex": return `CREATE ${operation.index.unique ? "UNIQUE " : ""}INDEX ${q(operation.index.name)} ON ${tableName(operation.table)} (${operation.index.columns.map(q).join(", ")})`;
73
+ case "createIndex": return `CREATE ${operation.index.unique ? "UNIQUE " : ""}INDEX ${q(operation.index.name)} ON ${tableName(operation.table)} (${operation.index.columns.map(q).join(", ")})${operation.index.predicate === undefined ? "" : ` WHERE ${renderCheck(operation.index.predicate, q)}`}`;
74
74
  }
75
75
  }
76
76
  function createTable(table: ExpectedTable): string {
@@ -2,7 +2,8 @@ import type { DatabaseProvider, SchemaAdmissionScope } from "../Providers/types"
2
2
  import { SchemaAdmissionError, SchemaMigrationRequiredError, SchemaVerificationError } from "../errors";
3
3
  import type { OrmModel } from "../Metadata/OrmModel";
4
4
  import { compileExpectedSchema, type OrmExpectedSchema } from "./ExpectedSchema";
5
- import { ExactSchemaVerifier, type SchemaVerificationResult } from "./ExactSchemaVerifier";
5
+ import { ExactSchemaVerifier, samePredicate, type SchemaVerificationResult } from "./ExactSchemaVerifier";
6
+ import { renderCheck } from "./CheckExpression";
6
7
  import { classifySafeAdditive, renderSafeAdditivePostgres, type SafeAdditiveSchemaOperation } from "./SafeAdditiveSchema";
7
8
  import type { IntrospectedSchema } from "./introspection";
8
9
 
@@ -113,7 +114,7 @@ function describeOperation(operation: SafeAdditiveSchemaOperation): string {
113
114
  case "addColumn": return `add column ${tableName(operation.table.schema, operation.table.table)}.${operation.column.column}`;
114
115
  case "addCheck": return `add check ${operation.check.name} on ${tableName(operation.table.schema, operation.table.table)}`;
115
116
  case "addForeignKey": return `add foreign key ${operation.foreignKey.name} on ${tableName(operation.table.schema, operation.table.table)}`;
116
- case "createIndex": return `create ${operation.index.unique ? "unique " : ""}index ${operation.index.name} on ${tableName(operation.table.schema, operation.table.table)} (${operation.index.columns.join(", ")})`;
117
+ case "createIndex": return `create ${operation.index.unique ? "unique " : ""}index ${operation.index.name} on ${tableName(operation.table.schema, operation.table.table)} (${operation.index.columns.join(", ")})${operation.index.predicate === undefined ? "" : ` where ${renderCheck(operation.index.predicate, (name) => name)}`}`;
117
118
  }
118
119
  }
119
120
 
@@ -135,7 +136,7 @@ function alignIndexNames(expected: OrmExpectedSchema, actual: IntrospectedSchema
135
136
  let indexes = found.indexes;
136
137
  for (const index of table.indexes) {
137
138
  if (indexes.some((item) => item.name === index.name)) continue;
138
- const match = indexes.find((item) => !item.backingConstraint && !item.unsupported && !item.predicate && item.method === "btree" && !expectedNames.has(item.name)
139
+ const match = indexes.find((item) => !item.backingConstraint && !item.unsupported && samePredicate(item.predicate, index.predicate) && item.method === "btree" && !expectedNames.has(item.name)
139
140
  && item.unique === index.unique && item.columns.length === index.columns.length && item.columns.every((column, position) => column === index.columns[position]));
140
141
  if (!match) continue;
141
142
  warnings.push(`table ${quote(table.schema)}.${quote(table.table)}: index is named ${quote(match.name)}, the model expects ${quote(index.name)}. `
@@ -1,6 +1,8 @@
1
1
  import { entityStorageKey } from "./tableKey";
2
2
  import type { ColumnType, EntityModel, IndexModel, PropertyModel } from "../Metadata/types";
3
3
  import type { IntrospectedSchema, IntrospectedTable } from "./introspection";
4
+ import { projectCheckAstIdentifiers, type CheckAst } from "./CheckExpression";
5
+ import { samePredicate } from "./ExactSchemaVerifier";
4
6
 
5
7
  /**
6
8
  * Additive schema change operation. Destructive changes (drop/type change) never
@@ -89,7 +91,7 @@ export class SchemaDiffer {
89
91
  for (const index of model.indexes) {
90
92
  if (existing.has(index.name)) continue;
91
93
  // The same index under another name (for example from before 0.98.20) is not duplicated.
92
- const renamed = table.indexes.find((item) => !declared.has(item.name) && !item.backingConstraint && !item.predicate && item.unique === index.unique
94
+ const renamed = table.indexes.find((item) => !declared.has(item.name) && !item.backingConstraint && !item.unsupported && samePredicate(item.predicate, projectedWhere(model, index)) && item.unique === index.unique
93
95
  && item.columns.length === index.columns.length && item.columns.every((column, position) => column === index.columns[position]));
94
96
  if (renamed) {
95
97
  warnings.push(`table "${model.tableName}": index "${renamed.name}" matches "${index.name}" from the model except for its name (left untouched; to align it run: ALTER INDEX "${renamed.name}" RENAME TO "${index.name}";).`);
@@ -99,3 +101,8 @@ export class SchemaDiffer {
99
101
  }
100
102
  }
101
103
  }
104
+
105
+ /** The index condition over column names, as the catalog reports it. */
106
+ function projectedWhere(model: EntityModel, index: EntityModel["indexes"][number]): CheckAst | undefined {
107
+ return index.where === undefined ? undefined : projectCheckAstIdentifiers(index.where, (name) => model.propertyByName(name)!.columnName);
108
+ }
@@ -18,6 +18,7 @@ const descriptions: Record<SchemaDifferenceCode, string> = {
18
18
  "index.columns": "index columns or their order differ",
19
19
  "index.uniqueness": "index uniqueness differs",
20
20
  "index.method": "index method differs",
21
+ "index.predicate": "partial index condition (where) differs",
21
22
  "index.unsupportedShape": "index shape is unsupported by exact schema admission",
22
23
  "foreignKey.missing": "foreign key is missing",
23
24
  "foreignKey.unexpected": "foreign key is not declared in the ORM model",
@@ -1,3 +1,4 @@
1
+ import type { CheckAst } from "./CheckExpression";
1
2
  /**
2
3
  * Snapshot of the actual database schema obtained by introspection. The source
3
4
  * of the "current" state for the additive auto-migration (the target is the
@@ -22,7 +23,8 @@ export interface IntrospectedIndex {
22
23
  readonly unique: boolean;
23
24
  readonly method?: "btree";
24
25
  readonly backingConstraint?: boolean;
25
- readonly predicate?: string;
26
+ /** Partial index condition read from the catalog; unreadable ones mark the index `unsupported`. */
27
+ readonly predicate?: CheckAst;
26
28
  readonly unsupported?: boolean;
27
29
  }
28
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.114.0",
3
+ "version": "0.115.0",
4
4
  "description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Alexander Alenin",