@rebasepro/server-postgres 0.16.1-canary.gef08a6e → 0.17.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/dist/{backup-service-BL5x6Fj5.js → backup-service-BtgHxfFm.js} +2 -1
- package/dist/{backup-service-BL5x6Fj5.js.map → backup-service-BtgHxfFm.js.map} +1 -1
- package/dist/cli-helpers.d.ts +41 -0
- package/dist/{ensure-collection-tables-DpGX_25A.js → collection-index-DxJBvVTH.js} +240 -1913
- package/dist/collection-index-DxJBvVTH.js.map +1 -0
- package/dist/{ensure-collection-policies-RK8-SFLs.js → ensure-collection-policies-DFpOl8SM.js} +3 -3
- package/dist/{ensure-collection-policies-RK8-SFLs.js.map → ensure-collection-policies-DFpOl8SM.js.map} +1 -1
- package/dist/ensure-collection-tables-DMjOkeRy.js +1952 -0
- package/dist/ensure-collection-tables-DMjOkeRy.js.map +1 -0
- package/dist/index.es.js +10 -9
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-enforcement-da7ekLw-.js → rls-enforcement-CInuYj1-.js} +2 -2
- package/dist/{rls-enforcement-da7ekLw-.js.map → rls-enforcement-CInuYj1-.js.map} +1 -1
- package/dist/schema/collection-index.d.ts +182 -0
- package/dist/schema/introspect-db-inference.d.ts +1 -1
- package/dist/schema/introspect-db-logic.d.ts +4 -4
- package/dist/schema/introspect-db-project.d.ts +2 -2
- package/dist/src-DiDgtX8P.js.map +1 -1
- package/dist/{websocket-6b7Iy4TP.js → websocket-HcyLl1ZM.js} +5 -4
- package/dist/{websocket-6b7Iy4TP.js.map → websocket-HcyLl1ZM.js.map} +1 -1
- package/package.json +6 -6
- package/src/cli-helpers.ts +114 -0
- package/src/cli.ts +22 -0
- package/src/schema/collection-index.ts +427 -0
- package/src/schema/ensure-collection-tables.ts +21 -0
- package/src/schema/generate-postgres-ddl-logic.ts +17 -5
- package/src/schema/introspect-db-inference.ts +1 -1
- package/src/schema/introspect-db-logic.ts +4 -4
- package/src/schema/introspect-db-project.ts +2 -2
- package/src/schema/introspect-db.ts +2 -2
- package/src/services/realtimeService.ts +17 -4
- package/src/websocket.ts +1 -1
- package/dist/ensure-collection-tables-DpGX_25A.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/server-postgres",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.17.0",
|
|
5
5
|
"description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
"execa": "^9.6.1",
|
|
48
48
|
"pg": "^8.22.0",
|
|
49
49
|
"ws": "^8.21.1",
|
|
50
|
-
"@rebasepro/codegen": "0.
|
|
51
|
-
"@rebasepro/
|
|
52
|
-
"@rebasepro/
|
|
53
|
-
"@rebasepro/
|
|
54
|
-
"@rebasepro/
|
|
50
|
+
"@rebasepro/codegen": "0.17.0",
|
|
51
|
+
"@rebasepro/server": "0.17.0",
|
|
52
|
+
"@rebasepro/utils": "0.17.0",
|
|
53
|
+
"@rebasepro/types": "0.17.0",
|
|
54
|
+
"@rebasepro/common": "0.17.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@hono/node-server": "^2.0.12",
|
package/src/cli-helpers.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { createRequire } from "module";
|
|
|
6
6
|
import readline from "readline";
|
|
7
7
|
import { pathToFileURL } from "url";
|
|
8
8
|
import chalk from "chalk";
|
|
9
|
+
import { isRebaseIndexName } from "./schema/collection-index";
|
|
9
10
|
import { out, outWarn } from "./cli-output";
|
|
10
11
|
import type { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
|
|
11
12
|
import { moduleDir as __helpersDirname } from "./module-dir";
|
|
@@ -306,6 +307,119 @@ export async function seedDevDatabaseSearchHelpers(
|
|
|
306
307
|
* catalogs. Separated from {@link getTableExcludes} so its failure mode can
|
|
307
308
|
* be handled explicitly (fail closed) and so tests can inject a stub.
|
|
308
309
|
*/
|
|
310
|
+
/**
|
|
311
|
+
* Every index Postgres holds on a table Rebase manages, as
|
|
312
|
+
* `schema.table.index`.
|
|
313
|
+
*
|
|
314
|
+
* Constraint-backed indexes are left out: a `PRIMARY KEY` or a `UNIQUE` is a
|
|
315
|
+
* *constraint* to Atlas, diffed from the constraint declaration, and naming its
|
|
316
|
+
* index in an exclude would shield the constraint itself.
|
|
317
|
+
*/
|
|
318
|
+
export async function queryExistingIndexes(databaseUrl: string): Promise<string[]> {
|
|
319
|
+
const { Client } = await import("pg");
|
|
320
|
+
const client = new Client({ connectionString: databaseUrl });
|
|
321
|
+
await client.connect();
|
|
322
|
+
try {
|
|
323
|
+
const res = await client.query(`
|
|
324
|
+
SELECT n.nspname || '.' || t.relname || '.' || i.relname AS full_name
|
|
325
|
+
FROM pg_index x
|
|
326
|
+
JOIN pg_class i ON i.oid = x.indexrelid
|
|
327
|
+
JOIN pg_class t ON t.oid = x.indrelid
|
|
328
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
329
|
+
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
330
|
+
AND t.relkind IN ('r', 'p')
|
|
331
|
+
AND NOT EXISTS (
|
|
332
|
+
SELECT 1 FROM pg_constraint c WHERE c.conindid = i.oid
|
|
333
|
+
);
|
|
334
|
+
`);
|
|
335
|
+
return res.rows.map((row: { full_name: string }) => row.full_name);
|
|
336
|
+
} finally {
|
|
337
|
+
await client.end();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Glob patterns keeping Atlas away from indexes that are not Rebase's.
|
|
343
|
+
*
|
|
344
|
+
* The problem this solves is live and predates the `indexes:` block. `db push`
|
|
345
|
+
* is declarative, so an index on a managed table that is absent from
|
|
346
|
+
* `schema.sql` is drift — and Atlas plans `DROP INDEX` for it. Verified
|
|
347
|
+
* against atlas v1.2.3: create one by hand, re-run an unchanged push, and the
|
|
348
|
+
* plan is a bare drop. `DROP INDEX` is not in `DESTRUCTIVE_PATTERNS`, so the
|
|
349
|
+
* auto-approved apply took it without asking. Until now the only way to have
|
|
350
|
+
* an index at all was to write it by hand, which made that the *only* outcome.
|
|
351
|
+
*
|
|
352
|
+
* Ownership rather than a prompt, because once indexes are declarable a drop
|
|
353
|
+
* is usually correct: removing one from the config should remove it from the
|
|
354
|
+
* database, quietly. What must never be dropped is an index Rebase never
|
|
355
|
+
* created — a hand-written one, or one an introspected database arrived with.
|
|
356
|
+
* {@link isRebaseIndexName} is the test, the same arrangement
|
|
357
|
+
* `isGeneratedPolicyName` uses to let policy reconciliation drop only what it
|
|
358
|
+
* generated.
|
|
359
|
+
*
|
|
360
|
+
* The named-in-the-desired-state check comes first and is what makes removal
|
|
361
|
+
* work: a declared index that the author has just deleted still matches the
|
|
362
|
+
* name pattern, is no longer in the plan, and so is *not* excluded — Atlas
|
|
363
|
+
* drops it, as intended.
|
|
364
|
+
*
|
|
365
|
+
* Three-part `schema.table.index`, never two: a two-part pattern reads as a
|
|
366
|
+
* *table* named `<index>` in a *schema* named `<table>`, matches nothing, and
|
|
367
|
+
* reports no error — the trap already recorded for the search excludes.
|
|
368
|
+
*/
|
|
369
|
+
export async function getForeignIndexExcludes(
|
|
370
|
+
databaseUrl: string,
|
|
371
|
+
collectionsPath: string,
|
|
372
|
+
deps: {
|
|
373
|
+
queryExistingIndexes?: (databaseUrl: string) => Promise<string[]>;
|
|
374
|
+
getManagedIndexNames?: (collectionsPath: string) => Promise<Set<string>>;
|
|
375
|
+
} = {}
|
|
376
|
+
): Promise<string[]> {
|
|
377
|
+
const queryIndexes = deps.queryExistingIndexes ?? queryExistingIndexes;
|
|
378
|
+
const getManaged = deps.getManagedIndexNames ?? managedIndexNames;
|
|
379
|
+
|
|
380
|
+
const managed = await getManaged(collectionsPath);
|
|
381
|
+
|
|
382
|
+
let existing: string[];
|
|
383
|
+
try {
|
|
384
|
+
existing = await queryIndexes(databaseUrl);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
// Fails CLOSED, like getTableExcludes: without the catalogue we cannot
|
|
387
|
+
// tell a foreign index from one of ours, and guessing wrong destroys
|
|
388
|
+
// an index somebody is relying on.
|
|
389
|
+
throw new ExcludeIntrospectionError(
|
|
390
|
+
`Failed to introspect the database for unmanaged indexes: ${err instanceof Error ? err.message : String(err)}`,
|
|
391
|
+
err
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return existing.filter(full => {
|
|
396
|
+
const indexName = full.slice(full.lastIndexOf(".") + 1);
|
|
397
|
+
if (managed.has(indexName)) return false; // ours, and still declared
|
|
398
|
+
return !isRebaseIndexName(indexName); // ours, but no longer declared -> let it drop
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The index names the current collections would create — search, vector and
|
|
404
|
+
* declared alike. Anything Atlas would emit is by definition not foreign.
|
|
405
|
+
*/
|
|
406
|
+
async function managedIndexNames(collectionsPath: string): Promise<Set<string>> {
|
|
407
|
+
const collections = await loadCollectionsForCli(collectionsPath);
|
|
408
|
+
const { resolveColumnName, searchExcludePatterns } = await import("./schema/generate-postgres-ddl-logic");
|
|
409
|
+
const { buildCollectionIndexPlan } = await import("./schema/collection-index");
|
|
410
|
+
|
|
411
|
+
const names = new Set<string>(
|
|
412
|
+
buildCollectionIndexPlan(collections, resolveColumnName).map(spec => spec.indexName)
|
|
413
|
+
);
|
|
414
|
+
// Search objects are excluded from Atlas wholesale by their own patterns;
|
|
415
|
+
// listing them here too is harmless and keeps this the single answer to
|
|
416
|
+
// "is this index one of ours".
|
|
417
|
+
for (const pattern of searchExcludePatterns(collections)) {
|
|
418
|
+
names.add(pattern.slice(pattern.lastIndexOf(".") + 1));
|
|
419
|
+
}
|
|
420
|
+
return names;
|
|
421
|
+
}
|
|
422
|
+
|
|
309
423
|
export async function queryExistingTables(databaseUrl: string): Promise<string[]> {
|
|
310
424
|
const { Client } = await import("pg");
|
|
311
425
|
const client = new Client({ connectionString: databaseUrl });
|
package/src/cli.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
readSearchDdl,
|
|
19
19
|
seedDevDatabaseSearchHelpers,
|
|
20
20
|
getTableExcludes,
|
|
21
|
+
getForeignIndexExcludes,
|
|
21
22
|
ExcludeIntrospectionError,
|
|
22
23
|
promptConfirm
|
|
23
24
|
} from "./cli-helpers";
|
|
@@ -872,6 +873,27 @@ async function runAtlas(
|
|
|
872
873
|
for (const exc of excludes) {
|
|
873
874
|
atlasArgs.push("--exclude", exc);
|
|
874
875
|
}
|
|
876
|
+
|
|
877
|
+
// And the indexes on those tables that Rebase did not create. Same
|
|
878
|
+
// fail-closed contract as the table list above, for the same reason: a
|
|
879
|
+
// partial answer here silently drops somebody's index.
|
|
880
|
+
let indexExcludes: string[];
|
|
881
|
+
try {
|
|
882
|
+
indexExcludes = await getForeignIndexExcludes(databaseUrl, collectionsPath);
|
|
883
|
+
} catch (err) {
|
|
884
|
+
if (err instanceof ExcludeIntrospectionError) {
|
|
885
|
+
outError(chalk.red("\n✗ Aborting push: could not determine which indexes to protect."));
|
|
886
|
+
outError(chalk.gray(` ${err.message}`));
|
|
887
|
+
outError(chalk.gray(" Refusing to apply — a partial exclude list could drop an index Rebase does not manage."));
|
|
888
|
+
const hint = diagnoseDbError(err.cause ?? err, databaseUrl);
|
|
889
|
+
if (hint) outError(hint);
|
|
890
|
+
process.exit(1);
|
|
891
|
+
}
|
|
892
|
+
throw err;
|
|
893
|
+
}
|
|
894
|
+
for (const exc of indexExcludes) {
|
|
895
|
+
atlasArgs.push("--exclude", exc);
|
|
896
|
+
}
|
|
875
897
|
}
|
|
876
898
|
|
|
877
899
|
// Stream stdout live but tee stderr so we can inspect Atlas's error text
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place a collection's `indexes:` block becomes `CREATE INDEX`.
|
|
3
|
+
*
|
|
4
|
+
* Like `search-column.ts` and `vector-index.ts`, this module exists so the DDL
|
|
5
|
+
* generator and the boot-time ensure render the *same* specification rather
|
|
6
|
+
* than describing the same index twice, differently.
|
|
7
|
+
*
|
|
8
|
+
* ## Why every form here is core Postgres
|
|
9
|
+
*
|
|
10
|
+
* `rebase db push` runs `atlas schema apply`, which materialises the desired
|
|
11
|
+
* state in a bare scratch database to plan against. `--exclude` does not
|
|
12
|
+
* suppress that replay, and `CREATE EXTENSION` cannot go in the file. So an
|
|
13
|
+
* index wanting `gin_trgm_ops` or `vector_cosine_ops` would parse, plan, and
|
|
14
|
+
* then fail against a database the author has never heard of. Those are
|
|
15
|
+
* refused here instead, and redirected to the feature that owns them: trigram
|
|
16
|
+
* search is `search:`, ANN is a `vector` property.
|
|
17
|
+
*
|
|
18
|
+
* Verified against atlas v1.2.3 and Postgres 18 before this was written: plain,
|
|
19
|
+
* composite with `DESC NULLS LAST`, partial, unique, covering `INCLUDE`, `GIN`
|
|
20
|
+
* and expression indexes all parse, apply, and re-plan clean. The Atlas
|
|
21
|
+
* limitation that forced the search carve-out is that it will not parse a file
|
|
22
|
+
* containing a function *definition* — a function *call* inside an index is
|
|
23
|
+
* fine. That is why this module needs no carve-out and search did.
|
|
24
|
+
*
|
|
25
|
+
* ## Why the name carries a hash
|
|
26
|
+
*
|
|
27
|
+
* `CREATE INDEX IF NOT EXISTS` is a **name** check, not a definition check. A
|
|
28
|
+
* readable name means a changed declaration keeps the old index and reports
|
|
29
|
+
* success, forever. Hashing the index's *semantics* into its name makes a
|
|
30
|
+
* redefinition a different object: the new one is built before the old one is
|
|
31
|
+
* dropped, there is never a window with no index, and drift detection reduces
|
|
32
|
+
* to a set difference over names.
|
|
33
|
+
*/
|
|
34
|
+
import type { CollectionConfig, CollectionIndex, IndexPredicate, Property, ResolvedRelation } from "@rebasepro/types";
|
|
35
|
+
import { isPostgresCollectionConfig } from "@rebasepro/types";
|
|
36
|
+
import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
|
|
37
|
+
import { sha1Hex, truncateToBytes } from "@rebasepro/utils";
|
|
38
|
+
|
|
39
|
+
/** Resolve a property key to its column name. Injected to avoid an import cycle. */
|
|
40
|
+
export type ResolveColumnName = (propName: string, prop?: Property | null) => string;
|
|
41
|
+
|
|
42
|
+
export type IndexMethod = "btree" | "gin" | "brin";
|
|
43
|
+
|
|
44
|
+
/** A key column, with the ordering Postgres will actually apply. */
|
|
45
|
+
export interface ResolvedIndexKey {
|
|
46
|
+
column: string;
|
|
47
|
+
/** Always concrete. `btree` defaults ascending; unordered methods report `asc`. */
|
|
48
|
+
direction: "asc" | "desc";
|
|
49
|
+
/** Postgres's own default: `last` under `asc`, `first` under `desc`. */
|
|
50
|
+
nulls: "first" | "last";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A predicate resolved onto column names, ready to render and to hash. */
|
|
54
|
+
export type ResolvedPredicate =
|
|
55
|
+
| { column: string; op: "=" | "!=" | "<" | "<=" | ">" | ">="; value: string | number | boolean }
|
|
56
|
+
| { column: string; op: "is null" | "is not null" }
|
|
57
|
+
| { column: string; op: "in"; value: readonly (string | number)[] }
|
|
58
|
+
| { and: readonly ResolvedPredicate[] };
|
|
59
|
+
|
|
60
|
+
/** One index, fully resolved. The only shape the renderers accept. */
|
|
61
|
+
export interface CollectionIndexSpec {
|
|
62
|
+
schema: string;
|
|
63
|
+
table: string;
|
|
64
|
+
method: IndexMethod;
|
|
65
|
+
unique: boolean;
|
|
66
|
+
keys: ResolvedIndexKey[];
|
|
67
|
+
include: string[];
|
|
68
|
+
predicate: ResolvedPredicate | null;
|
|
69
|
+
/** The author's one-line justification. Never enters the name. */
|
|
70
|
+
reason: string;
|
|
71
|
+
/** Derived by {@link deriveIndexName}. Frozen — see the module comment. */
|
|
72
|
+
indexName: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A declaration that cannot become an index.
|
|
77
|
+
*
|
|
78
|
+
* Thrown at build time, naming the collection and the array position, because
|
|
79
|
+
* the alternative is a `CREATE INDEX` that fails during a push with a Postgres
|
|
80
|
+
* error mentioning a column the author never wrote.
|
|
81
|
+
*/
|
|
82
|
+
export class CollectionIndexConfigError extends Error {
|
|
83
|
+
readonly collectionSlug: string;
|
|
84
|
+
readonly position: number;
|
|
85
|
+
|
|
86
|
+
constructor(collectionSlug: string, position: number, message: string) {
|
|
87
|
+
super(`${collectionSlug}.indexes[${position}]: ${message}`);
|
|
88
|
+
this.name = "CollectionIndexConfigError";
|
|
89
|
+
this.collectionSlug = collectionSlug;
|
|
90
|
+
this.position = position;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Postgres allows 32 key columns. See the doc comment on `on`. */
|
|
95
|
+
export const MAX_INDEX_KEYS = 5;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `_ix`/`_ux` plus `_` plus 7 hex — the part of the name that must always
|
|
99
|
+
* survive truncation, and therefore is never inside the truncated portion.
|
|
100
|
+
*/
|
|
101
|
+
const NAME_SUFFIX_BYTES = 11;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Every Rebase-managed index name, and nothing else.
|
|
105
|
+
*
|
|
106
|
+
* The terminal `_ix_`/`_ux_` plus exactly seven lowercase hex characters is
|
|
107
|
+
* what separates this scheme from every other producer in the codebase —
|
|
108
|
+
* `_fkey`, `_gin`, `_trgm`, `_pkey`, `_key`, the vector distances, and the
|
|
109
|
+
* `idx_` prefix auth uses. `_idx` was rejected as a tail because it is already
|
|
110
|
+
* taken for real: `users_email_verification_token_idx` is byte-for-byte what a
|
|
111
|
+
* naive `<table>_<column>_idx` derives on an auth-enabled `users` collection.
|
|
112
|
+
*
|
|
113
|
+
* Load-bearing for safety, not just tidiness. An index that does NOT match this
|
|
114
|
+
* belongs to somebody else — a hand-written one, or one an introspected
|
|
115
|
+
* database arrived with — and is excluded from the Atlas diff so the push
|
|
116
|
+
* cannot drop it.
|
|
117
|
+
*/
|
|
118
|
+
export const isRebaseIndexName = (name: string): boolean => /_(?:ix|ux)_[0-9a-f]{7}$/.test(name);
|
|
119
|
+
|
|
120
|
+
const isOrderedMethod = (method: IndexMethod): boolean => method === "btree";
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The parts of an index that decide what it *is*.
|
|
124
|
+
*
|
|
125
|
+
* A semantic projection, not the rendered statement — the same arrangement as
|
|
126
|
+
* `getPolicyNameHash`, and for the same reason. A change to how this file
|
|
127
|
+
* formats SQL (eliding a default `USING btree`, quoting differently, emitting
|
|
128
|
+
* `NULLS LAST` explicitly) must not silently rename every index in every
|
|
129
|
+
* deployed database. Hashing generator output would make every cosmetic edit a
|
|
130
|
+
* fleet-wide DROP + CREATE.
|
|
131
|
+
*
|
|
132
|
+
* `reason` is deliberately absent: rewording a comment must not rebuild an
|
|
133
|
+
* index. `nulls` is the *effective* placement, so writing Postgres's own
|
|
134
|
+
* default down is a no-op rather than a redefinition.
|
|
135
|
+
*
|
|
136
|
+
* `v` is the only escape hatch, and it is expensive on purpose: bumping it
|
|
137
|
+
* renames every index in the field.
|
|
138
|
+
*/
|
|
139
|
+
export const indexFingerprint = (spec: Omit<CollectionIndexSpec, "indexName">): string => sha1Hex(JSON.stringify({
|
|
140
|
+
v: 1,
|
|
141
|
+
s: spec.schema,
|
|
142
|
+
t: spec.table,
|
|
143
|
+
m: spec.method,
|
|
144
|
+
u: spec.unique,
|
|
145
|
+
k: spec.keys.map(k => [k.column, k.direction, k.nulls]),
|
|
146
|
+
i: spec.include,
|
|
147
|
+
w: spec.predicate
|
|
148
|
+
})).substring(0, 7);
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `<table>_<columns>_ix_<hash>`, or `_ux_` when unique.
|
|
152
|
+
*
|
|
153
|
+
* Truncation eats the readable head and never the hash. `toPostgresIdentifier`
|
|
154
|
+
* truncates the whole string at 63 bytes, which on a hashed name would cut off
|
|
155
|
+
* the one part that makes it unique — the failure already frozen into
|
|
156
|
+
* `contracts/derived-names.txt`, where a foreign key is recorded with its
|
|
157
|
+
* `_fkey` suffix truncated away, so a second foreign key on that table would
|
|
158
|
+
* derive a byte-identical name.
|
|
159
|
+
*/
|
|
160
|
+
export const deriveIndexName = (spec: Omit<CollectionIndexSpec, "indexName">): string => {
|
|
161
|
+
// Built suffix-first, so the two parts that carry meaning — the `_ix`/`_ux`
|
|
162
|
+
// tag that {@link isRebaseIndexName} matches on, and the fingerprint — are
|
|
163
|
+
// never in the string being truncated. Composing the whole name and then
|
|
164
|
+
// trimming it to 63 loses both, silently: an 80-byte table name yields
|
|
165
|
+
// `xxxx…xxx_610bb9e` with the tag gone, so the index stops being
|
|
166
|
+
// recognisable as Rebase's and `db push` treats it as foreign forever.
|
|
167
|
+
const tag = spec.unique ? "ux" : "ix";
|
|
168
|
+
const suffix = `_${tag}_${indexFingerprint(spec)}`;
|
|
169
|
+
const readable = `${spec.table}_${spec.keys.map(k => k.column).join("_")}`;
|
|
170
|
+
return `${truncateToBytes(readable, 63 - NAME_SUFFIX_BYTES)}${suffix}`;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const quoteLiteral = (value: string | number | boolean): string => {
|
|
174
|
+
if (typeof value === "number") return String(value);
|
|
175
|
+
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
|
|
176
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** Render a resolved predicate as the body of a `WHERE` clause. */
|
|
180
|
+
export const renderPredicate = (predicate: ResolvedPredicate): string => {
|
|
181
|
+
if ("and" in predicate) {
|
|
182
|
+
// Always parenthesised. Postgres would apply the same precedence
|
|
183
|
+
// without it, but the rendered SQL is read by people diffing a plan.
|
|
184
|
+
return predicate.and.map(renderPredicate).join(" AND ");
|
|
185
|
+
}
|
|
186
|
+
switch (predicate.op) {
|
|
187
|
+
case "is null":
|
|
188
|
+
case "is not null":
|
|
189
|
+
return `"${predicate.column}" ${predicate.op.toUpperCase()}`;
|
|
190
|
+
case "in":
|
|
191
|
+
return `"${predicate.column}" IN (${predicate.value.map(quoteLiteral).join(", ")})`;
|
|
192
|
+
default:
|
|
193
|
+
return `"${predicate.column}" ${predicate.op} ${quoteLiteral(predicate.value)}`;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The `CREATE INDEX` for one spec.
|
|
199
|
+
*
|
|
200
|
+
* `concurrently` is a parameter rather than a string replacement on the way
|
|
201
|
+
* out. `search-column.ts` and `vector-index.ts` both reach for
|
|
202
|
+
* `.replace("CREATE INDEX IF NOT EXISTS", …)` instead, which silently does
|
|
203
|
+
* nothing for a UNIQUE index — the rendered text is `CREATE UNIQUE INDEX …`
|
|
204
|
+
* and the pattern never matches.
|
|
205
|
+
*/
|
|
206
|
+
export const collectionIndexStatement = (
|
|
207
|
+
spec: CollectionIndexSpec,
|
|
208
|
+
options: { concurrently?: boolean; ifNotExists?: boolean } = {}
|
|
209
|
+
): string => {
|
|
210
|
+
const unique = spec.unique ? "UNIQUE " : "";
|
|
211
|
+
const concurrently = options.concurrently ? "CONCURRENTLY " : "";
|
|
212
|
+
const ifNotExists = options.ifNotExists ? "IF NOT EXISTS " : "";
|
|
213
|
+
const using = spec.method === "btree" ? "" : ` USING ${spec.method}`;
|
|
214
|
+
|
|
215
|
+
const keys = spec.keys.map(k => {
|
|
216
|
+
if (!isOrderedMethod(spec.method)) return `"${k.column}"`;
|
|
217
|
+
const direction = k.direction === "desc" ? " DESC" : "";
|
|
218
|
+
// Emitted only when it is not what the direction already implies, so
|
|
219
|
+
// the rendered SQL matches what `pg_get_indexdef` reads back and a
|
|
220
|
+
// re-plan finds no difference.
|
|
221
|
+
const impliedNulls = k.direction === "desc" ? "first" : "last";
|
|
222
|
+
const nulls = k.nulls === impliedNulls ? "" : ` NULLS ${k.nulls.toUpperCase()}`;
|
|
223
|
+
return `"${k.column}"${direction}${nulls}`;
|
|
224
|
+
}).join(", ");
|
|
225
|
+
|
|
226
|
+
const include = spec.include.length > 0
|
|
227
|
+
? ` INCLUDE (${spec.include.map(c => `"${c}"`).join(", ")})`
|
|
228
|
+
: "";
|
|
229
|
+
const where = spec.predicate ? ` WHERE ${renderPredicate(spec.predicate)}` : "";
|
|
230
|
+
|
|
231
|
+
return `CREATE ${unique}INDEX ${concurrently}${ifNotExists}"${spec.indexName}" ` +
|
|
232
|
+
`ON "${spec.schema}"."${spec.table}"${using} (${keys})${include}${where};`;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export const collectionIndexStatements = (
|
|
236
|
+
specs: readonly CollectionIndexSpec[],
|
|
237
|
+
options: { concurrently?: boolean; ifNotExists?: boolean } = {}
|
|
238
|
+
): string[] => specs.map(spec => collectionIndexStatement(spec, options));
|
|
239
|
+
|
|
240
|
+
const relationOf = (
|
|
241
|
+
collection: CollectionConfig,
|
|
242
|
+
propKey: string
|
|
243
|
+
): ResolvedRelation | undefined => resolveCollectionRelations(collection)[propKey];
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The column a property key indexes.
|
|
247
|
+
*
|
|
248
|
+
* A `belongsTo` resolves to its `localKey` — `primaryCategory` becomes
|
|
249
|
+
* `primary_category_id` — which is the case an index is most often wanted for
|
|
250
|
+
* and the case where the property key and the column differ. Everything else
|
|
251
|
+
* goes through `resolveColumnName`.
|
|
252
|
+
*
|
|
253
|
+
* The other relation kinds have no local column at all: the foreign key lives
|
|
254
|
+
* on the target's table, or in a junction. Indexing them here is refused
|
|
255
|
+
* rather than resolved to a column that does not exist.
|
|
256
|
+
*/
|
|
257
|
+
export const resolveIndexableColumn = (
|
|
258
|
+
collection: CollectionConfig,
|
|
259
|
+
propKey: string,
|
|
260
|
+
resolveColumnName: ResolveColumnName,
|
|
261
|
+
fail: (message: string) => never
|
|
262
|
+
): string => {
|
|
263
|
+
const relation = relationOf(collection, propKey);
|
|
264
|
+
if (relation) {
|
|
265
|
+
if (relation.kind === "belongsTo") return relation.localKey;
|
|
266
|
+
fail(
|
|
267
|
+
`"${propKey}" is a ${relation.kind} relation, which has no column on this table — ` +
|
|
268
|
+
`the foreign key lives on "${relation.targetSlug}". Declare the index there.`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const property = collection.properties?.[propKey] as Property | undefined;
|
|
273
|
+
if (!property) {
|
|
274
|
+
fail(`"${propKey}" is not a property of this collection.`);
|
|
275
|
+
}
|
|
276
|
+
return resolveColumnName(propKey, property);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const resolvePredicate = (
|
|
280
|
+
collection: CollectionConfig,
|
|
281
|
+
predicate: IndexPredicate,
|
|
282
|
+
resolveColumnName: ResolveColumnName,
|
|
283
|
+
fail: (message: string) => never
|
|
284
|
+
): ResolvedPredicate => {
|
|
285
|
+
if ("and" in predicate) {
|
|
286
|
+
return { and: predicate.and.map(p => resolvePredicate(collection, p, resolveColumnName, fail)) };
|
|
287
|
+
}
|
|
288
|
+
const column = resolveIndexableColumn(collection, predicate.prop, resolveColumnName, fail);
|
|
289
|
+
switch (predicate.op) {
|
|
290
|
+
case "is null":
|
|
291
|
+
case "is not null":
|
|
292
|
+
return { column, op: predicate.op };
|
|
293
|
+
case "in": {
|
|
294
|
+
const seen = new Set(predicate.value);
|
|
295
|
+
if (seen.size !== predicate.value.length) {
|
|
296
|
+
fail(`the \`in\` list for "${predicate.prop}" repeats a value, which changes nothing.`);
|
|
297
|
+
}
|
|
298
|
+
return { column, op: "in", value: [...predicate.value] };
|
|
299
|
+
}
|
|
300
|
+
default:
|
|
301
|
+
return { column, op: predicate.op, value: predicate.value };
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/** The primary key columns of a collection, for the "you already have this" refusal. */
|
|
306
|
+
const primaryKeyColumns = (collection: CollectionConfig, resolveColumnName: ResolveColumnName): string[] =>
|
|
307
|
+
Object.entries(collection.properties ?? {})
|
|
308
|
+
.filter(([, prop]) => prop && typeof prop === "object" && "isId" in prop && Boolean((prop as { isId?: unknown }).isId))
|
|
309
|
+
.map(([key, prop]) => resolveColumnName(key, prop as Property));
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Every index one collection declares, resolved and named.
|
|
313
|
+
*
|
|
314
|
+
* Throws {@link CollectionIndexConfigError} rather than dropping a bad entry:
|
|
315
|
+
* an index that silently does not exist is the failure mode this whole feature
|
|
316
|
+
* is here to remove.
|
|
317
|
+
*/
|
|
318
|
+
export const buildCollectionIndexSpecs = (
|
|
319
|
+
collection: CollectionConfig,
|
|
320
|
+
resolveColumnName: ResolveColumnName
|
|
321
|
+
): CollectionIndexSpec[] => {
|
|
322
|
+
if (!isPostgresCollectionConfig(collection)) return [];
|
|
323
|
+
const declared = collection.indexes;
|
|
324
|
+
if (!declared || declared.length === 0) return [];
|
|
325
|
+
|
|
326
|
+
const slug = collection.slug ?? getTableName(collection);
|
|
327
|
+
const schema = collection.schema ?? "public";
|
|
328
|
+
const table = getTableName(collection);
|
|
329
|
+
const pk = primaryKeyColumns(collection, resolveColumnName).sort().join(",");
|
|
330
|
+
|
|
331
|
+
const specs: CollectionIndexSpec[] = [];
|
|
332
|
+
const byName = new Map<string, number>();
|
|
333
|
+
|
|
334
|
+
declared.forEach((index: CollectionIndex, position: number) => {
|
|
335
|
+
const fail = (message: string): never => {
|
|
336
|
+
throw new CollectionIndexConfigError(slug, position, message);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
if (typeof index.reason !== "string" || index.reason.trim() === "") {
|
|
340
|
+
fail("`reason` is required — see the doc comment. An index nobody can justify is one nobody can delete.");
|
|
341
|
+
}
|
|
342
|
+
if (!Array.isArray(index.on) || index.on.length === 0) {
|
|
343
|
+
fail("`on` must name at least one property.");
|
|
344
|
+
}
|
|
345
|
+
if (index.on.length > MAX_INDEX_KEYS) {
|
|
346
|
+
fail(`\`on\` has ${index.on.length} keys; the limit is ${MAX_INDEX_KEYS}. Payload columns belong in \`include\`.`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const method: IndexMethod = index.using ?? "btree";
|
|
350
|
+
const unique = method === "btree" && Boolean((index as { unique?: boolean }).unique);
|
|
351
|
+
if (!isOrderedMethod(method)) {
|
|
352
|
+
for (const key of index.on) {
|
|
353
|
+
if (typeof key !== "string" && ("direction" in key || "nulls" in key)) {
|
|
354
|
+
fail(`access method "${method}" does not support ASC/DESC or NULLS options.`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const keys: ResolvedIndexKey[] = index.on.map(key => {
|
|
360
|
+
const propKey = typeof key === "string" ? key : key.prop;
|
|
361
|
+
const column = resolveIndexableColumn(collection, propKey, resolveColumnName, fail);
|
|
362
|
+
const direction = (typeof key === "string" ? undefined : (key as { direction?: "asc" | "desc" }).direction) ?? "asc";
|
|
363
|
+
const nulls = (typeof key === "string" ? undefined : (key as { nulls?: "first" | "last" }).nulls)
|
|
364
|
+
?? (direction === "desc" ? "first" : "last");
|
|
365
|
+
return { column, direction, nulls };
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const duplicateKey = keys.map(k => k.column).find((c, i, all) => all.indexOf(c) !== i);
|
|
369
|
+
if (duplicateKey) fail(`"${duplicateKey}" appears twice in \`on\`.`);
|
|
370
|
+
|
|
371
|
+
if (keys.map(k => k.column).sort().join(",") === pk && pk !== "") {
|
|
372
|
+
fail(`this is the primary key — "${table}_pkey" already indexes exactly these columns.`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const include = ((index as { include?: readonly string[] }).include ?? [])
|
|
376
|
+
.map(propKey => resolveIndexableColumn(collection, propKey, resolveColumnName, fail));
|
|
377
|
+
const overlap = include.find(c => keys.some(k => k.column === c));
|
|
378
|
+
if (overlap) fail(`"${overlap}" is in both \`on\` and \`include\`; Postgres rejects the overlap.`);
|
|
379
|
+
|
|
380
|
+
if (unique && keys.length === 1) {
|
|
381
|
+
const propKey = typeof index.on[0] === "string" ? index.on[0] as string : (index.on[0] as { prop: string }).prop;
|
|
382
|
+
const property = collection.properties?.[propKey] as { validation?: { unique?: boolean } } | undefined;
|
|
383
|
+
if (property?.validation?.unique) {
|
|
384
|
+
fail(
|
|
385
|
+
`"${propKey}" already declares \`validation.unique\`, which compiles to an inline UNIQUE. ` +
|
|
386
|
+
`Two declarations of one guarantee — remove one.`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const predicate = index.where
|
|
392
|
+
? resolvePredicate(collection, index.where, resolveColumnName, fail)
|
|
393
|
+
: null;
|
|
394
|
+
|
|
395
|
+
const withoutName = { schema, table, method, unique, keys, include, predicate, reason: index.reason };
|
|
396
|
+
const indexName = deriveIndexName(withoutName);
|
|
397
|
+
|
|
398
|
+
const clash = byName.get(indexName);
|
|
399
|
+
if (clash !== undefined) {
|
|
400
|
+
fail(`derives the same name as indexes[${clash}] — they are the same index declared twice.`);
|
|
401
|
+
}
|
|
402
|
+
byName.set(indexName, position);
|
|
403
|
+
|
|
404
|
+
specs.push({ ...withoutName, indexName });
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
return specs;
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Every declared index across a set of collections, in a stable order.
|
|
412
|
+
*
|
|
413
|
+
* Sorted because the result reaches `schema.sql`, which `doctor` string-
|
|
414
|
+
* compares against a regenerated copy — `generatePostgresDdl` does not sort its
|
|
415
|
+
* collections, so leaving this in declaration order would make the artifact
|
|
416
|
+
* depend on the order files happened to load in.
|
|
417
|
+
*/
|
|
418
|
+
export const buildCollectionIndexPlan = (
|
|
419
|
+
collections: readonly CollectionConfig[],
|
|
420
|
+
resolveColumnName: ResolveColumnName
|
|
421
|
+
): CollectionIndexSpec[] =>
|
|
422
|
+
collections
|
|
423
|
+
.flatMap(collection => buildCollectionIndexSpecs(collection, resolveColumnName))
|
|
424
|
+
.sort((a, b) =>
|
|
425
|
+
a.schema.localeCompare(b.schema) ||
|
|
426
|
+
a.table.localeCompare(b.table) ||
|
|
427
|
+
a.indexName.localeCompare(b.indexName));
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
quoteSqlLiteral
|
|
50
50
|
} from "./generate-postgres-ddl-logic";
|
|
51
51
|
import { buildVectorIndexPlan, vectorIndexStatement, type SkippedVectorIndex } from "./vector-index";
|
|
52
|
+
import { buildCollectionIndexPlan, collectionIndexStatement } from "./collection-index";
|
|
52
53
|
import {
|
|
53
54
|
AUTH_USERS_COLUMNS,
|
|
54
55
|
authUsersColumnDefinition,
|
|
@@ -812,6 +813,26 @@ export function planCollectionSchemaEnsure(
|
|
|
812
813
|
vectorIndexSkipped.push(...plan.skipped);
|
|
813
814
|
}
|
|
814
815
|
|
|
816
|
+
// Declared indexes, on exactly the same terms as the ANN ones above.
|
|
817
|
+
//
|
|
818
|
+
// Boot has to emit these, not just `db push`: the managed runtime
|
|
819
|
+
// provisions at boot and never runs a push, so a push-only index would
|
|
820
|
+
// simply not exist there — and nothing would say so.
|
|
821
|
+
// `contracts/derived-names.txt` states the rule ("Both, or it is a
|
|
822
|
+
// bug") and the gate enforces it, which is what caught this.
|
|
823
|
+
//
|
|
824
|
+
// `concurrently` is a parameter here rather than a string replacement
|
|
825
|
+
// on the rendered SQL. The `.replace("CREATE INDEX IF NOT EXISTS", …)`
|
|
826
|
+
// just above silently does nothing for a UNIQUE index, whose text is
|
|
827
|
+
// `CREATE UNIQUE INDEX …` and never matches the pattern.
|
|
828
|
+
for (const spec of buildCollectionIndexPlan(collections, resolveColumnName)) {
|
|
829
|
+
actions.push({
|
|
830
|
+
kind: "create-index",
|
|
831
|
+
target: `${spec.schema}.${spec.table}`,
|
|
832
|
+
sql: collectionIndexStatement(spec, { concurrently: true, ifNotExists: true })
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
|
|
815
836
|
return {
|
|
816
837
|
actions,
|
|
817
838
|
statements: actions.map(a => a.sql),
|