@rebasepro/server-postgres 0.13.1-canary.gcd6689e → 0.13.1-canary.gcef69d4

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/server-postgres",
3
3
  "type": "module",
4
- "version": "0.13.1-canary.gcd6689e",
4
+ "version": "0.13.1-canary.gcef69d4",
5
5
  "description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -47,11 +47,11 @@
47
47
  "execa": "^9.6.1",
48
48
  "pg": "^8.22.0",
49
49
  "ws": "^8.21.1",
50
- "@rebasepro/codegen": "0.13.1-canary.gcd6689e",
51
- "@rebasepro/common": "0.13.1-canary.gcd6689e",
52
- "@rebasepro/types": "0.13.1-canary.gcd6689e",
53
- "@rebasepro/utils": "0.13.1-canary.gcd6689e",
54
- "@rebasepro/server": "0.13.1-canary.gcd6689e"
50
+ "@rebasepro/codegen": "0.13.1-canary.gcef69d4",
51
+ "@rebasepro/server": "0.13.1-canary.gcef69d4",
52
+ "@rebasepro/types": "0.13.1-canary.gcef69d4",
53
+ "@rebasepro/common": "0.13.1-canary.gcef69d4",
54
+ "@rebasepro/utils": "0.13.1-canary.gcef69d4"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@hono/node-server": "^2.0.12",
@@ -60,7 +60,7 @@
60
60
  "@types/node": "^26.1.2",
61
61
  "@types/pg": "^8.20.0",
62
62
  "@types/ws": "^8.18.1",
63
- "hono": "^4.12.27",
63
+ "hono": "^4.13.0",
64
64
  "jest": "^30.4.2",
65
65
  "ts-jest": "^29.4.12",
66
66
  "typescript": "^6.0.3",
@@ -378,9 +378,25 @@ export function assertRelationsResolve(
378
378
  );
379
379
 
380
380
  throw new Error(
381
- `${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against the database schema.\n\n` +
381
+ `${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against ` +
382
+ "`backend/src/schema.generated.ts`.\n\n" +
382
383
  "Each of these would return no rows at query time rather than reporting an error, " +
383
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" +
384
400
  lines.join("\n\n") + "\n"
385
401
  );
386
402
  }
@@ -3,6 +3,7 @@ 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";
@@ -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
- newObj[key] = sanitizeAndConvertDates((obj as Record<string, unknown>)[key]);
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,9 @@ 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;
129
139
  const property = properties[key as keyof M] as Property;
130
140
 
131
141
  // Coerce empty strings to null for any field that acts as a foreign key
@@ -954,6 +954,16 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
954
954
  collectionPath: string,
955
955
  options: {
956
956
  filter?: FilterValues<Extract<keyof M, string>>;
957
+ /**
958
+ * An `or(...)`/`and(...)` group, applied alongside `filter`.
959
+ *
960
+ * `fetchRowsWithConditions` below has always applied this; it was
961
+ * simply absent from this signature, so the only callers that could
962
+ * pass one were the ones that went around this method. Realtime
963
+ * came through here, which is why a subscription filtered by a
964
+ * logical group was pushed every row in the table.
965
+ */
966
+ logical?: LogicalCondition;
957
967
  orderBy?: string;
958
968
  order?: "desc" | "asc";
959
969
  limit?: number;
@@ -477,7 +477,20 @@ export class PersistService {
477
477
  const pgError = extractPgError(error);
478
478
 
479
479
  if (pgError) {
480
- const { message } = pgErrorToFriendlyMessage(pgError, collectionSlug);
480
+ const { message, code } = pgErrorToFriendlyMessage(pgError, collectionSlug);
481
+ // This is the only layer that holds the SQLSTATE, so it is the only
482
+ // one that can say whose fault a failure was. Returning a bare
483
+ // `Error` threw that away, and the REST layer compensated by calling
484
+ // *every* unclassified error a 400 — so an unreachable database was
485
+ // reported to callers as a bad request. Classes 22 (data exception)
486
+ // and 23 (integrity constraint violation) are the caller's data;
487
+ // everything else — a dropped connection, a missing column, a
488
+ // permission problem — is ours, and stays a 500.
489
+ if (/^2[23]/.test(code)) {
490
+ return code === "23505"
491
+ ? ApiError.conflict(message, `PG_${code}`)
492
+ : ApiError.badRequest(message, `PG_${code}`);
493
+ }
481
494
  return new Error(message);
482
495
  }
483
496
 
@@ -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;
@@ -4,7 +4,7 @@ import { Client as PgClient } from "pg";
4
4
  import { randomUUID } from "crypto";
5
5
  import { DataService } from "./dataService";
6
6
 
7
- import { ANONYMOUS_USER_ID, FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, CollectionConfig, RebaseCallContext, resolveClientListLimit } from "@rebasepro/types";
7
+ import { ANONYMOUS_USER_ID, FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, LogicalCondition, CollectionConfig, RebaseCallContext, resolveClientListLimit } from "@rebasepro/types";
8
8
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
9
9
  import { sql as drizzleSql } from "drizzle-orm";
10
10
  import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces";
@@ -41,6 +41,28 @@ type RealTimeListenCollectionProps = ListenCollectionProps & {
41
41
  subscriptionId: string
42
42
  };
43
43
 
44
+ /**
45
+ * The narrowing a collection subscription was created with, kept so that every
46
+ * refetch answers the same query the initial fetch did.
47
+ *
48
+ * Named once because it used to be written out inline in five places, and a
49
+ * field missing from one of them is accepted over the wire and then silently
50
+ * ignored: `offset` was declared on the incoming props and never stored, so a
51
+ * live list on page three served page one, and `logical` was never stored
52
+ * either, so an `or(...)` subscription was pushed every row in the table.
53
+ */
54
+ type StoredCollectionRequest = {
55
+ filter?: Record<string, unknown>;
56
+ logical?: LogicalCondition;
57
+ orderBy?: string;
58
+ order?: "desc" | "asc";
59
+ limit?: number;
60
+ offset?: number;
61
+ startAfter?: Record<string, unknown>;
62
+ databaseId?: string;
63
+ searchString?: string;
64
+ };
65
+
44
66
  type RealTimeListenEntityProps = ListenOneProps & { subscriptionId: string };
45
67
 
46
68
  /**
@@ -119,16 +141,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
119
141
  path: string;
120
142
  id?: string | number;
121
143
  // Store full collection request parameters for proper refetching
122
- collectionRequest?: {
123
- filter?: Record<string, unknown>;
124
- orderBy?: string;
125
- order?: "desc" | "asc";
126
- limit?: number;
127
- offset?: number;
128
- startAfter?: Record<string, unknown>;
129
- databaseId?: string;
130
- searchString?: string;
131
- };
144
+ collectionRequest?: StoredCollectionRequest;
132
145
  // Auth context for RLS — when set, refetches run in a transaction
133
146
  // with set_config('app.uid', ...) / set_config('app.user_roles', ...)
134
147
  authContext?: SubscriptionAuthContext;
@@ -212,16 +225,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
212
225
  type: "collection" | "single";
213
226
  path: string;
214
227
  id?: string | number;
215
- collectionRequest?: {
216
- filter?: Record<string, unknown>;
217
- orderBy?: string;
218
- order?: "desc" | "asc";
219
- limit?: number;
220
- offset?: number;
221
- startAfter?: Record<string, unknown>;
222
- databaseId?: string;
223
- searchString?: string;
224
- };
228
+ collectionRequest?: StoredCollectionRequest;
225
229
  authContext?: SubscriptionAuthContext;
226
230
  }) {
227
231
  this.debugLog("📋 [RealtimeService] Registering DataDriver subscription:", subscriptionId, subscription.authContext ? "(with auth)" : "(no auth)");
@@ -448,9 +452,11 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
448
452
  path: request.path,
449
453
  collectionRequest: {
450
454
  filter: request.filter,
455
+ logical: request.logical,
451
456
  orderBy: request.orderBy,
452
457
  order: request.order,
453
458
  limit: boundedLimit,
459
+ offset: request.offset,
454
460
  startAfter: request.startAfter as Record<string, unknown> | undefined,
455
461
  databaseId: request.collection?.databaseId,
456
462
  searchString: request.searchString
@@ -458,17 +464,12 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
458
464
  authContext
459
465
  });
460
466
 
461
- // Send initial data
467
+ // Send initial data. Built from the request the subscription just
468
+ // stored, so the first answer and every refetch after it cannot
469
+ // describe different queries.
462
470
  const rows = await this.fetchCollectionWithAuth(
463
471
  request.path,
464
- {
465
- filter: request.filter,
466
- orderBy: request.orderBy,
467
- order: request.order,
468
- limit: boundedLimit,
469
- startAfter: request.startAfter as Record<string, unknown> | undefined,
470
- searchString: request.searchString
471
- },
472
+ this._subscriptions.get(subscriptionId)!.collectionRequest!,
472
473
  authContext
473
474
  );
474
475
 
@@ -679,7 +680,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
679
680
  private debouncedCollectionRefetch(
680
681
  subscriptionId: string,
681
682
  notifyPath: string,
682
- subscription: { clientId: string; collectionRequest?: { filter?: Record<string, unknown>; orderBy?: string; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record<string, unknown>; databaseId?: string; searchString?: string }; authContext?: SubscriptionAuthContext }
683
+ subscription: { clientId: string; collectionRequest?: StoredCollectionRequest; authContext?: SubscriptionAuthContext }
683
684
  ) {
684
685
  const timerKey = `ws_${subscriptionId}`;
685
686
  const existing = this.refetchTimers.get(timerKey);
@@ -705,7 +706,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
705
706
  private debouncedDriverRefetch(
706
707
  subscriptionId: string,
707
708
  notifyPath: string,
708
- subscription: { collectionRequest?: { filter?: Record<string, unknown>; orderBy?: string; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record<string, unknown>; databaseId?: string; searchString?: string }; authContext?: SubscriptionAuthContext },
709
+ subscription: { collectionRequest?: StoredCollectionRequest; authContext?: SubscriptionAuthContext },
709
710
  callback: (data: Record<string, unknown>[] | Record<string, unknown> | null) => void
710
711
  ) {
711
712
  const timerKey = `drv_${subscriptionId}`;
@@ -731,7 +732,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
731
732
  */
732
733
  private async fetchCollectionWithAuth(
733
734
  notifyPath: string,
734
- collectionRequest: { filter?: Record<string, unknown>; orderBy?: string; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record<string, unknown>; databaseId?: string; searchString?: string },
735
+ collectionRequest: StoredCollectionRequest,
735
736
  authContext?: SubscriptionAuthContext
736
737
  ): Promise<Record<string, unknown>[]> {
737
738
  if (this.driver) {
@@ -740,6 +741,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
740
741
  path: notifyPath,
741
742
  collection: collection,
742
743
  filter: collectionRequest.filter as FetchCollectionProps["filter"],
744
+ logical: collectionRequest.logical,
743
745
  orderBy: collectionRequest.orderBy,
744
746
  order: collectionRequest.order,
745
747
  limit: collectionRequest.limit,
@@ -772,6 +774,7 @@ roles: ["anon"] };
772
774
  } else {
773
775
  fetchedEntities = await txEntityService.fetchCollection(notifyPath, {
774
776
  filter: collectionRequest.filter as FilterValues<string>,
777
+ logical: collectionRequest.logical,
775
778
  orderBy: collectionRequest.orderBy,
776
779
  order: collectionRequest.order,
777
780
  limit: collectionRequest.limit,