@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.
Files changed (85) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/auth/services.d.ts +19 -0
  3. package/dist/{src-DlPBctw_.js → auth-users-columns-CBEOeYqa.js} +835 -63
  4. package/dist/auth-users-columns-CBEOeYqa.js.map +1 -0
  5. package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
  6. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
  7. package/dist/cli-helpers.d.ts +57 -1
  8. package/dist/data-transformer.d.ts +7 -2
  9. package/dist/data_driver-ULAyJEi9.js +193 -0
  10. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  11. package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-B_JMGa5K.js} +2 -2
  12. package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-B_JMGa5K.js.map} +1 -1
  13. package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DzeTEvMv.js} +170 -20
  14. package/dist/ensure-collection-tables-DzeTEvMv.js.map +1 -0
  15. package/dist/index.es.js +1365 -338
  16. package/dist/index.es.js.map +1 -1
  17. package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
  18. package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
  19. package/dist/schema/auth-schema.d.ts +102 -0
  20. package/dist/schema/auth-users-columns.d.ts +97 -0
  21. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  22. package/dist/schema/doctor.d.ts +23 -25
  23. package/dist/schema/ensure-collection-tables.d.ts +61 -7
  24. package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
  25. package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
  26. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  27. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  28. package/dist/schema/search-column.d.ts +248 -0
  29. package/dist/security/rls-enforcement.d.ts +61 -5
  30. package/dist/services/FetchService.d.ts +34 -7
  31. package/dist/services/RelationService.d.ts +30 -0
  32. package/dist/services/collection-helpers.d.ts +26 -0
  33. package/dist/services/dataService.d.ts +5 -0
  34. package/dist/services/realtimeService.d.ts +131 -21
  35. package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
  36. package/dist/src-C_wvdMnl.js.map +1 -0
  37. package/dist/utils/drizzle-conditions.d.ts +124 -2
  38. package/dist/{websocket-B2LsrINK.js → websocket-D1qbmLZ2.js} +75 -18
  39. package/dist/websocket-D1qbmLZ2.js.map +1 -0
  40. package/package.json +9 -8
  41. package/src/PostgresBackendDriver.ts +172 -6
  42. package/src/PostgresBootstrapper.ts +55 -6
  43. package/src/auth/ensure-tables.ts +207 -86
  44. package/src/auth/services.ts +69 -5
  45. package/src/cli-helpers.ts +129 -10
  46. package/src/cli.ts +232 -30
  47. package/src/collections/validate-relations.ts +124 -17
  48. package/src/data-transformer.ts +120 -17
  49. package/src/history/ensure-history-table.ts +7 -0
  50. package/src/schema/auth-schema.ts +17 -1
  51. package/src/schema/auth-users-columns.ts +131 -0
  52. package/src/schema/doctor-cli.ts +12 -63
  53. package/src/schema/doctor-policy-checks.ts +105 -0
  54. package/src/schema/doctor.ts +135 -76
  55. package/src/schema/ensure-collection-tables.ts +374 -32
  56. package/src/schema/generate-drizzle-schema-logic.ts +132 -42
  57. package/src/schema/generate-postgres-ddl-logic.ts +294 -16
  58. package/src/schema/generate-postgres-ddl.ts +25 -2
  59. package/src/schema/generated-schema-staleness.ts +169 -0
  60. package/src/schema/introspect-db-logic.ts +66 -34
  61. package/src/schema/non-sql-collections.test.ts +131 -0
  62. package/src/schema/rls-bootstrap-sql.ts +288 -0
  63. package/src/schema/search-column.ts +643 -0
  64. package/src/security/anonymous-grants.test.ts +4 -2
  65. package/src/security/rls-enforcement.ts +149 -6
  66. package/src/services/BranchService.ts +5 -0
  67. package/src/services/FetchService.ts +175 -108
  68. package/src/services/PersistService.ts +38 -2
  69. package/src/services/RelationService.ts +110 -67
  70. package/src/services/channel-history.ts +14 -0
  71. package/src/services/channel-presence.ts +13 -0
  72. package/src/services/collection-helpers.ts +54 -1
  73. package/src/services/dataService.ts +5 -0
  74. package/src/services/realtimeService.ts +344 -79
  75. package/src/utils/drizzle-conditions.ts +365 -23
  76. package/src/utils/pg-error-utils.ts +8 -3
  77. package/src/websocket.ts +113 -16
  78. package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
  79. package/dist/policy-CeA1JcxP.js +0 -105
  80. package/dist/policy-CeA1JcxP.js.map +0 -1
  81. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  82. package/dist/src-DlPBctw_.js.map +0 -1
  83. package/dist/src-DoU9yPqq.js.map +0 -1
  84. package/dist/websocket-B2LsrINK.js.map +0 -1
  85. 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
- // Delete existing relations for this row
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
- const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
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
- // Delete existing relations for this row
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
- const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
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
- // Clear existing entries for this source row
1362
- await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
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
- const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
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
- const parsedTargetIdObj = parseIdValues(targetId, targetPks);
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
- // Clear existing entries for this source row
1436
- await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
1437
-
1438
- // Add new entries if newValue is provided
1439
- if (newValue && Array.isArray(newValue) && newValue.length > 0) {
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
- const targetEntityIds = relationTargetIds(newValue, relation.relationName, sourceCollection.slug);
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);