@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/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-25nZOd87.js} +2 -2
- package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-25nZOd87.js.map} +1 -1
- package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DRkgVBV5.js} +2 -2
- package/dist/{ensure-collection-tables-CBQdOETu.js.map → ensure-collection-tables-DRkgVBV5.js.map} +1 -1
- package/dist/index.es.js +25 -33
- package/dist/index.es.js.map +1 -1
- package/dist/services/FetchService.d.ts +10 -0
- package/dist/services/dataService.d.ts +2 -0
- package/dist/services/realtimeService.d.ts +25 -21
- package/dist/{src-DlPBctw_.js → src-Bs2ZzSi4.js} +92 -15
- package/dist/src-Bs2ZzSi4.js.map +1 -0
- package/package.json +7 -7
- package/src/collections/validate-relations.ts +17 -1
- package/src/data-transformer.ts +13 -3
- package/src/services/FetchService.ts +10 -0
- package/src/services/PersistService.ts +14 -1
- package/src/services/dataService.ts +2 -0
- package/src/services/realtimeService.ts +36 -33
- package/dist/src-DlPBctw_.js.map +0 -1
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.
|
|
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.
|
|
51
|
-
"@rebasepro/
|
|
52
|
-
"@rebasepro/types": "0.13.1-canary.
|
|
53
|
-
"@rebasepro/
|
|
54
|
-
"@rebasepro/
|
|
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.
|
|
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
|
|
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
|
}
|
package/src/data-transformer.ts
CHANGED
|
@@ -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
|
-
|
|
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?:
|
|
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?:
|
|
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:
|
|
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,
|