@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g06dbe5b
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/auth/services.d.ts +19 -0
- package/dist/{src-DlPBctw_.js → auth-users-columns-CBEOeYqa.js} +835 -63
- package/dist/auth-users-columns-CBEOeYqa.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
- package/dist/cli-helpers.d.ts +57 -1
- 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-ViG8XiPn.js → ensure-collection-policies-B_JMGa5K.js} +2 -2
- package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-B_JMGa5K.js.map} +1 -1
- package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DzeTEvMv.js} +170 -20
- package/dist/ensure-collection-tables-DzeTEvMv.js.map +1 -0
- package/dist/index.es.js +1365 -338
- package/dist/index.es.js.map +1 -1
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.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 +23 -25
- 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/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +248 -0
- package/dist/security/rls-enforcement.d.ts +61 -5
- package/dist/services/FetchService.d.ts +34 -7
- package/dist/services/RelationService.d.ts +30 -0
- package/dist/services/collection-helpers.d.ts +26 -0
- package/dist/services/dataService.d.ts +5 -0
- package/dist/services/realtimeService.d.ts +131 -21
- package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
- package/dist/src-C_wvdMnl.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +124 -2
- package/dist/{websocket-B2LsrINK.js → websocket-D1qbmLZ2.js} +75 -18
- package/dist/websocket-D1qbmLZ2.js.map +1 -0
- package/package.json +9 -8
- package/src/PostgresBackendDriver.ts +172 -6
- package/src/PostgresBootstrapper.ts +55 -6
- package/src/auth/ensure-tables.ts +207 -86
- package/src/auth/services.ts +69 -5
- package/src/cli-helpers.ts +129 -10
- package/src/cli.ts +232 -30
- package/src/collections/validate-relations.ts +124 -17
- package/src/data-transformer.ts +120 -17
- package/src/history/ensure-history-table.ts +7 -0
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor-cli.ts +12 -63
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +135 -76
- package/src/schema/ensure-collection-tables.ts +374 -32
- package/src/schema/generate-drizzle-schema-logic.ts +132 -42
- package/src/schema/generate-postgres-ddl-logic.ts +294 -16
- package/src/schema/generate-postgres-ddl.ts +25 -2
- package/src/schema/generated-schema-staleness.ts +169 -0
- package/src/schema/introspect-db-logic.ts +66 -34
- 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/rls-enforcement.ts +149 -6
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +175 -108
- package/src/services/PersistService.ts +38 -2
- package/src/services/RelationService.ts +110 -67
- package/src/services/channel-history.ts +14 -0
- package/src/services/channel-presence.ts +13 -0
- package/src/services/collection-helpers.ts +54 -1
- package/src/services/dataService.ts +5 -0
- package/src/services/realtimeService.ts +344 -79
- package/src/utils/drizzle-conditions.ts +365 -23
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- 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
|
@@ -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
|
@@ -3,10 +3,11 @@ 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
5
|
import { getTableName, resolveCollectionRelations, findRelation, 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
|
}
|
|
@@ -126,6 +133,20 @@ joinPathRelationUpdates: [] };
|
|
|
126
133
|
});
|
|
127
134
|
|
|
128
135
|
for (const [key, value] of Object.entries(row)) {
|
|
136
|
+
// Same reasoning as `sanitizeAndConvertDates`: these keys come from the
|
|
137
|
+
// request body, and no column answers to them.
|
|
138
|
+
if (isPrototypePollutingKey(key)) continue;
|
|
139
|
+
|
|
140
|
+
// `{ subtitle: undefined }` means "I have no value for this", not "set
|
|
141
|
+
// this column to NULL" — it is what spreading an optional field
|
|
142
|
+
// produces, and what Drizzle itself skips. The key used to survive with
|
|
143
|
+
// `undefined` and `sanitizeAndConvertDates` then turned it into `null`,
|
|
144
|
+
// so `update(id, { title, subtitle: payload.subtitle })` wiped
|
|
145
|
+
// `subtitle` whenever the payload happened not to carry one. Unreachable
|
|
146
|
+
// over HTTP (JSON has no `undefined`); the in-process data API passes
|
|
147
|
+
// the caller's object straight through, so it is reachable there.
|
|
148
|
+
if (value === undefined) continue;
|
|
149
|
+
|
|
129
150
|
const property = properties[key as keyof M] as Property;
|
|
130
151
|
|
|
131
152
|
// Coerce empty strings to null for any field that acts as a foreign key
|
|
@@ -143,7 +164,7 @@ joinPathRelationUpdates: [] };
|
|
|
143
164
|
if (relation) {
|
|
144
165
|
if (relation.kind === "belongsTo") {
|
|
145
166
|
// Owning relation: Map relation object to FK column on current table
|
|
146
|
-
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
167
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property, key);
|
|
147
168
|
if (serializedValue !== undefined) {
|
|
148
169
|
result[relation.localKey] = serializedValue;
|
|
149
170
|
}
|
|
@@ -151,7 +172,7 @@ joinPathRelationUpdates: [] };
|
|
|
151
172
|
continue;
|
|
152
173
|
} else if (hasForeignKeyOnTarget(relation)) {
|
|
153
174
|
// Inverse relation: Need to update the target table's FK
|
|
154
|
-
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
175
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property, key);
|
|
155
176
|
inverseRelationUpdates.push({
|
|
156
177
|
relationKey: key,
|
|
157
178
|
relation,
|
|
@@ -165,7 +186,7 @@ joinPathRelationUpdates: [] };
|
|
|
165
186
|
// There used to be two arms here — "owning" and "inverse"
|
|
166
187
|
// joinPath — but a join chain has no owning side, so they
|
|
167
188
|
// only ever differed by which list they pushed to.
|
|
168
|
-
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
189
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property, key);
|
|
169
190
|
if (relation.cardinality === "one") {
|
|
170
191
|
// Ordering matters: PersistService applies these BEFORE
|
|
171
192
|
// the main UPDATE, so the mapping reads the pre-update
|
|
@@ -187,7 +208,7 @@ joinPathRelationUpdates: [] };
|
|
|
187
208
|
}
|
|
188
209
|
}
|
|
189
210
|
|
|
190
|
-
result[key] = serializePropertyToServer(effectiveValue, property);
|
|
211
|
+
result[key] = serializePropertyToServer(effectiveValue, property, key);
|
|
191
212
|
}
|
|
192
213
|
|
|
193
214
|
return {
|
|
@@ -198,19 +219,41 @@ joinPathRelationUpdates: [] };
|
|
|
198
219
|
}
|
|
199
220
|
|
|
200
221
|
/**
|
|
201
|
-
*
|
|
222
|
+
* How to name a rejected value in an error, without quoting it back.
|
|
223
|
+
*
|
|
224
|
+
* The value may be anything the caller sent, including a secret in the wrong
|
|
225
|
+
* field, so the message describes its shape rather than echoing it — an echoed
|
|
226
|
+
* value ends up in logs and in error-reporting services.
|
|
202
227
|
*/
|
|
203
|
-
|
|
228
|
+
function describeValue(value: unknown): string {
|
|
229
|
+
if (value === null) return "null";
|
|
230
|
+
if (Array.isArray(value)) return "an array";
|
|
231
|
+
if (value instanceof Date) return "a date";
|
|
232
|
+
const type = typeof value;
|
|
233
|
+
return type === "object" ? "an object" : `a ${type}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Serialize a single property value for database storage.
|
|
238
|
+
*
|
|
239
|
+
* `propertyKey` is only ever used to phrase errors and warnings. Without it the
|
|
240
|
+
* one trace a bad value left was `Expected array value for array property, got
|
|
241
|
+
* string` — no collection, no property, no value, which in the log of a
|
|
242
|
+
* thousand-row import names nothing at all.
|
|
243
|
+
*/
|
|
244
|
+
export function serializePropertyToServer(value: unknown, property: Property, propertyKey?: string): unknown {
|
|
204
245
|
if (value === null || value === undefined) {
|
|
205
246
|
return value;
|
|
206
247
|
}
|
|
207
248
|
|
|
249
|
+
const fieldLabel = propertyKey ? `'${propertyKey}'` : `a '${property.type}' field`;
|
|
250
|
+
|
|
208
251
|
const propertyType = property.type;
|
|
209
252
|
|
|
210
253
|
switch (propertyType) {
|
|
211
254
|
case "relation":
|
|
212
255
|
if (Array.isArray(value)) {
|
|
213
|
-
return value.map(v => serializePropertyToServer(v, property));
|
|
256
|
+
return value.map(v => serializePropertyToServer(v, property, propertyKey));
|
|
214
257
|
} else if (typeof value === "object" && value !== null && "id" in value) {
|
|
215
258
|
return (value as Record<string, unknown>).id;
|
|
216
259
|
}
|
|
@@ -220,7 +263,7 @@ export function serializePropertyToServer(value: unknown, property: Property): u
|
|
|
220
263
|
case "array":
|
|
221
264
|
if (Array.isArray(value)) {
|
|
222
265
|
if (property.of) {
|
|
223
|
-
return value.map(item => serializePropertyToServer(item, property.of as Property));
|
|
266
|
+
return value.map(item => serializePropertyToServer(item, property.of as Property, propertyKey));
|
|
224
267
|
} else if (property.oneOf) {
|
|
225
268
|
const typeField = property.oneOf.typeField ?? DEFAULT_ONE_OF_TYPE;
|
|
226
269
|
const valueField = property.oneOf.valueField ?? DEFAULT_ONE_OF_VALUE;
|
|
@@ -233,15 +276,50 @@ export function serializePropertyToServer(value: unknown, property: Property): u
|
|
|
233
276
|
if (!type || !childProperty) return e;
|
|
234
277
|
return {
|
|
235
278
|
[typeField]: type,
|
|
236
|
-
[valueField]: serializePropertyToServer(rec[valueField], childProperty)
|
|
279
|
+
[valueField]: serializePropertyToServer(rec[valueField], childProperty, propertyKey)
|
|
237
280
|
};
|
|
238
281
|
});
|
|
239
282
|
}
|
|
240
283
|
return value;
|
|
241
284
|
}
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
285
|
+
// A non-array value used to become `[]` here — the caller's value
|
|
286
|
+
// destroyed, answered 200/201, and the row reading back as an empty
|
|
287
|
+
// list on every later fetch. `POST /posts {"tags":"news"}` from a
|
|
288
|
+
// client that sent a single tag as a scalar, or a CSV import that
|
|
289
|
+
// did not split a column, was unrecoverable data loss on a `text[]`
|
|
290
|
+
// column. The stated reason for coercing — "avoid .map() crashes
|
|
291
|
+
// downstream" — is better served by refusing the value at the
|
|
292
|
+
// boundary, which is what a 400 does.
|
|
293
|
+
//
|
|
294
|
+
// The read-side twin (`parsePropertyFromServer`) still coerces, and
|
|
295
|
+
// should: a row already in the database is not this caller's fault.
|
|
296
|
+
throw ApiError.badRequest(
|
|
297
|
+
`${fieldLabel} expects an array, but received ${describeValue(value)}.`,
|
|
298
|
+
"VALIDATION_INVALID_VALUE"
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
case "geopoint": {
|
|
302
|
+
// Stored as `jsonb`, in the `{ latitude, longitude }` shape the
|
|
303
|
+
// OpenAPI schema and the generated TS type both promise. Nothing
|
|
304
|
+
// used to handle `geopoint` on either side, which is why the type
|
|
305
|
+
// was documented, code-generated, admin-editable — and dropped.
|
|
306
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
307
|
+
throw ApiError.badRequest(
|
|
308
|
+
`${fieldLabel} expects a geopoint object with \`latitude\` and \`longitude\`, ` +
|
|
309
|
+
`but received ${describeValue(value)}.`,
|
|
310
|
+
"VALIDATION_INVALID_VALUE"
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
const point = value as Record<string, unknown>;
|
|
314
|
+
if (typeof point.latitude !== "number" || typeof point.longitude !== "number") {
|
|
315
|
+
throw ApiError.badRequest(
|
|
316
|
+
`${fieldLabel} expects a geopoint object with numeric \`latitude\` and \`longitude\`.`,
|
|
317
|
+
"VALIDATION_INVALID_VALUE"
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
return { latitude: point.latitude,
|
|
321
|
+
longitude: point.longitude };
|
|
322
|
+
}
|
|
245
323
|
|
|
246
324
|
|
|
247
325
|
case "map":
|
|
@@ -250,7 +328,7 @@ export function serializePropertyToServer(value: unknown, property: Property): u
|
|
|
250
328
|
for (const [subKey, subValue] of Object.entries(value)) {
|
|
251
329
|
const subProperty = (property.properties as Properties)[subKey];
|
|
252
330
|
if (subProperty) {
|
|
253
|
-
result[subKey] = serializePropertyToServer(subValue, subProperty);
|
|
331
|
+
result[subKey] = serializePropertyToServer(subValue, subProperty, propertyKey ? `${propertyKey}.${subKey}` : subKey);
|
|
254
332
|
} else {
|
|
255
333
|
result[subKey] = subValue;
|
|
256
334
|
}
|
|
@@ -616,6 +694,13 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
|
|
|
616
694
|
}
|
|
617
695
|
return value;
|
|
618
696
|
|
|
697
|
+
case "geopoint":
|
|
698
|
+
// A `jsonb` column, so node-postgres hands back the object already.
|
|
699
|
+
// Named explicitly rather than left to `default:`, which would run
|
|
700
|
+
// it past the Buffer probe — and because a type with a write
|
|
701
|
+
// serializer and no read counterpart is how this one went missing.
|
|
702
|
+
return value;
|
|
703
|
+
|
|
619
704
|
case "vector": {
|
|
620
705
|
let nums: number[] = [];
|
|
621
706
|
if (typeof value === "string") {
|
|
@@ -675,6 +760,15 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
|
|
|
675
760
|
* from the result (used by `normalizeDbValues` where
|
|
676
761
|
* Drizzle's relational API already hydrates them).
|
|
677
762
|
*/
|
|
763
|
+
/**
|
|
764
|
+
* Keys a query computes and attaches to a row, rather than reads from a column.
|
|
765
|
+
*
|
|
766
|
+
* An explicit set rather than a `_` prefix rule: a user's column may perfectly
|
|
767
|
+
* well be called `_internal`, and passing it through here would put a value on
|
|
768
|
+
* the row that no property describes and nothing downstream knows how to type.
|
|
769
|
+
*/
|
|
770
|
+
const QUERY_METADATA_KEYS = new Set(["_score", "_distance", "_matches"]);
|
|
771
|
+
|
|
678
772
|
function normalizeScalarValues<M extends Record<string, unknown>>(
|
|
679
773
|
data: M,
|
|
680
774
|
properties: Properties,
|
|
@@ -699,6 +793,15 @@ function normalizeScalarValues<M extends Record<string, unknown>>(
|
|
|
699
793
|
continue;
|
|
700
794
|
}
|
|
701
795
|
|
|
796
|
+
// Query-computed metadata, which by definition is not a column and so
|
|
797
|
+
// has no property to be found. Dropped here until now — which meant
|
|
798
|
+
// `_distance` was computed for every vector search on this path and
|
|
799
|
+
// then discarded before the caller ever saw it.
|
|
800
|
+
if (QUERY_METADATA_KEYS.has(key)) {
|
|
801
|
+
result[key] = value;
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
|
|
702
805
|
const property = properties[key as keyof M] as Property;
|
|
703
806
|
if (!property) continue; // Skip DB columns not defined in properties
|
|
704
807
|
|
|
@@ -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.
|
|
@@ -38,6 +39,12 @@ 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
|
|
|
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
|
+
|
|
41
48
|
logger.info("✅ Entity history table ready");
|
|
42
49
|
} catch (error) {
|
|
43
50
|
logger.error("❌ Failed to create row history table", { error: error });
|
|
@@ -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
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one description of what an auth user table's columns must be.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* Three different code paths create `rebase.users`, and before this module they
|
|
7
|
+
* disagreed about it:
|
|
8
|
+
*
|
|
9
|
+
* | column | `db push` (generator) | `ensureAuthTablesExist` | `ensureCollectionTables` |
|
|
10
|
+
* |------------------|-----------------------|-------------------------|--------------------------|
|
|
11
|
+
* | `email` | `TEXT UNIQUE NOT NULL`| `TEXT NOT NULL` + CHECK | `TEXT` (nullable) |
|
|
12
|
+
* | `roles` | `TEXT[]` | `NOT NULL DEFAULT '{}'` | nullable, no default |
|
|
13
|
+
* | `email_verified` | `BOOLEAN` | `NOT NULL DEFAULT FALSE`| nullable |
|
|
14
|
+
* | `created_at` | `DEFAULT now()` | `NOT NULL DEFAULT NOW()`| no default |
|
|
15
|
+
*
|
|
16
|
+
* They are all `CREATE TABLE IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS`, so
|
|
17
|
+
* whichever ran first decided the table and the other two silently no-op'd.
|
|
18
|
+
* Boot order therefore chose the constraints: a managed deploy (collection
|
|
19
|
+
* tables first) got a users table whose `email` was nullable and whose
|
|
20
|
+
* `email_verified` had no default, while the same project pushed from a
|
|
21
|
+
* checkout got the strict one. In the same table, `is_anonymous` came out
|
|
22
|
+
* `NOT NULL DEFAULT false` — because *that* column existed in only one of the
|
|
23
|
+
* three lists, so its owner's definition won by default.
|
|
24
|
+
*
|
|
25
|
+
* A second consequence was drift in the other direction. The scaffold's
|
|
26
|
+
* `users.ts` describes 12 columns; auth needs 14. `db push` is declarative and
|
|
27
|
+
* builds its desired state from the collection alone, so the two columns only
|
|
28
|
+
* auth knows about (`is_anonymous`, `tokens_valid_after`) read as unmanaged
|
|
29
|
+
* drift, and a push run after the server had booted once planned to DROP them.
|
|
30
|
+
*
|
|
31
|
+
* Both problems are the same problem: no single place said what this table is.
|
|
32
|
+
* This is that place. Every creator asks here first and only then falls back to
|
|
33
|
+
* the collection's own property definitions, so a column auth owns has one
|
|
34
|
+
* definition regardless of who gets there first, and a column the developer
|
|
35
|
+
* added to their users collection still behaves like any other field.
|
|
36
|
+
*
|
|
37
|
+
* ## What belongs here
|
|
38
|
+
*
|
|
39
|
+
* Only columns the auth services read or write. A developer's own additions to
|
|
40
|
+
* their users collection (`bio`, `stripe_customer_id`) are ordinary columns and
|
|
41
|
+
* must NOT be listed — they are generated from the collection like every other
|
|
42
|
+
* field, and listing them here would freeze a user's schema.
|
|
43
|
+
*
|
|
44
|
+
* `id` is deliberately absent: its type comes from the collection's id property
|
|
45
|
+
* (uuid / increment / text) and each creator already derives it.
|
|
46
|
+
*
|
|
47
|
+
* Keep in step with {@link AUTH_SCHEMA_VERSION} when a change here makes an
|
|
48
|
+
* older runtime unable to work against a migrated table.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A column auth owns.
|
|
53
|
+
*
|
|
54
|
+
* Structured rather than one SQL string because the same facts are needed in
|
|
55
|
+
* three grammars — a `CREATE TABLE` column list, an `ADD COLUMN IF NOT EXISTS`,
|
|
56
|
+
* and a pair of `ALTER COLUMN … SET DEFAULT` / `SET NOT NULL` reconciles for a
|
|
57
|
+
* table that already exists with the wrong shape. Parsing a string back apart
|
|
58
|
+
* for the third of those is how the copies drifted in the first place.
|
|
59
|
+
*/
|
|
60
|
+
export interface AuthUsersColumn {
|
|
61
|
+
/** Physical column name. */
|
|
62
|
+
column: string;
|
|
63
|
+
/** Postgres type. */
|
|
64
|
+
type: string;
|
|
65
|
+
/** Default expression, verbatim, or absent for no default. */
|
|
66
|
+
default?: string;
|
|
67
|
+
/** Whether the column is NOT NULL. */
|
|
68
|
+
notNull?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* `email` is NOT NULL on purpose, and the anonymous sign-in route depends on it
|
|
73
|
+
* — it synthesizes `anon_<32 hex>@anonymous.local` rather than inserting NULL.
|
|
74
|
+
* The 320-char bound (RFC 5321) is a CHECK rather than a `VARCHAR(n)`, added
|
|
75
|
+
* separately by `ensureAuthTablesExist` so it can be `NOT VALID` on an adopted
|
|
76
|
+
* table that already holds a longer row.
|
|
77
|
+
*/
|
|
78
|
+
export const AUTH_USERS_COLUMNS: readonly AuthUsersColumn[] = [
|
|
79
|
+
{ column: "email", type: "TEXT", notNull: true },
|
|
80
|
+
{ column: "display_name", type: "TEXT" },
|
|
81
|
+
{ column: "photo_url", type: "TEXT" },
|
|
82
|
+
{ column: "roles", type: "TEXT[]", default: "'{}'", notNull: true },
|
|
83
|
+
{ column: "password_hash", type: "TEXT" },
|
|
84
|
+
{ column: "email_verified", type: "BOOLEAN", default: "FALSE", notNull: true },
|
|
85
|
+
{ column: "email_verification_token", type: "TEXT" },
|
|
86
|
+
{ column: "email_verification_sent_at", type: "TIMESTAMP WITH TIME ZONE" },
|
|
87
|
+
{ column: "is_anonymous", type: "BOOLEAN", default: "FALSE", notNull: true },
|
|
88
|
+
{ column: "metadata", type: "JSONB", default: "'{}'", notNull: true },
|
|
89
|
+
{ column: "tokens_valid_after", type: "TIMESTAMP WITH TIME ZONE" },
|
|
90
|
+
{ column: "created_at", type: "TIMESTAMP WITH TIME ZONE", default: "NOW()", notNull: true },
|
|
91
|
+
{ column: "updated_at", type: "TIMESTAMP WITH TIME ZONE", default: "NOW()", notNull: true }
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
const BY_COLUMN = new Map(AUTH_USERS_COLUMNS.map(c => [c.column, c]));
|
|
95
|
+
|
|
96
|
+
/** Type + inline constraints, as they appear after the column name. */
|
|
97
|
+
export function authUsersColumnSql(spec: AuthUsersColumn): string {
|
|
98
|
+
return [
|
|
99
|
+
spec.type,
|
|
100
|
+
spec.default !== undefined ? `DEFAULT ${spec.default}` : "",
|
|
101
|
+
spec.notNull ? "NOT NULL" : ""
|
|
102
|
+
].filter(Boolean).join(" ");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The auth-owned definition for a physical column name, or `undefined` when
|
|
107
|
+
* auth does not own it.
|
|
108
|
+
*
|
|
109
|
+
* Callers pass the RESOLVED column name (after `columnName` mapping), because
|
|
110
|
+
* that is the only name the three creators agree on: the scaffold's users
|
|
111
|
+
* collection spells the property `displayName` and the column `display_name`.
|
|
112
|
+
*/
|
|
113
|
+
export function authUsersColumnDefinition(column: string): string | undefined {
|
|
114
|
+
const spec = BY_COLUMN.get(column);
|
|
115
|
+
return spec ? authUsersColumnSql(spec) : undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether a collection is an auth collection, i.e. whether the definitions in
|
|
120
|
+
* this module apply to its table at all.
|
|
121
|
+
*
|
|
122
|
+
* Duplicated in shape from `@rebasepro/common`'s policy defaults on purpose:
|
|
123
|
+
* that one takes a `CollectionConfig`, this one is called from DDL code paths
|
|
124
|
+
* that hold looser objects, and both spellings must accept `auth: true` as well
|
|
125
|
+
* as `auth: { enabled: true }`.
|
|
126
|
+
*/
|
|
127
|
+
export function isAuthCollection(collection: unknown): boolean {
|
|
128
|
+
const auth = (collection as { auth?: unknown } | undefined)?.auth;
|
|
129
|
+
if (auth === true) return true;
|
|
130
|
+
return typeof auth === "object" && auth !== null && (auth as { enabled?: unknown }).enabled === true;
|
|
131
|
+
}
|