@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
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { eq, and, sql, SQL } from "drizzle-orm";
|
|
2
2
|
import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
3
3
|
// import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
|
-
import { CollectionConfig, Properties, ResolvedRelation, type ResolvedManyToMany, isManyToMany, hasForeignKeyOnTarget } from "@rebasepro/types";
|
|
4
|
+
import { CollectionConfig, Properties, Property, ResolvedRelation, type ResolvedManyToMany, isManyToMany, hasForeignKeyOnTarget } from "@rebasepro/types";
|
|
5
5
|
import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
|
|
6
6
|
import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
7
7
|
import {
|
|
8
|
+
assertWritableColumns,
|
|
8
9
|
getCollectionByPath,
|
|
9
10
|
getTableForCollection,
|
|
10
11
|
getPrimaryKeys,
|
|
@@ -307,6 +308,11 @@ export class PersistService {
|
|
|
307
308
|
|
|
308
309
|
const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
|
|
309
310
|
|
|
311
|
+
// Everything below builds SQL from the table's own column list, so
|
|
312
|
+
// a key that is not a column is silently left out rather than
|
|
313
|
+
// refused. Say so here, while the key is still in hand.
|
|
314
|
+
assertWritableColumns(entityData as Record<string, unknown>, table, effectiveCollectionPath);
|
|
315
|
+
|
|
310
316
|
savedId = await this.db.transaction(async (tx) => {
|
|
311
317
|
let currentId: string | number;
|
|
312
318
|
|
|
@@ -380,6 +386,23 @@ export class PersistService {
|
|
|
380
386
|
// branch; Postgres rejects that against the conflict target.
|
|
381
387
|
for (const info of idInfoArray) delete set[info.fieldName];
|
|
382
388
|
|
|
389
|
+
// A conflict means the row was already there, so its
|
|
390
|
+
// `on_create` stamp is a fact about the past and not
|
|
391
|
+
// this write's to redecide. Bulk rows are saved with
|
|
392
|
+
// `status: "new"` on purpose (an import's rows carry a
|
|
393
|
+
// natural key for rows that may not exist), which
|
|
394
|
+
// computes the creation timestamp for every row — and
|
|
395
|
+
// the conflict-update then wrote it over the original.
|
|
396
|
+
// A nightly re-import reset `createdAt` on everything
|
|
397
|
+
// it touched, and every "new this week" query with it.
|
|
398
|
+
// The INSERT branch keeps the value: it is right there.
|
|
399
|
+
for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
|
|
400
|
+
if ((prop as Property).type === "date"
|
|
401
|
+
&& (prop as { autoValue?: string }).autoValue === "on_create") {
|
|
402
|
+
delete set[propName];
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
383
406
|
result = Object.keys(set).length > 0
|
|
384
407
|
? await insertQuery.onConflictDoUpdate({ target, set }).returning(returningKeys)
|
|
385
408
|
: await insertQuery.onConflictDoNothing({ target }).returning(returningKeys);
|
|
@@ -477,7 +500,20 @@ export class PersistService {
|
|
|
477
500
|
const pgError = extractPgError(error);
|
|
478
501
|
|
|
479
502
|
if (pgError) {
|
|
480
|
-
const { message } = pgErrorToFriendlyMessage(pgError, collectionSlug);
|
|
503
|
+
const { message, code } = pgErrorToFriendlyMessage(pgError, collectionSlug);
|
|
504
|
+
// This is the only layer that holds the SQLSTATE, so it is the only
|
|
505
|
+
// one that can say whose fault a failure was. Returning a bare
|
|
506
|
+
// `Error` threw that away, and the REST layer compensated by calling
|
|
507
|
+
// *every* unclassified error a 400 — so an unreachable database was
|
|
508
|
+
// reported to callers as a bad request. Classes 22 (data exception)
|
|
509
|
+
// and 23 (integrity constraint violation) are the caller's data;
|
|
510
|
+
// everything else — a dropped connection, a missing column, a
|
|
511
|
+
// permission problem — is ours, and stays a 500.
|
|
512
|
+
if (/^2[23]/.test(code)) {
|
|
513
|
+
return code === "23505"
|
|
514
|
+
? ApiError.conflict(message, `PG_${code}`)
|
|
515
|
+
: ApiError.badRequest(message, `PG_${code}`);
|
|
516
|
+
}
|
|
481
517
|
return new Error(message);
|
|
482
518
|
}
|
|
483
519
|
|
|
@@ -423,7 +423,8 @@ export class RelationService {
|
|
|
423
423
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
424
424
|
options.searchString,
|
|
425
425
|
targetCollection.properties,
|
|
426
|
-
targetTable
|
|
426
|
+
targetTable,
|
|
427
|
+
targetCollection
|
|
427
428
|
);
|
|
428
429
|
|
|
429
430
|
if (searchConditions.length === 0) {
|
|
@@ -1004,6 +1005,87 @@ export class RelationService {
|
|
|
1004
1005
|
return resultMap;
|
|
1005
1006
|
}
|
|
1006
1007
|
|
|
1008
|
+
/**
|
|
1009
|
+
* Bring one row's junction links in line with the ids a save carried,
|
|
1010
|
+
* by diffing against what is stored rather than replacing the lot.
|
|
1011
|
+
*
|
|
1012
|
+
* The old shape was `DELETE every link for this parent` followed by
|
|
1013
|
+
* `INSERT what the client sent`, which makes a save of the parent a full
|
|
1014
|
+
* replacement of the membership set from a list the browser assembled out
|
|
1015
|
+
* of a read it did earlier. Three things follow from that, and the diff
|
|
1016
|
+
* closes all three:
|
|
1017
|
+
*
|
|
1018
|
+
* - **Lost update.** Two editors with post 7 open: A adds tag X and saves;
|
|
1019
|
+
* B saves any field and the whole set is rewritten from B's older list,
|
|
1020
|
+
* dropping X with nothing to show for it. A diff only names the ids that
|
|
1021
|
+
* actually changed, so edits to disjoint tags no longer collide.
|
|
1022
|
+
* - **Partial read, partial delete.** The read that fills the form runs
|
|
1023
|
+
* under RLS, so a user who may edit the parent but cannot *see* some of
|
|
1024
|
+
* the linked rows gets a shorter list — and writing it back deleted the
|
|
1025
|
+
* links they were never shown. The DELETE now names ids instead of
|
|
1026
|
+
* "everything for this parent", and the select that produces them runs
|
|
1027
|
+
* in this same transaction under the same policies, so a link the caller
|
|
1028
|
+
* cannot read is in neither list and survives the save.
|
|
1029
|
+
* - **Junction payload columns.** A junction carrying its own columns
|
|
1030
|
+
* (`position`, `role`, `created_at`) lost them on every save, because
|
|
1031
|
+
* every row was re-inserted with only the two keys. Untouched links are
|
|
1032
|
+
* now left alone.
|
|
1033
|
+
*
|
|
1034
|
+
* The insert is `ON CONFLICT DO NOTHING` so that two sessions adding the
|
|
1035
|
+
* same link concurrently is a no-op rather than a unique violation.
|
|
1036
|
+
*/
|
|
1037
|
+
private async syncJunctionLinks(
|
|
1038
|
+
tx: DrizzleClient,
|
|
1039
|
+
junctionTable: PgTable,
|
|
1040
|
+
sourceJunctionColumn: AnyPgColumn,
|
|
1041
|
+
targetJunctionColumn: AnyPgColumn,
|
|
1042
|
+
parsedParentId: unknown,
|
|
1043
|
+
parsedTargetIds: unknown[]
|
|
1044
|
+
) {
|
|
1045
|
+
const existingRows = await tx
|
|
1046
|
+
.select({ targetId: targetJunctionColumn })
|
|
1047
|
+
.from(junctionTable)
|
|
1048
|
+
.where(eq(sourceJunctionColumn, parsedParentId));
|
|
1049
|
+
|
|
1050
|
+
// Keyed by `String(...)` because a junction key can come back from the
|
|
1051
|
+
// driver as a string where the parsed value is a number, and a diff
|
|
1052
|
+
// that misses that would delete and re-insert every link.
|
|
1053
|
+
const existingById = new Map<string, unknown>();
|
|
1054
|
+
for (const row of existingRows as Array<{ targetId: unknown }>) {
|
|
1055
|
+
if (row.targetId === null || row.targetId === undefined) continue;
|
|
1056
|
+
existingById.set(String(row.targetId), row.targetId);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const wantedById = new Map<string, unknown>();
|
|
1060
|
+
for (const targetId of parsedTargetIds) {
|
|
1061
|
+
if (targetId === null || targetId === undefined) continue;
|
|
1062
|
+
wantedById.set(String(targetId), targetId);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const removed = [...existingById.entries()]
|
|
1066
|
+
.filter(([key]) => !wantedById.has(key))
|
|
1067
|
+
.map(([, value]) => value);
|
|
1068
|
+
const added = [...wantedById.entries()]
|
|
1069
|
+
.filter(([key]) => !existingById.has(key))
|
|
1070
|
+
.map(([, value]) => value);
|
|
1071
|
+
|
|
1072
|
+
if (removed.length > 0) {
|
|
1073
|
+
await tx.delete(junctionTable).where(and(
|
|
1074
|
+
eq(sourceJunctionColumn, parsedParentId),
|
|
1075
|
+
inArray(targetJunctionColumn, removed)
|
|
1076
|
+
));
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (added.length > 0) {
|
|
1080
|
+
await tx.insert(junctionTable)
|
|
1081
|
+
.values(added.map(targetId => ({
|
|
1082
|
+
[sourceJunctionColumn.name]: parsedParentId,
|
|
1083
|
+
[targetJunctionColumn.name]: targetId
|
|
1084
|
+
})))
|
|
1085
|
+
.onConflictDoNothing();
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1007
1089
|
/**
|
|
1008
1090
|
* Update many-to-many and junction relations
|
|
1009
1091
|
*/
|
|
@@ -1072,23 +1154,15 @@ export class RelationService {
|
|
|
1072
1154
|
const parsedParentIdObj = parseIdValues(id, parentPks);
|
|
1073
1155
|
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
1074
1156
|
|
|
1075
|
-
|
|
1076
|
-
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
|
|
1077
|
-
|
|
1157
|
+
let parsedTargetIds: unknown[] = [];
|
|
1078
1158
|
if (targetEntityIds.length > 0) {
|
|
1079
1159
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1080
1160
|
const targetIdInfo = targetPks[0];
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
const newLinks = parsedTargetIds.map(targetId => ({
|
|
1084
|
-
[sourceJunctionColumn.name]: parsedParentId,
|
|
1085
|
-
[targetJunctionColumn.name]: targetId
|
|
1086
|
-
}));
|
|
1087
|
-
|
|
1088
|
-
if (newLinks.length > 0) {
|
|
1089
|
-
await tx.insert(junctionTable).values(newLinks);
|
|
1090
|
-
}
|
|
1161
|
+
parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
1091
1162
|
}
|
|
1163
|
+
|
|
1164
|
+
await this.syncJunctionLinks(tx, junctionTable, sourceJunctionColumn, targetJunctionColumn,
|
|
1165
|
+
parsedParentId, parsedTargetIds);
|
|
1092
1166
|
} else if (relation.kind === "manyToMany") {
|
|
1093
1167
|
// Handle many-to-many relations with junction table using 'through' property
|
|
1094
1168
|
const junctionTable = this.registry.getTable(relation.through.table);
|
|
@@ -1110,23 +1184,15 @@ export class RelationService {
|
|
|
1110
1184
|
const parsedParentIdObj = parseIdValues(id, parentPks);
|
|
1111
1185
|
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
1112
1186
|
|
|
1113
|
-
|
|
1114
|
-
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
|
|
1115
|
-
|
|
1187
|
+
let parsedTargetIds: unknown[] = [];
|
|
1116
1188
|
if (targetEntityIds.length > 0) {
|
|
1117
1189
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1118
1190
|
const targetIdInfo = targetPks[0];
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
const newLinks = parsedTargetIds.map(targetId => ({
|
|
1122
|
-
[sourceJunctionColumn.name]: parsedParentId,
|
|
1123
|
-
[targetJunctionColumn.name]: targetId
|
|
1124
|
-
}));
|
|
1125
|
-
|
|
1126
|
-
if (newLinks.length > 0) {
|
|
1127
|
-
await tx.insert(junctionTable).values(newLinks);
|
|
1128
|
-
}
|
|
1191
|
+
parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
1129
1192
|
}
|
|
1193
|
+
|
|
1194
|
+
await this.syncJunctionLinks(tx, junctionTable, sourceJunctionColumn, targetJunctionColumn,
|
|
1195
|
+
parsedParentId, parsedTargetIds);
|
|
1130
1196
|
} else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
|
|
1131
1197
|
// Handle one-to-many (inverse) by updating target FK to point to parent
|
|
1132
1198
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
@@ -1358,41 +1424,25 @@ export class RelationService {
|
|
|
1358
1424
|
const parsedSourceIdObj = parseIdValues(sourceEntityId, sourcePks);
|
|
1359
1425
|
const parsedSourceId = parsedSourceIdObj[sourceIdInfo.fieldName];
|
|
1360
1426
|
|
|
1361
|
-
//
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
// Add new entries if newValue is provided
|
|
1427
|
+
// The membership this write asks for, as parsed keys. A single
|
|
1428
|
+
// value is the one-to-one case and reads as a set of one.
|
|
1429
|
+
let parsedTargetIds: unknown[] = [];
|
|
1365
1430
|
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
1366
1431
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1367
1432
|
const targetIdInfo = targetPks[0];
|
|
1368
1433
|
// This path already read both shapes; the other two did not.
|
|
1369
1434
|
// Same helper now, so the three cannot drift again.
|
|
1370
1435
|
const targetEntityIds = relationTargetIds(newValue, relation.relationName, sourceCollection.slug);
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
const newLinks = parsedTargetIds.map(targetId => ({
|
|
1374
|
-
[sourceJunctionColumn!.name]: parsedSourceId,
|
|
1375
|
-
[targetJunctionColumn!.name]: targetId
|
|
1376
|
-
}));
|
|
1377
|
-
|
|
1378
|
-
if (newLinks.length > 0) {
|
|
1379
|
-
await tx.insert(junctionTable).values(newLinks);
|
|
1380
|
-
}
|
|
1436
|
+
parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
1381
1437
|
} else if (newValue && !Array.isArray(newValue)) {
|
|
1382
|
-
// Single value for one-to-one
|
|
1383
1438
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1384
1439
|
const targetIdInfo = targetPks[0];
|
|
1385
1440
|
const targetId = typeof newValue === "object" && newValue !== null ? (newValue as Record<string, unknown>).id as string | number : newValue as string | number;
|
|
1386
|
-
|
|
1387
|
-
const parsedTargetId = parsedTargetIdObj[targetIdInfo.fieldName];
|
|
1388
|
-
|
|
1389
|
-
const newLink = {
|
|
1390
|
-
[sourceJunctionColumn.name]: parsedSourceId,
|
|
1391
|
-
[targetJunctionColumn.name]: parsedTargetId
|
|
1392
|
-
};
|
|
1393
|
-
|
|
1394
|
-
await tx.insert(junctionTable).values(newLink);
|
|
1441
|
+
parsedTargetIds = [parseIdValues(targetId, targetPks)[targetIdInfo.fieldName]];
|
|
1395
1442
|
}
|
|
1443
|
+
|
|
1444
|
+
await this.syncJunctionLinks(tx, junctionTable, sourceJunctionColumn, targetJunctionColumn,
|
|
1445
|
+
parsedSourceId, parsedTargetIds);
|
|
1396
1446
|
}
|
|
1397
1447
|
} catch (error) {
|
|
1398
1448
|
logger.error(`Failed to update inverse joinPath relation '${relation.relationName}'`, { error: error });
|
|
@@ -1432,25 +1482,18 @@ export class RelationService {
|
|
|
1432
1482
|
const parsedSourceIdObj = parseIdValues(sourceEntityId, sourcePks);
|
|
1433
1483
|
const parsedSourceId = parsedSourceIdObj[sourceIdInfo.fieldName];
|
|
1434
1484
|
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
if (
|
|
1485
|
+
const targetEntityIds = Array.isArray(newValue)
|
|
1486
|
+
? relationTargetIds(newValue, relation.relationName, sourceCollection.slug)
|
|
1487
|
+
: [];
|
|
1488
|
+
let parsedTargetIds: unknown[] = [];
|
|
1489
|
+
if (targetEntityIds.length > 0) {
|
|
1440
1490
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1441
1491
|
const targetIdInfo = targetPks[0];
|
|
1442
|
-
|
|
1443
|
-
const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
1444
|
-
|
|
1445
|
-
const newLinks = parsedTargetIds.map(targetId => ({
|
|
1446
|
-
[sourceJunctionColumn.name]: parsedSourceId,
|
|
1447
|
-
[targetJunctionColumn.name]: targetId
|
|
1448
|
-
}));
|
|
1449
|
-
|
|
1450
|
-
if (newLinks.length > 0) {
|
|
1451
|
-
await tx.insert(junctionTable).values(newLinks);
|
|
1452
|
-
}
|
|
1492
|
+
parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
1453
1493
|
}
|
|
1494
|
+
|
|
1495
|
+
await this.syncJunctionLinks(tx, junctionTable, sourceJunctionColumn, targetJunctionColumn,
|
|
1496
|
+
parsedSourceId, parsedTargetIds);
|
|
1454
1497
|
} catch (error) {
|
|
1455
1498
|
logger.error(`Failed to update many-to-many inverse relation '${relation.relationName}'`, { error: error });
|
|
1456
1499
|
throw error;
|
|
@@ -34,6 +34,7 @@ import { sql } from "drizzle-orm";
|
|
|
34
34
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
35
35
|
import type { ChannelHistoryEntry, ChannelRetentionRule } from "@rebasepro/types";
|
|
36
36
|
import { logger } from "@rebasepro/server";
|
|
37
|
+
import { revokeInternalTableSql } from "@rebasepro/common";
|
|
37
38
|
|
|
38
39
|
/** How many messages a replay returns when the caller does not say. */
|
|
39
40
|
const DEFAULT_REPLAY_LIMIT = 200;
|
|
@@ -198,6 +199,19 @@ export class ChannelHistoryStore {
|
|
|
198
199
|
)
|
|
199
200
|
`);
|
|
200
201
|
|
|
202
|
+
// Retained broadcasts for every channel in one table, with no RLS: who
|
|
203
|
+
// may replay a channel is decided before the read, by the channel gate
|
|
204
|
+
// in `realtimeService.authorizeChannelAction` — a replay is answered
|
|
205
|
+
// only for a client that has joined the channel, plus whatever an
|
|
206
|
+
// installed `ChannelAuthorizer` adds. That gate is the entire reason
|
|
207
|
+
// this table can sit outside the RLS model, so it fails closed; the
|
|
208
|
+
// rule language it does not yet have is written up in
|
|
209
|
+
// `docs/channel-authorization.md`. The driver's schema-wide
|
|
210
|
+
// grant reaches these (created here, after it ran), so take the
|
|
211
|
+
// privilege back.
|
|
212
|
+
await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_messages")));
|
|
213
|
+
await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_cursors")));
|
|
214
|
+
|
|
201
215
|
this.tablesReady = true;
|
|
202
216
|
logger.info(`✅ [ChannelHistory] Retained channels ready (${this.rules.length} rule(s)).`);
|
|
203
217
|
}
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
import { sql } from "drizzle-orm";
|
|
30
30
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
31
|
+
import { revokeInternalTableSql } from "@rebasepro/common";
|
|
31
32
|
|
|
32
33
|
/** A tracked client, as any instance sees it. */
|
|
33
34
|
export interface PresenceRow {
|
|
@@ -71,6 +72,18 @@ export class ChannelPresenceStore {
|
|
|
71
72
|
ON rebase.channel_presence (last_seen)
|
|
72
73
|
`);
|
|
73
74
|
|
|
75
|
+
// The roster of every client on every channel, with no RLS — a row
|
|
76
|
+
// policy has nothing to match on here, since a channel name is a string
|
|
77
|
+
// a client invents rather than a row anyone owns. What guards it is the
|
|
78
|
+
// channel gate in `realtimeService.authorizeChannelAction`: presence is
|
|
79
|
+
// readable only to a client that has joined the channel, plus whatever
|
|
80
|
+
// an installed `ChannelAuthorizer` adds. That gate is the entire reason
|
|
81
|
+
// this table can sit outside the RLS model, so it fails closed —
|
|
82
|
+
// see `docs/channel-authorization.md` for what it does *not*
|
|
83
|
+
// yet decide. Revoke the schema-wide grant the driver handed out
|
|
84
|
+
// before this table existed.
|
|
85
|
+
await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_presence")));
|
|
86
|
+
|
|
74
87
|
this.tablesReady = true;
|
|
75
88
|
}
|
|
76
89
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
|
|
2
|
+
import { getTableColumns } from "drizzle-orm";
|
|
2
3
|
import { CollectionConfig, Property, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
|
|
3
4
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
4
5
|
import { getTableName } from "@rebasepro/common";
|
|
5
|
-
import { logger } from "@rebasepro/server";
|
|
6
|
+
import { ApiError, logger } from "@rebasepro/server";
|
|
6
7
|
|
|
7
8
|
// Row identity is derived on both sides of the wire — the driver parses an
|
|
8
9
|
// incoming address into key columns, the admin derives one from a served row —
|
|
@@ -76,6 +77,58 @@ export function getCollectionByPath(collectionPath: string, registry: PostgresCo
|
|
|
76
77
|
return collection;
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Reject a write naming something that is not a column of the table.
|
|
82
|
+
*
|
|
83
|
+
* Drizzle builds INSERT from `Object.entries(table[Symbol.Columns])` and UPDATE
|
|
84
|
+
* from `Object.keys(tableColumns)`, so a key the table does not carry is not
|
|
85
|
+
* rejected by anything — it is *left out of the statement*. The insert answers
|
|
86
|
+
* 201 having stored nothing under that name; the update, if the key was the
|
|
87
|
+
* only one, builds `update "posts" set where …` and Postgres raises a syntax
|
|
88
|
+
* error (SQLSTATE 42601), which is neither class 22 nor 23 and so surfaces as a
|
|
89
|
+
* 500 for what is a caller's typo.
|
|
90
|
+
*
|
|
91
|
+
* That makes this the last honest place to check, and the only one every write
|
|
92
|
+
* passes through. `assertKnownWriteFields` in the REST layer checks the same
|
|
93
|
+
* thing against the *config* and is skipped on four paths — `strictWrites:
|
|
94
|
+
* false`, a collection declaring no properties, an auth adapter that owns the
|
|
95
|
+
* body's shape, and a nested route whose target cannot be walked — and it never
|
|
96
|
+
* sees an in-process `rebase.data` write at all.
|
|
97
|
+
*
|
|
98
|
+
* It also gives `strictWrites: false` a truthful implementation. The flag is
|
|
99
|
+
* documented for "a column that really does exist which the config never
|
|
100
|
+
* declared", and skipping the config check alone could not deliver that: the
|
|
101
|
+
* value was dropped a layer later regardless. Skipping the config check and
|
|
102
|
+
* keeping this one does exactly what the flag says — the column must exist,
|
|
103
|
+
* the property need not.
|
|
104
|
+
*/
|
|
105
|
+
export function assertWritableColumns(
|
|
106
|
+
values: Record<string, unknown>,
|
|
107
|
+
table: PgTable,
|
|
108
|
+
collectionPath: string
|
|
109
|
+
): void {
|
|
110
|
+
// Only a real Drizzle table carries a column list. A stand-in that does not
|
|
111
|
+
// (a test double, a registry entry built by hand) has nothing to check
|
|
112
|
+
// against, and inventing an answer from its own keys would reject writes
|
|
113
|
+
// over the shape of the double rather than the shape of the table.
|
|
114
|
+
const columns = getTableColumns(table) as Record<string, unknown> | undefined;
|
|
115
|
+
if (!columns || Object.keys(columns).length === 0) return;
|
|
116
|
+
|
|
117
|
+
const unknown = Object.keys(values).filter(key => !(key in columns));
|
|
118
|
+
if (unknown.length === 0) return;
|
|
119
|
+
|
|
120
|
+
// The offending keys, and deliberately not the list of real ones: this
|
|
121
|
+
// error is reachable on paths where the REST field check was skipped, and
|
|
122
|
+
// an `excludeFromApi` column is documented as never being served to a
|
|
123
|
+
// caller. Naming what was sent is the actionable half anyway.
|
|
124
|
+
throw ApiError.badRequest(
|
|
125
|
+
`'${collectionPath}' has no column${unknown.length > 1 ? "s" : ""} ` +
|
|
126
|
+
`${unknown.map(key => `'${key}'`).join(", ")}, so the value${unknown.length > 1 ? "s" : ""} ` +
|
|
127
|
+
"would have been dropped before the statement was built.",
|
|
128
|
+
"VALIDATION_UNKNOWN_FIELDS"
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
79
132
|
export function getTableForCollection(collection: CollectionConfig, registry: PostgresCollectionRegistry): PgTable<any> {
|
|
80
133
|
const tableName = getTableName(collection);
|
|
81
134
|
const table = registry.getTable(tableName);
|
|
@@ -60,6 +60,8 @@ export class DataService implements DataRepository {
|
|
|
60
60
|
collectionPath: string,
|
|
61
61
|
options: {
|
|
62
62
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
63
|
+
/** An `or(...)`/`and(...)` group, applied alongside `filter`. */
|
|
64
|
+
logical?: LogicalCondition;
|
|
63
65
|
orderBy?: string;
|
|
64
66
|
order?: "desc" | "asc";
|
|
65
67
|
limit?: number;
|
|
@@ -81,10 +83,13 @@ export class DataService implements DataRepository {
|
|
|
81
83
|
searchString: string,
|
|
82
84
|
options: {
|
|
83
85
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
86
|
+
/** An `or(...)`/`and(...)` group, applied alongside `filter`. */
|
|
87
|
+
logical?: LogicalCondition;
|
|
84
88
|
orderBy?: string;
|
|
85
89
|
order?: "desc" | "asc";
|
|
86
90
|
limit?: number;
|
|
87
91
|
databaseId?: string;
|
|
92
|
+
searchExplain?: boolean;
|
|
88
93
|
} = {}
|
|
89
94
|
): Promise<Record<string, unknown>[]> {
|
|
90
95
|
return this.fetchService.searchRows<M>(collectionPath, searchString, options);
|