@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g1822133

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.
Files changed (117) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/PostgresBootstrapper.d.ts +26 -0
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/{src-DlPBctw_.js → auth-users-columns-BfQHf9JE.js} +1222 -86
  5. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  6. package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
  7. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  8. package/dist/cli-helpers.d.ts +57 -1
  9. package/dist/cli-output.d.ts +34 -0
  10. package/dist/data-transformer.d.ts +7 -2
  11. package/dist/data_driver-ULAyJEi9.js +193 -0
  12. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  13. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  14. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  15. package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-CbvaGuVn.js} +170 -20
  16. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  17. package/dist/index.es.js +1815 -1023
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
  20. package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
  21. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  22. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  23. package/dist/schema/auth-schema.d.ts +102 -0
  24. package/dist/schema/auth-users-columns.d.ts +97 -0
  25. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  26. package/dist/schema/doctor.d.ts +41 -25
  27. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  28. package/dist/schema/ensure-collection-tables.d.ts +61 -7
  29. package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
  30. package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
  31. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  32. package/dist/schema/introspect-db-inference.d.ts +8 -1
  33. package/dist/schema/introspect-db-logic.d.ts +49 -0
  34. package/dist/schema/introspect-db-project.d.ts +21 -0
  35. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  36. package/dist/schema/search-column.d.ts +248 -0
  37. package/dist/security/policy-drift.d.ts +34 -0
  38. package/dist/security/rls-enforcement.d.ts +61 -5
  39. package/dist/services/FetchService.d.ts +34 -7
  40. package/dist/services/PersistService.d.ts +21 -17
  41. package/dist/services/RelationService.d.ts +9 -57
  42. package/dist/services/RelationWriteService.d.ts +82 -0
  43. package/dist/services/collection-helpers.d.ts +42 -0
  44. package/dist/services/dataService.d.ts +5 -0
  45. package/dist/services/junction-writes.d.ts +82 -0
  46. package/dist/services/realtimeService.d.ts +164 -23
  47. package/dist/services/write-denial.d.ts +36 -0
  48. package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
  49. package/dist/src-DCdn3Val.js.map +1 -0
  50. package/dist/utils/drizzle-conditions.d.ts +124 -2
  51. package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
  52. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  53. package/package.json +9 -8
  54. package/src/PostgresBackendDriver.ts +172 -6
  55. package/src/PostgresBootstrapper.ts +136 -11
  56. package/src/auth/ensure-tables.ts +212 -91
  57. package/src/auth/services.ts +82 -5
  58. package/src/backup/backup-cli.ts +59 -57
  59. package/src/cli-errors.ts +6 -6
  60. package/src/cli-helpers.ts +132 -13
  61. package/src/cli-output.ts +43 -0
  62. package/src/cli.ts +371 -161
  63. package/src/collections/buildRegistry.ts +3 -1
  64. package/src/collections/validate-relations.ts +124 -17
  65. package/src/data-transformer.ts +142 -28
  66. package/src/history/ensure-history-table.ts +9 -2
  67. package/src/schema/auth-schema.ts +17 -1
  68. package/src/schema/auth-users-columns.ts +131 -0
  69. package/src/schema/doctor-cli.ts +14 -65
  70. package/src/schema/doctor-policy-checks.ts +105 -0
  71. package/src/schema/doctor.ts +156 -77
  72. package/src/schema/ensure-collection-policies.ts +99 -6
  73. package/src/schema/ensure-collection-tables.ts +374 -32
  74. package/src/schema/generate-drizzle-schema-logic.ts +152 -66
  75. package/src/schema/generate-drizzle-schema.ts +11 -10
  76. package/src/schema/generate-postgres-ddl-logic.ts +294 -16
  77. package/src/schema/generate-postgres-ddl.ts +38 -14
  78. package/src/schema/generated-schema-staleness.ts +171 -0
  79. package/src/schema/introspect-db-inference.ts +9 -2
  80. package/src/schema/introspect-db-logic.ts +251 -75
  81. package/src/schema/introspect-db-project.ts +78 -0
  82. package/src/schema/introspect-db.ts +42 -25
  83. package/src/schema/introspect-runtime.ts +14 -2
  84. package/src/schema/non-sql-collections.test.ts +131 -0
  85. package/src/schema/rls-bootstrap-sql.ts +288 -0
  86. package/src/schema/search-column.ts +643 -0
  87. package/src/security/anonymous-grants.test.ts +4 -2
  88. package/src/security/policy-drift.test.ts +104 -3
  89. package/src/security/policy-drift.ts +129 -7
  90. package/src/security/rls-enforcement.ts +150 -7
  91. package/src/services/BranchService.ts +5 -0
  92. package/src/services/FetchService.ts +253 -115
  93. package/src/services/PersistService.ts +82 -43
  94. package/src/services/RelationService.ts +37 -696
  95. package/src/services/RelationWriteService.ts +653 -0
  96. package/src/services/cdc/trigger-cdc.ts +5 -1
  97. package/src/services/channel-history.ts +14 -0
  98. package/src/services/channel-presence.ts +13 -0
  99. package/src/services/collection-helpers.ts +89 -4
  100. package/src/services/dataService.ts +5 -0
  101. package/src/services/junction-writes.ts +295 -0
  102. package/src/services/pg-notify-listener.ts +1 -1
  103. package/src/services/realtimeService.ts +382 -118
  104. package/src/services/write-denial.ts +55 -0
  105. package/src/utils/drizzle-conditions.ts +433 -35
  106. package/src/utils/pg-error-utils.ts +8 -3
  107. package/src/websocket.ts +113 -16
  108. package/dist/ensure-collection-policies-ViG8XiPn.js +0 -57
  109. package/dist/ensure-collection-policies-ViG8XiPn.js.map +0 -1
  110. package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
  111. package/dist/policy-CeA1JcxP.js +0 -105
  112. package/dist/policy-CeA1JcxP.js.map +0 -1
  113. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  114. package/dist/src-DlPBctw_.js.map +0 -1
  115. package/dist/src-DoU9yPqq.js.map +0 -1
  116. package/dist/websocket-B2LsrINK.js.map +0 -1
  117. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -34,7 +34,9 @@ export function buildCollectionRegistry(schema: RegistrySchema): PostgresCollect
34
34
 
35
35
  if (schema.collections) {
36
36
  registry.registerMultiple(schema.collections);
37
- logger.info(
37
+ // `Auto-discovered collections` already reports the count and the
38
+ // directory they came from; this is the same fact with the names.
39
+ logger.debug(
38
40
  `📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: ` +
39
41
  `[${registry.getCollections().map(c => c.slug).join(", ")}]`
40
42
  );
@@ -2,6 +2,7 @@ import { getTableColumns } from "drizzle-orm";
2
2
  import { PgTable } from "drizzle-orm/pg-core";
3
3
  import { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
4
4
  import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
5
+ import { generateForeignKeyName, legacyForeignKeyName } from "@rebasepro/utils";
5
6
 
6
7
  import { PostgresCollectionRegistry } from "./PostgresCollectionRegistry";
7
8
 
@@ -58,6 +59,68 @@ const quote = (xs: Iterable<string>) => Array.from(xs).map(s => `\`${s}\``).join
58
59
  /** `on.from` / `on.to` accept a single column or a composite tuple. */
59
60
  const asColumns = (value: string | string[]): string[] => Array.isArray(value) ? value : [value];
60
61
 
62
+ /**
63
+ * Distinguish "this column name is wrong" from "the generated schema is old".
64
+ *
65
+ * They present identically here — a relation asks for a column the registered
66
+ * table does not have — but they are opposite problems with opposite fixes, and
67
+ * getting them the wrong way round is how the 0.12 → 0.13 upgrade bricked
68
+ * projects.
69
+ *
70
+ * The registered table is not the database. It comes from the project's
71
+ * checked-in `backend/src/schema.generated.ts`, and 0.13 changed the rule that
72
+ * derives foreign-key names: `categories` yields `category_id` where it used to
73
+ * yield `categorie_id`. Boot-ensure renames the database column to match, so by
74
+ * the time this runs the *database* is correct and the *generated module* is the
75
+ * stale one. Reporting "not a column" then points at the wrong artifact, and the
76
+ * generic fix — "set `through.targetColumn` to one of: …", listing the legacy
77
+ * name because that is what the stale module still has — talks the reader into
78
+ * pinning a column that no longer exists.
79
+ *
80
+ * So when the wanted name is what the current rule derives, and the table
81
+ * carries what the *previous* rule would have derived from the same source, say
82
+ * that instead.
83
+ *
84
+ * @param wanted the column the relation asks for
85
+ * @param available every column the registered table has
86
+ * @param sources names the default could have been derived from (a slug, a
87
+ * relation name) — checking against these rather than guessing
88
+ * backwards from `wanted` keeps the match exact
89
+ */
90
+ function staleCodegenRename(
91
+ wanted: string,
92
+ available: Set<string>,
93
+ sources: string[]
94
+ ): { legacy: string; current: string } | null {
95
+ for (const source of sources) {
96
+ if (!source) continue;
97
+ const current = generateForeignKeyName(source);
98
+ const legacy = legacyForeignKeyName(source);
99
+ // Only a name that actually moved, and only when the table still has the
100
+ // old spelling and not the new one.
101
+ if (current !== wanted || legacy === current) continue;
102
+ if (available.has(legacy) && !available.has(current)) return { legacy, current };
103
+ }
104
+ return null;
105
+ }
106
+
107
+ /** The shared explanation, so every relation kind reports it identically. */
108
+ function staleCodegenDefect(
109
+ table: string,
110
+ { legacy, current }: { legacy: string; current: string }
111
+ ): Pick<RelationDefect, "problem" | "fix"> {
112
+ return {
113
+ problem:
114
+ `the generated Drizzle schema still declares \`${legacy}\` on \`${table}\`, but this ` +
115
+ `release derives \`${current}\` — the generated schema predates the foreign-key ` +
116
+ "naming fix and no longer describes the database",
117
+ fix:
118
+ "regenerate it with `rebase schema generate` (or `pnpm run schema:generate`). The " +
119
+ "database column has already been renamed for you at boot, so nothing else is needed. " +
120
+ `To keep \`${legacy}\` instead, name it explicitly on the relation and regenerate.`
121
+ };
122
+ }
123
+
61
124
  /**
62
125
  * Relations whose names do not resolve against the registered schema.
63
126
  *
@@ -117,11 +180,20 @@ kind: relation.kind };
117
180
  switch (relation.kind) {
118
181
  case "belongsTo": {
119
182
  if (!sourceColumns.has(relation.localKey)) {
120
- defects.push({
121
- ...at,
122
- problem: `\`localKey: "${relation.localKey}"\` is not a column on \`${sourceTableName}\``,
123
- fix: `add the column, or set \`localKey\` to one of: ${quote(sourceColumns)}`
124
- });
183
+ // `localKey` defaults to the relation name run through
184
+ // the foreign-key rule, so it moves with that rule.
185
+ const stale = staleCodegenRename(
186
+ relation.localKey,
187
+ sourceColumns,
188
+ [relation.relationName, targetCollection.slug]
189
+ );
190
+ defects.push(stale
191
+ ? { ...at, ...staleCodegenDefect(sourceTableName, stale) }
192
+ : {
193
+ ...at,
194
+ problem: `\`localKey: "${relation.localKey}"\` is not a column on \`${sourceTableName}\``,
195
+ fix: `add the column, or set \`localKey\` to one of: ${quote(sourceColumns)}`
196
+ });
125
197
  }
126
198
  break;
127
199
  }
@@ -129,11 +201,20 @@ kind: relation.kind };
129
201
  case "hasOne":
130
202
  case "hasMany": {
131
203
  if (!targetColumns.has(relation.foreignKeyOnTarget)) {
132
- defects.push({
133
- ...at,
134
- problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
135
- fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
136
- });
204
+ // The default is derived from *this* collection's slug —
205
+ // the column on the target that points back here.
206
+ const stale = staleCodegenRename(
207
+ relation.foreignKeyOnTarget,
208
+ targetColumns,
209
+ [collection.slug]
210
+ );
211
+ defects.push(stale
212
+ ? { ...at, ...staleCodegenDefect(targetTableName, stale) }
213
+ : {
214
+ ...at,
215
+ problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
216
+ fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
217
+ });
137
218
  }
138
219
  // `sourceKey` is the easiest of the two to put on the wrong
139
220
  // side — it is the only column in a `hasMany` that lives
@@ -167,14 +248,24 @@ kind: relation.kind };
167
248
  break;
168
249
  }
169
250
  const junctionColumns = columnNames(junction);
251
+ // Junction columns are the ones that actually moved in 0.13:
252
+ // each defaults to its endpoint collection's *slug* run
253
+ // through the foreign-key rule, and slugs are plural.
254
+ const derivedFrom = {
255
+ sourceColumn: [collection.slug],
256
+ targetColumn: [targetCollection.slug]
257
+ } as const;
170
258
  for (const [label, column] of [["sourceColumn", sourceColumn], ["targetColumn", targetColumn]] as const) {
171
259
  if (!junctionColumns.has(column)) {
172
- defects.push({
173
- ...at,
174
- problem: `\`through.${label}: "${column}"\` is not a column on the junction table \`${table}\``,
175
- fix: `set \`through.${label}\` to one of: ${quote(junctionColumns)}` +
176
- (label === "sourceColumn" ? " — it is the column naming *this* collection" : "")
177
- });
260
+ const stale = staleCodegenRename(column, junctionColumns, [...derivedFrom[label]]);
261
+ defects.push(stale
262
+ ? { ...at, ...staleCodegenDefect(table, stale) }
263
+ : {
264
+ ...at,
265
+ problem: `\`through.${label}: "${column}"\` is not a column on the junction table \`${table}\``,
266
+ fix: `set \`through.${label}\` to one of: ${quote(junctionColumns)}` +
267
+ (label === "sourceColumn" ? " — it is the column naming *this* collection" : "")
268
+ });
178
269
  }
179
270
  }
180
271
  break;
@@ -287,9 +378,25 @@ export function assertRelationsResolve(
287
378
  );
288
379
 
289
380
  throw new Error(
290
- `${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against the database schema.\n\n` +
381
+ `${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against ` +
382
+ "`backend/src/schema.generated.ts`.\n\n" +
291
383
  "Each of these would return no rows at query time rather than reporting an error, " +
292
384
  "so they are fatal at boot instead.\n\n" +
385
+ // This reads the *generated file*, not the database, and the difference
386
+ // is the whole diagnosis after an upgrade. Boot-ensure renames columns
387
+ // in the database — a 0.12 → 0.13 upgrade singularises a junction key,
388
+ // `categorie_id` → `category_id` — and the checked-in file still
389
+ // declares the old name. The config is then correct and the file is
390
+ // stale, so the per-defect advice below, which lists the columns this
391
+ // file has, names a column that no longer exists in the database.
392
+ // Following it turns a recoverable state into a broken config.
393
+ //
394
+ // Hence the ordering: regenerate first, and only then consider that the
395
+ // collection might be the thing that is wrong.
396
+ "If the database was migrated recently — an upgrade, a `db push`, a restore — this file is\n" +
397
+ "probably older than the schema it describes. Regenerate it before changing anything else:\n\n" +
398
+ " rebase schema generate\n\n" +
399
+ "If it is already current, then the collection is what disagrees with it:\n\n" +
293
400
  lines.join("\n\n") + "\n"
294
401
  );
295
402
  }
@@ -2,11 +2,12 @@ import { eq, SQL } from "drizzle-orm";
2
2
  import { AnyPgColumn } from "drizzle-orm/pg-core";
3
3
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
4
4
  import { CollectionConfig, Properties, Property, ResolvedRelation, RelationProperty, Vector, BinaryProperty, hasForeignKeyOnTarget, type ResolvedBelongsTo, type ResolvedForeignKeyOnTarget, type ResolvedVia } from "@rebasepro/types";
5
- import { getTableName, resolveCollectionRelations, findRelation, createRelationRef, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE } from "@rebasepro/common";
5
+ import { getTableName, resolveCollectionRelations, findRelation, fieldKeyForColumn, createRelationRef, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE } from "@rebasepro/common";
6
+ import { isPrototypePollutingKey } from "@rebasepro/utils";
6
7
  import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
7
8
  import { DrizzleConditionBuilder } from "./utils/drizzle-conditions";
8
9
  import { getPrimaryKeys, buildCompositeId } from "./services/collection-helpers";
9
- import { logger } from "@rebasepro/server";
10
+ import { ApiError, logger } from "@rebasepro/server";
10
11
 
11
12
  /**
12
13
  * Data transformation utilities for converting between frontend and database formats.
@@ -68,9 +69,15 @@ export function sanitizeAndConvertDates(obj: unknown): unknown {
68
69
  if (typeof obj === "object") {
69
70
  const newObj: Record<string, unknown> = {};
70
71
  for (const key in obj) {
71
- if (Object.prototype.hasOwnProperty.call(obj, key)) {
72
- newObj[key] = sanitizeAndConvertDates((obj as Record<string, unknown>)[key]);
73
- }
72
+ if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
73
+ // `JSON.parse` creates `__proto__` as an *own* property, so it gets
74
+ // past the check above — and `newObj[key] = …` then invokes the
75
+ // prototype setter instead of creating a property. The row's
76
+ // prototype becomes whatever the request body supplied, so
77
+ // `row.isAdmin` answers `true` while `Object.keys(row)` shows
78
+ // nothing of the sort. No column can be reached by these names.
79
+ if (isPrototypePollutingKey(key)) continue;
80
+ newObj[key] = sanitizeAndConvertDates((obj as Record<string, unknown>)[key]);
74
81
  }
75
82
  return newObj;
76
83
  }
@@ -119,13 +126,30 @@ joinPathRelationUpdates: [] };
119
126
  newTargetId: string | number | null;
120
127
  }> = [];
121
128
 
122
- // Pre-calculate all local keys used as foreign keys
129
+ // Pre-calculate all local keys used as foreign keys.
130
+ //
131
+ // Keyed by the *wire* name: the payload arrives from a client, which knows
132
+ // the field as `authorId`, never as the `author_id` column behind it.
123
133
  const foreignKeys = new Set<string>();
124
134
  Object.values(resolvedRelations).forEach(relation => {
125
- if (relation.kind === "belongsTo") foreignKeys.add(relation.localKey);
135
+ if (relation.kind === "belongsTo") foreignKeys.add(fieldKeyForColumn(collection, relation.localKey));
126
136
  });
127
137
 
128
138
  for (const [key, value] of Object.entries(row)) {
139
+ // Same reasoning as `sanitizeAndConvertDates`: these keys come from the
140
+ // request body, and no column answers to them.
141
+ if (isPrototypePollutingKey(key)) continue;
142
+
143
+ // `{ subtitle: undefined }` means "I have no value for this", not "set
144
+ // this column to NULL" — it is what spreading an optional field
145
+ // produces, and what Drizzle itself skips. The key used to survive with
146
+ // `undefined` and `sanitizeAndConvertDates` then turned it into `null`,
147
+ // so `update(id, { title, subtitle: payload.subtitle })` wiped
148
+ // `subtitle` whenever the payload happened not to carry one. Unreachable
149
+ // over HTTP (JSON has no `undefined`); the in-process data API passes
150
+ // the caller's object straight through, so it is reachable there.
151
+ if (value === undefined) continue;
152
+
129
153
  const property = properties[key as keyof M] as Property;
130
154
 
131
155
  // Coerce empty strings to null for any field that acts as a foreign key
@@ -143,15 +167,17 @@ joinPathRelationUpdates: [] };
143
167
  if (relation) {
144
168
  if (relation.kind === "belongsTo") {
145
169
  // Owning relation: Map relation object to FK column on current table
146
- const serializedValue = serializePropertyToServer(effectiveValue, property);
170
+ const serializedValue = serializePropertyToServer(effectiveValue, property, key);
147
171
  if (serializedValue !== undefined) {
148
- result[relation.localKey] = serializedValue;
172
+ // The Drizzle key, not the column: Drizzle maps it back
173
+ // to `author_id` when it builds the statement.
174
+ result[fieldKeyForColumn(collection, relation.localKey)] = serializedValue;
149
175
  }
150
176
  // Don't add the original relation property to the result
151
177
  continue;
152
178
  } else if (hasForeignKeyOnTarget(relation)) {
153
179
  // Inverse relation: Need to update the target table's FK
154
- const serializedValue = serializePropertyToServer(effectiveValue, property);
180
+ const serializedValue = serializePropertyToServer(effectiveValue, property, key);
155
181
  inverseRelationUpdates.push({
156
182
  relationKey: key,
157
183
  relation,
@@ -165,7 +191,7 @@ joinPathRelationUpdates: [] };
165
191
  // There used to be two arms here — "owning" and "inverse"
166
192
  // joinPath — but a join chain has no owning side, so they
167
193
  // only ever differed by which list they pushed to.
168
- const serializedValue = serializePropertyToServer(effectiveValue, property);
194
+ const serializedValue = serializePropertyToServer(effectiveValue, property, key);
169
195
  if (relation.cardinality === "one") {
170
196
  // Ordering matters: PersistService applies these BEFORE
171
197
  // the main UPDATE, so the mapping reads the pre-update
@@ -187,7 +213,7 @@ joinPathRelationUpdates: [] };
187
213
  }
188
214
  }
189
215
 
190
- result[key] = serializePropertyToServer(effectiveValue, property);
216
+ result[key] = serializePropertyToServer(effectiveValue, property, key);
191
217
  }
192
218
 
193
219
  return {
@@ -198,19 +224,41 @@ joinPathRelationUpdates: [] };
198
224
  }
199
225
 
200
226
  /**
201
- * Serialize a single property value for database storage
227
+ * How to name a rejected value in an error, without quoting it back.
228
+ *
229
+ * The value may be anything the caller sent, including a secret in the wrong
230
+ * field, so the message describes its shape rather than echoing it — an echoed
231
+ * value ends up in logs and in error-reporting services.
202
232
  */
203
- export function serializePropertyToServer(value: unknown, property: Property): unknown {
233
+ function describeValue(value: unknown): string {
234
+ if (value === null) return "null";
235
+ if (Array.isArray(value)) return "an array";
236
+ if (value instanceof Date) return "a date";
237
+ const type = typeof value;
238
+ return type === "object" ? "an object" : `a ${type}`;
239
+ }
240
+
241
+ /**
242
+ * Serialize a single property value for database storage.
243
+ *
244
+ * `propertyKey` is only ever used to phrase errors and warnings. Without it the
245
+ * one trace a bad value left was `Expected array value for array property, got
246
+ * string` — no collection, no property, no value, which in the log of a
247
+ * thousand-row import names nothing at all.
248
+ */
249
+ export function serializePropertyToServer(value: unknown, property: Property, propertyKey?: string): unknown {
204
250
  if (value === null || value === undefined) {
205
251
  return value;
206
252
  }
207
253
 
254
+ const fieldLabel = propertyKey ? `'${propertyKey}'` : `a '${property.type}' field`;
255
+
208
256
  const propertyType = property.type;
209
257
 
210
258
  switch (propertyType) {
211
259
  case "relation":
212
260
  if (Array.isArray(value)) {
213
- return value.map(v => serializePropertyToServer(v, property));
261
+ return value.map(v => serializePropertyToServer(v, property, propertyKey));
214
262
  } else if (typeof value === "object" && value !== null && "id" in value) {
215
263
  return (value as Record<string, unknown>).id;
216
264
  }
@@ -220,7 +268,7 @@ export function serializePropertyToServer(value: unknown, property: Property): u
220
268
  case "array":
221
269
  if (Array.isArray(value)) {
222
270
  if (property.of) {
223
- return value.map(item => serializePropertyToServer(item, property.of as Property));
271
+ return value.map(item => serializePropertyToServer(item, property.of as Property, propertyKey));
224
272
  } else if (property.oneOf) {
225
273
  const typeField = property.oneOf.typeField ?? DEFAULT_ONE_OF_TYPE;
226
274
  const valueField = property.oneOf.valueField ?? DEFAULT_ONE_OF_VALUE;
@@ -233,15 +281,50 @@ export function serializePropertyToServer(value: unknown, property: Property): u
233
281
  if (!type || !childProperty) return e;
234
282
  return {
235
283
  [typeField]: type,
236
- [valueField]: serializePropertyToServer(rec[valueField], childProperty)
284
+ [valueField]: serializePropertyToServer(rec[valueField], childProperty, propertyKey)
237
285
  };
238
286
  });
239
287
  }
240
288
  return value;
241
289
  }
242
- // Non-array value for an array propertycoerce to avoid .map() crashes downstream
243
- logger.warn(`Expected array value for array property, got ${typeof value}. Coercing to empty array.`);
244
- return [];
290
+ // A non-array value used to become `[]` here the caller's value
291
+ // destroyed, answered 200/201, and the row reading back as an empty
292
+ // list on every later fetch. `POST /posts {"tags":"news"}` from a
293
+ // client that sent a single tag as a scalar, or a CSV import that
294
+ // did not split a column, was unrecoverable data loss on a `text[]`
295
+ // column. The stated reason for coercing — "avoid .map() crashes
296
+ // downstream" — is better served by refusing the value at the
297
+ // boundary, which is what a 400 does.
298
+ //
299
+ // The read-side twin (`parsePropertyFromServer`) still coerces, and
300
+ // should: a row already in the database is not this caller's fault.
301
+ throw ApiError.badRequest(
302
+ `${fieldLabel} expects an array, but received ${describeValue(value)}.`,
303
+ "VALIDATION_INVALID_VALUE"
304
+ );
305
+
306
+ case "geopoint": {
307
+ // Stored as `jsonb`, in the `{ latitude, longitude }` shape the
308
+ // OpenAPI schema and the generated TS type both promise. Nothing
309
+ // used to handle `geopoint` on either side, which is why the type
310
+ // was documented, code-generated, admin-editable — and dropped.
311
+ if (typeof value !== "object" || Array.isArray(value)) {
312
+ throw ApiError.badRequest(
313
+ `${fieldLabel} expects a geopoint object with \`latitude\` and \`longitude\`, ` +
314
+ `but received ${describeValue(value)}.`,
315
+ "VALIDATION_INVALID_VALUE"
316
+ );
317
+ }
318
+ const point = value as Record<string, unknown>;
319
+ if (typeof point.latitude !== "number" || typeof point.longitude !== "number") {
320
+ throw ApiError.badRequest(
321
+ `${fieldLabel} expects a geopoint object with numeric \`latitude\` and \`longitude\`.`,
322
+ "VALIDATION_INVALID_VALUE"
323
+ );
324
+ }
325
+ return { latitude: point.latitude,
326
+ longitude: point.longitude };
327
+ }
245
328
 
246
329
 
247
330
  case "map":
@@ -250,7 +333,7 @@ export function serializePropertyToServer(value: unknown, property: Property): u
250
333
  for (const [subKey, subValue] of Object.entries(value)) {
251
334
  const subProperty = (property.properties as Properties)[subKey];
252
335
  if (subProperty) {
253
- result[subKey] = serializePropertyToServer(subValue, subProperty);
336
+ result[subKey] = serializePropertyToServer(subValue, subProperty, propertyKey ? `${propertyKey}.${subKey}` : subKey);
254
337
  } else {
255
338
  result[subKey] = subValue;
256
339
  }
@@ -311,9 +394,12 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
311
394
  // Find the normalized relation for this property
312
395
  const relation = findRelation(resolvedRelations, propKey);
313
396
  if (relation) {
314
- if (relation.kind === "belongsTo" && relation.localKey in data) {
315
- // Owning relation: FK is in current table
316
- const fkValue = data[relation.localKey as keyof M];
397
+ const localField = relation.kind === "belongsTo"
398
+ ? fieldKeyForColumn(collection, relation.localKey)
399
+ : "";
400
+ if (relation.kind === "belongsTo" && localField in data) {
401
+ // Owning relation: FK is in current table, under its wire name
402
+ const fkValue = data[localField as keyof M];
317
403
  if (fkValue !== null && fkValue !== undefined) {
318
404
  try {
319
405
  const targetCollection = relation.target();
@@ -337,7 +423,8 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
337
423
  : buildCompositeId(data, pks);
338
424
 
339
425
  if (targetTable && currentId !== undefined && currentId !== null && currentId !== "") {
340
- const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget as keyof typeof targetTable] as AnyPgColumn;
426
+ const fkFieldKey = fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget);
427
+ const foreignKeyColumn = targetTable[fkFieldKey as keyof typeof targetTable] as AnyPgColumn;
341
428
  if (foreignKeyColumn) {
342
429
  // Query the target table to find row that references this row
343
430
  const relatedRows = await db
@@ -616,6 +703,13 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
616
703
  }
617
704
  return value;
618
705
 
706
+ case "geopoint":
707
+ // A `jsonb` column, so node-postgres hands back the object already.
708
+ // Named explicitly rather than left to `default:`, which would run
709
+ // it past the Buffer probe — and because a type with a write
710
+ // serializer and no read counterpart is how this one went missing.
711
+ return value;
712
+
619
713
  case "vector": {
620
714
  let nums: number[] = [];
621
715
  if (typeof value === "string") {
@@ -675,6 +769,15 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
675
769
  * from the result (used by `normalizeDbValues` where
676
770
  * Drizzle's relational API already hydrates them).
677
771
  */
772
+ /**
773
+ * Keys a query computes and attaches to a row, rather than reads from a column.
774
+ *
775
+ * An explicit set rather than a `_` prefix rule: a user's column may perfectly
776
+ * well be called `_internal`, and passing it through here would put a value on
777
+ * the row that no property describes and nothing downstream knows how to type.
778
+ */
779
+ const QUERY_METADATA_KEYS = new Set(["_score", "_distance", "_matches"]);
780
+
678
781
  function normalizeScalarValues<M extends Record<string, unknown>>(
679
782
  data: M,
680
783
  properties: Properties,
@@ -687,9 +790,11 @@ function normalizeScalarValues<M extends Record<string, unknown>>(
687
790
  // Identify FK columns used only for relations and not exposed as properties
688
791
  const internalFKColumns = new Set<string>();
689
792
  Object.values(resolvedRelations).forEach(relation => {
690
- if (relation.kind === "belongsTo" && !properties[relation.localKey]) {
691
- internalFKColumns.add(relation.localKey);
692
- }
793
+ if (relation.kind !== "belongsTo") return;
794
+ // The key a row carries this foreign key under, which is the wire name
795
+ // — `authorId`, not the `author_id` column.
796
+ const localField = fieldKeyForColumn(collection, relation.localKey);
797
+ if (!properties[localField]) internalFKColumns.add(localField);
693
798
  });
694
799
 
695
800
  for (const [key, value] of Object.entries(data)) {
@@ -699,6 +804,15 @@ function normalizeScalarValues<M extends Record<string, unknown>>(
699
804
  continue;
700
805
  }
701
806
 
807
+ // Query-computed metadata, which by definition is not a column and so
808
+ // has no property to be found. Dropped here until now — which meant
809
+ // `_distance` was computed for every vector search on this path and
810
+ // then discarded before the caller ever saw it.
811
+ if (QUERY_METADATA_KEYS.has(key)) {
812
+ result[key] = value;
813
+ continue;
814
+ }
815
+
702
816
  const property = properties[key as keyof M] as Property;
703
817
  if (!property) continue; // Skip DB columns not defined in properties
704
818
 
@@ -1,6 +1,7 @@
1
1
  import { sql } from "drizzle-orm";
2
2
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
3
3
  import { logger } from "@rebasepro/server";
4
+ import { revokeInternalTableSql } from "@rebasepro/common";
4
5
 
5
6
  /**
6
7
  * Auto-create the row history table if it doesn't exist.
@@ -8,7 +9,7 @@ import { logger } from "@rebasepro/server";
8
9
  * pattern as `ensureAuthTablesExist`.
9
10
  */
10
11
  export async function ensureHistoryTableExists(db: NodePgDatabase): Promise<void> {
11
- logger.info("🔍 Checking row history table...");
12
+ logger.debug("🔍 Checking row history table...");
12
13
 
13
14
  try {
14
15
  // Create the rebase schema (idempotent — may already exist from auth init)
@@ -38,7 +39,13 @@ export async function ensureHistoryTableExists(db: NodePgDatabase): Promise<void
38
39
  ON rebase.entity_history(table_name, entity_id, updated_at DESC)
39
40
  `);
40
41
 
41
- logger.info("✅ Entity history table ready");
42
+ // Every previous value of every audited row, in one table with no RLS
43
+ // and no tenant scoping — so a readable copy defeats the row policies on
44
+ // the tables it shadows. The driver's schema-wide grant reaches it
45
+ // (created here, after that grant ran), so take it back.
46
+ await db.execute(sql.raw(revokeInternalTableSql("rebase", "entity_history")));
47
+
48
+ logger.debug("✅ Entity history table ready");
42
49
  } catch (error) {
43
50
  logger.error("❌ Failed to create row history table", { error: error });
44
51
  logger.warn("⚠️ Continuing without creating history table.");
@@ -1,4 +1,4 @@
1
- import { pgSchema, pgTable, uuid, timestamp, boolean, jsonb, text, unique, index } from "drizzle-orm/pg-core";
1
+ import { pgSchema, pgTable, uuid, timestamp, boolean, jsonb, text, unique, index, integer, bigint } from "drizzle-orm/pg-core";
2
2
  import { relations } from "drizzle-orm";
3
3
 
4
4
  /**
@@ -87,6 +87,13 @@ export function createAuthSchema(usersSchemaName = "rebase") {
87
87
  * that rotates immediately after it.
88
88
  */
89
89
  sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
90
+ /**
91
+ * The assurance level the sign-in was established at — `aal2` only
92
+ * where a second factor was actually presented. Carried across
93
+ * rotations, because refresh is not a new authentication and has
94
+ * nothing else to read the level from.
95
+ */
96
+ aal: text("aal"),
90
97
  userAgent: text("user_agent"),
91
98
  ipAddress: text("ip_address"),
92
99
  createdAt: timestamp("created_at").defaultNow().notNull()
@@ -140,6 +147,13 @@ export function createAuthSchema(usersSchemaName = "rebase") {
140
147
  secretEncrypted: text("secret_encrypted").notNull(),
141
148
  friendlyName: text("friendly_name"),
142
149
  verified: boolean("verified").default(false).notNull(),
150
+ /**
151
+ * The highest TOTP time step ever accepted for this factor. RFC 6238
152
+ * §5.2 forbids accepting an OTP twice, and the ±1 step window that
153
+ * exists for clock drift is also a 90-second replay window: without
154
+ * this, one observed code buys a fresh session for a minute and a half.
155
+ */
156
+ lastUsedCounter: bigint("last_used_counter", { mode: "number" }),
143
157
  createdAt: timestamp("created_at").defaultNow().notNull(),
144
158
  updatedAt: timestamp("updated_at").defaultNow().notNull()
145
159
  });
@@ -153,6 +167,8 @@ export function createAuthSchema(usersSchemaName = "rebase") {
153
167
  createdAt: timestamp("created_at").defaultNow().notNull(),
154
168
  verifiedAt: timestamp("verified_at"),
155
169
  ipAddress: text("ip_address"),
170
+ /** Failed guesses recorded against this challenge; bounded by the route. */
171
+ attempts: integer("attempts").default(0).notNull(),
156
172
  expiresAt: timestamp("expires_at").notNull()
157
173
  });
158
174