@rebasepro/server-postgres 0.17.3-canary.gdd23447 → 0.18.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.
- package/LICENSE +0 -1
- package/README.md +31 -0
- package/dist/backup-argv.d.ts +19 -0
- package/dist/branch-argv.d.ts +28 -0
- package/dist/branch-prune.d.ts +77 -0
- package/dist/cli-collections-path.d.ts +31 -0
- package/dist/cli-errors.d.ts +123 -0
- package/dist/cli-flags.d.ts +101 -0
- package/dist/cli-helpers.d.ts +94 -10
- package/dist/collection-index-C7LopMyX.js +247 -0
- package/dist/collection-index-C7LopMyX.js.map +1 -0
- package/dist/databasePoolManager.d.ts +27 -0
- package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
- package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
- package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
- package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.es.js +19307 -3059
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
- package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
- package/dist/schema/column-type-drift.d.ts +79 -0
- package/dist/schema/doctor.d.ts +42 -0
- package/dist/schema/ensure-collection-tables.d.ts +90 -0
- package/dist/schema/generate-next-step.d.ts +30 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
- package/dist/schema/generated-schema-staleness.d.ts +37 -0
- package/dist/schema/introspect-db-queries.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +63 -2
- package/dist/services/BranchService.d.ts +86 -1
- package/dist/services/collection-helpers.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +30 -2
- package/dist/services/row-pipeline.d.ts +12 -1
- package/dist/utils/pg-error-utils.d.ts +3 -1
- package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
- package/package.json +32 -27
- package/dist/backup-service-DCk7KhhL.js +0 -9044
- package/dist/backup-service-DCk7KhhL.js.map +0 -1
- package/dist/collection-index-BRUg10H5.js +0 -5690
- package/dist/collection-index-BRUg10H5.js.map +0 -1
- package/dist/connection-GOKU3Hu5.js +0 -277
- package/dist/connection-GOKU3Hu5.js.map +0 -1
- package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
- package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
- package/dist/src-DiB5RP2Z.js +0 -541
- package/dist/src-DiB5RP2Z.js.map +0 -1
- package/dist/websocket-BZ4H5wUz.js +0 -8198
- package/dist/websocket-BZ4H5wUz.js.map +0 -1
- package/src/PostgresAdapter.ts +0 -91
- package/src/PostgresBackendDriver.ts +0 -1857
- package/src/PostgresBootstrapper.ts +0 -1157
- package/src/auth/ensure-tables.ts +0 -896
- package/src/auth/schema-version.ts +0 -260
- package/src/auth/services.ts +0 -1610
- package/src/backup/backup-cli.ts +0 -520
- package/src/backup/backup-cron.ts +0 -212
- package/src/backup/backup-logic.ts +0 -93
- package/src/backup/backup-service.ts +0 -485
- package/src/backup/index.ts +0 -12
- package/src/backup/pg-tools.ts +0 -403
- package/src/backup/retention.ts +0 -86
- package/src/cli-errors.ts +0 -265
- package/src/cli-helpers.ts +0 -569
- package/src/cli-output.ts +0 -43
- package/src/cli.ts +0 -1339
- package/src/collections/PostgresCollectionRegistry.ts +0 -108
- package/src/collections/buildRegistry.ts +0 -68
- package/src/collections/validate-relations.ts +0 -402
- package/src/connection.ts +0 -333
- package/src/data-transformer.ts +0 -845
- package/src/databasePoolManager.ts +0 -95
- package/src/history/HistoryService.ts +0 -254
- package/src/history/ensure-history-table.ts +0 -53
- package/src/index.ts +0 -19
- package/src/interfaces.ts +0 -60
- package/src/module-dir.ts +0 -7
- package/src/schema/atlas-argv.ts +0 -94
- package/src/schema/auth-schema.ts +0 -298
- package/src/schema/auth-users-columns.ts +0 -131
- package/src/schema/carved-out-migration.ts +0 -404
- package/src/schema/classify-change.ts +0 -436
- package/src/schema/collection-index.ts +0 -427
- package/src/schema/collections-schema-version.ts +0 -103
- package/src/schema/destructive-sql.ts +0 -94
- package/src/schema/doctor-cli.ts +0 -66
- package/src/schema/doctor-policy-checks.ts +0 -105
- package/src/schema/doctor.ts +0 -837
- package/src/schema/drizzle-ddl.ts +0 -33
- package/src/schema/dynamic-tables.test.ts +0 -302
- package/src/schema/dynamic-tables.ts +0 -293
- package/src/schema/ensure-collection-policies.ts +0 -198
- package/src/schema/ensure-collection-tables.test.ts +0 -518
- package/src/schema/ensure-collection-tables.ts +0 -1267
- package/src/schema/generate-drizzle-schema-logic.ts +0 -957
- package/src/schema/generate-drizzle-schema.ts +0 -143
- package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
- package/src/schema/generate-postgres-ddl.ts +0 -180
- package/src/schema/generate-schema-commit.ts +0 -267
- package/src/schema/generated-schema-staleness.ts +0 -284
- package/src/schema/introspect-db-constraints.ts +0 -385
- package/src/schema/introspect-db-inference.ts +0 -255
- package/src/schema/introspect-db-logic.ts +0 -1387
- package/src/schema/introspect-db-naming.ts +0 -15
- package/src/schema/introspect-db-project.ts +0 -78
- package/src/schema/introspect-db-queries.ts +0 -326
- package/src/schema/introspect-db-structure.ts +0 -670
- package/src/schema/introspect-db-types.ts +0 -56
- package/src/schema/introspect-db.ts +0 -257
- package/src/schema/introspect-runtime.test.ts +0 -260
- package/src/schema/introspect-runtime.ts +0 -327
- package/src/schema/non-sql-collections.test.ts +0 -131
- package/src/schema/rls-bootstrap-sql.ts +0 -288
- package/src/schema/search-column.ts +0 -643
- package/src/schema/test-schema.ts +0 -11
- package/src/schema/vector-index.ts +0 -491
- package/src/security/anonymous-grants.test.ts +0 -73
- package/src/security/policy-drift.test.ts +0 -435
- package/src/security/policy-drift.ts +0 -473
- package/src/security/rls-enforcement.ts +0 -505
- package/src/services/BranchService.ts +0 -326
- package/src/services/FetchService.ts +0 -2115
- package/src/services/PersistService.ts +0 -557
- package/src/services/RelationService.ts +0 -978
- package/src/services/RelationWriteService.ts +0 -653
- package/src/services/cdc/CdcListener.ts +0 -103
- package/src/services/cdc/junction-tables.ts +0 -91
- package/src/services/cdc/trigger-cdc.ts +0 -173
- package/src/services/channel-bus/ChannelBus.ts +0 -44
- package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
- package/src/services/channel-bus/index.ts +0 -114
- package/src/services/channel-history.ts +0 -424
- package/src/services/channel-presence.ts +0 -185
- package/src/services/collection-helpers.ts +0 -420
- package/src/services/dataService.ts +0 -256
- package/src/services/index.ts +0 -14
- package/src/services/junction-writes.ts +0 -295
- package/src/services/nested-path.ts +0 -145
- package/src/services/pg-notify-listener.ts +0 -151
- package/src/services/realtimeService.ts +0 -2545
- package/src/services/row-pipeline.ts +0 -240
- package/src/services/write-denial.ts +0 -55
- package/src/types.ts +0 -4
- package/src/utils/connection-string.ts +0 -58
- package/src/utils/drizzle-conditions.ts +0 -2743
- package/src/utils/pg-array-null-patch.ts +0 -42
- package/src/utils/pg-error-utils.ts +0 -391
- package/src/websocket.ts +0 -855
|
@@ -1,491 +0,0 @@
|
|
|
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
|
-
* ## And why the column itself is described here too
|
|
33
|
-
*
|
|
34
|
-
* `VECTOR(n)` is a type Atlas cannot be shown. It has to materialise the
|
|
35
|
-
* desired state in a dev database to diff against, that database is created
|
|
36
|
-
* empty and *emptied again* by Atlas at the start of every run, and nothing in
|
|
37
|
-
* the free tier can put `CREATE EXTENSION vector` back — so a `schema.sql`
|
|
38
|
-
* mentioning the type fails with `type "vector" does not exist` on every push,
|
|
39
|
-
* for good. Search hit the same wall for its own reasons and took the same way
|
|
40
|
-
* out: the objects leave `schema.sql`, Atlas is told to exclude them, and
|
|
41
|
-
* Rebase applies them itself. See `generatePostgresVectorDdl`.
|
|
42
|
-
*/
|
|
43
|
-
import type { CollectionConfig, Property, VectorDistance, VectorIndexConfig } from "@rebasepro/types";
|
|
44
|
-
import { isPostgresCollectionConfig } from "@rebasepro/types";
|
|
45
|
-
import { getTableName } from "@rebasepro/common";
|
|
46
|
-
import { toPostgresIdentifier } from "@rebasepro/utils";
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* The widest `vector` pgvector will build an HNSW or IVFFlat index over.
|
|
50
|
-
* Storage and exact search are unaffected by this limit.
|
|
51
|
-
*/
|
|
52
|
-
export const MAX_INDEXABLE_VECTOR_DIMENSIONS = 2000;
|
|
53
|
-
|
|
54
|
-
/** The distance indexed when a property does not name one. */
|
|
55
|
-
export const DEFAULT_VECTOR_DISTANCE: VectorDistance = "cosine";
|
|
56
|
-
|
|
57
|
-
/** The index method used when a property does not name one. */
|
|
58
|
-
export const DEFAULT_VECTOR_INDEX_METHOD = "hnsw" as const;
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Operator class per distance. These strings are part of the database contract:
|
|
62
|
-
* they appear in `CREATE INDEX`, so renaming one renames an index.
|
|
63
|
-
*/
|
|
64
|
-
const OPERATOR_CLASS: Record<VectorDistance, string> = {
|
|
65
|
-
cosine: "vector_cosine_ops",
|
|
66
|
-
l2: "vector_l2_ops",
|
|
67
|
-
inner_product: "vector_ip_ops"
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
/** Short, stable tag per distance, used to name the index. */
|
|
71
|
-
const DISTANCE_TAG: Record<VectorDistance, string> = {
|
|
72
|
-
cosine: "cosine",
|
|
73
|
-
l2: "l2",
|
|
74
|
-
inner_product: "ip"
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
export interface VectorIndexSpec {
|
|
78
|
-
schema: string;
|
|
79
|
-
table: string;
|
|
80
|
-
column: string;
|
|
81
|
-
indexName: string;
|
|
82
|
-
method: "hnsw" | "ivfflat";
|
|
83
|
-
distance: VectorDistance;
|
|
84
|
-
operatorClass: string;
|
|
85
|
-
/** Rendered into `WITH (...)`; empty when every parameter is defaulted. */
|
|
86
|
-
parameters: Array<[string, number]>;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** A vector column that will not be indexed, and why. */
|
|
90
|
-
export interface SkippedVectorIndex {
|
|
91
|
-
schema: string;
|
|
92
|
-
table: string;
|
|
93
|
-
column: string;
|
|
94
|
-
dimensions: number;
|
|
95
|
-
reason: string;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export interface VectorIndexPlan {
|
|
99
|
-
specs: VectorIndexSpec[];
|
|
100
|
-
skipped: SkippedVectorIndex[];
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export class VectorIndexConfigError extends Error {
|
|
104
|
-
constructor(message: string) {
|
|
105
|
-
super(message);
|
|
106
|
-
this.name = "VectorIndexConfigError";
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const isVectorProperty = (prop: unknown): prop is Property & {
|
|
111
|
-
type: "vector";
|
|
112
|
-
dimensions: number;
|
|
113
|
-
index?: VectorIndexConfig | false;
|
|
114
|
-
} => !!prop && typeof prop === "object" && (prop as { type?: string }).type === "vector";
|
|
115
|
-
|
|
116
|
-
const asDistances = (config: VectorIndexConfig, label: string): VectorDistance[] => {
|
|
117
|
-
const raw = config.distance ?? DEFAULT_VECTOR_DISTANCE;
|
|
118
|
-
const list = Array.isArray(raw) ? raw : [raw];
|
|
119
|
-
if (list.length === 0) {
|
|
120
|
-
throw new VectorIndexConfigError(
|
|
121
|
-
`${label}: \`index.distance\` is an empty array. Name at least one distance, or set \`index: false\` to create no index.`
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
const seen = new Set<VectorDistance>();
|
|
125
|
-
for (const distance of list) {
|
|
126
|
-
if (!(distance in OPERATOR_CLASS)) {
|
|
127
|
-
throw new VectorIndexConfigError(
|
|
128
|
-
`${label}: \`index.distance\` is "${distance}", which is not a pgvector distance. Use ${Object.keys(OPERATOR_CLASS).map(d => `"${d}"`).join(", ")}.`
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
if (seen.has(distance)) {
|
|
132
|
-
throw new VectorIndexConfigError(`${label}: \`index.distance\` lists "${distance}" twice.`);
|
|
133
|
-
}
|
|
134
|
-
seen.add(distance);
|
|
135
|
-
}
|
|
136
|
-
return list;
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
const assertPositiveInteger = (value: number | undefined, key: string, label: string): void => {
|
|
140
|
-
if (value === undefined) return;
|
|
141
|
-
if (!Number.isInteger(value) || value <= 0) {
|
|
142
|
-
throw new VectorIndexConfigError(
|
|
143
|
-
`${label}: \`index.${key}\` is ${JSON.stringify(value)}. It must be a positive integer.`
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Index parameters for one method. Parameters belonging to the *other* method
|
|
150
|
-
* are rejected rather than ignored, because a silently dropped `lists` on an
|
|
151
|
-
* HNSW index reads, from the config, exactly like a tuned index.
|
|
152
|
-
*/
|
|
153
|
-
const parametersFor = (
|
|
154
|
-
method: "hnsw" | "ivfflat",
|
|
155
|
-
config: VectorIndexConfig,
|
|
156
|
-
label: string
|
|
157
|
-
): Array<[string, number]> => {
|
|
158
|
-
assertPositiveInteger(config.m, "m", label);
|
|
159
|
-
assertPositiveInteger(config.efConstruction, "efConstruction", label);
|
|
160
|
-
assertPositiveInteger(config.lists, "lists", label);
|
|
161
|
-
|
|
162
|
-
if (method === "hnsw") {
|
|
163
|
-
if (config.lists !== undefined) {
|
|
164
|
-
throw new VectorIndexConfigError(
|
|
165
|
-
`${label}: \`index.lists\` only applies to \`method: "ivfflat"\`. Remove it, or switch the method.`
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
const params: Array<[string, number]> = [];
|
|
169
|
-
if (config.m !== undefined) params.push(["m", config.m]);
|
|
170
|
-
if (config.efConstruction !== undefined) params.push(["ef_construction", config.efConstruction]);
|
|
171
|
-
return params;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
for (const key of ["m", "efConstruction"] as const) {
|
|
175
|
-
if (config[key] !== undefined) {
|
|
176
|
-
throw new VectorIndexConfigError(
|
|
177
|
-
`${label}: \`index.${key}\` only applies to \`method: "hnsw"\`. Remove it, or switch the method.`
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
return config.lists !== undefined ? [["lists", config.lists]] : [];
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Every ANN index a collection's vector properties call for.
|
|
186
|
-
*
|
|
187
|
-
* `resolveColumn` is passed in rather than imported so that this module stays
|
|
188
|
-
* free of the DDL generator, which imports *it*. Both callers hand it the same
|
|
189
|
-
* `resolveColumnName`, and a contract test asserts the names agree.
|
|
190
|
-
*/
|
|
191
|
-
export const buildVectorIndexPlan = (
|
|
192
|
-
collection: CollectionConfig,
|
|
193
|
-
resolveColumn: (propName: string, prop?: Property | null) => string
|
|
194
|
-
): VectorIndexPlan => {
|
|
195
|
-
const specs: VectorIndexSpec[] = [];
|
|
196
|
-
const skipped: SkippedVectorIndex[] = [];
|
|
197
|
-
const properties = collection.properties ?? {};
|
|
198
|
-
|
|
199
|
-
const table = getTableName(collection);
|
|
200
|
-
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
201
|
-
|
|
202
|
-
for (const [propName, prop] of Object.entries(properties)) {
|
|
203
|
-
if (!isVectorProperty(prop)) continue;
|
|
204
|
-
if (prop.index === false) continue;
|
|
205
|
-
|
|
206
|
-
const label = `${collection.slug}.${propName}`;
|
|
207
|
-
const column = resolveColumn(propName, prop as Property);
|
|
208
|
-
const config: VectorIndexConfig = prop.index ?? {};
|
|
209
|
-
const method = config.method ?? DEFAULT_VECTOR_INDEX_METHOD;
|
|
210
|
-
|
|
211
|
-
if (method !== "hnsw" && method !== "ivfflat") {
|
|
212
|
-
throw new VectorIndexConfigError(
|
|
213
|
-
`${label}: \`index.method\` is "${method}". Use "hnsw" or "ivfflat".`
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Read before the dimension gate so a malformed block is reported even
|
|
218
|
-
// on a column too wide to index — the config is wrong either way.
|
|
219
|
-
const distances = asDistances(config, label);
|
|
220
|
-
const parameters = parametersFor(method, config, label);
|
|
221
|
-
|
|
222
|
-
if (!Number.isInteger(prop.dimensions) || prop.dimensions <= 0) {
|
|
223
|
-
throw new VectorIndexConfigError(
|
|
224
|
-
`${label}: \`dimensions\` is ${JSON.stringify(prop.dimensions)}. It must be a positive integer.`
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
if (prop.dimensions > MAX_INDEXABLE_VECTOR_DIMENSIONS) {
|
|
229
|
-
skipped.push({
|
|
230
|
-
schema,
|
|
231
|
-
table,
|
|
232
|
-
column,
|
|
233
|
-
dimensions: prop.dimensions,
|
|
234
|
-
reason:
|
|
235
|
-
`pgvector cannot index a vector wider than ${MAX_INDEXABLE_VECTOR_DIMENSIONS} dimensions, and ` +
|
|
236
|
-
`${label} declares ${prop.dimensions}. The column works and \`vectorSearch\` still answers, as an ` +
|
|
237
|
-
`exact scan. To index it, reduce the dimensions (many embedding models support a shorter output) ` +
|
|
238
|
-
`or set \`index: false\` to state that the scan is intended.`
|
|
239
|
-
});
|
|
240
|
-
continue;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
for (const distance of distances) {
|
|
244
|
-
specs.push({
|
|
245
|
-
schema,
|
|
246
|
-
table,
|
|
247
|
-
column,
|
|
248
|
-
// Distance is in the name because one column may carry an index
|
|
249
|
-
// per distance, and two indexes cannot share a name. The method
|
|
250
|
-
// is in it because switching methods is a different index, not
|
|
251
|
-
// a redefinition of the same one — `CREATE INDEX IF NOT EXISTS`
|
|
252
|
-
// would otherwise keep the old one and report success.
|
|
253
|
-
indexName: toPostgresIdentifier(`${table}_${column}_${method}_${DISTANCE_TAG[distance]}`),
|
|
254
|
-
method,
|
|
255
|
-
distance,
|
|
256
|
-
operatorClass: OPERATOR_CLASS[distance],
|
|
257
|
-
parameters
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
return { specs, skipped };
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* The `CREATE INDEX` for one spec.
|
|
267
|
-
*
|
|
268
|
-
* `CONCURRENTLY` is deliberately absent, for the same reason it is absent from
|
|
269
|
-
* `searchIndexStatements`: this form is replayed as part of a migration, where
|
|
270
|
-
* a concurrent build is not allowed. The boot-time ensure rewrites it — see
|
|
271
|
-
* `ensureCollectionTables`.
|
|
272
|
-
*/
|
|
273
|
-
export const vectorIndexStatement = (spec: VectorIndexSpec): string => {
|
|
274
|
-
const params = spec.parameters.length
|
|
275
|
-
? ` WITH (${spec.parameters.map(([key, value]) => `${key} = ${value}`).join(", ")})`
|
|
276
|
-
: "";
|
|
277
|
-
return (
|
|
278
|
-
`CREATE INDEX IF NOT EXISTS "${spec.indexName}" ON "${spec.schema}"."${spec.table}" ` +
|
|
279
|
-
`USING ${spec.method} ("${spec.column}" ${spec.operatorClass})${params};`
|
|
280
|
-
);
|
|
281
|
-
};
|
|
282
|
-
|
|
283
|
-
/** Every statement for a plan, in a stable order. */
|
|
284
|
-
export const vectorIndexStatements = (plan: VectorIndexPlan): string[] =>
|
|
285
|
-
plan.specs.map(vectorIndexStatement);
|
|
286
|
-
|
|
287
|
-
/** The index names a plan creates — what the derived-names contract records. */
|
|
288
|
-
export const vectorIndexNames = (plan: VectorIndexPlan): string[] =>
|
|
289
|
-
plan.specs.map(spec => spec.indexName);
|
|
290
|
-
|
|
291
|
-
// ── The column, for the file Rebase applies itself ──────────────────────────
|
|
292
|
-
|
|
293
|
-
const quote = (v: string): string => `'${v.replace(/'/g, "''")}'`;
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* The schema pgvector's types are installed into.
|
|
297
|
-
*
|
|
298
|
-
* `WITH SCHEMA public` for the same load-bearing reason `searchExtensionStatements`
|
|
299
|
-
* gives: an unqualified `CREATE EXTENSION` lands in the first schema on
|
|
300
|
-
* `search_path`, which is `"$user", public` — and the scaffold's role is named
|
|
301
|
-
* `rebase`, the same as a schema the generator creates. Left to itself the
|
|
302
|
-
* extension would install into `rebase`, and every unqualified `VECTOR(n)`
|
|
303
|
-
* below would fail to resolve.
|
|
304
|
-
*/
|
|
305
|
-
export const VECTOR_EXTENSION_SCHEMA = "public";
|
|
306
|
-
|
|
307
|
-
/** The extension name a database has to name to let Rebase install pgvector. */
|
|
308
|
-
export const VECTOR_EXTENSION = "vector";
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* How a project says Rebase may install pgvector, quoted into the messages that
|
|
312
|
-
* have to name it. Spelled once so the option and the advice cannot drift.
|
|
313
|
-
*/
|
|
314
|
-
export const VECTOR_EXTENSION_OPT_IN = `database({ extensions: ["${VECTOR_EXTENSION}"] })`;
|
|
315
|
-
|
|
316
|
-
/** Did the project give Rebase leave to install pgvector? */
|
|
317
|
-
export const vectorExtensionDeclared = (extensions: readonly string[] | undefined): boolean =>
|
|
318
|
-
(extensions ?? []).includes(VECTOR_EXTENSION);
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Install pgvector — emitted only when the database asked for it.
|
|
322
|
-
*
|
|
323
|
-
* Opt-in because installing an extension is a decision with a deployment behind
|
|
324
|
-
* it: the image has to ship the library, the role has to be allowed to install
|
|
325
|
-
* it, and a managed provider has to have it on an allow-list. None of that is
|
|
326
|
-
* visible from inside the connection, so Rebase does not decide it. See
|
|
327
|
-
* `DatabaseOptions.extensions`.
|
|
328
|
-
*
|
|
329
|
-
* Withholding the statement is not withholding the *column*: the column is
|
|
330
|
-
* still created, and Postgres refuses it with `type "vector" does not exist`
|
|
331
|
-
* on a database where pgvector was never installed by hand. That error is the
|
|
332
|
-
* one this design accepts, and `vectorExtensionHint` is what makes it name
|
|
333
|
-
* {@link VECTOR_EXTENSION_OPT_IN} rather than nothing.
|
|
334
|
-
*/
|
|
335
|
-
export const vectorExtensionStatement = (): string =>
|
|
336
|
-
`CREATE EXTENSION IF NOT EXISTS ${VECTOR_EXTENSION} WITH SCHEMA ${VECTOR_EXTENSION_SCHEMA};`;
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* The missing-pgvector explanation, appended to whichever error revealed it.
|
|
340
|
-
*
|
|
341
|
-
* Two readers, needing opposite things, which is why the branch is on the error
|
|
342
|
-
* text rather than on the configuration:
|
|
343
|
-
*
|
|
344
|
-
* - **`type "vector" does not exist`** — nobody opted in and the database has
|
|
345
|
-
* no pgvector. The fix is one line of config they cannot guess, so naming
|
|
346
|
-
* the option *is* the hint.
|
|
347
|
-
* - **the install itself failed** — the config is already right, and repeating
|
|
348
|
-
* the option would send them to edit a correct line. What is missing is the
|
|
349
|
-
* library on the server, or the grant.
|
|
350
|
-
*
|
|
351
|
-
* Lives here rather than beside either caller because both need it: the boot
|
|
352
|
-
* ensure raises the first through its action applier, and `rebase db push`
|
|
353
|
-
* raises it out of `applyVectorDdl`. A hint on only one path is how a bare
|
|
354
|
-
* `type "vector" does not exist` reaches somebody — which is the thing this
|
|
355
|
-
* exists to prevent.
|
|
356
|
-
*/
|
|
357
|
-
export const vectorExtensionHint = (message: string): string => {
|
|
358
|
-
const installRefused = /extension "vector" is not available/i.test(message)
|
|
359
|
-
|| /(permission denied to create extension|must be (superuser|owner).{0,40}extension)/i.test(message);
|
|
360
|
-
if (installRefused) {
|
|
361
|
-
return (
|
|
362
|
-
"\n pgvector was declared and could not be installed. It is a server extension, so it needs an image " +
|
|
363
|
-
"that ships the library (the scaffold's `pgvector/pgvector:pg18` does; a stock `postgres:18` does not) " +
|
|
364
|
-
"and a role allowed to run `CREATE EXTENSION vector;`. Managed Postgres usually allows it once the " +
|
|
365
|
-
"extension is on the provider's allow-list."
|
|
366
|
-
);
|
|
367
|
-
}
|
|
368
|
-
if (!/type "(vector|halfvec|sparsevec)" does not exist/i.test(message)) return "";
|
|
369
|
-
return (
|
|
370
|
-
"\n pgvector is not installed on this database, and Rebase installs it only where a database says it may: " +
|
|
371
|
-
`add \`${VECTOR_EXTENSION_OPT_IN}\` in config/resources.ts, or install it once by hand with ` +
|
|
372
|
-
"`CREATE EXTENSION vector;`. Either way the server needs an image that ships the library — the scaffold's " +
|
|
373
|
-
"`pgvector/pgvector:pg18` does, a stock `postgres:18` does not. Rebase then creates the column and its ANN " +
|
|
374
|
-
"index automatically — see the `index` option on the property."
|
|
375
|
-
);
|
|
376
|
-
};
|
|
377
|
-
|
|
378
|
-
/** One `{ type: "vector" }` property, as a column. */
|
|
379
|
-
export interface VectorColumnSpec {
|
|
380
|
-
schema: string;
|
|
381
|
-
table: string;
|
|
382
|
-
column: string;
|
|
383
|
-
dimensions: number;
|
|
384
|
-
/** Rendered after the type, in the generator's order: UNIQUE then NOT NULL. */
|
|
385
|
-
modifiers: string;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/**
|
|
389
|
-
* Every vector column a collection declares.
|
|
390
|
-
*
|
|
391
|
-
* Wider than {@link buildVectorIndexPlan} on purpose: that one answers "what
|
|
392
|
-
* gets an ANN index", and skips both a property with `index: false` and one too
|
|
393
|
-
* wide to index. Either still needs its column.
|
|
394
|
-
*/
|
|
395
|
-
export const buildVectorColumnSpecs = (
|
|
396
|
-
collection: CollectionConfig,
|
|
397
|
-
resolveColumn: (propName: string, prop?: Property | null) => string
|
|
398
|
-
): VectorColumnSpec[] => {
|
|
399
|
-
const table = getTableName(collection);
|
|
400
|
-
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
401
|
-
const specs: VectorColumnSpec[] = [];
|
|
402
|
-
|
|
403
|
-
for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
|
|
404
|
-
if (!isVectorProperty(prop)) continue;
|
|
405
|
-
// Assembled in the CREATE TABLE generator's order, so the column this
|
|
406
|
-
// file adds and the column `schema.sql` used to declare are the same
|
|
407
|
-
// column. A vector property is never a primary key and carries no
|
|
408
|
-
// SQL-level default, which is what leaves only these two.
|
|
409
|
-
const validation = (prop as { validation?: { required?: boolean; unique?: boolean } }).validation;
|
|
410
|
-
let modifiers = "";
|
|
411
|
-
if (validation?.unique) modifiers += " UNIQUE";
|
|
412
|
-
if (validation?.required) modifiers += " NOT NULL";
|
|
413
|
-
specs.push({
|
|
414
|
-
schema,
|
|
415
|
-
table,
|
|
416
|
-
column: resolveColumn(propName, prop as Property),
|
|
417
|
-
dimensions: prop.dimensions,
|
|
418
|
-
modifiers
|
|
419
|
-
});
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
return specs;
|
|
423
|
-
};
|
|
424
|
-
|
|
425
|
-
/** The column type — the one place `VECTOR(n)` is spelled. */
|
|
426
|
-
export const vectorColumnType = (spec: Pick<VectorColumnSpec, "dimensions">): string =>
|
|
427
|
-
`VECTOR(${spec.dimensions})`;
|
|
428
|
-
|
|
429
|
-
/** The column definition as it appears inside `CREATE TABLE`. */
|
|
430
|
-
export const vectorColumnDefinition = (spec: VectorColumnSpec): string =>
|
|
431
|
-
`"${spec.column}" ${vectorColumnType(spec)}${spec.modifiers}`;
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* Refuse — or perform — a `dimensions` change that `ADD COLUMN IF NOT EXISTS`
|
|
435
|
-
* would otherwise swallow.
|
|
436
|
-
*
|
|
437
|
-
* Without this the file *launders* the change: the ADD is a no-op against a
|
|
438
|
-
* column that exists, so a project that went from 384 to 768 dimensions would
|
|
439
|
-
* push clean, keep a 384-wide column, and fail on the next insert with a
|
|
440
|
-
* message about the row rather than about the config. Atlas used to catch this
|
|
441
|
-
* — it owned the column and planned the `ALTER … TYPE` — and taking the column
|
|
442
|
-
* out of its sight is exactly what makes the guard necessary.
|
|
443
|
-
*
|
|
444
|
-
* The widening is performed when the column holds no values, because that is
|
|
445
|
-
* the case Atlas handled and it is the common one: a developer changing
|
|
446
|
-
* embedding models before there are any embeddings. With values present the
|
|
447
|
-
* conversion is pgvector's to reject — every stored vector is the old width —
|
|
448
|
-
* so this refuses first and names the statement to run afterwards.
|
|
449
|
-
*
|
|
450
|
-
* `atttypmod` on a `vector` column *is* the dimension count: pgvector stores it
|
|
451
|
-
* directly rather than offsetting it the way `varchar` does. `-1` means the
|
|
452
|
-
* column was declared as a bare `vector`, which is drift in the same sense.
|
|
453
|
-
*/
|
|
454
|
-
export const vectorDimensionGuard = (spec: VectorColumnSpec): string => {
|
|
455
|
-
const relation = `"${spec.schema}"."${spec.table}"`;
|
|
456
|
-
return `DO $rebase_vector$
|
|
457
|
-
DECLARE actual int;
|
|
458
|
-
BEGIN
|
|
459
|
-
SELECT a.atttypmod INTO actual
|
|
460
|
-
FROM pg_attribute a
|
|
461
|
-
JOIN pg_type t ON t.oid = a.atttypid
|
|
462
|
-
WHERE a.attrelid = ${quote(relation)}::regclass
|
|
463
|
-
AND a.attname = ${quote(spec.column)}
|
|
464
|
-
AND NOT a.attisdropped
|
|
465
|
-
AND t.typname = 'vector';
|
|
466
|
-
IF actual IS NOT NULL AND actual <> ${spec.dimensions} THEN
|
|
467
|
-
IF EXISTS (SELECT 1 FROM ${relation} WHERE "${spec.column}" IS NOT NULL) THEN
|
|
468
|
-
RAISE EXCEPTION 'Rebase: ${spec.schema}.${spec.table}."${spec.column}" declares ${spec.dimensions} dimensions but the column is vector(%), and it already holds values of the old width. pgvector cannot convert them. Re-embed the rows at ${spec.dimensions} dimensions (or clear the column), then re-apply: ALTER TABLE ${relation} ALTER COLUMN "${spec.column}" TYPE ${vectorColumnType(spec)};', actual;
|
|
469
|
-
END IF;
|
|
470
|
-
ALTER TABLE ${relation} ALTER COLUMN "${spec.column}" TYPE ${vectorColumnType(spec)};
|
|
471
|
-
END IF;
|
|
472
|
-
END
|
|
473
|
-
$rebase_vector$;`;
|
|
474
|
-
};
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
* The object names a collection's vector properties own, unqualified.
|
|
478
|
-
*
|
|
479
|
-
* What Atlas has to be told to exclude. Only the column and the ANN indexes: a
|
|
480
|
-
* `UNIQUE` or `NOT NULL` on the column is a property *of* the column, and
|
|
481
|
-
* excluding the column takes them with it — measured against Atlas 1.2.3 and
|
|
482
|
-
* 1.3.2, where a target carrying `vector(3) NOT NULL UNIQUE` and a `schema.sql`
|
|
483
|
-
* carrying neither reported "Schema is synced, no changes to be made".
|
|
484
|
-
*/
|
|
485
|
-
export const vectorObjectNames = (
|
|
486
|
-
collection: CollectionConfig,
|
|
487
|
-
resolveColumn: (propName: string, prop?: Property | null) => string
|
|
488
|
-
): string[] => [
|
|
489
|
-
...buildVectorColumnSpecs(collection, resolveColumn).map(spec => spec.column),
|
|
490
|
-
...vectorIndexNames(buildVectorIndexPlan(collection, resolveColumn))
|
|
491
|
-
];
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, jest, afterEach } from "@jest/globals";
|
|
2
|
-
import { logger } from "@rebasepro/server";
|
|
3
|
-
|
|
4
|
-
import { warnOnAnonymousGrants } from "./rls-enforcement";
|
|
5
|
-
|
|
6
|
-
/** Capture what the operator would actually see at boot. */
|
|
7
|
-
function captureWarning(fn: () => void): string | null {
|
|
8
|
-
const spy = jest.spyOn(logger, "warn").mockImplementation(() => undefined as never);
|
|
9
|
-
try {
|
|
10
|
-
fn();
|
|
11
|
-
return spy.mock.calls.length > 0 ? String(spy.mock.calls[0][0]) : null;
|
|
12
|
-
} finally {
|
|
13
|
-
spy.mockRestore();
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const collectionWith = (slug: string, using: string) => ({
|
|
18
|
-
slug,
|
|
19
|
-
securityRules: [{ name: "authenticated_access", operation: "all", using, withCheck: using }]
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
afterEach(() => jest.restoreAllMocks());
|
|
23
|
-
|
|
24
|
-
describe("warnOnAnonymousGrants", () => {
|
|
25
|
-
// The rule found in a real app, across 8 collections of PII. It reads as
|
|
26
|
-
// "any logged-in user" and grants every anonymous visitor read and write.
|
|
27
|
-
const FROM_THE_WILD = "auth.uid() IS NOT NULL AND auth.uid() != 'anon'";
|
|
28
|
-
|
|
29
|
-
it("reports both mistakes in the rule from the wild", () => {
|
|
30
|
-
const warning = captureWarning(() => warnOnAnonymousGrants([collectionWith("talents", FROM_THE_WILD)]));
|
|
31
|
-
|
|
32
|
-
// The rule is written in the pre-1.0 spelling and must still be
|
|
33
|
-
// detected; the advice is printed in the current one.
|
|
34
|
-
expect(warning).toContain("rebase.uid() IS NOT NULL");
|
|
35
|
-
expect(warning).toContain("'anon' is a Supabase convention");
|
|
36
|
-
expect(warning).toContain("policy.authenticated()");
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("groups by mistake and names every collection it appears on", () => {
|
|
40
|
-
const warning = captureWarning(() => warnOnAnonymousGrants(
|
|
41
|
-
["talents", "companies", "contact_messages"].map(s => collectionWith(s, FROM_THE_WILD))
|
|
42
|
-
));
|
|
43
|
-
|
|
44
|
-
expect(warning).toContain("talents");
|
|
45
|
-
expect(warning).toContain("companies");
|
|
46
|
-
expect(warning).toContain("contact_messages");
|
|
47
|
-
// Grouped, not repeated once per collection: the same habit across 8
|
|
48
|
-
// collections should not print the same paragraph 16 times.
|
|
49
|
-
expect(warning!.match(/Supabase convention/g)).toHaveLength(1);
|
|
50
|
-
expect(warning).toContain("3 rule(s)");
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("catches the bare tautology, which carries no literal to notice", () => {
|
|
54
|
-
const warning = captureWarning(() => warnOnAnonymousGrants([collectionWith("talents", "auth.uid() IS NOT NULL")]));
|
|
55
|
-
|
|
56
|
-
expect(warning).toContain("rebase.uid() IS NOT NULL");
|
|
57
|
-
expect(warning).not.toContain("Supabase convention");
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("says nothing about rules that are actually correct", () => {
|
|
61
|
-
const warning = captureWarning(() => warnOnAnonymousGrants([
|
|
62
|
-
{ slug: "talents", securityRules: [{ name: "own_rows", operation: "all", ownerField: "owner_id" }] },
|
|
63
|
-
collectionWith("posts", `auth.uid() != 'anonymous'`),
|
|
64
|
-
{ slug: "public_page", securityRules: [{ name: "read", operation: "select", access: "public" }] }
|
|
65
|
-
] as never));
|
|
66
|
-
|
|
67
|
-
expect(warning).toBeNull();
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("says nothing when no collection has rules at all", () => {
|
|
71
|
-
expect(captureWarning(() => warnOnAnonymousGrants([{ slug: "empty" }]))).toBeNull();
|
|
72
|
-
});
|
|
73
|
-
});
|