@rebasepro/server-postgres 0.9.1-canary.97e305f → 0.9.1-canary.a57c262
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/PostgresBootstrapper.d.ts +0 -10
- package/dist/auth/services.d.ts +0 -16
- package/dist/connection.d.ts +0 -21
- package/dist/index.es.js +37 -208
- package/dist/index.es.js.map +1 -1
- package/dist/schema/doctor.d.ts +1 -1
- package/dist/services/row-pipeline.d.ts +2 -5
- package/package.json +6 -6
- package/src/PostgresBackendDriver.ts +5 -18
- package/src/PostgresBootstrapper.ts +2 -51
- package/src/auth/ensure-tables.ts +11 -73
- package/src/auth/services.ts +19 -49
- package/src/connection.ts +1 -61
- package/src/databasePoolManager.ts +0 -2
- package/src/schema/doctor.ts +20 -45
- package/src/schema/introspect-db.ts +1 -11
- package/src/services/PersistService.ts +2 -9
- package/src/services/row-pipeline.ts +7 -70
- package/src/utils/drizzle-conditions.ts +0 -13
package/src/schema/doctor.ts
CHANGED
|
@@ -37,7 +37,7 @@ export type IssueSeverity = "error" | "warning" | "info";
|
|
|
37
37
|
|
|
38
38
|
export interface DoctorIssue {
|
|
39
39
|
severity: IssueSeverity;
|
|
40
|
-
category: "missing_table" | "missing_column" | "type_mismatch" | "missing_constraint" | "schema_stale" | "missing_enum" | "enum_value_mismatch" | "missing_foreign_key" | "sdk_stale"
|
|
40
|
+
category: "missing_table" | "missing_column" | "type_mismatch" | "missing_constraint" | "schema_stale" | "missing_enum" | "enum_value_mismatch" | "missing_foreign_key" | "sdk_stale";
|
|
41
41
|
table?: string;
|
|
42
42
|
column?: string;
|
|
43
43
|
expected?: string;
|
|
@@ -61,29 +61,19 @@ export function getExpectedColumnType(prop: Property): string | null {
|
|
|
61
61
|
const sp = prop as StringProperty;
|
|
62
62
|
if (sp.enum) return "USER-DEFINED"; // pgEnum → USER-DEFINED in information_schema
|
|
63
63
|
if ("isId" in sp && sp.isId === "uuid") return "uuid";
|
|
64
|
-
if (sp.columnType === "
|
|
65
|
-
// A markdown/multiline string compiles to `text`, not varchar — the
|
|
66
|
-
// generator treats those UI hints as column-type signals, so the
|
|
67
|
-
// expectation here must too or every such column reads as drift.
|
|
68
|
-
if (sp.columnType === "text" || sp.ui?.markdown || sp.ui?.multiline) return "text";
|
|
64
|
+
if (sp.columnType === "text") return "text";
|
|
69
65
|
if (sp.columnType === "char") return "character";
|
|
70
66
|
return "character varying";
|
|
71
67
|
}
|
|
72
68
|
case "number": {
|
|
73
69
|
const np = prop as NumberProperty;
|
|
74
|
-
if (np.columnType)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
serial: "integer",
|
|
82
|
-
bigserial: "bigint",
|
|
83
|
-
smallserial: "smallint"
|
|
84
|
-
};
|
|
85
|
-
return serialWidths[np.columnType] ?? np.columnType;
|
|
86
|
-
}
|
|
70
|
+
if (np.columnType === "double precision") return "double precision";
|
|
71
|
+
if (np.columnType === "real") return "real";
|
|
72
|
+
if (np.columnType === "bigint") return "bigint";
|
|
73
|
+
if (np.columnType === "serial") return "integer"; // serial is integer under the hood
|
|
74
|
+
if (np.columnType === "bigserial") return "bigint";
|
|
75
|
+
if (np.columnType === "integer") return "integer";
|
|
76
|
+
if (np.columnType === "numeric") return "numeric";
|
|
87
77
|
if (np.validation?.integer || ("isId" in np && np.isId)) return "integer";
|
|
88
78
|
return "numeric";
|
|
89
79
|
}
|
|
@@ -211,18 +201,15 @@ export async function checkCollectionsVsSdk(
|
|
|
211
201
|
): Promise<{ passed: boolean; issues: DoctorIssue[] }> {
|
|
212
202
|
const issues: DoctorIssue[] = [];
|
|
213
203
|
|
|
214
|
-
//
|
|
215
|
-
// you choose to. A project that never generated one isn't drifting, so report
|
|
216
|
-
// it as information rather than a warning; otherwise `doctor` can never come
|
|
217
|
-
// back clean on a fresh project and users learn to ignore its output.
|
|
204
|
+
// Check if SDK file exists
|
|
218
205
|
if (!fs.existsSync(sdkFilePath)) {
|
|
219
206
|
issues.push({
|
|
220
|
-
severity: "
|
|
221
|
-
category: "
|
|
222
|
-
message:
|
|
223
|
-
fix: "Run `rebase generate-sdk`
|
|
207
|
+
severity: "warning",
|
|
208
|
+
category: "sdk_stale",
|
|
209
|
+
message: `Generated SDK typedefs file does not exist at "${sdkFilePath}".`,
|
|
210
|
+
fix: "Run `rebase generate-sdk`"
|
|
224
211
|
});
|
|
225
|
-
return { passed:
|
|
212
|
+
return { passed: false,
|
|
226
213
|
issues };
|
|
227
214
|
}
|
|
228
215
|
|
|
@@ -644,15 +631,11 @@ export function renderReport(report: DoctorReport): void {
|
|
|
644
631
|
}
|
|
645
632
|
|
|
646
633
|
function renderPhase(label: string, passed: boolean, issues: DoctorIssue[]): void {
|
|
647
|
-
|
|
648
|
-
const warnCount = issues.filter((i) => i.severity === "warning").length;
|
|
649
|
-
const infoIssues = issues.filter((i) => i.severity === "info");
|
|
650
|
-
|
|
651
|
-
// Informational notes don't make a phase unhealthy, so key the header off
|
|
652
|
-
// real problems rather than `passed` alone.
|
|
653
|
-
if (errorCount === 0 && warnCount === 0) {
|
|
634
|
+
if (passed) {
|
|
654
635
|
logger.info(` ${chalk.green("✅")} ${label}: ${chalk.green("In sync")}`);
|
|
655
636
|
} else {
|
|
637
|
+
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
638
|
+
const warnCount = issues.filter((i) => i.severity === "warning").length;
|
|
656
639
|
const parts: string[] = [];
|
|
657
640
|
if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
|
|
658
641
|
if (warnCount > 0) parts.push(`${warnCount} warning${warnCount > 1 ? "s" : ""}`);
|
|
@@ -660,14 +643,7 @@ function renderPhase(label: string, passed: boolean, issues: DoctorIssue[]): voi
|
|
|
660
643
|
}
|
|
661
644
|
logger.info("");
|
|
662
645
|
|
|
663
|
-
|
|
664
|
-
for (const issue of infoIssues) {
|
|
665
|
-
const fixPart = issue.fix ? chalk.gray(` — ${issue.fix}`) : "";
|
|
666
|
-
logger.info(` ${chalk.gray("ℹ")} ${chalk.gray(issue.message)}${fixPart}`);
|
|
667
|
-
logger.info("");
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
for (const issue of issues.filter((i) => i.severity !== "info")) {
|
|
646
|
+
for (const issue of issues) {
|
|
671
647
|
const severityIcon = issue.severity === "error" ? chalk.red("✗") : chalk.yellow("⚠");
|
|
672
648
|
const categoryLabel = formatCategory(issue.category);
|
|
673
649
|
logger.info(` ${chalk.gray("┌─")} ${severityIcon} ${chalk.bold(categoryLabel)} ${chalk.gray("─".repeat(Math.max(0, 42 - categoryLabel.length)))}`);
|
|
@@ -698,8 +674,7 @@ function formatCategory(cat: DoctorIssue["category"]): string {
|
|
|
698
674
|
missing_enum: "Missing Enum",
|
|
699
675
|
enum_value_mismatch: "Enum Value Mismatch",
|
|
700
676
|
missing_foreign_key: "Missing Foreign Key",
|
|
701
|
-
sdk_stale: "Stale SDK Types"
|
|
702
|
-
sdk_not_generated: "SDK Types Not Generated"
|
|
677
|
+
sdk_stale: "Stale SDK Types"
|
|
703
678
|
};
|
|
704
679
|
return labels[cat];
|
|
705
680
|
}
|
|
@@ -243,17 +243,7 @@ async function main() {
|
|
|
243
243
|
const merged = mergeIndexContent(existing, generatedFiles);
|
|
244
244
|
fs.writeFileSync(indexPath, merged, "utf-8");
|
|
245
245
|
} else {
|
|
246
|
-
|
|
247
|
-
// directory can also hold hand-written ones with no table in the
|
|
248
|
-
// introspected schema (the auth users collection lives in
|
|
249
|
-
// "rebase"). The backend discovers the whole directory, so an
|
|
250
|
-
// index listing only introspected tables would silently drop them
|
|
251
|
-
// from the admin UI while the API still served them.
|
|
252
|
-
const siblings = fs.readdirSync(outDir)
|
|
253
|
-
.filter(f => f.endsWith(".ts") && f !== "index.ts")
|
|
254
|
-
.map(f => f.replace(/\.ts$/, ""));
|
|
255
|
-
const allFiles = [...new Set([...generatedFiles, ...siblings])];
|
|
256
|
-
const indexContent = generateIndexContent(allFiles);
|
|
246
|
+
const indexContent = generateIndexContent(generatedFiles);
|
|
257
247
|
fs.writeFileSync(indexPath, indexContent, "utf-8");
|
|
258
248
|
}
|
|
259
249
|
logger.info(chalk.green(` ✓ ${indexPath}`));
|
|
@@ -383,15 +383,8 @@ export class PersistService {
|
|
|
383
383
|
throw this.toUserFriendlyError(error, collection.slug);
|
|
384
384
|
}
|
|
385
385
|
|
|
386
|
-
// Fetch the
|
|
387
|
-
|
|
388
|
-
// — the admin view-model walk — whose `__type: "relation"` refs then
|
|
389
|
-
// leaked into REST responses, afterSave callbacks, history records and
|
|
390
|
-
// app-level realtime payloads, none of which see that shape from any
|
|
391
|
-
// read path. The admin does not need them here either: its rows arrive
|
|
392
|
-
// over the realtime subscription refetch, which still serves the
|
|
393
|
-
// view-model walk.
|
|
394
|
-
const finalEntity = await this.fetchService.fetchOneForRest(collection.slug, savedId, undefined, databaseId);
|
|
386
|
+
// Fetch the updated/created row to return with proper relation objects
|
|
387
|
+
const finalEntity = await this.fetchService.fetchOne<M>(collection.slug, savedId, databaseId);
|
|
395
388
|
if (!finalEntity) throw new Error("Could not fetch row after save.");
|
|
396
389
|
return finalEntity;
|
|
397
390
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CollectionConfig,
|
|
1
|
+
import { CollectionConfig, Relation } from "@rebasepro/types";
|
|
2
2
|
import { resolveCollectionRelations, findRelation, createRelationRefWithData } from "@rebasepro/common";
|
|
3
3
|
import { normalizeDbValues } from "../data-transformer";
|
|
4
4
|
import { deriveRowAddress } from "./collection-helpers";
|
|
@@ -54,67 +54,7 @@ function unwrapJunctionRow(item: Record<string, unknown>): Record<string, unknow
|
|
|
54
54
|
return nestedKey ? item[nestedKey] as Record<string, unknown> : item;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
/**
|
|
58
|
-
* Give back the number a `number` property was declared to be.
|
|
59
|
-
*
|
|
60
|
-
* Postgres returns NUMERIC as a string and drizzle keeps it that way, since a
|
|
61
|
-
* numeric can hold more precision than a double. But the collection declared
|
|
62
|
-
* this property `number` and the OpenAPI spec this server publishes says
|
|
63
|
-
* `type: number`, so serving `"9.99"` breaks its own contract — and breaks it
|
|
64
|
-
* asymmetrically, because a create answers with the number and the read that
|
|
65
|
-
* follows answers with the string. Any client that multiplies a price works
|
|
66
|
-
* until the first refresh.
|
|
67
|
-
*
|
|
68
|
-
* Declaring a property `number` already accepts double precision — that is what
|
|
69
|
-
* the admin has always parsed it to. Columns REST serves that no property
|
|
70
|
-
* declares are left exactly as the database returned them.
|
|
71
|
-
*/
|
|
72
|
-
function coerceDeclaredNumber(value: unknown, property: Property | undefined): unknown {
|
|
73
|
-
if (property?.type !== "number" || typeof value !== "string") return value;
|
|
74
|
-
const parsed = parseFloat(value);
|
|
75
|
-
return isNaN(parsed) ? null : parsed;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Apply {@link coerceDeclaredNumber} across a row, leaving every other column alone. */
|
|
79
|
-
function coerceDeclaredNumbers(
|
|
80
|
-
row: Record<string, unknown>,
|
|
81
|
-
collection: CollectionConfig
|
|
82
|
-
): Record<string, unknown> {
|
|
83
|
-
const properties = collection.properties;
|
|
84
|
-
if (!properties) return row;
|
|
85
|
-
|
|
86
|
-
const out: Record<string, unknown> = {};
|
|
87
|
-
for (const [key, value] of Object.entries(row)) {
|
|
88
|
-
out[key] = coerceDeclaredNumber(value, properties[key] as Property | undefined);
|
|
89
|
-
}
|
|
90
|
-
return out;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
57
|
/** Render one target row in the requested style. */
|
|
94
|
-
/**
|
|
95
|
-
* Drop every column the collection marked `excludeFromApi`.
|
|
96
|
-
*
|
|
97
|
-
* Password hashes and verification tokens have to be readable server-side but
|
|
98
|
-
* must never reach a client — and "never" has to mean every exit from this
|
|
99
|
-
* pipeline, including relation targets, or a secret leaks through whichever
|
|
100
|
-
* path was overlooked. Keyed by both the property name and its column name,
|
|
101
|
-
* since a row can arrive keyed either way depending on the caller.
|
|
102
|
-
*/
|
|
103
|
-
function stripExcluded(
|
|
104
|
-
row: Record<string, unknown>,
|
|
105
|
-
collection: CollectionConfig
|
|
106
|
-
): Record<string, unknown> {
|
|
107
|
-
const properties = collection.properties as Record<string, Property> | undefined;
|
|
108
|
-
if (!properties) return row;
|
|
109
|
-
|
|
110
|
-
for (const [key, property] of Object.entries(properties)) {
|
|
111
|
-
if (!property?.excludeFromApi) continue;
|
|
112
|
-
delete row[key];
|
|
113
|
-
if (property.columnName) delete row[property.columnName];
|
|
114
|
-
}
|
|
115
|
-
return row;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
58
|
function renderTarget(
|
|
119
59
|
targetRow: Record<string, unknown>,
|
|
120
60
|
targetCollection: CollectionConfig,
|
|
@@ -124,7 +64,7 @@ function renderTarget(
|
|
|
124
64
|
if (style === "inline") {
|
|
125
65
|
// The target's columns, and only those: its address is the consumer's
|
|
126
66
|
// to derive, and merging one in overwrites a real `id` column.
|
|
127
|
-
return
|
|
67
|
+
return { ...targetRow };
|
|
128
68
|
}
|
|
129
69
|
|
|
130
70
|
const address = relationTargetAddress(targetRow, targetCollection, registry);
|
|
@@ -192,18 +132,15 @@ export function toCmsRow(
|
|
|
192
132
|
}
|
|
193
133
|
}
|
|
194
134
|
|
|
195
|
-
return
|
|
135
|
+
return normalized;
|
|
196
136
|
}
|
|
197
137
|
|
|
198
138
|
/**
|
|
199
139
|
* The row REST serves: every column under its own name, with the value Postgres
|
|
200
140
|
* returned, and relations inlined as the target's columns.
|
|
201
141
|
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
* {@link coerceDeclaredNumber}). Dates stay as the database returned them —
|
|
205
|
-
* JSON has its own opinions about dates that the admin's view-model does not
|
|
206
|
-
* share.
|
|
142
|
+
* Deliberately not normalized: REST serves what the database holds, and JSON
|
|
143
|
+
* has its own opinions about dates that the admin's view-model does not share.
|
|
207
144
|
*
|
|
208
145
|
* Keyed by the row rather than by the relation list — a REST fetch only loads
|
|
209
146
|
* the relations `include` asked for, so the row is the authority on which are
|
|
@@ -231,9 +168,9 @@ export function toRestRow(
|
|
|
231
168
|
} else if (relation && typeof value === "object" && value !== null) {
|
|
232
169
|
flat[key] = renderTarget(value as Record<string, unknown>, relation.target(), "inline", registry);
|
|
233
170
|
} else {
|
|
234
|
-
flat[key] =
|
|
171
|
+
flat[key] = value;
|
|
235
172
|
}
|
|
236
173
|
}
|
|
237
174
|
|
|
238
|
-
return
|
|
175
|
+
return flat;
|
|
239
176
|
}
|
|
@@ -1126,19 +1126,6 @@ export class DrizzleConditionBuilder {
|
|
|
1126
1126
|
throw new Error(`Vector column '${vectorSearch.property}' not found in table`);
|
|
1127
1127
|
}
|
|
1128
1128
|
|
|
1129
|
-
// The vector is interpolated as a raw SQL literal below (pgvector has no
|
|
1130
|
-
// bind form for the `::vector` cast), so every element must be a finite
|
|
1131
|
-
// number. The REST query parser already enforces this, but this builder
|
|
1132
|
-
// is a shared entry point — validate here too so no future caller can
|
|
1133
|
-
// turn an unchecked value into SQL injection.
|
|
1134
|
-
if (
|
|
1135
|
-
!Array.isArray(vectorSearch.vector) ||
|
|
1136
|
-
vectorSearch.vector.length === 0 ||
|
|
1137
|
-
!vectorSearch.vector.every((n) => typeof n === "number" && Number.isFinite(n))
|
|
1138
|
-
) {
|
|
1139
|
-
throw new Error("Vector search requires a non-empty array of finite numbers");
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
1129
|
const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
|
|
1143
1130
|
const distanceFn = vectorSearch.distance || "cosine";
|
|
1144
1131
|
|