@rebasepro/server-postgres 0.13.1-canary.gef9608c → 0.14.0

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 (114) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/PostgresBootstrapper.d.ts +26 -0
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/{src-CU6WZGYV.js → auth-users-columns-BfQHf9JE.js} +1111 -92
  5. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  6. package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
  7. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  8. package/dist/cli-helpers.d.ts +56 -0
  9. package/dist/cli-output.d.ts +34 -0
  10. package/dist/data-transformer.d.ts +7 -2
  11. package/dist/data_driver-ULAyJEi9.js +193 -0
  12. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  13. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  14. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  15. package/dist/{ensure-collection-tables-BLIIACla.js → ensure-collection-tables-CbvaGuVn.js} +162 -16
  16. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  17. package/dist/index.es.js +1720 -946
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
  20. package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
  21. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  22. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  23. package/dist/schema/auth-schema.d.ts +102 -0
  24. package/dist/schema/auth-users-columns.d.ts +97 -0
  25. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  26. package/dist/schema/doctor.d.ts +41 -25
  27. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  28. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  29. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  30. package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
  31. package/dist/schema/introspect-db-inference.d.ts +8 -1
  32. package/dist/schema/introspect-db-logic.d.ts +49 -0
  33. package/dist/schema/introspect-db-project.d.ts +21 -0
  34. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  35. package/dist/schema/search-column.d.ts +248 -0
  36. package/dist/security/policy-drift.d.ts +34 -0
  37. package/dist/security/rls-enforcement.d.ts +61 -5
  38. package/dist/services/FetchService.d.ts +24 -0
  39. package/dist/services/PersistService.d.ts +21 -17
  40. package/dist/services/RelationService.d.ts +9 -57
  41. package/dist/services/RelationWriteService.d.ts +82 -0
  42. package/dist/services/collection-helpers.d.ts +42 -0
  43. package/dist/services/dataService.d.ts +3 -0
  44. package/dist/services/junction-writes.d.ts +82 -0
  45. package/dist/services/realtimeService.d.ts +139 -2
  46. package/dist/services/write-denial.d.ts +36 -0
  47. package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
  48. package/dist/src-DCdn3Val.js.map +1 -0
  49. package/dist/utils/drizzle-conditions.d.ts +124 -2
  50. package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
  51. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  52. package/package.json +8 -7
  53. package/src/PostgresBackendDriver.ts +172 -6
  54. package/src/PostgresBootstrapper.ts +136 -11
  55. package/src/auth/ensure-tables.ts +212 -91
  56. package/src/auth/services.ts +82 -5
  57. package/src/backup/backup-cli.ts +59 -57
  58. package/src/cli-errors.ts +6 -6
  59. package/src/cli-helpers.ts +124 -11
  60. package/src/cli-output.ts +43 -0
  61. package/src/cli.ts +299 -168
  62. package/src/collections/buildRegistry.ts +3 -1
  63. package/src/data-transformer.ts +129 -25
  64. package/src/history/ensure-history-table.ts +9 -2
  65. package/src/schema/auth-schema.ts +17 -1
  66. package/src/schema/auth-users-columns.ts +131 -0
  67. package/src/schema/doctor-cli.ts +14 -65
  68. package/src/schema/doctor-policy-checks.ts +105 -0
  69. package/src/schema/doctor.ts +149 -72
  70. package/src/schema/ensure-collection-policies.ts +99 -6
  71. package/src/schema/ensure-collection-tables.ts +366 -30
  72. package/src/schema/generate-drizzle-schema-logic.ts +146 -66
  73. package/src/schema/generate-drizzle-schema.ts +11 -10
  74. package/src/schema/generate-postgres-ddl-logic.ts +277 -10
  75. package/src/schema/generate-postgres-ddl.ts +38 -14
  76. package/src/schema/generated-schema-staleness.ts +14 -7
  77. package/src/schema/introspect-db-inference.ts +9 -2
  78. package/src/schema/introspect-db-logic.ts +251 -75
  79. package/src/schema/introspect-db-project.ts +78 -0
  80. package/src/schema/introspect-db.ts +42 -25
  81. package/src/schema/introspect-runtime.ts +14 -2
  82. package/src/schema/rls-bootstrap-sql.ts +288 -0
  83. package/src/schema/search-column.ts +643 -0
  84. package/src/security/anonymous-grants.test.ts +4 -2
  85. package/src/security/policy-drift.test.ts +104 -3
  86. package/src/security/policy-drift.ts +129 -7
  87. package/src/security/rls-enforcement.ts +150 -7
  88. package/src/services/BranchService.ts +5 -0
  89. package/src/services/FetchService.ts +243 -22
  90. package/src/services/PersistService.ts +68 -42
  91. package/src/services/RelationService.ts +37 -696
  92. package/src/services/RelationWriteService.ts +653 -0
  93. package/src/services/cdc/trigger-cdc.ts +5 -1
  94. package/src/services/channel-history.ts +14 -0
  95. package/src/services/channel-presence.ts +13 -0
  96. package/src/services/collection-helpers.ts +89 -4
  97. package/src/services/dataService.ts +3 -0
  98. package/src/services/junction-writes.ts +295 -0
  99. package/src/services/pg-notify-listener.ts +1 -1
  100. package/src/services/realtimeService.ts +347 -86
  101. package/src/services/write-denial.ts +55 -0
  102. package/src/utils/drizzle-conditions.ts +433 -35
  103. package/src/utils/pg-error-utils.ts +8 -3
  104. package/src/websocket.ts +113 -16
  105. package/dist/ensure-collection-policies-Bck0ky4u.js +0 -57
  106. package/dist/ensure-collection-policies-Bck0ky4u.js.map +0 -1
  107. package/dist/ensure-collection-tables-BLIIACla.js.map +0 -1
  108. package/dist/policy-CeA1JcxP.js +0 -105
  109. package/dist/policy-CeA1JcxP.js.map +0 -1
  110. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  111. package/dist/src-CU6WZGYV.js.map +0 -1
  112. package/dist/src-DoU9yPqq.js.map +0 -1
  113. package/dist/websocket-B2LsrINK.js.map +0 -1
  114. 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";
5
- import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
4
+ import { CollectionConfig, Properties, Property, ResolvedRelation, type ResolvedManyToMany, isManyToMany, hasForeignKeyOnTarget } from "@rebasepro/types";
5
+ import { getTableName, resolveCollectionRelations, fieldKeyForColumn } 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,
@@ -13,6 +14,7 @@ import {
13
14
  } from "./collection-helpers";
14
15
  import { sanitizeAndConvertDates, serializeDataToServer } from "../data-transformer";
15
16
  import { RelationService } from "./RelationService";
17
+ import { RelationWriteService } from "./RelationWriteService";
16
18
  import { FetchService } from "./FetchService";
17
19
  import { DrizzleClient } from "../interfaces";
18
20
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
@@ -25,39 +27,31 @@ import {
25
27
  } from "./nested-path";
26
28
  import { ApiError, logger } from "@rebasepro/server";
27
29
  import { extractPgError, extractCauseMessage, pgErrorToFriendlyMessage } from "../utils/pg-error-utils";
30
+ import { explainZeroRowWrite } from "./write-denial";
28
31
 
29
32
  /**
30
33
  * Service for handling all row write operations.
31
34
  * Handles saving, deleting, and updating rows.
32
35
  */
33
36
  export class PersistService {
37
+ /** Reads: whether a row is under a parent, the key a link joins on. */
34
38
  private relationService: RelationService;
39
+ /** Writes: junction membership, foreign-key stamping, links. */
40
+ private relationWrites: RelationWriteService;
35
41
  private fetchService: FetchService;
36
42
 
37
43
  constructor(private db: DrizzleClient, private registry: PostgresCollectionRegistry) {
38
44
  this.relationService = new RelationService(db, registry);
45
+ this.relationWrites = new RelationWriteService(db, registry);
39
46
  this.fetchService = new FetchService(db, registry);
40
47
  }
41
48
 
42
49
 
43
50
  /**
44
- * Explain a write that matched no rows.
45
- *
46
- * Row-level security filters UPDATE and DELETE through the policy's USING
47
- * clause instead of raising: a denied write is reported by Postgres exactly
48
- * like a successful one that happened to match nothing. Left unchecked, a
49
- * caller cannot tell "denied" from "done" — the write returns 200/204 and
50
- * the row is untouched.
51
- *
52
- * Re-reading the target over the *same* RLS-scoped handle separates the two
53
- * cases. A visible row means the policy rejected the write (403); an
54
- * invisible one means there is nothing there to write for this caller (404,
55
- * matching what a GET would say). The re-read is bound by the caller's own
56
- * policies, so it discloses nothing a plain read wouldn't.
57
- *
58
- * Only reached when zero rows matched, so the happy path pays nothing.
51
+ * Explain a row write that matched nothing — see {@link explainZeroRowWrite}
52
+ * for why a zero-row write cannot be reported as success.
59
53
  */
60
- private async explainZeroRowWrite(
54
+ private explainZeroRowWrite(
61
55
  handle: DrizzleClient,
62
56
  table: PgTable,
63
57
  conditions: SQL[],
@@ -65,20 +59,11 @@ export class PersistService {
65
59
  id: string | number,
66
60
  operation: "update" | "delete"
67
61
  ): Promise<ApiError> {
68
- const visible = await handle
69
- .select({ present: sql<number>`1` })
70
- .from(table)
71
- .where(and(...conditions))
72
- .limit(1);
73
-
74
- if (visible.length > 0) {
75
- return ApiError.forbidden(
76
- `Not allowed to ${operation} "${id}" in "${collectionPath}": a row-level security policy rejected the write.`,
77
- "WRITE_DENIED"
78
- );
79
- }
80
-
81
- return ApiError.notFound(
62
+ return explainZeroRowWrite(
63
+ handle,
64
+ table,
65
+ conditions,
66
+ `Not allowed to ${operation} "${id}" in "${collectionPath}": a row-level security policy rejected the write.`,
82
67
  `No row "${id}" in "${collectionPath}" to ${operation}.`
83
68
  );
84
69
  }
@@ -107,7 +92,7 @@ export class PersistService {
107
92
  "RELATION_NOT_UNLINKABLE"
108
93
  );
109
94
  }
110
- await this.relationService.unlinkRelatedEntity(this.db, hop, id);
95
+ await this.relationWrites.unlinkRelatedEntity(this.db, hop, id);
111
96
  return;
112
97
  }
113
98
  // Owned child (inverse FK): deleting the row is the right meaning,
@@ -148,8 +133,14 @@ export class PersistService {
148
133
  }
149
134
 
150
135
  /**
151
- * The column on the *target* table that records the parent, for a create
152
- * under a nested one-to-many path.
136
+ * The field on the *target* row that records the parent, for a create under
137
+ * a nested one-to-many path.
138
+ *
139
+ * A **field**, not the column: the value is stamped into the caller's
140
+ * payload, which is keyed by wire names — `authorId`, never the `author_id`
141
+ * the relation names its link by. Stamping the column instead put a key on
142
+ * the payload that no property answers to, and `strictWrites` rejected the
143
+ * request the framework had just written to.
153
144
  *
154
145
  * Returns `undefined` when the link is not a column at all (a multi-hop
155
146
  * `joinPath`), so the caller writes the row without stamping anything.
@@ -164,13 +155,16 @@ export class PersistService {
164
155
  switch (relation.kind) {
165
156
  case "hasOne":
166
157
  case "hasMany":
167
- return relation.foreignKeyOnTarget;
158
+ return fieldKeyForColumn(hop.targetCollection, relation.foreignKeyOnTarget);
168
159
 
169
160
  case "via":
170
161
  // The link lives in an intermediate table, not in a column on
171
162
  // the target — nothing to stamp.
172
163
  return relation.joinPath.length === 1
173
- ? DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to)[0]
164
+ ? fieldKeyForColumn(
165
+ hop.targetCollection,
166
+ DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to)[0]
167
+ )
174
168
  : undefined;
175
169
 
176
170
  default:
@@ -307,6 +301,11 @@ export class PersistService {
307
301
 
308
302
  const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
309
303
 
304
+ // Everything below builds SQL from the table's own column list, so
305
+ // a key that is not a column is silently left out rather than
306
+ // refused. Say so here, while the key is still in hand.
307
+ assertWritableColumns(entityData as Record<string, unknown>, table, effectiveCollectionPath);
308
+
310
309
  savedId = await this.db.transaction(async (tx) => {
311
310
  let currentId: string | number;
312
311
 
@@ -322,7 +321,7 @@ export class PersistService {
322
321
  // Example: changing author A→B with stale profile P1 (A's):
323
322
  // reads old author_id=A → clears P1.author_id → re-sets P1.author_id=A (no-op).
324
323
  if (joinPathRelationUpdates.length > 0) {
325
- await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
324
+ await this.relationWrites.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
326
325
  }
327
326
 
328
327
  // Only issue an UPDATE if there are scalar columns to set.
@@ -380,6 +379,23 @@ export class PersistService {
380
379
  // branch; Postgres rejects that against the conflict target.
381
380
  for (const info of idInfoArray) delete set[info.fieldName];
382
381
 
382
+ // A conflict means the row was already there, so its
383
+ // `on_create` stamp is a fact about the past and not
384
+ // this write's to redecide. Bulk rows are saved with
385
+ // `status: "new"` on purpose (an import's rows carry a
386
+ // natural key for rows that may not exist), which
387
+ // computes the creation timestamp for every row — and
388
+ // the conflict-update then wrote it over the original.
389
+ // A nightly re-import reset `createdAt` on everything
390
+ // it touched, and every "new this week" query with it.
391
+ // The INSERT branch keeps the value: it is right there.
392
+ for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
393
+ if ((prop as Property).type === "date"
394
+ && (prop as { autoValue?: string }).autoValue === "on_create") {
395
+ delete set[propName];
396
+ }
397
+ }
398
+
383
399
  result = Object.keys(set).length > 0
384
400
  ? await insertQuery.onConflictDoUpdate({ target, set }).returning(returningKeys)
385
401
  : await insertQuery.onConflictDoNothing({ target }).returning(returningKeys);
@@ -406,18 +422,18 @@ export class PersistService {
406
422
 
407
423
  // For inserts, apply joinPath after since the parent row didn't exist before
408
424
  if (joinPathRelationUpdates.length > 0) {
409
- await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
425
+ await this.relationWrites.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
410
426
  }
411
427
  }
412
428
 
413
429
  // Handle inverse relation updates
414
430
  if (inverseRelationUpdates.length > 0) {
415
- await this.relationService.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
431
+ await this.relationWrites.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
416
432
  }
417
433
 
418
434
  // Update many-to-many relations
419
435
  if (Object.keys(relationValues).length > 0) {
420
- await this.relationService.updateRelationsUsingJoins(tx, collection, currentId, relationValues);
436
+ await this.relationWrites.updateRelationsUsingJoins(tx, collection, currentId, relationValues);
421
437
  }
422
438
 
423
439
  // Attach the row to the parent's set. Runs for updates as well as
@@ -426,7 +442,7 @@ export class PersistService {
426
442
  // path. The insert is idempotent, so re-asserting a link is a
427
443
  // no-op rather than a duplicate-key error.
428
444
  if (junctionTableInfo) {
429
- await this.relationService.handleJunctionTableCreation(tx, currentId, junctionTableInfo);
445
+ await this.relationWrites.handleJunctionTableCreation(tx, currentId, junctionTableInfo);
430
446
  }
431
447
 
432
448
  return currentId;
@@ -455,6 +471,16 @@ export class PersistService {
455
471
  return this.relationService;
456
472
  }
457
473
 
474
+ /**
475
+ * The write half, for external use. Separate from
476
+ * {@link getRelationService} because they are separate objects now: reads
477
+ * answer questions, writes change rows, and the callers of one are not the
478
+ * callers of the other.
479
+ */
480
+ getRelationWriteService(): RelationWriteService {
481
+ return this.relationWrites;
482
+ }
483
+
458
484
  /**
459
485
  * Get the FetchService instance for external use
460
486
  */