@rebasepro/server-postgresql 0.0.1-canary.4d4fb3e → 0.0.1-canary.ca2cb6e
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/common/src/collections/CollectionRegistry.d.ts +8 -0
- package/dist/common/src/util/entities.d.ts +22 -0
- package/dist/common/src/util/relations.d.ts +14 -4
- package/dist/common/src/util/resolutions.d.ts +1 -1
- package/dist/index.es.js +1254 -591
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +1254 -591
- package/dist/index.umd.js.map +1 -1
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +17 -29
- package/dist/server-postgresql/src/auth/services.d.ts +7 -3
- package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +1 -1
- package/dist/server-postgresql/src/connection.d.ts +34 -1
- package/dist/server-postgresql/src/data-transformer.d.ts +26 -4
- package/dist/server-postgresql/src/databasePoolManager.d.ts +2 -2
- package/dist/server-postgresql/src/schema/auth-schema.d.ts +139 -38
- package/dist/server-postgresql/src/schema/doctor-cli.d.ts +2 -0
- package/dist/server-postgresql/src/schema/doctor.d.ts +43 -0
- package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +1 -1
- package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
- package/dist/server-postgresql/src/services/EntityFetchService.d.ts +22 -8
- package/dist/server-postgresql/src/services/EntityPersistService.d.ts +1 -1
- package/dist/server-postgresql/src/services/RelationService.d.ts +11 -5
- package/dist/server-postgresql/src/services/entity-helpers.d.ts +16 -2
- package/dist/server-postgresql/src/services/entityService.d.ts +8 -6
- package/dist/server-postgresql/src/services/realtimeService.d.ts +2 -0
- package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +2 -2
- package/dist/types/src/controllers/auth.d.ts +2 -0
- package/dist/types/src/controllers/client.d.ts +119 -7
- package/dist/types/src/controllers/collection_registry.d.ts +4 -3
- package/dist/types/src/controllers/customization_controller.d.ts +7 -1
- package/dist/types/src/controllers/data.d.ts +34 -7
- package/dist/types/src/controllers/data_driver.d.ts +20 -28
- package/dist/types/src/controllers/database_admin.d.ts +2 -2
- package/dist/types/src/controllers/email.d.ts +34 -0
- package/dist/types/src/controllers/index.d.ts +1 -0
- package/dist/types/src/controllers/local_config_persistence.d.ts +4 -4
- package/dist/types/src/controllers/navigation.d.ts +5 -5
- package/dist/types/src/controllers/registry.d.ts +6 -3
- package/dist/types/src/controllers/side_entity_controller.d.ts +7 -6
- package/dist/types/src/controllers/storage.d.ts +24 -26
- package/dist/types/src/rebase_context.d.ts +8 -4
- package/dist/types/src/types/backend.d.ts +4 -1
- package/dist/types/src/types/builders.d.ts +5 -4
- package/dist/types/src/types/chips.d.ts +1 -1
- package/dist/types/src/types/collections.d.ts +169 -125
- package/dist/types/src/types/cron.d.ts +102 -0
- package/dist/types/src/types/data_source.d.ts +1 -1
- package/dist/types/src/types/entity_actions.d.ts +8 -8
- package/dist/types/src/types/entity_callbacks.d.ts +15 -15
- package/dist/types/src/types/entity_link_builder.d.ts +1 -1
- package/dist/types/src/types/entity_overrides.d.ts +2 -1
- package/dist/types/src/types/entity_views.d.ts +8 -8
- package/dist/types/src/types/export_import.d.ts +3 -3
- package/dist/types/src/types/index.d.ts +1 -0
- package/dist/types/src/types/plugins.d.ts +72 -18
- package/dist/types/src/types/properties.d.ts +118 -33
- package/dist/types/src/types/relations.d.ts +1 -1
- package/dist/types/src/types/slots.d.ts +30 -6
- package/dist/types/src/types/translations.d.ts +44 -0
- package/dist/types/src/types/user_management_delegate.d.ts +1 -0
- package/drizzle-test/0000_woozy_junta.sql +6 -0
- package/drizzle-test/0001_youthful_arachne.sql +1 -0
- package/drizzle-test/0002_lively_dragon_lord.sql +2 -0
- package/drizzle-test/0003_mean_king_cobra.sql +2 -0
- package/drizzle-test/meta/0000_snapshot.json +47 -0
- package/drizzle-test/meta/0001_snapshot.json +48 -0
- package/drizzle-test/meta/0002_snapshot.json +38 -0
- package/drizzle-test/meta/0003_snapshot.json +48 -0
- package/drizzle-test/meta/_journal.json +34 -0
- package/drizzle-test-out/0000_tan_trauma.sql +6 -0
- package/drizzle-test-out/0001_rapid_drax.sql +1 -0
- package/drizzle-test-out/meta/0000_snapshot.json +44 -0
- package/drizzle-test-out/meta/0001_snapshot.json +54 -0
- package/drizzle-test-out/meta/_journal.json +20 -0
- package/drizzle.test.config.ts +10 -0
- package/package.json +88 -89
- package/scratch.ts +41 -0
- package/src/PostgresBackendDriver.ts +63 -79
- package/src/PostgresBootstrapper.ts +7 -8
- package/src/auth/ensure-tables.ts +158 -86
- package/src/auth/services.ts +109 -50
- package/src/cli.ts +259 -16
- package/src/collections/PostgresCollectionRegistry.ts +6 -6
- package/src/connection.ts +70 -48
- package/src/data-transformer.ts +155 -116
- package/src/databasePoolManager.ts +6 -5
- package/src/history/HistoryService.ts +3 -12
- package/src/interfaces.ts +3 -3
- package/src/schema/auth-schema.ts +26 -3
- package/src/schema/doctor-cli.ts +47 -0
- package/src/schema/doctor.ts +595 -0
- package/src/schema/generate-drizzle-schema-logic.ts +204 -57
- package/src/schema/generate-drizzle-schema.ts +6 -6
- package/src/schema/test-schema.ts +11 -0
- package/src/services/BranchService.ts +5 -5
- package/src/services/EntityFetchService.ts +317 -188
- package/src/services/EntityPersistService.ts +15 -17
- package/src/services/RelationService.ts +299 -37
- package/src/services/entity-helpers.ts +39 -13
- package/src/services/entityService.ts +11 -9
- package/src/services/realtimeService.ts +58 -29
- package/src/utils/drizzle-conditions.ts +25 -24
- package/src/websocket.ts +52 -21
- package/test/auth-services.test.ts +131 -39
- package/test/batch-many-to-many-regression.test.ts +573 -0
- package/test/branchService.test.ts +22 -12
- package/test/data-transformer-hardening.test.ts +417 -0
- package/test/data-transformer.test.ts +175 -0
- package/test/doctor.test.ts +182 -0
- package/test/entityService.errors.test.ts +31 -16
- package/test/entityService.relations.test.ts +155 -59
- package/test/entityService.subcollection-search.test.ts +107 -57
- package/test/entityService.test.ts +105 -47
- package/test/generate-drizzle-schema.test.ts +262 -69
- package/test/historyService.test.ts +31 -16
- package/test/n-plus-one-regression.test.ts +314 -0
- package/test/postgresDataDriver.test.ts +260 -168
- package/test/realtimeService.test.ts +70 -39
- package/test/relation-pipeline-gaps.test.ts +637 -0
- package/test/relations.test.ts +492 -39
- package/test-drizzle-bug.ts +18 -0
- package/test-drizzle-out/0000_cultured_freak.sql +7 -0
- package/test-drizzle-out/0001_tiresome_professor_monster.sql +1 -0
- package/test-drizzle-out/meta/0000_snapshot.json +55 -0
- package/test-drizzle-out/meta/0001_snapshot.json +63 -0
- package/test-drizzle-out/meta/_journal.json +20 -0
- package/test-drizzle-prompt.sh +2 -0
- package/test-policy-prompt.sh +3 -0
- package/test-programmatic.ts +30 -0
- package/test-programmatic2.ts +59 -0
- package/test-schema-no-policies.ts +12 -0
- package/test_drizzle_mock.js +2 -2
- package/test_find_changed.mjs +3 -1
- package/test_hash.js +14 -0
- package/tsconfig.json +1 -1
- package/vite.config.ts +5 -5
package/src/data-transformer.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { eq, SQL } from "drizzle-orm";
|
|
2
2
|
import { AnyPgColumn } from "drizzle-orm/pg-core";
|
|
3
3
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
|
-
import { EntityCollection, Properties, Property, Relation, RelationProperty } from "@rebasepro/types";
|
|
5
|
-
import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
|
|
4
|
+
import { CollectionWithRelations, EntityCollection, Properties, Property, Relation, RelationProperty } from "@rebasepro/types";
|
|
5
|
+
import { getTableName, resolveCollectionRelations, findRelation, createRelationRef, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE } from "@rebasepro/common";
|
|
6
6
|
import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
|
|
7
7
|
import { DrizzleConditionBuilder } from "./utils/drizzle-conditions";
|
|
8
8
|
import { getPrimaryKeys, buildCompositeId } from "./services/entity-helpers";
|
|
@@ -11,6 +11,28 @@ import { getPrimaryKeys, buildCompositeId } from "./services/entity-helpers";
|
|
|
11
11
|
* Data transformation utilities for converting between frontend and database formats.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Typed result from `serializeDataToServer`.
|
|
16
|
+
* Replaces the hidden `__inverseRelationUpdates` / `__joinPathRelationUpdates`
|
|
17
|
+
* dunder-property mutation pattern with explicit, typed state management.
|
|
18
|
+
*/
|
|
19
|
+
export interface SerializedEntityData {
|
|
20
|
+
/** Scalar column values ready for INSERT/UPDATE. */
|
|
21
|
+
scalarData: Record<string, unknown>;
|
|
22
|
+
/** Inverse relation updates that must be applied to target tables. */
|
|
23
|
+
inverseRelationUpdates: Array<{
|
|
24
|
+
relationKey: string;
|
|
25
|
+
relation: Relation;
|
|
26
|
+
newValue: unknown;
|
|
27
|
+
currentEntityId?: string | number;
|
|
28
|
+
}>;
|
|
29
|
+
/** JoinPath relation updates that require multi-hop writes. */
|
|
30
|
+
joinPathRelationUpdates: Array<{
|
|
31
|
+
relationKey: string;
|
|
32
|
+
relation: Relation;
|
|
33
|
+
newTargetId: string | number | null;
|
|
34
|
+
}>;
|
|
35
|
+
}
|
|
14
36
|
/**
|
|
15
37
|
* Helper function to sanitize and convert dates to ISO strings
|
|
16
38
|
*/
|
|
@@ -62,18 +84,18 @@ export function sanitizeAndConvertDates(obj: unknown): unknown {
|
|
|
62
84
|
/**
|
|
63
85
|
* Transform relations for database storage (relation objects to IDs)
|
|
64
86
|
*/
|
|
65
|
-
export function serializeDataToServer<M extends Record<string,
|
|
87
|
+
export function serializeDataToServer<M extends Record<string, unknown>>(
|
|
66
88
|
entity: M,
|
|
67
89
|
properties: Properties,
|
|
68
90
|
collection?: EntityCollection,
|
|
69
91
|
registry?: PostgresCollectionRegistry
|
|
70
|
-
):
|
|
71
|
-
if (!entity || !properties) return entity;
|
|
92
|
+
): SerializedEntityData {
|
|
93
|
+
if (!entity || !properties) return { scalarData: entity ?? {}, inverseRelationUpdates: [], joinPathRelationUpdates: [] };
|
|
72
94
|
|
|
73
95
|
const result: Record<string, unknown> = {};
|
|
74
96
|
|
|
75
97
|
// Get normalized relations if collection is provided
|
|
76
|
-
const resolvedRelations = collection ? resolveCollectionRelations(collection
|
|
98
|
+
const resolvedRelations = collection ? resolveCollectionRelations(collection) : {};
|
|
77
99
|
|
|
78
100
|
// Track inverse relations that need to be handled separately
|
|
79
101
|
const inverseRelationUpdates: Array<{
|
|
@@ -88,39 +110,49 @@ export function serializeDataToServer<M extends Record<string, any>>(
|
|
|
88
110
|
newTargetId: string | number | null;
|
|
89
111
|
}> = [];
|
|
90
112
|
|
|
113
|
+
// Pre-calculate all local keys used as foreign keys
|
|
114
|
+
const foreignKeys = new Set<string>();
|
|
115
|
+
Object.values(resolvedRelations).forEach(relation => {
|
|
116
|
+
if (relation.localKey) foreignKeys.add(relation.localKey);
|
|
117
|
+
});
|
|
118
|
+
|
|
91
119
|
for (const [key, value] of Object.entries(entity)) {
|
|
92
120
|
const property = properties[key as keyof M] as Property;
|
|
121
|
+
|
|
122
|
+
// Coerce empty strings to null for any field that acts as a foreign key
|
|
123
|
+
const effectiveValue = (foreignKeys.has(key) && value === "") ? null : value;
|
|
124
|
+
|
|
93
125
|
if (!property) {
|
|
94
|
-
result[key] =
|
|
126
|
+
result[key] = effectiveValue;
|
|
95
127
|
continue;
|
|
96
128
|
}
|
|
97
129
|
|
|
98
130
|
// Handle relation properties specially
|
|
99
131
|
if (property.type === "relation" && collection) {
|
|
100
|
-
const relation = resolvedRelations
|
|
132
|
+
const relation = findRelation(resolvedRelations, key);
|
|
101
133
|
if (relation) {
|
|
102
134
|
if (relation.direction === "owning" && relation.localKey) {
|
|
103
135
|
// Owning relation: Map relation object to FK column on current table
|
|
104
|
-
const serializedValue = serializePropertyToServer(
|
|
105
|
-
if (serializedValue !==
|
|
136
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
137
|
+
if (serializedValue !== undefined) {
|
|
106
138
|
result[relation.localKey] = serializedValue;
|
|
107
139
|
}
|
|
108
140
|
// Don't add the original relation property to the result
|
|
109
141
|
continue;
|
|
110
142
|
} else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
111
143
|
// Inverse relation: Need to update the target table's FK
|
|
112
|
-
const serializedValue = serializePropertyToServer(
|
|
144
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
113
145
|
const pks = getPrimaryKeys(collection, registry!);
|
|
114
146
|
inverseRelationUpdates.push({
|
|
115
147
|
relationKey: key,
|
|
116
148
|
relation,
|
|
117
149
|
newValue: serializedValue,
|
|
118
|
-
currentEntityId: entity.id || buildCompositeId(entity, pks)
|
|
150
|
+
currentEntityId: (entity.id as string | number | undefined) || buildCompositeId(entity, pks)
|
|
119
151
|
});
|
|
120
152
|
// Don't add the original relation property to the result
|
|
121
153
|
continue;
|
|
122
154
|
} else if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
|
|
123
|
-
const serializedValue = serializePropertyToServer(
|
|
155
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
124
156
|
if (relation.cardinality === "one") {
|
|
125
157
|
// One-to-one inverse joinPath: route through joinPathRelationUpdates.
|
|
126
158
|
// The write ordering in EntityPersistService ensures these are processed
|
|
@@ -139,14 +171,14 @@ export function serializeDataToServer<M extends Record<string, any>>(
|
|
|
139
171
|
relationKey: key,
|
|
140
172
|
relation,
|
|
141
173
|
newValue: serializedValue,
|
|
142
|
-
currentEntityId: entity.id || buildCompositeId(entity, pks)
|
|
174
|
+
currentEntityId: (entity.id as string | number | undefined) || buildCompositeId(entity, pks)
|
|
143
175
|
});
|
|
144
176
|
}
|
|
145
177
|
// Don't add the original relation property to the result
|
|
146
178
|
continue;
|
|
147
179
|
} else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
|
|
148
180
|
// Owning one-to-one via joinPath: capture as a write intent
|
|
149
|
-
const serializedValue = serializePropertyToServer(
|
|
181
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
150
182
|
joinPathRelationUpdates.push({
|
|
151
183
|
relationKey: key,
|
|
152
184
|
relation,
|
|
@@ -158,17 +190,14 @@ export function serializeDataToServer<M extends Record<string, any>>(
|
|
|
158
190
|
}
|
|
159
191
|
}
|
|
160
192
|
|
|
161
|
-
result[key] = serializePropertyToServer(
|
|
193
|
+
result[key] = serializePropertyToServer(effectiveValue, property);
|
|
162
194
|
}
|
|
163
195
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
return result;
|
|
196
|
+
return {
|
|
197
|
+
scalarData: result,
|
|
198
|
+
inverseRelationUpdates,
|
|
199
|
+
joinPathRelationUpdates
|
|
200
|
+
};
|
|
172
201
|
}
|
|
173
202
|
|
|
174
203
|
/**
|
|
@@ -188,11 +217,29 @@ export function serializePropertyToServer(value: unknown, property: Property): u
|
|
|
188
217
|
} else if (typeof value === "object" && value !== null && "id" in value) {
|
|
189
218
|
return (value as Record<string, unknown>).id;
|
|
190
219
|
}
|
|
220
|
+
if (value === "") return null;
|
|
191
221
|
return value;
|
|
192
222
|
|
|
193
223
|
case "array":
|
|
194
|
-
if (Array.isArray(value)
|
|
195
|
-
|
|
224
|
+
if (Array.isArray(value)) {
|
|
225
|
+
if (property.of) {
|
|
226
|
+
return value.map(item => serializePropertyToServer(item, property.of as Property));
|
|
227
|
+
} else if (property.oneOf) {
|
|
228
|
+
const typeField = property.oneOf.typeField ?? DEFAULT_ONE_OF_TYPE;
|
|
229
|
+
const valueField = property.oneOf.valueField ?? DEFAULT_ONE_OF_VALUE;
|
|
230
|
+
return value.map((e) => {
|
|
231
|
+
if (e === null) return null;
|
|
232
|
+
if (typeof e !== "object") return e;
|
|
233
|
+
const rec = e as Record<string, unknown>;
|
|
234
|
+
const type = rec[typeField] as string;
|
|
235
|
+
const childProperty = property.oneOf?.properties[type];
|
|
236
|
+
if (!type || !childProperty) return e;
|
|
237
|
+
return {
|
|
238
|
+
[typeField]: type,
|
|
239
|
+
[valueField]: serializePropertyToServer(rec[valueField], childProperty)
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
}
|
|
196
243
|
}
|
|
197
244
|
return value;
|
|
198
245
|
|
|
@@ -219,50 +266,26 @@ export function serializePropertyToServer(value: unknown, property: Property): u
|
|
|
219
266
|
/**
|
|
220
267
|
* Transform IDs back to relation objects for frontend
|
|
221
268
|
*/
|
|
222
|
-
export async function parseDataFromServer<M extends Record<string,
|
|
269
|
+
export async function parseDataFromServer<M extends Record<string, unknown>>(
|
|
223
270
|
data: M,
|
|
224
271
|
collection: EntityCollection,
|
|
225
|
-
db?: NodePgDatabase<
|
|
272
|
+
db?: NodePgDatabase<Record<string, unknown>>,
|
|
226
273
|
registry?: PostgresCollectionRegistry
|
|
227
274
|
): Promise<M> {
|
|
228
275
|
const properties = collection.properties;
|
|
229
276
|
if (!data || !properties) return data;
|
|
230
277
|
|
|
231
|
-
const result: Record<string, unknown> = {};
|
|
232
|
-
|
|
233
278
|
// Get the normalized relations once
|
|
234
|
-
const resolvedRelations = resolveCollectionRelations(collection
|
|
279
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
235
280
|
|
|
236
|
-
//
|
|
237
|
-
const
|
|
238
|
-
Object.values(resolvedRelations).forEach(relation => {
|
|
239
|
-
if (relation.localKey && !properties[relation.localKey]) {
|
|
240
|
-
// This FK is used internally but not exposed as a property
|
|
241
|
-
internalFKColumns.add(relation.localKey);
|
|
242
|
-
}
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
// Process only the properties that are defined in the collection
|
|
246
|
-
for (const [key, value] of Object.entries(data)) {
|
|
247
|
-
// Skip internal FK columns that aren't defined as properties
|
|
248
|
-
if (internalFKColumns.has(key)) {
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const property = properties[key as keyof M] as Property;
|
|
253
|
-
if (!property) {
|
|
254
|
-
// Also skip any other database columns not defined in properties
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
result[key] = parsePropertyFromServer(value, property, collection, key);
|
|
259
|
-
}
|
|
281
|
+
// Shared scalar + relation value normalization
|
|
282
|
+
const result = normalizeScalarValues(data, properties, collection, resolvedRelations, { skipRelations: false });
|
|
260
283
|
|
|
261
284
|
// Add relation properties that should be populated from FK values or inverse queries
|
|
262
285
|
for (const [propKey, property] of Object.entries(properties)) {
|
|
263
286
|
if (property.type === "relation" && !(propKey in result)) {
|
|
264
287
|
// Find the normalized relation for this property
|
|
265
|
-
const relation = resolvedRelations
|
|
288
|
+
const relation = findRelation(resolvedRelations, propKey);
|
|
266
289
|
if (relation) {
|
|
267
290
|
if (relation.direction === "owning" && relation.localKey && relation.localKey in data) {
|
|
268
291
|
// Owning relation: FK is in current table
|
|
@@ -270,11 +293,7 @@ export async function parseDataFromServer<M extends Record<string, any>>(
|
|
|
270
293
|
if (fkValue !== null && fkValue !== undefined) {
|
|
271
294
|
try {
|
|
272
295
|
const targetCollection = relation.target();
|
|
273
|
-
result[propKey] =
|
|
274
|
-
id: fkValue.toString(),
|
|
275
|
-
path: targetCollection.slug,
|
|
276
|
-
__type: "relation"
|
|
277
|
-
};
|
|
296
|
+
result[propKey] = createRelationRef(fkValue.toString(), targetCollection.slug);
|
|
278
297
|
} catch (e) {
|
|
279
298
|
console.warn(`Could not resolve target collection for relation property: ${propKey}`, e);
|
|
280
299
|
}
|
|
@@ -302,19 +321,13 @@ export async function parseDataFromServer<M extends Record<string, any>>(
|
|
|
302
321
|
// One-to-one: return single relation object
|
|
303
322
|
const targetPks = getPrimaryKeys(targetCollection, registry!);
|
|
304
323
|
const relatedEntity = relatedEntities[0] as Record<string, unknown>;
|
|
305
|
-
result[propKey] =
|
|
306
|
-
id: buildCompositeId(relatedEntity, targetPks),
|
|
307
|
-
path: targetCollection.slug,
|
|
308
|
-
__type: "relation"
|
|
309
|
-
};
|
|
324
|
+
result[propKey] = createRelationRef(buildCompositeId(relatedEntity, targetPks), targetCollection.slug);
|
|
310
325
|
} else {
|
|
311
326
|
// One-to-many: return array of relation objects
|
|
312
327
|
const targetPks = getPrimaryKeys(targetCollection, registry!);
|
|
313
|
-
result[propKey] = relatedEntities.map((entity: Record<string, unknown>) =>
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
__type: "relation"
|
|
317
|
-
}));
|
|
328
|
+
result[propKey] = relatedEntities.map((entity: Record<string, unknown>) =>
|
|
329
|
+
createRelationRef(buildCompositeId(entity, targetPks), targetCollection.slug)
|
|
330
|
+
);
|
|
318
331
|
}
|
|
319
332
|
}
|
|
320
333
|
}
|
|
@@ -403,21 +416,13 @@ export async function parseDataFromServer<M extends Record<string, any>>(
|
|
|
403
416
|
if (relation.cardinality === "one") {
|
|
404
417
|
// One-to-one: return single relation object
|
|
405
418
|
const joinResult = joinResults[0] as Record<string, unknown>;
|
|
406
|
-
const targetEntity = joinResult[targetTableName] || joinResult
|
|
407
|
-
result[propKey] =
|
|
408
|
-
id: buildCompositeId(targetEntity, targetPks),
|
|
409
|
-
path: targetCollection.slug,
|
|
410
|
-
__type: "relation"
|
|
411
|
-
};
|
|
419
|
+
const targetEntity = (joinResult[targetTableName] || joinResult) as Record<string, unknown>;
|
|
420
|
+
result[propKey] = createRelationRef(buildCompositeId(targetEntity, targetPks), targetCollection.slug);
|
|
412
421
|
} else {
|
|
413
422
|
// One-to-many: return array of relation objects
|
|
414
423
|
result[propKey] = joinResults.map((joinResult: Record<string, unknown>) => {
|
|
415
|
-
const targetEntity = joinResult[targetTableName] || joinResult
|
|
416
|
-
return
|
|
417
|
-
id: buildCompositeId(targetEntity, targetPks),
|
|
418
|
-
path: targetCollection.slug,
|
|
419
|
-
__type: "relation"
|
|
420
|
-
};
|
|
424
|
+
const targetEntity = (joinResult[targetTableName] || joinResult) as Record<string, unknown>;
|
|
425
|
+
return createRelationRef(buildCompositeId(targetEntity, targetPks), targetCollection.slug);
|
|
421
426
|
});
|
|
422
427
|
}
|
|
423
428
|
}
|
|
@@ -447,35 +452,48 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
|
|
|
447
452
|
if (typeof value === "string" || typeof value === "number") {
|
|
448
453
|
let relationDef: Relation | undefined = (property as RelationProperty).relation;
|
|
449
454
|
if (!relationDef && propertyKey) {
|
|
450
|
-
const resolvedRelations = resolveCollectionRelations(collection
|
|
451
|
-
relationDef = resolvedRelations
|
|
455
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
456
|
+
relationDef = findRelation(resolvedRelations, propertyKey);
|
|
452
457
|
}
|
|
453
458
|
if (!relationDef) {
|
|
454
|
-
relationDef = (collection as
|
|
459
|
+
relationDef = (collection as CollectionWithRelations).relations?.find((rel: Relation) => rel.relationName === (property as RelationProperty).relationName);
|
|
455
460
|
}
|
|
456
|
-
|
|
461
|
+
|
|
457
462
|
if (!relationDef) {
|
|
458
|
-
console.warn(`Relation not defined in property for key: ${propertyKey ||
|
|
463
|
+
console.warn(`Relation not defined in property for key: ${propertyKey || "unknown"}`);
|
|
459
464
|
return value;
|
|
460
465
|
}
|
|
461
|
-
|
|
466
|
+
|
|
462
467
|
try {
|
|
463
468
|
const targetCollection = relationDef.target();
|
|
464
|
-
return
|
|
465
|
-
id: value.toString(),
|
|
466
|
-
path: targetCollection.slug,
|
|
467
|
-
__type: "relation"
|
|
468
|
-
};
|
|
469
|
+
return createRelationRef(value.toString(), targetCollection.slug);
|
|
469
470
|
} catch (e) {
|
|
470
|
-
console.warn(`Could not resolve target collection for relation property: ${propertyKey ||
|
|
471
|
+
console.warn(`Could not resolve target collection for relation property: ${propertyKey || "unknown"}`, e);
|
|
471
472
|
return value;
|
|
472
473
|
}
|
|
473
474
|
}
|
|
474
475
|
return value;
|
|
475
476
|
|
|
476
477
|
case "array":
|
|
477
|
-
if (Array.isArray(value)
|
|
478
|
-
|
|
478
|
+
if (Array.isArray(value)) {
|
|
479
|
+
if (property.of) {
|
|
480
|
+
return value.map(item => parsePropertyFromServer(item, property.of as Property, collection));
|
|
481
|
+
} else if (property.oneOf) {
|
|
482
|
+
const typeField = property.oneOf.typeField ?? DEFAULT_ONE_OF_TYPE;
|
|
483
|
+
const valueField = property.oneOf.valueField ?? DEFAULT_ONE_OF_VALUE;
|
|
484
|
+
return value.map((e) => {
|
|
485
|
+
if (e === null) return null;
|
|
486
|
+
if (typeof e !== "object") return e;
|
|
487
|
+
const rec = e as Record<string, unknown>;
|
|
488
|
+
const type = rec[typeField] as string;
|
|
489
|
+
const childProperty = property.oneOf?.properties[type];
|
|
490
|
+
if (!type || !childProperty) return e;
|
|
491
|
+
return {
|
|
492
|
+
[typeField]: type,
|
|
493
|
+
[valueField]: parsePropertyFromServer(rec[valueField], childProperty, collection)
|
|
494
|
+
};
|
|
495
|
+
});
|
|
496
|
+
}
|
|
479
497
|
}
|
|
480
498
|
return value;
|
|
481
499
|
|
|
@@ -526,25 +544,24 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
|
|
|
526
544
|
}
|
|
527
545
|
|
|
528
546
|
/**
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
* by Drizzle's relational query API.
|
|
547
|
+
* Shared internal helper: normalizes scalar column values from a DB row
|
|
548
|
+
* into frontend format. Handles FK-column preservation, type coercion
|
|
549
|
+
* (dates, numbers, NaN), and property filtering.
|
|
533
550
|
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
551
|
+
* @param skipRelations When `true`, relation-typed properties are omitted
|
|
552
|
+
* from the result (used by `normalizeDbValues` where
|
|
553
|
+
* Drizzle's relational API already hydrates them).
|
|
536
554
|
*/
|
|
537
|
-
|
|
555
|
+
function normalizeScalarValues<M extends Record<string, unknown>>(
|
|
538
556
|
data: M,
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
557
|
+
properties: Properties,
|
|
558
|
+
collection: EntityCollection,
|
|
559
|
+
resolvedRelations: Record<string, Relation>,
|
|
560
|
+
options: { skipRelations: boolean }
|
|
561
|
+
): Record<string, unknown> {
|
|
544
562
|
const result: Record<string, unknown> = {};
|
|
545
563
|
|
|
546
|
-
//
|
|
547
|
-
const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
|
|
564
|
+
// Identify FK columns used only for relations and not exposed as properties
|
|
548
565
|
const internalFKColumns = new Set<string>();
|
|
549
566
|
Object.values(resolvedRelations).forEach(relation => {
|
|
550
567
|
if (relation.localKey && !properties[relation.localKey]) {
|
|
@@ -553,17 +570,39 @@ export function normalizeDbValues<M extends Record<string, any>>(
|
|
|
553
570
|
});
|
|
554
571
|
|
|
555
572
|
for (const [key, value] of Object.entries(data)) {
|
|
556
|
-
//
|
|
557
|
-
if (internalFKColumns.has(key))
|
|
573
|
+
// Keep internal FK columns as primitives
|
|
574
|
+
if (internalFKColumns.has(key)) {
|
|
575
|
+
result[key] = value === null ? null : (typeof value === "number" ? value : String(value));
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
558
578
|
|
|
559
579
|
const property = properties[key as keyof M] as Property;
|
|
560
580
|
if (!property) continue; // Skip DB columns not defined in properties
|
|
561
581
|
|
|
562
|
-
|
|
563
|
-
if (property.type === "relation") continue;
|
|
582
|
+
if (options.skipRelations && property.type === "relation") continue;
|
|
564
583
|
|
|
565
584
|
result[key] = parsePropertyFromServer(value, property, collection, key);
|
|
566
585
|
}
|
|
567
586
|
|
|
568
|
-
return result
|
|
587
|
+
return result;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Lightweight value normalization for db.query results.
|
|
592
|
+
* Only handles type coercion (dates, numbers, NaN) and property filtering.
|
|
593
|
+
* Does NOT query the database for relations — those are already resolved
|
|
594
|
+
* by Drizzle's relational query API.
|
|
595
|
+
*
|
|
596
|
+
* Use this instead of `parseDataFromServer` when processing results from
|
|
597
|
+
* `db.query.findFirst/findMany` which return pre-hydrated relation data.
|
|
598
|
+
*/
|
|
599
|
+
export function normalizeDbValues<M extends Record<string, unknown>>(
|
|
600
|
+
data: M,
|
|
601
|
+
collection: EntityCollection
|
|
602
|
+
): M {
|
|
603
|
+
const properties = collection.properties;
|
|
604
|
+
if (!data || !properties) return data;
|
|
605
|
+
|
|
606
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
607
|
+
return normalizeScalarValues(data, properties, collection, resolvedRelations, { skipRelations: true }) as M;
|
|
569
608
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { Pool } from
|
|
2
|
-
import { drizzle } from
|
|
3
|
-
import { NodePgDatabase } from
|
|
1
|
+
import { Pool } from "pg";
|
|
2
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
3
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
4
|
|
|
5
5
|
export class DatabasePoolManager {
|
|
6
6
|
private pools: Map<string, Pool> = new Map();
|
|
@@ -41,11 +41,12 @@ export class DatabasePoolManager {
|
|
|
41
41
|
const pool = new Pool({
|
|
42
42
|
connectionString: url.toString(),
|
|
43
43
|
max: 10, // Default sensible limit, can be tuned later
|
|
44
|
-
idleTimeoutMillis: 30000
|
|
44
|
+
idleTimeoutMillis: 10000, // Reduced from 30000 for aggressive cleanup
|
|
45
|
+
allowExitOnIdle: true // Prevent idle clients from hanging the Node.js process
|
|
45
46
|
});
|
|
46
47
|
|
|
47
48
|
// Prevent idle client errors from crashing the Node.js process
|
|
48
|
-
pool.on(
|
|
49
|
+
pool.on("error", (err) => {
|
|
49
50
|
console.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, err);
|
|
50
51
|
});
|
|
51
52
|
|
|
@@ -50,7 +50,8 @@ export class HistoryService {
|
|
|
50
50
|
private db: NodePgDatabase,
|
|
51
51
|
retention?: Partial<HistoryRetentionConfig>
|
|
52
52
|
) {
|
|
53
|
-
this.retention = { ...DEFAULT_RETENTION,
|
|
53
|
+
this.retention = { ...DEFAULT_RETENTION,
|
|
54
|
+
...retention };
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
/**
|
|
@@ -74,19 +75,9 @@ export class HistoryService {
|
|
|
74
75
|
? findChangedFields(previousValues, values)
|
|
75
76
|
: null;
|
|
76
77
|
|
|
77
|
-
try {
|
|
78
|
-
require('fs').appendFileSync(
|
|
79
|
-
'/Users/francesco/rebase/packages/backend/history_diff.log',
|
|
80
|
-
`[recordHistory: ${tableName}/${entityId} - ${action}]\n` +
|
|
81
|
-
`CHANGED FIELDS: ${JSON.stringify(changedFields)}\n` +
|
|
82
|
-
`PREVIOUS: ${JSON.stringify(previousValues, null, 2)}\n` +
|
|
83
|
-
`NEW: ${JSON.stringify(values, null, 2)}\n\n`
|
|
84
|
-
);
|
|
85
|
-
} catch (e) {
|
|
86
|
-
console.error("DEBUG FILE WRITE ERROR:", e);
|
|
87
|
-
}
|
|
88
78
|
|
|
89
79
|
// Skip recording if this is an update with zero actual changes
|
|
80
|
+
|
|
90
81
|
if (action === "update" && (!changedFields || changedFields.length === 0)) {
|
|
91
82
|
return;
|
|
92
83
|
}
|
package/src/interfaces.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Database Abstraction Interfaces
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
4
|
* These interfaces define the contracts that any database backend must implement
|
|
5
5
|
* to be used with Rebase. This allows for pluggable database backends like
|
|
6
6
|
* PostgreSQL, MongoDB, MySQL, etc.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
10
|
Entity,
|
|
11
11
|
EntityCollection,
|
|
12
12
|
FilterValues,
|
|
@@ -37,7 +37,7 @@ import { PgTransaction } from "drizzle-orm/pg-core";
|
|
|
37
37
|
* Note: `any` is intentional here — it represents a Drizzle client with
|
|
38
38
|
* a dynamic schema, enabling `db.query[tableName]` access without casts.
|
|
39
39
|
*/
|
|
40
|
-
|
|
40
|
+
|
|
41
41
|
export type DrizzleClient = NodePgDatabase<any> | PgTransaction<any, any, any>;
|
|
42
42
|
|
|
43
43
|
export type {
|
|
@@ -16,8 +16,6 @@ export const users = rebaseSchema.table("users", {
|
|
|
16
16
|
passwordHash: varchar("password_hash", { length: 255 }), // NULL for OAuth-only users
|
|
17
17
|
displayName: varchar("display_name", { length: 255 }),
|
|
18
18
|
photoUrl: varchar("photo_url", { length: 500 }),
|
|
19
|
-
provider: varchar("provider", { length: 50 }).notNull().default("email"), // 'email' | 'google'
|
|
20
|
-
googleId: varchar("google_id", { length: 255 }).unique(),
|
|
21
19
|
emailVerified: boolean("email_verified").default(false).notNull(),
|
|
22
20
|
emailVerificationToken: varchar("email_verification_token", { length: 255 }),
|
|
23
21
|
emailVerificationSentAt: timestamp("email_verification_sent_at"),
|
|
@@ -99,11 +97,27 @@ export const appConfig = rebaseSchema.table("app_config", {
|
|
|
99
97
|
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
100
98
|
});
|
|
101
99
|
|
|
100
|
+
/**
|
|
101
|
+
* User identities - maps external OAuth profiles back to local users
|
|
102
|
+
*/
|
|
103
|
+
export const userIdentities = rebaseSchema.table("user_identities", {
|
|
104
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
105
|
+
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
106
|
+
provider: varchar("provider", { length: 50 }).notNull(), // e.g. 'google', 'linkedin'
|
|
107
|
+
providerId: varchar("provider_id", { length: 255 }).notNull(),
|
|
108
|
+
profileData: jsonb("profile_data"),
|
|
109
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
110
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
111
|
+
}, (table) => ({
|
|
112
|
+
uniqueProviderId: unique("unique_provider_id").on(table.provider, table.providerId)
|
|
113
|
+
}));
|
|
114
|
+
|
|
102
115
|
// Relations
|
|
103
116
|
export const usersRelations = relations(users, ({ many }) => ({
|
|
104
117
|
userRoles: many(userRoles),
|
|
105
118
|
refreshTokens: many(refreshTokens),
|
|
106
|
-
passwordResetTokens: many(passwordResetTokens)
|
|
119
|
+
passwordResetTokens: many(passwordResetTokens),
|
|
120
|
+
userIdentities: many(userIdentities)
|
|
107
121
|
}));
|
|
108
122
|
|
|
109
123
|
export const rolesRelations = relations(roles, ({ many }) => ({
|
|
@@ -135,6 +149,13 @@ export const passwordResetTokensRelations = relations(passwordResetTokens, ({ on
|
|
|
135
149
|
})
|
|
136
150
|
}));
|
|
137
151
|
|
|
152
|
+
export const userIdentitiesRelations = relations(userIdentities, ({ one }) => ({
|
|
153
|
+
user: one(users, {
|
|
154
|
+
fields: [userIdentities.userId],
|
|
155
|
+
references: [users.id]
|
|
156
|
+
})
|
|
157
|
+
}));
|
|
158
|
+
|
|
138
159
|
// Type exports
|
|
139
160
|
export type User = typeof users.$inferSelect;
|
|
140
161
|
export type NewUser = typeof users.$inferInsert;
|
|
@@ -144,3 +165,5 @@ export type UserRole = typeof userRoles.$inferSelect;
|
|
|
144
165
|
export type RefreshToken = typeof refreshTokens.$inferSelect;
|
|
145
166
|
export type PasswordResetToken = typeof passwordResetTokens.$inferSelect;
|
|
146
167
|
export type AppConfig = typeof appConfig.$inferSelect;
|
|
168
|
+
export type UserIdentity = typeof userIdentities.$inferSelect;
|
|
169
|
+
export type NewUserIdentity = typeof userIdentities.$inferInsert;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI entry point for `rebase doctor`.
|
|
4
|
+
* Invoked via tsx by the server-postgresql CLI plugin.
|
|
5
|
+
*/
|
|
6
|
+
import path from "path";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import { runDoctor } from "./doctor";
|
|
9
|
+
|
|
10
|
+
async function main() {
|
|
11
|
+
const collectionsArg = process.argv.find((a) => a.startsWith("--collections="));
|
|
12
|
+
const schemaArg = process.argv.find((a) => a.startsWith("--schema="));
|
|
13
|
+
|
|
14
|
+
const collectionsPath = collectionsArg?.split("=")[1] ?? path.join("..", "shared", "collections");
|
|
15
|
+
const schemaPath = schemaArg?.split("=")[1] ?? path.join("src", "schema.generated.ts");
|
|
16
|
+
|
|
17
|
+
// Load .env
|
|
18
|
+
try {
|
|
19
|
+
const dotenv = await import("dotenv");
|
|
20
|
+
const envPath = process.env.DOTENV_CONFIG_PATH;
|
|
21
|
+
if (envPath) {
|
|
22
|
+
dotenv.config({ path: envPath });
|
|
23
|
+
} else {
|
|
24
|
+
dotenv.config();
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
// dotenv may not be installed
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const databaseUrl = process.env.DATABASE_URL || process.env.ADMIN_CONNECTION_STRING;
|
|
31
|
+
|
|
32
|
+
const report = await runDoctor({
|
|
33
|
+
collectionsPath: path.resolve(process.cwd(), collectionsPath),
|
|
34
|
+
schemaPath: path.resolve(process.cwd(), schemaPath),
|
|
35
|
+
databaseUrl: databaseUrl ?? undefined
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Exit with non-zero code if there are errors
|
|
39
|
+
if (report.summary.errors > 0) {
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
main().catch((err) => {
|
|
45
|
+
console.error(chalk.red(" ✗ Doctor failed:"), err instanceof Error ? err.message : String(err));
|
|
46
|
+
process.exit(1);
|
|
47
|
+
});
|