@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.
- package/dist/PostgresBackendDriver.d.ts +48 -1
- package/dist/PostgresBootstrapper.d.ts +26 -0
- package/dist/auth/services.d.ts +21 -0
- package/dist/{src-DlPBctw_.js → auth-users-columns-BfQHf9JE.js} +1222 -86
- package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
- package/dist/cli-helpers.d.ts +57 -1
- package/dist/cli-output.d.ts +34 -0
- package/dist/data-transformer.d.ts +7 -2
- package/dist/data_driver-ULAyJEi9.js +193 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
- package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-CbvaGuVn.js} +170 -20
- package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
- package/dist/index.es.js +1815 -1023
- package/dist/index.es.js.map +1 -1
- package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
- package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
- package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
- package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +102 -0
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/doctor-policy-checks.d.ts +28 -0
- package/dist/schema/doctor.d.ts +41 -25
- package/dist/schema/ensure-collection-policies.d.ts +33 -9
- package/dist/schema/ensure-collection-tables.d.ts +61 -7
- package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- package/dist/schema/introspect-db-inference.d.ts +8 -1
- package/dist/schema/introspect-db-logic.d.ts +49 -0
- package/dist/schema/introspect-db-project.d.ts +21 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +248 -0
- package/dist/security/policy-drift.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +61 -5
- package/dist/services/FetchService.d.ts +34 -7
- package/dist/services/PersistService.d.ts +21 -17
- package/dist/services/RelationService.d.ts +9 -57
- package/dist/services/RelationWriteService.d.ts +82 -0
- package/dist/services/collection-helpers.d.ts +42 -0
- package/dist/services/dataService.d.ts +5 -0
- package/dist/services/junction-writes.d.ts +82 -0
- package/dist/services/realtimeService.d.ts +164 -23
- package/dist/services/write-denial.d.ts +36 -0
- package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
- package/dist/src-DCdn3Val.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +124 -2
- package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
- package/dist/websocket-C8ZqVBiV.js.map +1 -0
- package/package.json +9 -8
- package/src/PostgresBackendDriver.ts +172 -6
- package/src/PostgresBootstrapper.ts +136 -11
- package/src/auth/ensure-tables.ts +212 -91
- package/src/auth/services.ts +82 -5
- package/src/backup/backup-cli.ts +59 -57
- package/src/cli-errors.ts +6 -6
- package/src/cli-helpers.ts +132 -13
- package/src/cli-output.ts +43 -0
- package/src/cli.ts +371 -161
- package/src/collections/buildRegistry.ts +3 -1
- package/src/collections/validate-relations.ts +124 -17
- package/src/data-transformer.ts +142 -28
- package/src/history/ensure-history-table.ts +9 -2
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor-cli.ts +14 -65
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +156 -77
- package/src/schema/ensure-collection-policies.ts +99 -6
- package/src/schema/ensure-collection-tables.ts +374 -32
- package/src/schema/generate-drizzle-schema-logic.ts +152 -66
- package/src/schema/generate-drizzle-schema.ts +11 -10
- package/src/schema/generate-postgres-ddl-logic.ts +294 -16
- package/src/schema/generate-postgres-ddl.ts +38 -14
- package/src/schema/generated-schema-staleness.ts +171 -0
- package/src/schema/introspect-db-inference.ts +9 -2
- package/src/schema/introspect-db-logic.ts +251 -75
- package/src/schema/introspect-db-project.ts +78 -0
- package/src/schema/introspect-db.ts +42 -25
- package/src/schema/introspect-runtime.ts +14 -2
- package/src/schema/non-sql-collections.test.ts +131 -0
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/schema/search-column.ts +643 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/policy-drift.test.ts +104 -3
- package/src/security/policy-drift.ts +129 -7
- package/src/security/rls-enforcement.ts +150 -7
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +253 -115
- package/src/services/PersistService.ts +82 -43
- package/src/services/RelationService.ts +37 -696
- package/src/services/RelationWriteService.ts +653 -0
- package/src/services/cdc/trigger-cdc.ts +5 -1
- package/src/services/channel-history.ts +14 -0
- package/src/services/channel-presence.ts +13 -0
- package/src/services/collection-helpers.ts +89 -4
- package/src/services/dataService.ts +5 -0
- package/src/services/junction-writes.ts +295 -0
- package/src/services/pg-notify-listener.ts +1 -1
- package/src/services/realtimeService.ts +382 -118
- package/src/services/write-denial.ts +55 -0
- package/src/utils/drizzle-conditions.ts +433 -35
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/ensure-collection-policies-ViG8XiPn.js +0 -57
- package/dist/ensure-collection-policies-ViG8XiPn.js.map +0 -1
- package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
- package/dist/policy-CeA1JcxP.js +0 -105
- package/dist/policy-CeA1JcxP.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-DlPBctw_.js.map +0 -1
- package/dist/src-DoU9yPqq.js.map +0 -1
- package/dist/websocket-B2LsrINK.js.map +0 -1
- 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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
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
|
}
|
package/src/data-transformer.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
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
|
|
691
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|