@rebasepro/server-postgres 0.23.0 → 0.24.1-canary.g19596ef
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/README.md +16 -0
- package/dist/{BranchService-DGPL6G_C.js → BranchService-UgKa416x.js} +47 -19
- package/dist/BranchService-UgKa416x.js.map +1 -0
- package/dist/PostgresBackendDriver.d.ts +187 -13
- package/dist/PostgresBootstrapper.d.ts +101 -2
- package/dist/auth/ensure-tables.d.ts +12 -0
- package/dist/auth/services.d.ts +78 -14
- package/dist/{auth-users-columns-D2LBFrMH.js → auth-users-columns-C72EMoDJ.js} +17 -1
- package/dist/{auth-users-columns-D2LBFrMH.js.map → auth-users-columns-C72EMoDJ.js.map} +1 -1
- package/dist/backup/backup-cli.d.ts +75 -2
- package/dist/backup/backup-service.d.ts +2 -2
- package/dist/backup/pg-binary.d.ts +5 -0
- package/dist/{backup-cli-24v4OlSp.js → backup-cli-yiKd1GsC.js} +168 -75
- package/dist/backup-cli-yiKd1GsC.js.map +1 -0
- package/dist/{backup-service-HQ9GC4tN.js → backup-service-AZWc2wvI.js} +104 -35
- package/dist/backup-service-AZWc2wvI.js.map +1 -0
- package/dist/boot-ddl-B9ZmBJ75.js +105 -0
- package/dist/boot-ddl-B9ZmBJ75.js.map +1 -0
- package/dist/cli-console-DGee3Zl7.js +225 -0
- package/dist/cli-console-DGee3Zl7.js.map +1 -0
- package/dist/cli-console.d.ts +39 -0
- package/dist/cli-errors-oVUuD8k0.js +2180 -0
- package/dist/cli-errors-oVUuD8k0.js.map +1 -0
- package/dist/cli-errors.d.ts +133 -9
- package/dist/{cli-flags-BglvpjHv.js → cli-flags-DnPHXjRH.js} +56 -6
- package/dist/cli-flags-DnPHXjRH.js.map +1 -0
- package/dist/cli-flags.d.ts +15 -0
- package/dist/cli-helpers.d.ts +88 -34
- package/dist/cli-output.d.ts +16 -28
- package/dist/{arg-Dni7MzLB.js → cli-prompt-egtNKkZl.js} +49 -3
- package/dist/cli-prompt-egtNKkZl.js.map +1 -0
- package/dist/cli-prompt.d.ts +19 -0
- package/dist/cli-push.d.ts +87 -0
- package/dist/cli-scratch-database.d.ts +33 -8
- package/dist/cli.js +826 -230
- package/dist/cli.js.map +1 -1
- package/dist/{column-plan-helpers-DF-8dTVa.js → column-plan-helpers-D_V4oaZh.js} +43 -27
- package/dist/column-plan-helpers-D_V4oaZh.js.map +1 -0
- package/dist/{databasePoolManager-Bj5FbeAs.js → connection-C938nR3E.js} +25 -113
- package/dist/connection-C938nR3E.js.map +1 -0
- package/dist/connection.d.ts +13 -2
- package/dist/data-transformer.d.ts +0 -8
- package/dist/databasePoolManager-DZgnBOgj.js +119 -0
- package/dist/databasePoolManager-DZgnBOgj.js.map +1 -0
- package/dist/deferred-index-build-BqmEHGaD.js +1051 -0
- package/dist/deferred-index-build-BqmEHGaD.js.map +1 -0
- package/dist/{doctor-Cb2thZ8s.js → doctor-CUAAOFZm.js} +281 -89
- package/dist/doctor-CUAAOFZm.js.map +1 -0
- package/dist/ensure-collection-policies-Bb51V2OV.js +541 -0
- package/dist/ensure-collection-policies-Bb51V2OV.js.map +1 -0
- package/dist/ensure-collection-tables-egjGBjhv.js +532 -0
- package/dist/ensure-collection-tables-egjGBjhv.js.map +1 -0
- package/dist/{ensure-tables-Dhn9KM3B.js → ensure-tables-DMPWhJ6R.js} +180 -77
- package/dist/ensure-tables-DMPWhJ6R.js.map +1 -0
- package/dist/enum-labels-kept-Msq5gMcb.js +56 -0
- package/dist/enum-labels-kept-Msq5gMcb.js.map +1 -0
- package/dist/generate-drizzle-schema-DrA-YgXv.js +57 -0
- package/dist/generate-drizzle-schema-DrA-YgXv.js.map +1 -0
- package/dist/{generate-drizzle-schema-logic-BfzK7UQd.js → generate-drizzle-schema-logic-BaT0H7Gq.js} +29 -11
- package/dist/generate-drizzle-schema-logic-BaT0H7Gq.js.map +1 -0
- package/dist/generate-files-OoRJLEdk.js +199 -0
- package/dist/generate-files-OoRJLEdk.js.map +1 -0
- package/dist/generate-postgres-ddl-logic-B4k9djAV.js +152 -0
- package/dist/generate-postgres-ddl-logic-B4k9djAV.js.map +1 -0
- package/dist/{generated-schema-staleness-Di9c4ZxN.js → generated-schema-staleness-BWN_PsMi.js} +2 -2
- package/dist/{generated-schema-staleness-Di9c4ZxN.js.map → generated-schema-staleness-BWN_PsMi.js.map} +1 -1
- package/dist/generated-sql.d.ts +34 -0
- package/dist/history/HistoryService.d.ts +64 -5
- package/dist/history/ensure-history-table.d.ts +63 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +139 -16738
- package/dist/index.es.js.map +1 -1
- package/dist/{introspect-db-logic-WMuAfxvw.js → introspect-db-logic-BCc1Jym2.js} +654 -353
- package/dist/introspect-db-logic-BCc1Jym2.js.map +1 -0
- package/dist/introspect-db-queries-BhhJg0b8.js +364 -0
- package/dist/introspect-db-queries-BhhJg0b8.js.map +1 -0
- package/dist/open-rebase-CAjWpPjs.js +20895 -0
- package/dist/open-rebase-CAjWpPjs.js.map +1 -0
- package/dist/open-rebase.d.ts +94 -0
- package/dist/pg-error-utils-Bs_Ue4wn.js +395 -0
- package/dist/pg-error-utils-Bs_Ue4wn.js.map +1 -0
- package/dist/{plan-schema-C0fxM8dY.js → plan-schema-CsNgK0qi.js} +279 -534
- package/dist/plan-schema-CsNgK0qi.js.map +1 -0
- package/dist/{policy-drift-DljYdrpW.js → policy-drift-BKC50y2p.js} +33 -9
- package/dist/policy-drift-BKC50y2p.js.map +1 -0
- package/dist/push-index-prebuild-C_qhyoJa.js +45 -0
- package/dist/push-index-prebuild-C_qhyoJa.js.map +1 -0
- package/dist/push-legacy-renames-5g9h3HOa.js +34 -0
- package/dist/push-legacy-renames-5g9h3HOa.js.map +1 -0
- package/dist/{generate-postgres-ddl-logic-Bt2d2mRH.js → render-ddl-CrfEe__B.js} +24 -157
- package/dist/render-ddl-CrfEe__B.js.map +1 -0
- package/dist/{rls-bootstrap-sql-B8EclDyM.js → rls-bootstrap-sql-Bd-hJk08.js} +125 -1101
- package/dist/rls-bootstrap-sql-Bd-hJk08.js.map +1 -0
- package/dist/{rls-enforcement-C6Xk0lA6.js → rls-enforcement-D7P4-ZYX.js} +138 -18
- package/dist/rls-enforcement-D7P4-ZYX.js.map +1 -0
- package/dist/{__vite-browser-external-BnuHet1e.js → rolldown-runtime-V7rFbMRe.js} +1 -8
- package/dist/schema/atlas-exclude.d.ts +42 -0
- package/dist/schema/auth-schema.d.ts +172 -2
- package/dist/schema/boot-ddl.d.ts +47 -0
- package/dist/schema/carved-out-migration.d.ts +5 -4
- package/dist/schema/classify-change.d.ts +29 -1
- package/dist/schema/collection-index.d.ts +45 -0
- package/dist/schema/column-plan-helpers.d.ts +38 -21
- package/dist/schema/deferred-index-build.d.ts +131 -0
- package/dist/schema/destructive-sql.d.ts +50 -2
- package/dist/schema/doctor-cli.js +110 -6
- package/dist/schema/doctor-cli.js.map +1 -1
- package/dist/schema/doctor-search-scale.d.ts +45 -0
- package/dist/schema/doctor.d.ts +53 -1
- package/dist/schema/ensure-collection-policies.d.ts +95 -35
- package/dist/schema/ensure-collection-tables.d.ts +56 -3
- package/dist/schema/enum-labels-kept.d.ts +26 -0
- package/dist/schema/generate-drizzle-schema.js +1 -1
- package/dist/schema/generate-files.d.ts +44 -0
- package/dist/schema/generate-next-step.d.ts +0 -13
- package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -5
- package/dist/schema/generate-postgres-ddl.js +10 -81
- package/dist/schema/generate-postgres-ddl.js.map +1 -1
- package/dist/schema/generate-schema-commit.d.ts +12 -0
- package/dist/schema/generated-schema-diff.d.ts +7 -0
- package/dist/schema/introspect-db-logic.d.ts +63 -0
- package/dist/schema/introspect-db-queries.d.ts +21 -2
- package/dist/schema/introspect-db-search.d.ts +22 -0
- package/dist/schema/introspect-db-storage.d.ts +83 -0
- package/dist/schema/introspect-db-structure.d.ts +10 -0
- package/dist/schema/introspect-db.js +29 -21
- package/dist/schema/introspect-db.js.map +1 -1
- package/dist/schema/migrate-baseline.d.ts +33 -0
- package/dist/schema/migration-post-process.d.ts +34 -0
- package/dist/schema/plan/diff-plan.d.ts +127 -1
- package/dist/schema/plan/plan-schema.d.ts +16 -8
- package/dist/schema/plan/render-ddl.d.ts +8 -1
- package/dist/schema/plan/types.d.ts +45 -11
- package/dist/schema/push-index-prebuild.d.ts +25 -0
- package/dist/schema/push-legacy-renames.d.ts +20 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +15 -0
- package/dist/schema/undeclarable-constraints.d.ts +59 -0
- package/dist/search-column-BM-GV6vH.js +442 -0
- package/dist/search-column-BM-GV6vH.js.map +1 -0
- package/dist/security/policy-drift.d.ts +1 -1
- package/dist/security/rls-enforcement.d.ts +39 -4
- package/dist/services/BranchService.d.ts +29 -1
- package/dist/services/FetchService.d.ts +63 -12
- package/dist/services/PersistService.d.ts +15 -0
- package/dist/services/RelationService.d.ts +106 -3
- package/dist/services/cdc/CdcListener.d.ts +26 -10
- package/dist/services/cdc/identity-columns.d.ts +19 -0
- package/dist/services/cdc/trigger-cdc.d.ts +180 -18
- package/dist/services/channel-bus/PostgresChannelBus.d.ts +12 -7
- package/dist/services/channel-bus/index.d.ts +1 -1
- package/dist/services/channel-history.d.ts +32 -5
- package/dist/services/collection-helpers.d.ts +26 -1
- package/dist/services/field-viewer.d.ts +15 -1
- package/dist/services/pg-notify-listener.d.ts +95 -6
- package/dist/services/realtimeService.d.ts +467 -53
- package/dist/services/row-pipeline.d.ts +16 -9
- package/dist/services/socket-liveness.d.ts +85 -0
- package/dist/services/soft-delete.d.ts +12 -0
- package/dist/services/sql-script.d.ts +275 -0
- package/dist/services/vector-iterative-scan.d.ts +40 -0
- package/dist/services/write-depth.d.ts +22 -0
- package/dist/services/write-transaction-scope.d.ts +15 -3
- package/dist/socket-opt-ins.d.ts +64 -0
- package/dist/source-DB6xJv3p.js +873 -0
- package/dist/source-DB6xJv3p.js.map +1 -0
- package/dist/tsx-loader.d.ts +14 -0
- package/dist/utils/connection-string.d.ts +31 -0
- package/dist/utils/drizzle-conditions.d.ts +63 -3
- package/dist/utils/error-cause.d.ts +7 -0
- package/dist/utils/pg-error-utils.d.ts +2 -4
- package/dist/utils/result-rows.d.ts +10 -0
- package/dist/{vector-index-CMJKb3C9.js → vector-index-BFhyEaiz.js} +2 -2
- package/dist/{vector-index-CMJKb3C9.js.map → vector-index-BFhyEaiz.js.map} +1 -1
- package/dist/websocket-DUiiwQd2.js +1632 -0
- package/dist/websocket-DUiiwQd2.js.map +1 -0
- package/dist/websocket.d.ts +63 -18
- package/package.json +7 -7
- package/dist/BranchService-DGPL6G_C.js.map +0 -1
- package/dist/__vite-browser-external-BnuHet1e.js.map +0 -1
- package/dist/arg-Dni7MzLB.js.map +0 -1
- package/dist/backup-cli-24v4OlSp.js.map +0 -1
- package/dist/backup-service-HQ9GC4tN.js.map +0 -1
- package/dist/cli-errors-B8qHg02P.js +0 -798
- package/dist/cli-errors-B8qHg02P.js.map +0 -1
- package/dist/cli-flags-BglvpjHv.js.map +0 -1
- package/dist/cli-output-CNdMql-L.js +0 -47
- package/dist/cli-output-CNdMql-L.js.map +0 -1
- package/dist/collection-index-DdnsxiJ_.js +0 -286
- package/dist/collection-index-DdnsxiJ_.js.map +0 -1
- package/dist/column-plan-helpers-DF-8dTVa.js.map +0 -1
- package/dist/databasePoolManager-Bj5FbeAs.js.map +0 -1
- package/dist/doctor-Cb2thZ8s.js.map +0 -1
- package/dist/ensure-collection-policies-RHUcEp4v.js +0 -105
- package/dist/ensure-collection-policies-RHUcEp4v.js.map +0 -1
- package/dist/ensure-collection-tables-BEEjn5cn.js +0 -857
- package/dist/ensure-collection-tables-BEEjn5cn.js.map +0 -1
- package/dist/ensure-tables-Dhn9KM3B.js.map +0 -1
- package/dist/generate-drizzle-schema-logic-BfzK7UQd.js.map +0 -1
- package/dist/generate-drizzle-schema-yzY_BLhr.js +0 -123
- package/dist/generate-drizzle-schema-yzY_BLhr.js.map +0 -1
- package/dist/generate-postgres-ddl-logic-Bt2d2mRH.js.map +0 -1
- package/dist/introspect-db-logic-WMuAfxvw.js.map +0 -1
- package/dist/plan-schema-C0fxM8dY.js.map +0 -1
- package/dist/policy-drift-DljYdrpW.js.map +0 -1
- package/dist/rls-bootstrap-sql-B8EclDyM.js.map +0 -1
- package/dist/rls-enforcement-C6Xk0lA6.js.map +0 -1
- package/dist/source-Br7L7GOI.js +0 -410
- package/dist/source-Br7L7GOI.js.map +0 -1
|
@@ -0,0 +1,1051 @@
|
|
|
1
|
+
import { createRequire as __createRequire } from "module";
|
|
2
|
+
__createRequire(import.meta.url);
|
|
3
|
+
import { n as __exportAll } from "./rolldown-runtime-V7rFbMRe.js";
|
|
4
|
+
import { h as searchIndexStatements, n as SEARCH_TEXT_FN, r as SEARCH_UNACCENT_FN, u as searchColumnStamps } from "./search-column-BM-GV6vH.js";
|
|
5
|
+
import { n as extractPgError } from "./pg-error-utils-Bs_Ue4wn.js";
|
|
6
|
+
import { u as vectorIndexStatement } from "./vector-index-BFhyEaiz.js";
|
|
7
|
+
import { _ as sortIndexSpecs, l as collectionIndexStatement } from "./source-DB6xJv3p.js";
|
|
8
|
+
import { a as triggerStatements, i as SET_UPDATED_AT_FN, r as quoteSqlLiteral } from "./plan-schema-CsNgK0qi.js";
|
|
9
|
+
import { n as renderEnableRls, r as renderPgType, s as renderPrimaryKeyConstraint, t as renderColumnDefinition } from "./render-ddl-CrfEe__B.js";
|
|
10
|
+
import { a as isLockTimeout, c as resolveBootLockOptions, l as sleep, o as isMultiStatementRefusal, r as atomicBatch } from "./boot-ddl-B9ZmBJ75.js";
|
|
11
|
+
import { logger } from "@rebasepro/server";
|
|
12
|
+
//#region src/schema/column-type-drift.ts
|
|
13
|
+
/**
|
|
14
|
+
* Types that mean the same thing to everything above the driver.
|
|
15
|
+
*
|
|
16
|
+
* Keyed by every spelling either side can produce: `information_schema` reports
|
|
17
|
+
* `udt_name` (`int4`, `_numeric`, `bpchar`), while the generators emit SQL
|
|
18
|
+
* (`INTEGER`, `NUMERIC[]`, `CHAR(3)`). Both are normalised through here.
|
|
19
|
+
*/
|
|
20
|
+
var FAMILIES = {
|
|
21
|
+
int2: "number",
|
|
22
|
+
smallint: "number",
|
|
23
|
+
int4: "number",
|
|
24
|
+
int: "number",
|
|
25
|
+
integer: "number",
|
|
26
|
+
int8: "number",
|
|
27
|
+
bigint: "number",
|
|
28
|
+
numeric: "number",
|
|
29
|
+
decimal: "number",
|
|
30
|
+
float4: "number",
|
|
31
|
+
real: "number",
|
|
32
|
+
float8: "number",
|
|
33
|
+
"double precision": "number",
|
|
34
|
+
money: "number",
|
|
35
|
+
text: "text",
|
|
36
|
+
varchar: "text",
|
|
37
|
+
"character varying": "text",
|
|
38
|
+
bpchar: "text",
|
|
39
|
+
char: "text",
|
|
40
|
+
character: "text",
|
|
41
|
+
citext: "text",
|
|
42
|
+
bool: "boolean",
|
|
43
|
+
boolean: "boolean",
|
|
44
|
+
json: "json",
|
|
45
|
+
jsonb: "jsonb",
|
|
46
|
+
uuid: "uuid",
|
|
47
|
+
bytea: "bytea",
|
|
48
|
+
date: "date",
|
|
49
|
+
time: "time",
|
|
50
|
+
"time without time zone": "time",
|
|
51
|
+
timetz: "time",
|
|
52
|
+
"time with time zone": "time",
|
|
53
|
+
timestamp: "timestamp",
|
|
54
|
+
"timestamp without time zone": "timestamp",
|
|
55
|
+
timestamptz: "timestamptz",
|
|
56
|
+
"timestamp with time zone": "timestamptz",
|
|
57
|
+
interval: "interval",
|
|
58
|
+
tsvector: "tsvector",
|
|
59
|
+
vector: "vector",
|
|
60
|
+
halfvec: "halfvec",
|
|
61
|
+
sparsevec: "sparsevec"
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* One SQL or `udt_name` spelling reduced to its family, or `null` when this
|
|
65
|
+
* module has no opinion about it.
|
|
66
|
+
*
|
|
67
|
+
* `null` is not a failure. It is the answer for an enum type, a domain, a
|
|
68
|
+
* PostGIS column, a quoted qualified name — everything whose identity is a
|
|
69
|
+
* *name* rather than a shape, where two spellings being different says nothing
|
|
70
|
+
* about whether the values are compatible.
|
|
71
|
+
*/
|
|
72
|
+
function typeFamily(raw) {
|
|
73
|
+
if (!raw) return null;
|
|
74
|
+
let type = raw.trim().toLowerCase();
|
|
75
|
+
if (!type) return null;
|
|
76
|
+
if (type.startsWith("\"")) return null;
|
|
77
|
+
const generated = type.indexOf(" generated ");
|
|
78
|
+
if (generated > 0) type = type.slice(0, generated).trim();
|
|
79
|
+
if (type.endsWith("[]")) {
|
|
80
|
+
const element = typeFamily(type.slice(0, -2));
|
|
81
|
+
return element && `array:${element}`;
|
|
82
|
+
}
|
|
83
|
+
if (type.startsWith("_")) {
|
|
84
|
+
const element = typeFamily(type.slice(1));
|
|
85
|
+
return element && `array:${element}`;
|
|
86
|
+
}
|
|
87
|
+
const paren = type.indexOf("(");
|
|
88
|
+
if (paren > 0) type = type.slice(0, paren).trim();
|
|
89
|
+
return FAMILIES[type] ?? null;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Do these two spellings describe columns that hold the same kind of value?
|
|
93
|
+
*
|
|
94
|
+
* `true` whenever the answer is not confidently "no" — an unrecognised type on
|
|
95
|
+
* either side agrees with everything, because a report needs evidence and an
|
|
96
|
+
* unknown type is the absence of it.
|
|
97
|
+
*/
|
|
98
|
+
function typesAgree(declared, actual) {
|
|
99
|
+
const a = typeFamily(declared);
|
|
100
|
+
const b = typeFamily(actual);
|
|
101
|
+
if (a === null || b === null) return true;
|
|
102
|
+
if (a !== b) return false;
|
|
103
|
+
if (!isNumeric(declared) || !isNumeric(actual)) return true;
|
|
104
|
+
const declaredModifier = numericModifier(declared);
|
|
105
|
+
if (declaredModifier === null) return true;
|
|
106
|
+
return declaredModifier === numericModifier(actual);
|
|
107
|
+
}
|
|
108
|
+
/** `NUMERIC`, `numeric(10,2)`, `DECIMAL(10, 2)` — the arbitrary-precision type itself. */
|
|
109
|
+
function isNumeric(raw) {
|
|
110
|
+
return /^\s*(?:numeric|decimal)\s*(?:\(|$)/i.test(raw);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* `numeric(10,2)` / `NUMERIC(10, 2)` → `10,2`; a bare `numeric` → `null`. A
|
|
114
|
+
* precision alone means a scale of 0, which is how the catalogue reports it:
|
|
115
|
+
* `NUMERIC(10)` → `10,0`.
|
|
116
|
+
*/
|
|
117
|
+
function numericModifier(raw) {
|
|
118
|
+
const match = raw.match(/\(([^)]*)\)/);
|
|
119
|
+
if (!match) return null;
|
|
120
|
+
const [precision, scale = "0"] = match[1].split(",").map((part) => part.trim());
|
|
121
|
+
return `${precision},${scale}`;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The sentence a boot log says about one drifted column.
|
|
125
|
+
*
|
|
126
|
+
* Written to be actionable in the place it is read, which is a runtime log with
|
|
127
|
+
* no way to run a migration from it: it names both types, says which surface
|
|
128
|
+
* will break first, and gives the statement that resolves it.
|
|
129
|
+
*/
|
|
130
|
+
function columnTypeDriftMessage(drift) {
|
|
131
|
+
const [schema, table] = drift.table.split(".");
|
|
132
|
+
return `Column "${drift.table}"."${drift.column}" is ${drift.actual} in the database, but this collection declares ${drift.declared}. Boot does not change an existing column's type, so the database keeps what it has and writes that match the declaration will be rejected by Postgres. Change it deliberately, when you can watch it:
|
|
133
|
+
ALTER TABLE "${schema}"."${table}" ALTER COLUMN "${drift.column}" TYPE ${drift.declared} USING "${drift.column}"::${drift.declared};`;
|
|
134
|
+
}
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region src/schema/plan/diff-plan.ts
|
|
137
|
+
/** Postgres identifiers this module is willing to interpolate. */
|
|
138
|
+
var SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
|
139
|
+
/**
|
|
140
|
+
* Validation used to run only on the names read back OUT of the catalogue,
|
|
141
|
+
* which is the direction that cannot hurt anyone: those came from Postgres. The
|
|
142
|
+
* names going IN — the schema and table a collection declares — were quoted and
|
|
143
|
+
* concatenated on trust, and quoting is not escaping. A table named
|
|
144
|
+
* `x" (id int); DROP TABLE users; --` closes the quote and the statement, and
|
|
145
|
+
* the whole thing runs on the owner connection, which is the one connection in
|
|
146
|
+
* the system that is exempt from every RLS policy.
|
|
147
|
+
*
|
|
148
|
+
* That is only a config file on a self-hosted project, where the person writing
|
|
149
|
+
* it could run the SQL directly anyway. It is not only a config file where a
|
|
150
|
+
* collection can be defined over the wire — the live-schema and source editors
|
|
151
|
+
* both do that — and there the caller is an admin on the app, not an operator
|
|
152
|
+
* of the database.
|
|
153
|
+
*/
|
|
154
|
+
function assertSafeIdentifier(value, what) {
|
|
155
|
+
if (!SAFE_IDENTIFIER.test(value)) throw new Error(`Refusing to build SQL with an unsafe ${what}: ${JSON.stringify(value)}`);
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The functions the plan creates with `CREATE OR REPLACE`, which the catalogue
|
|
160
|
+
* reader looks up so a boot that finds them unchanged plans nothing.
|
|
161
|
+
*
|
|
162
|
+
* A helper missing from this list is not wrong, only re-created on every boot,
|
|
163
|
+
* which is what all of them were before.
|
|
164
|
+
*/
|
|
165
|
+
var HELPER_FUNCTIONS = [
|
|
166
|
+
SEARCH_TEXT_FN,
|
|
167
|
+
SEARCH_UNACCENT_FN,
|
|
168
|
+
SET_UPDATED_AT_FN
|
|
169
|
+
];
|
|
170
|
+
var helperFunctionKey = (schema, name, args) => `${schema}.${name}(${args.replace(/\s+/g, " ").trim().toLowerCase()})`;
|
|
171
|
+
var describeHelperFunction = (facts) => JSON.stringify([
|
|
172
|
+
facts.returns.toLowerCase(),
|
|
173
|
+
facts.language.toLowerCase(),
|
|
174
|
+
facts.volatility,
|
|
175
|
+
facts.strict,
|
|
176
|
+
facts.parallel,
|
|
177
|
+
facts.securityDefiner,
|
|
178
|
+
facts.leakproof,
|
|
179
|
+
Number(facts.cost),
|
|
180
|
+
facts.hasConfig,
|
|
181
|
+
facts.body
|
|
182
|
+
]);
|
|
183
|
+
var HELPER_FUNCTION_RE = /* @__PURE__ */ new RegExp("^\\s*CREATE OR REPLACE FUNCTION\\s+([A-Za-z_]\\w*)\\.([A-Za-z_]\\w*)\\(([^)]*)\\)\\s+RETURNS\\s+([\\w\\[\\]]+)\\s+LANGUAGE\\s+(\\w+)((?:\\s+(?:IMMUTABLE|STABLE|VOLATILE|STRICT|PARALLEL\\s+(?:SAFE|RESTRICTED|UNSAFE)))*)\\s+AS\\s+\\$\\$([\\s\\S]*)\\$\\$;?\\s*$", "i");
|
|
184
|
+
/**
|
|
185
|
+
* What a helper's `CREATE OR REPLACE FUNCTION` statement would leave in the
|
|
186
|
+
* catalogue, or `undefined` for a statement this does not recognise — which is
|
|
187
|
+
* then planned unconditionally, as every statement was before.
|
|
188
|
+
*
|
|
189
|
+
* Only ever given statements this package renders (`searchHelperFunctions`,
|
|
190
|
+
* `setUpdatedAtFunction`), so a strict pattern is the right tool: anything it
|
|
191
|
+
* does not match exactly falls back to the safe behaviour.
|
|
192
|
+
*/
|
|
193
|
+
function parseHelperFunction(statement) {
|
|
194
|
+
const match = HELPER_FUNCTION_RE.exec(statement);
|
|
195
|
+
if (!match) return void 0;
|
|
196
|
+
const [, schema, name, args, returns, language, attributes, body] = match;
|
|
197
|
+
const words = attributes.toUpperCase();
|
|
198
|
+
const parallel = /PARALLEL\s+SAFE/.test(words) ? "s" : /PARALLEL\s+RESTRICTED/.test(words) ? "r" : "u";
|
|
199
|
+
const volatility = /\bIMMUTABLE\b/.test(words) ? "i" : /\bSTABLE\b/.test(words) ? "s" : "v";
|
|
200
|
+
return {
|
|
201
|
+
key: helperFunctionKey(schema, name, args),
|
|
202
|
+
definition: describeHelperFunction({
|
|
203
|
+
returns,
|
|
204
|
+
language,
|
|
205
|
+
volatility,
|
|
206
|
+
strict: /\bSTRICT\b/.test(words),
|
|
207
|
+
parallel,
|
|
208
|
+
securityDefiner: false,
|
|
209
|
+
leakproof: false,
|
|
210
|
+
cost: 100,
|
|
211
|
+
hasConfig: false,
|
|
212
|
+
body
|
|
213
|
+
})
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/** The name a rendered `CREATE [UNIQUE] INDEX [CONCURRENTLY] IF NOT EXISTS "<name>"` creates. */
|
|
217
|
+
var indexNameOf = (statement) => /^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?IF\s+NOT\s+EXISTS\s+"([^"]+)"/i.exec(statement)?.[1];
|
|
218
|
+
/** The extension a rendered `CREATE EXTENSION IF NOT EXISTS <name> …` installs. */
|
|
219
|
+
var extensionNameOf = (statement) => /^\s*CREATE\s+EXTENSION\s+IF\s+NOT\s+EXISTS\s+"?([\w-]+)"?/i.exec(statement)?.[1];
|
|
220
|
+
/**
|
|
221
|
+
* Whether a column is part of the table's primary key.
|
|
222
|
+
*
|
|
223
|
+
* Those are created with the table, in step 2, and never added afterwards: a
|
|
224
|
+
* key column missing from a table that already exists cannot be made part of
|
|
225
|
+
* its key by an additive change, so there is nothing safe to plan for one.
|
|
226
|
+
*
|
|
227
|
+
* A relation's entry for a column a declared property owns is not the column —
|
|
228
|
+
* it carries only the constraint — so it is not a key column either, or the
|
|
229
|
+
* key would be created twice.
|
|
230
|
+
*/
|
|
231
|
+
var isKeyColumn = (table, column) => !column.columnOwnedByProperty && table.primaryKey.includes(column.column);
|
|
232
|
+
/**
|
|
233
|
+
* A declared key whose default is the built-in uuid generator: the default
|
|
234
|
+
* `isId: "uuid"` plans. It is the one declared-key default boot sets on an
|
|
235
|
+
* existing table, because it is the one that cannot fail there.
|
|
236
|
+
*/
|
|
237
|
+
var isUuidKeyDefault = (column) => column.source.kind === "property" && column.default?.kind === "sql" && column.default.expression === "gen_random_uuid()";
|
|
238
|
+
/** Which pass a column belongs to. The order below is the order they run in. */
|
|
239
|
+
var isScalarProperty = (table, column) => (column.source.kind === "property" || column.source.kind === "implicit-id") && !isKeyColumn(table, column);
|
|
240
|
+
/**
|
|
241
|
+
* `ENABLE ROW LEVEL SECURITY` on a table this plan has just created — the
|
|
242
|
+
* statement immediately after its `CREATE TABLE`, never later.
|
|
243
|
+
*
|
|
244
|
+
* The user role every authenticated request runs as holds DML on a new table
|
|
245
|
+
* the moment it exists: the schema's default privileges grant it, and boot's
|
|
246
|
+
* `ensureAppRole` grants it on every table before the policies run. The
|
|
247
|
+
* policies — which also switch RLS on — come after the driver and auth have
|
|
248
|
+
* initialised, so any failure in between left a table with full DML and no row
|
|
249
|
+
* filtering, served by every process in the deployment that does not
|
|
250
|
+
* provision. Born locked, a table denies until its policies arrive, which is
|
|
251
|
+
* the state a failure is allowed to leave behind. Idempotent, so a table a
|
|
252
|
+
* concurrent boot created gets the same statement and nothing changes.
|
|
253
|
+
*/
|
|
254
|
+
var lockAction = (table) => ({
|
|
255
|
+
kind: "enable-rls",
|
|
256
|
+
target: table.qualified,
|
|
257
|
+
sql: renderEnableRls(table)
|
|
258
|
+
});
|
|
259
|
+
function diffPlanAgainstCatalogue(plan, existing, options = {}) {
|
|
260
|
+
const constraintPolicy = options.constraints ?? "additive";
|
|
261
|
+
const actions = [];
|
|
262
|
+
const withheldConstraints = [];
|
|
263
|
+
const columnTypeDrift = [];
|
|
264
|
+
const legacyForeignKeys = [];
|
|
265
|
+
const searchDrift = [];
|
|
266
|
+
const searchAdopted = [];
|
|
267
|
+
const collectionTables = plan.tables.filter((t) => t.kind === "collection");
|
|
268
|
+
const junctionTables = plan.tables.filter((t) => t.kind === "junction");
|
|
269
|
+
for (const enumPlan of plan.enums) {
|
|
270
|
+
assertSafeIdentifier(enumPlan.schema, "schema name");
|
|
271
|
+
if (existing.enums.has(enumPlan.qualified)) {
|
|
272
|
+
const current = existing.enumValues?.get(enumPlan.qualified);
|
|
273
|
+
if (!current) continue;
|
|
274
|
+
for (const value of enumPlan.labels) {
|
|
275
|
+
if (current.includes(value)) continue;
|
|
276
|
+
actions.push({
|
|
277
|
+
kind: "add-enum-value",
|
|
278
|
+
target: `${enumPlan.qualified}.${value}`,
|
|
279
|
+
sql: `ALTER TYPE "${enumPlan.schema}"."${enumPlan.name}" ADD VALUE IF NOT EXISTS ${quoteSqlLiteral(value)};`
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
actions.push({
|
|
285
|
+
kind: "create-enum",
|
|
286
|
+
target: enumPlan.qualified,
|
|
287
|
+
sql: `CREATE TYPE "${enumPlan.schema}"."${enumPlan.name}" AS ENUM (${enumPlan.labels.map(quoteSqlLiteral).join(", ")});`
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
for (const statement of plan.extensions) {
|
|
291
|
+
const extension = extensionNameOf(statement);
|
|
292
|
+
if (extension && existing.extensions?.has(extension)) continue;
|
|
293
|
+
actions.push({
|
|
294
|
+
kind: "create-extension",
|
|
295
|
+
target: statement.replace(/^CREATE EXTENSION IF NOT EXISTS |;$/g, ""),
|
|
296
|
+
sql: statement
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
for (const statement of plan.functions) {
|
|
300
|
+
const helper = parseHelperFunction(statement);
|
|
301
|
+
if (helper && existing.functions?.get(helper.key) === helper.definition) continue;
|
|
302
|
+
actions.push({
|
|
303
|
+
kind: "create-function",
|
|
304
|
+
target: functionTarget(statement),
|
|
305
|
+
sql: statement
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
const created = /* @__PURE__ */ new Set();
|
|
309
|
+
for (const table of collectionTables) {
|
|
310
|
+
if (existing.tables.has(table.qualified) || created.has(table.qualified)) continue;
|
|
311
|
+
created.add(table.qualified);
|
|
312
|
+
const schema = assertSafeIdentifier(table.schema, "schema name");
|
|
313
|
+
const name = assertSafeIdentifier(table.table, "table name");
|
|
314
|
+
const keyColumns = table.columns.filter((c) => isKeyColumn(table, c));
|
|
315
|
+
const definitions = keyColumns.length > 0 ? keyColumns.map((c) => `"${assertSafeIdentifier(c.column, "column name")}" ${renderColumnDefinition(c)}`) : ["\"id\" TEXT PRIMARY KEY"];
|
|
316
|
+
if (keyColumns.length > 1) definitions.push(renderPrimaryKeyConstraint(table));
|
|
317
|
+
actions.push({
|
|
318
|
+
kind: "create-table",
|
|
319
|
+
target: table.qualified,
|
|
320
|
+
sql: `CREATE TABLE IF NOT EXISTS "${schema}"."${name}" (${definitions.join(", ")});`
|
|
321
|
+
});
|
|
322
|
+
actions.push(lockAction(table));
|
|
323
|
+
}
|
|
324
|
+
for (const table of junctionTables) {
|
|
325
|
+
if (existing.tables.has(table.qualified) || created.has(table.qualified)) continue;
|
|
326
|
+
created.add(table.qualified);
|
|
327
|
+
const columns = table.columns.map((c) => `"${c.column}" ${renderColumnDefinition(c)}`).join(", ");
|
|
328
|
+
actions.push({
|
|
329
|
+
kind: "create-table",
|
|
330
|
+
target: table.qualified,
|
|
331
|
+
sql: `CREATE TABLE IF NOT EXISTS "${table.schema}"."${table.table}" (${columns}, ${renderPrimaryKeyConstraint(table)});`
|
|
332
|
+
});
|
|
333
|
+
actions.push(lockAction(table));
|
|
334
|
+
}
|
|
335
|
+
const addColumn = (table, column, definition) => {
|
|
336
|
+
if (existing.tables.get(table.qualified)?.has(column)) return;
|
|
337
|
+
assertSafeIdentifier(column, "column name");
|
|
338
|
+
actions.push({
|
|
339
|
+
kind: "add-column",
|
|
340
|
+
target: `${table.qualified}.${column}`,
|
|
341
|
+
sql: `ALTER TABLE "${table.schema}"."${table.table}" ADD COLUMN IF NOT EXISTS "${column}" ${definition};`
|
|
342
|
+
});
|
|
343
|
+
};
|
|
344
|
+
/**
|
|
345
|
+
* Move a column that is only missing because it was renamed.
|
|
346
|
+
*
|
|
347
|
+
* Returns true when it handled the column, so the caller skips the ordinary
|
|
348
|
+
* ADD. Adding would be the wrong move and a quiet one: the data is in the
|
|
349
|
+
* old column, `ADD COLUMN` creates the new one empty beside it, every
|
|
350
|
+
* statement succeeds, and the relation reads the empty one. A rename is
|
|
351
|
+
* metadata-only in Postgres, keeps the values, and carries the column's
|
|
352
|
+
* indexes and constraints with it.
|
|
353
|
+
*/
|
|
354
|
+
const renameLegacyColumn = (table, column) => {
|
|
355
|
+
const present = existing.tables.get(table.qualified);
|
|
356
|
+
if (!column.legacyColumn || !present) return false;
|
|
357
|
+
if (present.has(column.column) || !present.has(column.legacyColumn)) return false;
|
|
358
|
+
legacyForeignKeys.push({
|
|
359
|
+
table: table.qualified,
|
|
360
|
+
expected: column.column,
|
|
361
|
+
legacy: column.legacyColumn
|
|
362
|
+
});
|
|
363
|
+
actions.push({
|
|
364
|
+
kind: "rename-column",
|
|
365
|
+
target: `${table.qualified}.${column.column}`,
|
|
366
|
+
sql: `ALTER TABLE "${table.schema}"."${table.table}" RENAME COLUMN "${column.legacyColumn}" TO "${column.column}";`
|
|
367
|
+
});
|
|
368
|
+
return true;
|
|
369
|
+
};
|
|
370
|
+
/**
|
|
371
|
+
* A `NOT NULL` is safe exactly when it cannot fail against rows that are
|
|
372
|
+
* already there, and there are three ways to know that:
|
|
373
|
+
*
|
|
374
|
+
* - the table is being created by this plan (no rows yet);
|
|
375
|
+
* - the table exists and is empty;
|
|
376
|
+
* - the column arrives with a DEFAULT, which Postgres backfills into every
|
|
377
|
+
* existing row as part of ADD COLUMN.
|
|
378
|
+
*
|
|
379
|
+
* `populatedTables` absent means the caller does not know, and not knowing
|
|
380
|
+
* has to read as "assume rows" — the other direction emits a NOT NULL that
|
|
381
|
+
* is checked against live data and aborts the boot. Written as an explicit
|
|
382
|
+
* `!== undefined` because the optional-chain form quietly says *empty* when
|
|
383
|
+
* the fact is missing, which is the wrong way to be wrong.
|
|
384
|
+
*/
|
|
385
|
+
const tableIsEmpty = (table) => existing.populatedTables !== void 0 && existing.tables.has(table.qualified) && !existing.populatedTables.has(table.qualified);
|
|
386
|
+
/**
|
|
387
|
+
* The column is there and holds a different kind of value than the
|
|
388
|
+
* collection says it does. Reported, never altered: an unattended `ALTER
|
|
389
|
+
* COLUMN … TYPE` over customer data is not a thing to do quietly, and the
|
|
390
|
+
* *quiet* is what this fixes. A deploy that changed a `columnType` used to
|
|
391
|
+
* report success while the column stayed as it was, and the divergence
|
|
392
|
+
* surfaced later as every write failing. A column whose definition is owned
|
|
393
|
+
* elsewhere (auth, or a generated search column) is not compared: the
|
|
394
|
+
* declaration there is a rendered string rather than a type, and auth's own
|
|
395
|
+
* reconcile owns it.
|
|
396
|
+
*/
|
|
397
|
+
const reportTypeDrift = (table, column) => {
|
|
398
|
+
const key = `${table.qualified}.${column.column}`;
|
|
399
|
+
if (existing.tables.get(table.qualified)?.has(column.column) !== true || column.sqlDefinition) return;
|
|
400
|
+
const declaredType = renderPgType(column.type);
|
|
401
|
+
const actualType = existing.columnTypes?.get(key);
|
|
402
|
+
if (actualType && !typesAgree(declaredType, actualType)) columnTypeDrift.push({
|
|
403
|
+
table: table.qualified,
|
|
404
|
+
column: column.column,
|
|
405
|
+
declared: declaredType,
|
|
406
|
+
actual: actualType
|
|
407
|
+
});
|
|
408
|
+
};
|
|
409
|
+
/**
|
|
410
|
+
* `SET DEFAULT` on a column that is already there and has none.
|
|
411
|
+
*
|
|
412
|
+
* A DEFAULT binds future writes only, so `SET DEFAULT` cannot fail on data
|
|
413
|
+
* and cannot touch a row that already exists. That makes it the one
|
|
414
|
+
* convergence this path may do unattended — and it has to, because
|
|
415
|
+
* `ADD COLUMN IF NOT EXISTS` is a no-op against an existing column, so a
|
|
416
|
+
* `defaultValue` added to a live collection would otherwise never reach the
|
|
417
|
+
* database at all.
|
|
418
|
+
*/
|
|
419
|
+
const planMissingDefault = (table, column) => {
|
|
420
|
+
if (!column.default || column.default.kind === "identity" || column.generated) return;
|
|
421
|
+
const key = `${table.qualified}.${column.column}`;
|
|
422
|
+
if (existing.columnDefaults?.has(key)) return;
|
|
423
|
+
const expression = column.default.kind === "sql" ? column.default.expression : column.default.sql;
|
|
424
|
+
actions.push({
|
|
425
|
+
kind: "set-default",
|
|
426
|
+
target: key,
|
|
427
|
+
sql: `ALTER TABLE "${table.schema}"."${table.table}" ALTER COLUMN "${column.column}" SET DEFAULT ${expression};`
|
|
428
|
+
});
|
|
429
|
+
};
|
|
430
|
+
/**
|
|
431
|
+
* Plan one column, with the constraints this database can safely take.
|
|
432
|
+
*
|
|
433
|
+
* The definition comes from the same renderer `schema.sql` uses, minus the
|
|
434
|
+
* constraints that are checked against live rows. That subtraction is the
|
|
435
|
+
* whole of what this path is allowed to decide for itself.
|
|
436
|
+
*/
|
|
437
|
+
const planColumn = (table, column) => {
|
|
438
|
+
const key = `${table.qualified}.${column.column}`;
|
|
439
|
+
const columnExists = existing.tables.get(table.qualified)?.has(column.column) === true;
|
|
440
|
+
const fresh = created.has(table.qualified);
|
|
441
|
+
const empty = tableIsEmpty(table);
|
|
442
|
+
const required = !column.nullable;
|
|
443
|
+
const hasDefault = column.default !== void 0;
|
|
444
|
+
reportTypeDrift(table, column);
|
|
445
|
+
const notNullIsSafe = fresh || empty || hasDefault;
|
|
446
|
+
const uniqueIsSafe = fresh || empty || !hasDefault;
|
|
447
|
+
const applicable = {
|
|
448
|
+
...column,
|
|
449
|
+
unique: column.unique && !columnExists && uniqueIsSafe,
|
|
450
|
+
nullable: column.nullable || !(notNullIsSafe || columnExists)
|
|
451
|
+
};
|
|
452
|
+
if (column.unique && !columnExists && !uniqueIsSafe) withheldConstraints.push({
|
|
453
|
+
target: key,
|
|
454
|
+
kind: "unique",
|
|
455
|
+
reason: `"${column.column}" is unique, but "${table.qualified}" ` + (existing.populatedTables === void 0 ? "may already hold rows" : "already holds rows") + " and the column's default would give every one of them the same value, so UNIQUE would fail as it was added.",
|
|
456
|
+
remedy: `Give each existing row its own value, then add the constraint — \`ALTER TABLE "${table.schema}"."${table.table}" ADD UNIQUE ("${column.column}")\` — or drop the property's default.`
|
|
457
|
+
});
|
|
458
|
+
if (required && !columnExists && !notNullIsSafe) withheldConstraints.push({
|
|
459
|
+
target: key,
|
|
460
|
+
kind: "not-null",
|
|
461
|
+
reason: column.source.kind === "relation" || column.source.kind === "reference" ? `"${column.column}" is a required link, but "${table.qualified}" already holds rows and the column has no default to backfill them with, so NOT NULL would be checked against data that does not have a value yet.` : `"${column.column}" is required, but "${table.qualified}" already holds rows and the column has no default to backfill them with, so NOT NULL would be checked against data that does not have a value yet.`,
|
|
462
|
+
remedy: column.source.kind === "relation" || column.source.kind === "reference" ? "Backfill the column, then add the constraint — or make the relation optional." : "Backfill the column, then add the constraint — or give the property a default so every existing row gets one."
|
|
463
|
+
});
|
|
464
|
+
addColumn(table, column.column, renderColumnDefinition(applicable));
|
|
465
|
+
if (!columnExists) return;
|
|
466
|
+
planMissingDefault(table, column);
|
|
467
|
+
if (constraintPolicy !== "converge") return;
|
|
468
|
+
const isNotNull = existing.notNullColumns?.has(key) === true;
|
|
469
|
+
if (required && !isNotNull) if (empty) actions.push({
|
|
470
|
+
kind: "set-not-null",
|
|
471
|
+
target: key,
|
|
472
|
+
sql: `ALTER TABLE "${table.schema}"."${table.table}" ALTER COLUMN "${column.column}" SET NOT NULL;`
|
|
473
|
+
});
|
|
474
|
+
else withheldConstraints.push({
|
|
475
|
+
target: key,
|
|
476
|
+
kind: "not-null",
|
|
477
|
+
reason: `"${column.column}" became required, but "${table.qualified}" holds rows and any of them with no value would make SET NOT NULL fail.`,
|
|
478
|
+
remedy: "Backfill the column first — `UPDATE … SET \"" + column.column + "\" = … WHERE \"" + column.column + "\" IS NULL` — then apply this again."
|
|
479
|
+
});
|
|
480
|
+
if (!required && isNotNull) actions.push({
|
|
481
|
+
kind: "drop-not-null",
|
|
482
|
+
target: key,
|
|
483
|
+
sql: `ALTER TABLE "${table.schema}"."${table.table}" ALTER COLUMN "${column.column}" DROP NOT NULL;`
|
|
484
|
+
});
|
|
485
|
+
};
|
|
486
|
+
for (const table of collectionTables) {
|
|
487
|
+
assertSafeIdentifier(table.schema, "schema name");
|
|
488
|
+
assertSafeIdentifier(table.table, "table name");
|
|
489
|
+
for (const column of table.columns) {
|
|
490
|
+
if (!isScalarProperty(table, column)) continue;
|
|
491
|
+
planColumn(table, column);
|
|
492
|
+
}
|
|
493
|
+
for (const column of table.columns) {
|
|
494
|
+
if (!column.primaryKey) continue;
|
|
495
|
+
if (column.source.kind !== "implicit-id" && !isUuidKeyDefault(column)) continue;
|
|
496
|
+
if (existing.tables.get(table.qualified)?.has(column.column) !== true) continue;
|
|
497
|
+
planMissingDefault(table, column);
|
|
498
|
+
}
|
|
499
|
+
for (const column of table.columns) {
|
|
500
|
+
if (column.source.kind !== "auth") continue;
|
|
501
|
+
addColumn(table, column.column, renderColumnDefinition(column));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
for (const table of collectionTables) {
|
|
505
|
+
if (!table.search) continue;
|
|
506
|
+
const definitions = new Map(table.columns.filter((c) => c.source.kind === "search").map((c) => [c.column, renderColumnDefinition(c)]));
|
|
507
|
+
for (const stamp of searchColumnStamps(table.search)) {
|
|
508
|
+
const definition = definitions.get(stamp.column);
|
|
509
|
+
const exists = existing.tables.get(table.qualified)?.has(stamp.column) === true;
|
|
510
|
+
const recorded = existing.columnComments?.get(`${table.qualified}.${stamp.column}`);
|
|
511
|
+
if (exists && recorded?.startsWith("rebase:search:v1:") && recorded !== stamp.fingerprint) {
|
|
512
|
+
searchDrift.push({
|
|
513
|
+
table: table.qualified,
|
|
514
|
+
column: stamp.column,
|
|
515
|
+
found: recorded,
|
|
516
|
+
expected: stamp.fingerprint,
|
|
517
|
+
rebuild: [
|
|
518
|
+
`ALTER TABLE "${table.schema}"."${table.table}" DROP COLUMN "${stamp.column}";`,
|
|
519
|
+
`ALTER TABLE "${table.schema}"."${table.table}" ADD COLUMN "${stamp.column}" ${definition};`,
|
|
520
|
+
stamp.sql
|
|
521
|
+
]
|
|
522
|
+
});
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
addColumn(table, stamp.column, definition);
|
|
526
|
+
if (exists && recorded === void 0) searchAdopted.push({
|
|
527
|
+
table: table.qualified,
|
|
528
|
+
column: stamp.column
|
|
529
|
+
});
|
|
530
|
+
if (recorded !== stamp.fingerprint) actions.push({
|
|
531
|
+
kind: "comment-column",
|
|
532
|
+
target: `${table.qualified}.${stamp.column}`,
|
|
533
|
+
sql: stamp.sql
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
for (const table of junctionTables) {
|
|
538
|
+
if (created.has(table.qualified)) continue;
|
|
539
|
+
for (const column of table.columns) {
|
|
540
|
+
if (renameLegacyColumn(table, column)) continue;
|
|
541
|
+
if (column.source.kind === "property") {
|
|
542
|
+
planColumn(table, column);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
reportTypeDrift(table, column);
|
|
546
|
+
addColumn(table, column.column, renderPgType(column.type));
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
for (const table of collectionTables) for (const column of table.columns) {
|
|
550
|
+
if (column.source.kind !== "relation" && column.source.kind !== "reference") continue;
|
|
551
|
+
if (column.columnOwnedByProperty) continue;
|
|
552
|
+
if (isKeyColumn(table, column)) continue;
|
|
553
|
+
if (renameLegacyColumn(table, column)) continue;
|
|
554
|
+
planColumn(table, column);
|
|
555
|
+
}
|
|
556
|
+
const knownConstraints = existing.constraints ?? /* @__PURE__ */ new Set();
|
|
557
|
+
const plannedConstraints = /* @__PURE__ */ new Set();
|
|
558
|
+
for (const table of [...collectionTables, ...junctionTables]) for (const column of table.columns) {
|
|
559
|
+
const fk = column.foreignKey;
|
|
560
|
+
if (!fk) continue;
|
|
561
|
+
const name = `${fk.schema}.${fk.table}.${fk.constraintName}`;
|
|
562
|
+
if (knownConstraints.has(name) || plannedConstraints.has(name)) continue;
|
|
563
|
+
plannedConstraints.add(name);
|
|
564
|
+
actions.push({
|
|
565
|
+
kind: "add-constraint",
|
|
566
|
+
target: name,
|
|
567
|
+
sql: `${fk.sql};`
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
const concurrent = (schema, table) => !created.has(`${schema}.${table}`);
|
|
571
|
+
const indexExists = (schema, name) => name !== void 0 && existing.indexes?.has(`${schema}.${name}`) === true;
|
|
572
|
+
const deferredIndexes = [];
|
|
573
|
+
const toBuilder = (schema, table, derived) => concurrent(schema, table) && (derived || options.deferLiveIndexes === true);
|
|
574
|
+
const deferIndex = (build) => {
|
|
575
|
+
const invalid = existing.invalidIndexes?.has(`${build.schema}.${build.indexName}`) === true;
|
|
576
|
+
if (indexExists(build.schema, build.indexName) && !invalid) return;
|
|
577
|
+
deferredIndexes.push({
|
|
578
|
+
...build,
|
|
579
|
+
invalid
|
|
580
|
+
});
|
|
581
|
+
};
|
|
582
|
+
/** A search or vector statement in its concurrent form, or undefined if it has no plain `CREATE INDEX IF NOT EXISTS`. */
|
|
583
|
+
const concurrentForm = (statement) => {
|
|
584
|
+
const rewritten = statement.replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS");
|
|
585
|
+
return rewritten === statement ? void 0 : rewritten;
|
|
586
|
+
};
|
|
587
|
+
for (const table of collectionTables) {
|
|
588
|
+
if (!table.search) continue;
|
|
589
|
+
const { schema, table: tableName } = table.search;
|
|
590
|
+
for (const statement of searchIndexStatements(table.search)) {
|
|
591
|
+
const name = indexNameOf(statement);
|
|
592
|
+
const sql = concurrentForm(statement);
|
|
593
|
+
if (name !== void 0 && sql !== void 0 && toBuilder(schema, tableName, false)) {
|
|
594
|
+
deferIndex({
|
|
595
|
+
schema,
|
|
596
|
+
table: tableName,
|
|
597
|
+
indexName: name,
|
|
598
|
+
sql
|
|
599
|
+
});
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
if (indexExists(table.schema, name)) continue;
|
|
603
|
+
actions.push({
|
|
604
|
+
kind: "create-index",
|
|
605
|
+
target: table.qualified,
|
|
606
|
+
sql: concurrent(table.schema, table.table) ? statement.replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS") : statement
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const vectorIndexSkipped = [];
|
|
611
|
+
for (const table of collectionTables) {
|
|
612
|
+
if (!table.vector) continue;
|
|
613
|
+
for (const spec of table.vector.specs) {
|
|
614
|
+
const sql = concurrentForm(vectorIndexStatement(spec));
|
|
615
|
+
if (sql !== void 0 && toBuilder(spec.schema, spec.table, false)) {
|
|
616
|
+
deferIndex({
|
|
617
|
+
schema: spec.schema,
|
|
618
|
+
table: spec.table,
|
|
619
|
+
indexName: spec.indexName,
|
|
620
|
+
sql
|
|
621
|
+
});
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
if (indexExists(spec.schema, spec.indexName)) continue;
|
|
625
|
+
actions.push({
|
|
626
|
+
kind: "create-index",
|
|
627
|
+
target: `${spec.schema}.${spec.table}`,
|
|
628
|
+
sql: concurrent(spec.schema, spec.table) ? vectorIndexStatement(spec).replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS") : vectorIndexStatement(spec)
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
vectorIndexSkipped.push(...table.vector.skipped);
|
|
632
|
+
}
|
|
633
|
+
for (const spec of sortIndexSpecs([...collectionTables, ...junctionTables].flatMap((t) => t.indexes))) {
|
|
634
|
+
if (toBuilder(spec.schema, spec.table, spec.derived === "foreign-key")) {
|
|
635
|
+
deferIndex({
|
|
636
|
+
schema: spec.schema,
|
|
637
|
+
table: spec.table,
|
|
638
|
+
indexName: spec.indexName,
|
|
639
|
+
sql: collectionIndexStatement(spec, {
|
|
640
|
+
concurrently: true,
|
|
641
|
+
ifNotExists: true
|
|
642
|
+
}),
|
|
643
|
+
...spec.unique ? { unique: true } : {}
|
|
644
|
+
});
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
if (indexExists(spec.schema, spec.indexName)) continue;
|
|
648
|
+
actions.push({
|
|
649
|
+
kind: "create-index",
|
|
650
|
+
target: `${spec.schema}.${spec.table}`,
|
|
651
|
+
sql: collectionIndexStatement(spec, {
|
|
652
|
+
concurrently: concurrent(spec.schema, spec.table),
|
|
653
|
+
ifNotExists: true
|
|
654
|
+
})
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
for (const table of collectionTables) for (const trigger of table.triggers) {
|
|
658
|
+
if (existing.triggers?.has(`${table.qualified}.${trigger.name}`)) continue;
|
|
659
|
+
for (const statement of triggerStatements(trigger)) actions.push({
|
|
660
|
+
kind: "create-trigger",
|
|
661
|
+
target: `${table.qualified}.${trigger.name}`,
|
|
662
|
+
sql: statement
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
const orphanedRequiredColumns = [];
|
|
666
|
+
if (existing.notNullColumns && existing.columnDefaults) for (const table of collectionTables) {
|
|
667
|
+
const live = existing.tables.get(table.qualified);
|
|
668
|
+
if (!live || created.has(table.qualified)) continue;
|
|
669
|
+
const declared = /* @__PURE__ */ new Set();
|
|
670
|
+
for (const column of table.columns) {
|
|
671
|
+
declared.add(column.column);
|
|
672
|
+
if (column.legacyColumn) declared.add(column.legacyColumn);
|
|
673
|
+
}
|
|
674
|
+
for (const column of live) {
|
|
675
|
+
const key = `${table.qualified}.${column}`;
|
|
676
|
+
if (declared.has(column)) continue;
|
|
677
|
+
if (!existing.notNullColumns.has(key)) continue;
|
|
678
|
+
if (existing.columnDefaults.has(key)) continue;
|
|
679
|
+
orphanedRequiredColumns.push({
|
|
680
|
+
table: table.qualified,
|
|
681
|
+
column,
|
|
682
|
+
slug: table.slug
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return {
|
|
687
|
+
actions,
|
|
688
|
+
statements: actions.map((a) => a.sql),
|
|
689
|
+
legacyForeignKeys,
|
|
690
|
+
searchDrift,
|
|
691
|
+
searchAdopted,
|
|
692
|
+
vectorIndexSkipped,
|
|
693
|
+
withheldConstraints,
|
|
694
|
+
columnTypeDrift,
|
|
695
|
+
orphanedRequiredColumns,
|
|
696
|
+
deferredIndexes
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
/** What a `CREATE FUNCTION` statement is called, for the action log. */
|
|
700
|
+
var functionTarget = (statement) => {
|
|
701
|
+
if (statement.includes("set_updated_at")) return SET_UPDATED_AT_FN;
|
|
702
|
+
return statement.includes("unaccent") ? SEARCH_UNACCENT_FN : SEARCH_TEXT_FN;
|
|
703
|
+
};
|
|
704
|
+
//#endregion
|
|
705
|
+
//#region src/schema/deferred-index-build.ts
|
|
706
|
+
var deferred_index_build_exports = /* @__PURE__ */ __exportAll({
|
|
707
|
+
TABLE_BUSY_REASON: () => TABLE_BUSY_REASON,
|
|
708
|
+
buildDeferredIndexes: () => buildDeferredIndexes,
|
|
709
|
+
isPgPool: () => isPgPool,
|
|
710
|
+
poolIndexBuildSession: () => poolIndexBuildSession,
|
|
711
|
+
scheduleDeferredIndexBuild: () => scheduleDeferredIndexBuild
|
|
712
|
+
});
|
|
713
|
+
/** A client-side read timeout no index build reaches. A pool's is 60 s. */
|
|
714
|
+
var INDEX_BUILD_READ_TIMEOUT_MS = 1440 * 60 * 1e3;
|
|
715
|
+
/** A `pg.Pool` — what `connect()` checks a client out of — and not a `pg.Client`, whose `connect()` opens a socket. */
|
|
716
|
+
var isPgPool = (value) => typeof value === "object" && value !== null && "connect" in value && typeof value.connect === "function" && "totalCount" in value;
|
|
717
|
+
/**
|
|
718
|
+
* Sessions checked out of a `pg.Pool`, one connection each.
|
|
719
|
+
*
|
|
720
|
+
* Its own connection rather than the pool's next free one, because a build
|
|
721
|
+
* has to outlive both of a pool's timeouts: the server-side
|
|
722
|
+
* `statement_timeout` (30 s on this framework's pools), which the builder
|
|
723
|
+
* lifts for the session — something it may only do on a connection nobody
|
|
724
|
+
* else is handed until it is reset — and the client-side read timeout, which
|
|
725
|
+
* is lifted here per query.
|
|
726
|
+
*/
|
|
727
|
+
var poolIndexBuildSession = (pool) => async () => {
|
|
728
|
+
const client = await pool.connect();
|
|
729
|
+
return {
|
|
730
|
+
dedicated: true,
|
|
731
|
+
async query(text) {
|
|
732
|
+
const config = {
|
|
733
|
+
text,
|
|
734
|
+
query_timeout: INDEX_BUILD_READ_TIMEOUT_MS
|
|
735
|
+
};
|
|
736
|
+
return { rows: (await client.query(config)).rows };
|
|
737
|
+
},
|
|
738
|
+
release: () => client.release()
|
|
739
|
+
};
|
|
740
|
+
};
|
|
741
|
+
/** `42P01 undefined_table` — `pg_stat_progress_create_index` before Postgres 12. */
|
|
742
|
+
var isUndefinedTable = (err) => extractPgError(err)?.code === "42P01";
|
|
743
|
+
/** `25001 active_sql_transaction` — `CONCURRENTLY` on a connection inside a transaction block. */
|
|
744
|
+
var isTransactionBlockRefusal = (err) => extractPgError(err)?.code === "25001";
|
|
745
|
+
/**
|
|
746
|
+
* The rows themselves violate a UNIQUE index being built: `23505` naming the
|
|
747
|
+
* index (a lost catalogue race names a `pg_` index instead). Building it again
|
|
748
|
+
* would only fail again, so it is not retried.
|
|
749
|
+
*/
|
|
750
|
+
var isDuplicateRows = (err, index) => {
|
|
751
|
+
const pg = extractPgError(err);
|
|
752
|
+
return pg?.code === "23505" && (pg.constraint === index.indexName || /could not create unique index/i.test(pg.message));
|
|
753
|
+
};
|
|
754
|
+
var CONCURRENTLY_RE = /\sCONCURRENTLY\s/i;
|
|
755
|
+
/** Why an index is skipped while a build of another index holds its table. */
|
|
756
|
+
var TABLE_BUSY_REASON = "another index on the table is being built";
|
|
757
|
+
/**
|
|
758
|
+
* Run a `CONCURRENTLY` statement — or its plain form, when the connection
|
|
759
|
+
* turns out to be inside a transaction block. Only a shared handle can be: an
|
|
760
|
+
* application that builds its own adapter may hand over a connection already
|
|
761
|
+
* in one. The plain form is always accepted and takes a write lock for the
|
|
762
|
+
* build, which is the cost of getting the index at all — the same fallback the
|
|
763
|
+
* ensure's own index actions have.
|
|
764
|
+
*/
|
|
765
|
+
async function runConcurrently(session, sql, qualified) {
|
|
766
|
+
try {
|
|
767
|
+
await session.query(sql);
|
|
768
|
+
} catch (err) {
|
|
769
|
+
if (!isTransactionBlockRefusal(err) || !CONCURRENTLY_RE.test(sql)) throw err;
|
|
770
|
+
logger.warn(`[schema] ${qualified}: this connection is inside a transaction, so the index is built with a write lock rather than concurrently. Writes to the table block until it finishes.`);
|
|
771
|
+
await session.query(sql.replace(CONCURRENTLY_RE, " "));
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
async function readIndexState(session, index) {
|
|
775
|
+
const schema = quoteSqlLiteral(index.schema);
|
|
776
|
+
const name = quoteSqlLiteral(index.indexName);
|
|
777
|
+
const { rows } = await session.query(`SELECT i.indisvalid AS valid
|
|
778
|
+
FROM pg_index i
|
|
779
|
+
JOIN pg_class c ON c.oid = i.indexrelid
|
|
780
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
781
|
+
WHERE n.nspname = ${schema} AND c.relname = ${name}`);
|
|
782
|
+
const valid = rows.length === 0 ? null : rows[0].valid === true;
|
|
783
|
+
if (valid !== false) return {
|
|
784
|
+
valid,
|
|
785
|
+
building: false
|
|
786
|
+
};
|
|
787
|
+
return {
|
|
788
|
+
valid,
|
|
789
|
+
building: await tableIsBeingIndexed(session, index)
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Wait, within the boot's lock budget, until the table's lock is free.
|
|
794
|
+
*
|
|
795
|
+
* A probe rather than a `lock_timeout` on the build itself: the build waits on
|
|
796
|
+
* lock *queues* in three phases, and only the first — getting the table — is
|
|
797
|
+
* a wait worth bounding. The other two wait for transactions that were
|
|
798
|
+
* already running, which block nobody while they run and must simply be let
|
|
799
|
+
* finish. `true` when the table was free (or cannot be probed on this
|
|
800
|
+
* handle), `false` when something held it for the whole budget.
|
|
801
|
+
*/
|
|
802
|
+
async function tableLockIsFree(session, index, options) {
|
|
803
|
+
const lock = resolveBootLockOptions(options);
|
|
804
|
+
const probe = atomicBatch([`LOCK TABLE "${index.schema}"."${index.table}" IN SHARE UPDATE EXCLUSIVE MODE`], lock.lockTimeoutMs);
|
|
805
|
+
const deadline = Date.now() + (options.retryBudgetMs ?? 1e4);
|
|
806
|
+
for (let round = 1;; round++) try {
|
|
807
|
+
await session.query(probe);
|
|
808
|
+
return true;
|
|
809
|
+
} catch (err) {
|
|
810
|
+
if (isMultiStatementRefusal(err)) return true;
|
|
811
|
+
if (!isLockTimeout(err)) throw err;
|
|
812
|
+
if (round >= lock.attempts || Date.now() >= deadline) return false;
|
|
813
|
+
await sleep(Math.min(lock.backoffMs(round), Math.max(0, deadline - Date.now())));
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
/** Whether another backend is running `CREATE INDEX` on the index's table right now. */
|
|
817
|
+
async function tableIsBeingIndexed(session, index) {
|
|
818
|
+
const table = quoteSqlLiteral(`"${index.schema}"."${index.table}"`);
|
|
819
|
+
try {
|
|
820
|
+
const { rows } = await session.query(`SELECT EXISTS (
|
|
821
|
+
SELECT 1 FROM pg_stat_progress_create_index
|
|
822
|
+
WHERE relid = to_regclass(${table}) AND pid <> pg_backend_pid()
|
|
823
|
+
) AS building`);
|
|
824
|
+
return rows[0]?.building === true;
|
|
825
|
+
} catch (err) {
|
|
826
|
+
if (isUndefinedTable(err)) return false;
|
|
827
|
+
throw err;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* The advisory-lock key one index is claimed under, across every instance.
|
|
832
|
+
*
|
|
833
|
+
* Without a claim, instances booting together all issue the same
|
|
834
|
+
* `CREATE INDEX CONCURRENTLY`: one wins, the others skip by name — reporting
|
|
835
|
+
* success while the winner is still building — or wait on each other's
|
|
836
|
+
* transactions until Postgres breaks the deadlock by cancelling one, which
|
|
837
|
+
* leaves a husk. With it, one instance builds and the rest step aside.
|
|
838
|
+
*/
|
|
839
|
+
var claimKey = (index) => `hashtext(${quoteSqlLiteral(`rebase_fk_index:${index.schema}.${index.indexName}`)})`;
|
|
840
|
+
/**
|
|
841
|
+
* Build one index under its claim — session-level, so only on a connection
|
|
842
|
+
* of our own. On a shared handle the claim could be taken on one backend and
|
|
843
|
+
* released on another, so there is none, and concurrent instances fall back
|
|
844
|
+
* on the state checks below.
|
|
845
|
+
*/
|
|
846
|
+
async function buildClaimed(session, index, options, outcome) {
|
|
847
|
+
if (!session.dedicated) return buildOne(session, index, options, outcome);
|
|
848
|
+
const { rows } = await session.query(`SELECT pg_try_advisory_lock(${claimKey(index)}) AS claimed`);
|
|
849
|
+
if (rows[0]?.claimed !== true) {
|
|
850
|
+
outcome.skipped.push({
|
|
851
|
+
index: `${index.schema}.${index.indexName}`,
|
|
852
|
+
reason: "another instance is building it"
|
|
853
|
+
});
|
|
854
|
+
options.log?.(`${index.schema}.${index.indexName}: another instance is building it — leaving it to that one`);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
try {
|
|
858
|
+
await buildOne(session, index, options, outcome);
|
|
859
|
+
} finally {
|
|
860
|
+
await session.query(`SELECT pg_advisory_unlock(${claimKey(index)})`);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
/** One index, from whatever state it is in to valid — or a reason it is not. */
|
|
864
|
+
async function buildOne(session, index, options, outcome) {
|
|
865
|
+
const qualified = `${index.schema}.${index.indexName}`;
|
|
866
|
+
const target = `"${index.schema}"."${index.indexName}"`;
|
|
867
|
+
const say = (message) => options.log?.(message);
|
|
868
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
869
|
+
const before = await readIndexState(session, index);
|
|
870
|
+
if (before.valid === true) {
|
|
871
|
+
if (attempt === 1) outcome.skipped.push({
|
|
872
|
+
index: qualified,
|
|
873
|
+
reason: "already built"
|
|
874
|
+
});
|
|
875
|
+
else outcome.built.push(qualified);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
if (before.building) {
|
|
879
|
+
outcome.skipped.push({
|
|
880
|
+
index: qualified,
|
|
881
|
+
reason: "another instance is building it"
|
|
882
|
+
});
|
|
883
|
+
say(`${qualified}: another instance is building it — leaving it to that one`);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if (!await tableLockIsFree(session, index, options)) {
|
|
887
|
+
if (await tableIsBeingIndexed(session, index)) {
|
|
888
|
+
outcome.skipped.push({
|
|
889
|
+
index: qualified,
|
|
890
|
+
reason: TABLE_BUSY_REASON
|
|
891
|
+
});
|
|
892
|
+
say(`${qualified}: another index on "${index.schema}"."${index.table}" is being built — leaving it for now`);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
outcome.failed.push({
|
|
896
|
+
index: qualified,
|
|
897
|
+
error: `"${index.schema}"."${index.table}" stayed locked by another session for the whole lock budget (an index build, a VACUUM, an ALTER TABLE). Nothing was changed; the next boot tries again.`
|
|
898
|
+
});
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if (before.valid === false) {
|
|
902
|
+
say(`${qualified}: an earlier build left it INVALID — dropping it to build it again`);
|
|
903
|
+
await runConcurrently(session, `DROP INDEX CONCURRENTLY IF EXISTS ${target};`, qualified);
|
|
904
|
+
}
|
|
905
|
+
const started = Date.now();
|
|
906
|
+
say(`${qualified}: building on "${index.schema}"."${index.table}"…`);
|
|
907
|
+
try {
|
|
908
|
+
await runConcurrently(session, index.sql, qualified);
|
|
909
|
+
} catch (err) {
|
|
910
|
+
const after = await readIndexState(session, index).catch(() => void 0);
|
|
911
|
+
if (after?.valid === true || after?.building) {
|
|
912
|
+
outcome.skipped.push({
|
|
913
|
+
index: qualified,
|
|
914
|
+
reason: "another instance built it"
|
|
915
|
+
});
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (after?.valid === false && attempt === 1 && !isDuplicateRows(err, index)) {
|
|
919
|
+
say(`${qualified}: the build failed (${describe(err)}); retrying once`);
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
outcome.failed.push({
|
|
923
|
+
index: qualified,
|
|
924
|
+
error: describe(err)
|
|
925
|
+
});
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
const after = await readIndexState(session, index);
|
|
929
|
+
if (after.valid === true) {
|
|
930
|
+
outcome.built.push(qualified);
|
|
931
|
+
say(`${qualified}: built in ${((Date.now() - started) / 1e3).toFixed(1)} s`);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (after.building) {
|
|
935
|
+
outcome.skipped.push({
|
|
936
|
+
index: qualified,
|
|
937
|
+
reason: "another instance is building it"
|
|
938
|
+
});
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
if (attempt === 2) {
|
|
942
|
+
outcome.failed.push({
|
|
943
|
+
index: qualified,
|
|
944
|
+
error: "left INVALID by two builds in a row"
|
|
945
|
+
});
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
var describe = (err) => {
|
|
951
|
+
const pg = extractPgError(err);
|
|
952
|
+
if (pg) return pg.code ? `${pg.message} [${pg.code}]` : pg.message;
|
|
953
|
+
return err instanceof Error ? err.message : String(err);
|
|
954
|
+
};
|
|
955
|
+
/**
|
|
956
|
+
* Build every deferred index, one at a time, on one connection.
|
|
957
|
+
*
|
|
958
|
+
* Never throws for an index: each failure is recorded and the next one is
|
|
959
|
+
* attempted. Throws only when no connection can be had at all.
|
|
960
|
+
*/
|
|
961
|
+
async function buildDeferredIndexes(openSession, indexes, options = {}) {
|
|
962
|
+
const outcome = {
|
|
963
|
+
built: [],
|
|
964
|
+
skipped: [],
|
|
965
|
+
failed: []
|
|
966
|
+
};
|
|
967
|
+
if (indexes.length === 0) return outcome;
|
|
968
|
+
for (const index of indexes) {
|
|
969
|
+
assertSafeIdentifier(index.schema, "schema name");
|
|
970
|
+
assertSafeIdentifier(index.table, "table name");
|
|
971
|
+
assertSafeIdentifier(index.indexName, "index name");
|
|
972
|
+
}
|
|
973
|
+
const session = await openSession();
|
|
974
|
+
try {
|
|
975
|
+
if (session.dedicated) await session.query("SET statement_timeout = 0");
|
|
976
|
+
for (const index of indexes) {
|
|
977
|
+
if (options.cancelled?.()) break;
|
|
978
|
+
try {
|
|
979
|
+
await buildClaimed(session, index, options, outcome);
|
|
980
|
+
} catch (err) {
|
|
981
|
+
outcome.failed.push({
|
|
982
|
+
index: `${index.schema}.${index.indexName}`,
|
|
983
|
+
error: describe(err)
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
} finally {
|
|
988
|
+
try {
|
|
989
|
+
if (session.dedicated) await session.query("RESET statement_timeout");
|
|
990
|
+
} finally {
|
|
991
|
+
await session.release();
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
for (const failure of outcome.failed) logger.warn(`[schema] Index ${failure.index} was not built: ${failure.error}. Queries it would serve scan the table until it is; the next boot tries again.`);
|
|
995
|
+
return outcome;
|
|
996
|
+
}
|
|
997
|
+
/** Every deferred build in this process, chained so they run one at a time. */
|
|
998
|
+
var queue = Promise.resolve();
|
|
999
|
+
function scheduleDeferredIndexBuild(openSession, indexes, options = {}) {
|
|
1000
|
+
let cancelled = false;
|
|
1001
|
+
let timer;
|
|
1002
|
+
let resolveDone;
|
|
1003
|
+
const done = new Promise((resolve) => {
|
|
1004
|
+
resolveDone = resolve;
|
|
1005
|
+
});
|
|
1006
|
+
const run = () => {
|
|
1007
|
+
if (timer) clearTimeout(timer);
|
|
1008
|
+
timer = void 0;
|
|
1009
|
+
if (cancelled) {
|
|
1010
|
+
resolveDone(void 0);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
const job = queue.then(async () => {
|
|
1014
|
+
if (cancelled) return void 0;
|
|
1015
|
+
logger.info(`[schema] Building ${indexes.length} index(es) in the background, one at a time (CREATE INDEX CONCURRENTLY — reads and writes are not blocked).`);
|
|
1016
|
+
return buildDeferredIndexes(openSession, indexes, {
|
|
1017
|
+
...options,
|
|
1018
|
+
cancelled: () => cancelled
|
|
1019
|
+
});
|
|
1020
|
+
}).catch((err) => {
|
|
1021
|
+
logger.warn(`[schema] Indexes were not built this boot: ${describe(err)}`);
|
|
1022
|
+
});
|
|
1023
|
+
queue = job;
|
|
1024
|
+
job.then(resolveDone);
|
|
1025
|
+
};
|
|
1026
|
+
const arm = (ms) => {
|
|
1027
|
+
if (timer) clearTimeout(timer);
|
|
1028
|
+
timer = setTimeout(run, ms);
|
|
1029
|
+
timer.unref?.();
|
|
1030
|
+
};
|
|
1031
|
+
arm(options.fallbackMs ?? 6e4);
|
|
1032
|
+
return {
|
|
1033
|
+
start: () => {
|
|
1034
|
+
if (cancelled || timer === void 0) return;
|
|
1035
|
+
arm(options.delayMs ?? 0);
|
|
1036
|
+
},
|
|
1037
|
+
cancel: () => {
|
|
1038
|
+
cancelled = true;
|
|
1039
|
+
if (timer) {
|
|
1040
|
+
clearTimeout(timer);
|
|
1041
|
+
timer = void 0;
|
|
1042
|
+
resolveDone(void 0);
|
|
1043
|
+
}
|
|
1044
|
+
},
|
|
1045
|
+
done
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
//#endregion
|
|
1049
|
+
export { assertSafeIdentifier as a, helperFunctionKey as c, HELPER_FUNCTIONS as i, columnTypeDriftMessage as l, buildDeferredIndexes as n, describeHelperFunction as o, deferred_index_build_exports as r, diffPlanAgainstCatalogue as s, TABLE_BUSY_REASON as t, typesAgree as u };
|
|
1050
|
+
|
|
1051
|
+
//# sourceMappingURL=deferred-index-build-BqmEHGaD.js.map
|