@rebasepro/server-postgres 0.16.0 → 0.16.1-canary.g2d1aec8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/PostgresAdapter.d.ts +1 -1
  2. package/dist/PostgresBackendDriver.d.ts +16 -7
  3. package/dist/PostgresBootstrapper.d.ts +6 -6
  4. package/dist/auth/services.d.ts +1 -1
  5. package/dist/backup/backup-cron.d.ts +1 -1
  6. package/dist/backup/backup-service.d.ts +2 -2
  7. package/dist/backup/index.d.ts +4 -4
  8. package/dist/collections/PostgresCollectionRegistry.d.ts +1 -1
  9. package/dist/collections/buildRegistry.d.ts +1 -1
  10. package/dist/collections/validate-relations.d.ts +1 -1
  11. package/dist/data-transformer.d.ts +1 -1
  12. package/dist/{ensure-collection-policies-BVFb2olB.js → ensure-collection-policies-CVfVHS6o.js} +3 -3
  13. package/dist/{ensure-collection-policies-BVFb2olB.js.map → ensure-collection-policies-CVfVHS6o.js.map} +1 -1
  14. package/dist/{auth-users-columns-CgyPWQ18.js → ensure-collection-tables-jYsvOLZF.js} +1443 -11
  15. package/dist/ensure-collection-tables-jYsvOLZF.js.map +1 -0
  16. package/dist/index.d.ts +16 -16
  17. package/dist/index.es.js +2169 -1803
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/{rls-enforcement-Ch0T6OwW.js → rls-enforcement-CsD7nZDn.js} +2 -2
  20. package/dist/{rls-enforcement-Ch0T6OwW.js.map → rls-enforcement-CsD7nZDn.js.map} +1 -1
  21. package/dist/schema/classify-change.d.ts +82 -0
  22. package/dist/schema/dynamic-tables.d.ts +1 -1
  23. package/dist/schema/ensure-collection-policies.d.ts +1 -1
  24. package/dist/schema/ensure-collection-tables.d.ts +93 -2
  25. package/dist/schema/generate-schema-commit.d.ts +136 -0
  26. package/dist/schema/introspect-db-constraints.d.ts +1 -1
  27. package/dist/schema/introspect-db-logic.d.ts +3 -3
  28. package/dist/schema/introspect-db-project.d.ts +1 -1
  29. package/dist/schema/introspect-db-queries.d.ts +1 -1
  30. package/dist/schema/introspect-db-structure.d.ts +2 -2
  31. package/dist/schema/introspect-runtime.d.ts +1 -1
  32. package/dist/schema/vector-index.d.ts +88 -0
  33. package/dist/services/BranchService.d.ts +2 -2
  34. package/dist/services/FetchService.d.ts +4 -4
  35. package/dist/services/PersistService.d.ts +5 -5
  36. package/dist/services/RelationService.d.ts +3 -3
  37. package/dist/services/RelationWriteService.d.ts +3 -3
  38. package/dist/services/cdc/junction-tables.d.ts +1 -1
  39. package/dist/services/cdc/trigger-cdc.d.ts +1 -1
  40. package/dist/services/channel-bus/PostgresChannelBus.d.ts +1 -1
  41. package/dist/services/channel-bus/index.d.ts +2 -2
  42. package/dist/services/collection-helpers.d.ts +1 -1
  43. package/dist/services/dataService.d.ts +10 -10
  44. package/dist/services/index.d.ts +4 -4
  45. package/dist/services/junction-writes.d.ts +2 -2
  46. package/dist/services/nested-path.d.ts +1 -1
  47. package/dist/services/realtimeService.d.ts +3 -3
  48. package/dist/services/row-pipeline.d.ts +1 -1
  49. package/dist/services/write-denial.d.ts +1 -1
  50. package/dist/utils/drizzle-conditions.d.ts +2 -2
  51. package/dist/websocket-BVgDVO-V.js.map +1 -1
  52. package/dist/websocket.d.ts +2 -2
  53. package/package.json +7 -7
  54. package/src/PostgresBackendDriver.ts +40 -0
  55. package/src/schema/classify-change.ts +436 -0
  56. package/src/schema/ensure-collection-tables.test.ts +168 -1
  57. package/src/schema/ensure-collection-tables.ts +344 -14
  58. package/src/schema/generate-postgres-ddl-logic.ts +15 -0
  59. package/src/schema/generate-schema-commit.ts +242 -0
  60. package/src/schema/vector-index.ts +278 -0
  61. package/dist/auth-users-columns-CgyPWQ18.js.map +0 -1
  62. package/dist/ensure-collection-tables-BY1pHRD_.js +0 -840
  63. package/dist/ensure-collection-tables-BY1pHRD_.js.map +0 -1
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Everything a schema change has to write, as file contents.
3
+ *
4
+ * A live schema editor that only edits the collection source produces a repo
5
+ * that does not build: `backend/src/schema.generated.ts` is a committed
6
+ * artifact, and a stale one has broken every deploy at least once. So the unit
7
+ * of a schema change is not a file, it is a **commit** — and this module
8
+ * produces one, without touching a disk, a database or a network.
9
+ *
10
+ * Pure on purpose. The risky half of "commit, then apply" is generating a
11
+ * correct commit; keeping it a function from collections to file contents is
12
+ * what lets that half be tested by building a database from the result and
13
+ * comparing it to the one the change describes.
14
+ *
15
+ * ## Where the migration comes from, and why not from Atlas
16
+ *
17
+ * `rebase db generate` mints migrations by running Atlas over the generated
18
+ * `schema.sql`. Atlas is an external binary, it wants a dev database, and it
19
+ * maintains an `atlas.sum` integrity file whose hash this module would have to
20
+ * reproduce byte-for-byte to stay valid.
21
+ *
22
+ * None of that is necessary here, because of what the editor is allowed to do.
23
+ * `classify-change.ts` refuses anything the boot-time ensure path cannot
24
+ * express, which leaves only additive statements — and those are computable as
25
+ * a plain difference between two ensure plans:
26
+ *
27
+ * plan(after, nothing) − plan(before, nothing)
28
+ *
29
+ * Both plans are pure functions of the collections, every statement is
30
+ * idempotent, and the difference is exactly what the change adds. No diff
31
+ * engine, no database, no binary.
32
+ *
33
+ * The statements are *returned* rather than written into a migration file. A
34
+ * project provisioned by boot-ensure needs no migration at all — its
35
+ * collections are the schema — while a project provisioned by migrations needs
36
+ * the file to carry an Atlas hash, which only Atlas can mint. Writing a
37
+ * migration this module cannot make valid would be worse than handing the
38
+ * statements to a caller who knows which kind of project it is.
39
+ */
40
+ import { DEFAULT_COMMIT_PATHS, type CollectionConfig, type SchemaCommitPaths } from "@rebasepro/types";
41
+ import {
42
+ generatePostgresDdl,
43
+ generatePostgresPoliciesDdl,
44
+ generatePostgresSearchDdl
45
+ } from "./generate-postgres-ddl-logic";
46
+ import { generateSchema } from "./generate-drizzle-schema-logic";
47
+ import {
48
+ planCollectionSchemaEnsure,
49
+ type EnsureOptions,
50
+ type ExistingSchema,
51
+ type WithheldConstraint
52
+ } from "./ensure-collection-tables";
53
+ import { classifyCollectionChanges, type ClassifiedChanges } from "./classify-change";
54
+
55
+ /**
56
+ * Re-exported from the shared kernel. `@rebasepro/server` derives these for a
57
+ * project in a subdirectory and cannot import a driver to do it, so the shape
58
+ * and the defaults live in `@rebasepro/types`.
59
+ */
60
+ export { DEFAULT_COMMIT_PATHS, type SchemaCommitPaths } from "@rebasepro/types";
61
+
62
+ export interface SchemaCommitFile {
63
+ path: string;
64
+ contents: string;
65
+ }
66
+
67
+ export interface SchemaCommitInput {
68
+ /** What the running database was built from. */
69
+ before: CollectionConfig[];
70
+ /** What the editor is proposing. */
71
+ after: CollectionConfig[];
72
+ /**
73
+ * Files the caller has already produced — in practice the rewritten
74
+ * collection source from the AST editor. Carried through unchanged so the
75
+ * commit is complete in one object.
76
+ */
77
+ sourceFiles?: SchemaCommitFile[];
78
+ paths?: Partial<SchemaCommitPaths>;
79
+ /**
80
+ * What the database this change is destined for actually has.
81
+ *
82
+ * Supplied by the live editor, which read it a moment ago; omitted by the
83
+ * pure callers, which have no database. It decides two things a plan cannot
84
+ * know from the collections alone — whether a table holds rows, and which
85
+ * values an enum type already carries — and both are the difference between
86
+ * a statement that applies and one that is rejected.
87
+ */
88
+ existing?: ExistingSchema;
89
+ }
90
+
91
+ export interface SchemaCommit {
92
+ /** Every file the commit writes, source and generated alike. */
93
+ files: SchemaCommitFile[];
94
+ /**
95
+ * The additive statements this change adds, in dependency order.
96
+ *
97
+ * Empty when the change needs no DDL. Not written to a migration file —
98
+ * see the module comment.
99
+ */
100
+ statements: string[];
101
+ classified: ClassifiedChanges;
102
+ /** A commit message describing the change in the terms a reader wants. */
103
+ message: string;
104
+ /**
105
+ * Constraints this change asks for that the statements do not carry, and
106
+ * why. Empty for almost every change; when it is not, it is the thing the
107
+ * person confirming needs to read before they confirm.
108
+ */
109
+ withheldConstraints: WithheldConstraint[];
110
+ }
111
+
112
+ export class SchemaCommitError extends Error {
113
+ constructor(message: string, readonly classified: ClassifiedChanges) {
114
+ super(message);
115
+ this.name = "SchemaCommitError";
116
+ }
117
+ }
118
+
119
+ /** An `ExistingSchema` describing a database that has nothing in it. */
120
+ const nothing = () => ({ tables: new Map<string, Set<string>>(), enums: new Set<string>() });
121
+
122
+ /**
123
+ * The statements that take `before` to `after`.
124
+ *
125
+ * Both sides are planned against the *same* database and the difference is
126
+ * taken by exact statement text. That works because the planner is
127
+ * deterministic: the same collections against the same schema produce the same
128
+ * strings, so anything in the second plan and absent from the first is what
129
+ * this change adds — and nothing else. Planning both sides is what keeps
130
+ * pre-existing drift, which belongs to neither side of the edit, out of the
131
+ * statements this change gets credited with.
132
+ *
133
+ * ## Why `existing` matters more than it looks
134
+ *
135
+ * Planned against `nothing()`, every table reads as one this plan is creating,
136
+ * and the planner is then free to attach constraints that only hold on a table
137
+ * with no rows: a new required property comes out as
138
+ * `ADD COLUMN "x" TEXT NOT NULL`, which is right for a fresh table and fails
139
+ * against a live one holding rows. Those statements would be generated,
140
+ * committed, and then rejected by the very database they were written for.
141
+ *
142
+ * So a caller holding a real database passes it, and gets statements that
143
+ * describe that database. `nothing()` stays the default for the pure uses —
144
+ * generating a commit for inspection, and the tests that compare two plans —
145
+ * where there is no database to describe.
146
+ */
147
+ export function additiveStatements(
148
+ before: CollectionConfig[],
149
+ after: CollectionConfig[],
150
+ existing: ExistingSchema = nothing(),
151
+ options: EnsureOptions = {}
152
+ ): string[] {
153
+ const previous = new Set(planCollectionSchemaEnsure(before, existing, options).statements);
154
+ return planCollectionSchemaEnsure(after, existing, options).statements
155
+ .filter(statement => !previous.has(statement));
156
+ }
157
+
158
+ /** A commit message that says what changed rather than that something did. */
159
+ export function commitMessage(classified: ClassifiedChanges): string {
160
+ const { changes } = classified;
161
+ if (changes.length === 0) return "chore(schema): no change";
162
+
163
+ const collections = [...new Set(changes.map(change => change.collection))].sort();
164
+ const added = changes.filter(c => c.kind === "add-collection").map(c => c.collection);
165
+ const properties = changes.filter(c => c.kind === "add-property");
166
+
167
+ let subject: string;
168
+ if (added.length === 1 && changes.length === 1) {
169
+ subject = `add the ${added[0]} collection`;
170
+ } else if (properties.length === 1 && changes.length === 1) {
171
+ subject = `add ${properties[0].property} to ${properties[0].collection}`;
172
+ } else if (collections.length === 1) {
173
+ subject = `${changes.length} change(s) to ${collections[0]}`;
174
+ } else {
175
+ subject = `${changes.length} change(s) across ${collections.length} collections`;
176
+ }
177
+
178
+ const body = changes.map(change => `- ${change.detail}`).join("\n");
179
+ return `feat(schema): ${subject}\n\n${body}\n`;
180
+ }
181
+
182
+ /**
183
+ * Build the commit.
184
+ *
185
+ * Refuses when the change is not applicable — a commit describing a schema the
186
+ * ensure path will not produce is a commit that makes the repository lie about
187
+ * the database. The classification travels on the error so a caller can show
188
+ * exactly which change was the problem.
189
+ */
190
+ export async function generateSchemaCommit(input: SchemaCommitInput): Promise<SchemaCommit> {
191
+ const paths = { ...DEFAULT_COMMIT_PATHS, ...input.paths };
192
+ const existing = input.existing ?? nothing();
193
+ // `converge` because every statement this produces is shown to somebody
194
+ // before it runs. See `ConstraintPolicy` for why the unattended boot does
195
+ // not get the same latitude.
196
+ const options: EnsureOptions = { constraints: "converge" };
197
+ const classified = classifyCollectionChanges(input.before, input.after, input.existing);
198
+
199
+ if (!classified.applicable) {
200
+ const blocking = classified.changes.filter(change => change.verdict !== "safe");
201
+ throw new SchemaCommitError(
202
+ `This change cannot be applied to a running database:\n` +
203
+ blocking.map(change =>
204
+ ` • ${change.detail}${change.remedy ? `\n ${change.remedy}` : ""}`
205
+ ).join("\n"),
206
+ classified
207
+ );
208
+ }
209
+
210
+ const [schema, ddl, policies, search] = await Promise.all([
211
+ generateSchema(input.after),
212
+ generatePostgresDdl(input.after),
213
+ Promise.resolve(generatePostgresPoliciesDdl(input.after)),
214
+ Promise.resolve(generatePostgresSearchDdl(input.after))
215
+ ]);
216
+
217
+ const generated: SchemaCommitFile[] = [
218
+ { path: paths.schemaFile, contents: schema },
219
+ { path: paths.ddlFile, contents: ddl },
220
+ { path: paths.policiesFile, contents: policies },
221
+ { path: paths.searchFile, contents: search }
222
+ ];
223
+
224
+ // Both sides planned once, here, rather than through `additiveStatements` —
225
+ // which would plan `after` a second time for the withheld constraints. The
226
+ // planner is pure, so a second call is only wasted work rather than a
227
+ // correctness problem, but this runs on every `/plan` keystroke in the panel.
228
+ const previous = planCollectionSchemaEnsure(input.before, existing, options);
229
+ const next = planCollectionSchemaEnsure(input.after, existing, options);
230
+ const already = new Set(previous.statements);
231
+
232
+ return {
233
+ files: [...(input.sourceFiles ?? []), ...generated],
234
+ statements: next.statements.filter(statement => !already.has(statement)),
235
+ classified,
236
+ message: commitMessage(classified),
237
+ // From the `after` plan alone rather than differenced against `before`:
238
+ // a constraint that was already unenforceable is still something the
239
+ // person confirming this change should see named.
240
+ withheldConstraints: next.withheldConstraints
241
+ };
242
+ }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * The one place a `{ type: "vector" }` property becomes an ANN index.
3
+ *
4
+ * Without an index, pgvector answers `ORDER BY embedding <=> $1` by computing
5
+ * the distance to every row and sorting — exact, and linear. That is the right
6
+ * answer at ten thousand rows and the wrong one at a million, which is why the
7
+ * default here is to build an index rather than to leave the column bare.
8
+ *
9
+ * ## The operator class is not a detail
10
+ *
11
+ * An index is built for exactly one operator class, and the planner uses it
12
+ * only for the operator that class implements. `vector_cosine_ops` answers
13
+ * `<=>` and nothing else; a query asking for `<->` against a cosine index gets
14
+ * a sequential scan and no error. So the default indexed distance here is
15
+ * `cosine`, because `cosine` is what `vectorSearch` measures with when the
16
+ * caller does not say — see `DrizzleConditionBuilder.buildVectorSearch`. The
17
+ * two defaults have to agree, and this comment is the reason they do.
18
+ *
19
+ * ## Why 2000 dimensions is a ceiling and not an error
20
+ *
21
+ * pgvector cannot index a `vector` wider than 2000 dimensions with either
22
+ * method. A 3072-dimension embedding (`text-embedding-3-large`) is therefore
23
+ * storable and searchable but not indexable. Refusing the boot over that would
24
+ * make a working configuration unbootable; silently indexing it is impossible.
25
+ * So the column is created, the index is skipped, and the reason is reported.
26
+ *
27
+ * Like `search-column.ts`, this module exists so that the DDL generator and the
28
+ * boot-time ensure render the *same* specification rather than describing the
29
+ * same index twice, differently. `contracts/derived-names.txt` records the
30
+ * names both produce, and CI fails if they diverge.
31
+ */
32
+ import type { CollectionConfig, Property, VectorDistance, VectorIndexConfig } from "@rebasepro/types";
33
+ import { isPostgresCollectionConfig } from "@rebasepro/types";
34
+ import { getTableName } from "@rebasepro/common";
35
+ import { toPostgresIdentifier } from "@rebasepro/utils";
36
+
37
+ /**
38
+ * The widest `vector` pgvector will build an HNSW or IVFFlat index over.
39
+ * Storage and exact search are unaffected by this limit.
40
+ */
41
+ export const MAX_INDEXABLE_VECTOR_DIMENSIONS = 2000;
42
+
43
+ /** The distance indexed when a property does not name one. */
44
+ export const DEFAULT_VECTOR_DISTANCE: VectorDistance = "cosine";
45
+
46
+ /** The index method used when a property does not name one. */
47
+ export const DEFAULT_VECTOR_INDEX_METHOD = "hnsw" as const;
48
+
49
+ /**
50
+ * Operator class per distance. These strings are part of the database contract:
51
+ * they appear in `CREATE INDEX`, so renaming one renames an index.
52
+ */
53
+ const OPERATOR_CLASS: Record<VectorDistance, string> = {
54
+ cosine: "vector_cosine_ops",
55
+ l2: "vector_l2_ops",
56
+ inner_product: "vector_ip_ops"
57
+ };
58
+
59
+ /** Short, stable tag per distance, used to name the index. */
60
+ const DISTANCE_TAG: Record<VectorDistance, string> = {
61
+ cosine: "cosine",
62
+ l2: "l2",
63
+ inner_product: "ip"
64
+ };
65
+
66
+ export interface VectorIndexSpec {
67
+ schema: string;
68
+ table: string;
69
+ column: string;
70
+ indexName: string;
71
+ method: "hnsw" | "ivfflat";
72
+ distance: VectorDistance;
73
+ operatorClass: string;
74
+ /** Rendered into `WITH (...)`; empty when every parameter is defaulted. */
75
+ parameters: Array<[string, number]>;
76
+ }
77
+
78
+ /** A vector column that will not be indexed, and why. */
79
+ export interface SkippedVectorIndex {
80
+ schema: string;
81
+ table: string;
82
+ column: string;
83
+ dimensions: number;
84
+ reason: string;
85
+ }
86
+
87
+ export interface VectorIndexPlan {
88
+ specs: VectorIndexSpec[];
89
+ skipped: SkippedVectorIndex[];
90
+ }
91
+
92
+ export class VectorIndexConfigError extends Error {
93
+ constructor(message: string) {
94
+ super(message);
95
+ this.name = "VectorIndexConfigError";
96
+ }
97
+ }
98
+
99
+ const isVectorProperty = (prop: unknown): prop is Property & {
100
+ type: "vector";
101
+ dimensions: number;
102
+ index?: VectorIndexConfig | false;
103
+ } => !!prop && typeof prop === "object" && (prop as { type?: string }).type === "vector";
104
+
105
+ const asDistances = (config: VectorIndexConfig, label: string): VectorDistance[] => {
106
+ const raw = config.distance ?? DEFAULT_VECTOR_DISTANCE;
107
+ const list = Array.isArray(raw) ? raw : [raw];
108
+ if (list.length === 0) {
109
+ throw new VectorIndexConfigError(
110
+ `${label}: \`index.distance\` is an empty array. Name at least one distance, or set \`index: false\` to create no index.`
111
+ );
112
+ }
113
+ const seen = new Set<VectorDistance>();
114
+ for (const distance of list) {
115
+ if (!(distance in OPERATOR_CLASS)) {
116
+ throw new VectorIndexConfigError(
117
+ `${label}: \`index.distance\` is "${distance}", which is not a pgvector distance. Use ${Object.keys(OPERATOR_CLASS).map(d => `"${d}"`).join(", ")}.`
118
+ );
119
+ }
120
+ if (seen.has(distance)) {
121
+ throw new VectorIndexConfigError(`${label}: \`index.distance\` lists "${distance}" twice.`);
122
+ }
123
+ seen.add(distance);
124
+ }
125
+ return list;
126
+ };
127
+
128
+ const assertPositiveInteger = (value: number | undefined, key: string, label: string): void => {
129
+ if (value === undefined) return;
130
+ if (!Number.isInteger(value) || value <= 0) {
131
+ throw new VectorIndexConfigError(
132
+ `${label}: \`index.${key}\` is ${JSON.stringify(value)}. It must be a positive integer.`
133
+ );
134
+ }
135
+ };
136
+
137
+ /**
138
+ * Index parameters for one method. Parameters belonging to the *other* method
139
+ * are rejected rather than ignored, because a silently dropped `lists` on an
140
+ * HNSW index reads, from the config, exactly like a tuned index.
141
+ */
142
+ const parametersFor = (
143
+ method: "hnsw" | "ivfflat",
144
+ config: VectorIndexConfig,
145
+ label: string
146
+ ): Array<[string, number]> => {
147
+ assertPositiveInteger(config.m, "m", label);
148
+ assertPositiveInteger(config.efConstruction, "efConstruction", label);
149
+ assertPositiveInteger(config.lists, "lists", label);
150
+
151
+ if (method === "hnsw") {
152
+ if (config.lists !== undefined) {
153
+ throw new VectorIndexConfigError(
154
+ `${label}: \`index.lists\` only applies to \`method: "ivfflat"\`. Remove it, or switch the method.`
155
+ );
156
+ }
157
+ const params: Array<[string, number]> = [];
158
+ if (config.m !== undefined) params.push(["m", config.m]);
159
+ if (config.efConstruction !== undefined) params.push(["ef_construction", config.efConstruction]);
160
+ return params;
161
+ }
162
+
163
+ for (const key of ["m", "efConstruction"] as const) {
164
+ if (config[key] !== undefined) {
165
+ throw new VectorIndexConfigError(
166
+ `${label}: \`index.${key}\` only applies to \`method: "hnsw"\`. Remove it, or switch the method.`
167
+ );
168
+ }
169
+ }
170
+ return config.lists !== undefined ? [["lists", config.lists]] : [];
171
+ };
172
+
173
+ /**
174
+ * Every ANN index a collection's vector properties call for.
175
+ *
176
+ * `resolveColumn` is passed in rather than imported so that this module stays
177
+ * free of the DDL generator, which imports *it*. Both callers hand it the same
178
+ * `resolveColumnName`, and a contract test asserts the names agree.
179
+ */
180
+ export const buildVectorIndexPlan = (
181
+ collection: CollectionConfig,
182
+ resolveColumn: (propName: string, prop?: Property | null) => string
183
+ ): VectorIndexPlan => {
184
+ const specs: VectorIndexSpec[] = [];
185
+ const skipped: SkippedVectorIndex[] = [];
186
+ const properties = collection.properties ?? {};
187
+
188
+ const table = getTableName(collection);
189
+ const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
190
+
191
+ for (const [propName, prop] of Object.entries(properties)) {
192
+ if (!isVectorProperty(prop)) continue;
193
+ if (prop.index === false) continue;
194
+
195
+ const label = `${collection.slug}.${propName}`;
196
+ const column = resolveColumn(propName, prop as Property);
197
+ const config: VectorIndexConfig = prop.index ?? {};
198
+ const method = config.method ?? DEFAULT_VECTOR_INDEX_METHOD;
199
+
200
+ if (method !== "hnsw" && method !== "ivfflat") {
201
+ throw new VectorIndexConfigError(
202
+ `${label}: \`index.method\` is "${method}". Use "hnsw" or "ivfflat".`
203
+ );
204
+ }
205
+
206
+ // Read before the dimension gate so a malformed block is reported even
207
+ // on a column too wide to index — the config is wrong either way.
208
+ const distances = asDistances(config, label);
209
+ const parameters = parametersFor(method, config, label);
210
+
211
+ if (!Number.isInteger(prop.dimensions) || prop.dimensions <= 0) {
212
+ throw new VectorIndexConfigError(
213
+ `${label}: \`dimensions\` is ${JSON.stringify(prop.dimensions)}. It must be a positive integer.`
214
+ );
215
+ }
216
+
217
+ if (prop.dimensions > MAX_INDEXABLE_VECTOR_DIMENSIONS) {
218
+ skipped.push({
219
+ schema,
220
+ table,
221
+ column,
222
+ dimensions: prop.dimensions,
223
+ reason:
224
+ `pgvector cannot index a vector wider than ${MAX_INDEXABLE_VECTOR_DIMENSIONS} dimensions, and ` +
225
+ `${label} declares ${prop.dimensions}. The column works and \`vectorSearch\` still answers, as an ` +
226
+ `exact scan. To index it, reduce the dimensions (many embedding models support a shorter output) ` +
227
+ `or set \`index: false\` to state that the scan is intended.`
228
+ });
229
+ continue;
230
+ }
231
+
232
+ for (const distance of distances) {
233
+ specs.push({
234
+ schema,
235
+ table,
236
+ column,
237
+ // Distance is in the name because one column may carry an index
238
+ // per distance, and two indexes cannot share a name. The method
239
+ // is in it because switching methods is a different index, not
240
+ // a redefinition of the same one — `CREATE INDEX IF NOT EXISTS`
241
+ // would otherwise keep the old one and report success.
242
+ indexName: toPostgresIdentifier(`${table}_${column}_${method}_${DISTANCE_TAG[distance]}`),
243
+ method,
244
+ distance,
245
+ operatorClass: OPERATOR_CLASS[distance],
246
+ parameters
247
+ });
248
+ }
249
+ }
250
+
251
+ return { specs, skipped };
252
+ };
253
+
254
+ /**
255
+ * The `CREATE INDEX` for one spec.
256
+ *
257
+ * `CONCURRENTLY` is deliberately absent, for the same reason it is absent from
258
+ * `searchIndexStatements`: this form is replayed as part of a migration, where
259
+ * a concurrent build is not allowed. The boot-time ensure rewrites it — see
260
+ * `ensureCollectionTables`.
261
+ */
262
+ export const vectorIndexStatement = (spec: VectorIndexSpec): string => {
263
+ const params = spec.parameters.length
264
+ ? ` WITH (${spec.parameters.map(([key, value]) => `${key} = ${value}`).join(", ")})`
265
+ : "";
266
+ return (
267
+ `CREATE INDEX IF NOT EXISTS "${spec.indexName}" ON "${spec.schema}"."${spec.table}" ` +
268
+ `USING ${spec.method} ("${spec.column}" ${spec.operatorClass})${params};`
269
+ );
270
+ };
271
+
272
+ /** Every statement for a plan, in a stable order. */
273
+ export const vectorIndexStatements = (plan: VectorIndexPlan): string[] =>
274
+ plan.specs.map(vectorIndexStatement);
275
+
276
+ /** The index names a plan creates — what the derived-names contract records. */
277
+ export const vectorIndexNames = (plan: VectorIndexPlan): string[] =>
278
+ plan.specs.map(spec => spec.indexName);