@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.
@@ -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" | "sdk_not_generated";
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 === "uuid") return "uuid";
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
- // The generator passes any columnType straight through to drizzle,
76
- // so mirror that rather than enumerating a subset (which reported
77
- // drift for anything unlisted, e.g. smallint). Serial types are
78
- // integers with a sequence default; information_schema reports the
79
- // underlying width.
80
- const serialWidths: Record<string, string> = {
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
- // The typed SDK is opt-in — nothing in a scaffolded project imports it until
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: "info",
221
- category: "sdk_not_generated",
222
- message: "Typed SDK not generated (optional).",
223
- fix: "Run `rebase generate-sdk` if you want typed collection access"
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: true,
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
- const errorCount = issues.filter((i) => i.severity === "error").length;
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
- // Notes render as a quiet one-liner, not a full drift box.
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
- // --force replaces collections derived from the database, but the
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 saved row back through the same walk `GET /:id` serves, so a
387
- // write's answer is the read that follows it. This used to be `fetchOne`
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, Property, Relation } from "@rebasepro/types";
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 stripExcluded(coerceDeclaredNumbers({ ...targetRow }, targetCollection), targetCollection);
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 stripExcluded(normalized, collection);
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
- * Values are the ones the database returned, except where that contradicts the
203
- * declared type: a `number` property is served as a number (see
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] = coerceDeclaredNumber(value, collection.properties?.[key] as Property | undefined);
171
+ flat[key] = value;
235
172
  }
236
173
  }
237
174
 
238
- return stripExcluded(flat, collection);
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