@rebasepro/server-postgresql 0.0.1-canary.09e5ec5
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/LICENSE +6 -0
- package/README.md +106 -0
- package/build-errors.txt +37 -0
- package/dist/common/src/collections/CollectionRegistry.d.ts +56 -0
- package/dist/common/src/collections/index.d.ts +1 -0
- package/dist/common/src/data/buildRebaseData.d.ts +14 -0
- package/dist/common/src/index.d.ts +3 -0
- package/dist/common/src/util/builders.d.ts +57 -0
- package/dist/common/src/util/callbacks.d.ts +6 -0
- package/dist/common/src/util/collections.d.ts +11 -0
- package/dist/common/src/util/common.d.ts +2 -0
- package/dist/common/src/util/conditions.d.ts +26 -0
- package/dist/common/src/util/entities.d.ts +58 -0
- package/dist/common/src/util/enums.d.ts +3 -0
- package/dist/common/src/util/index.d.ts +16 -0
- package/dist/common/src/util/navigation_from_path.d.ts +34 -0
- package/dist/common/src/util/navigation_utils.d.ts +20 -0
- package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
- package/dist/common/src/util/paths.d.ts +14 -0
- package/dist/common/src/util/permissions.d.ts +5 -0
- package/dist/common/src/util/references.d.ts +2 -0
- package/dist/common/src/util/relations.d.ts +22 -0
- package/dist/common/src/util/resolutions.d.ts +72 -0
- package/dist/common/src/util/storage.d.ts +24 -0
- package/dist/index.es.js +11298 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +11306 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +100 -0
- package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +40 -0
- package/dist/server-postgresql/src/auth/ensure-tables.d.ts +6 -0
- package/dist/server-postgresql/src/auth/services.d.ts +192 -0
- package/dist/server-postgresql/src/cli.d.ts +1 -0
- package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +43 -0
- package/dist/server-postgresql/src/connection.d.ts +40 -0
- package/dist/server-postgresql/src/data-transformer.d.ts +58 -0
- package/dist/server-postgresql/src/databasePoolManager.d.ts +20 -0
- package/dist/server-postgresql/src/history/HistoryService.d.ts +71 -0
- package/dist/server-postgresql/src/history/ensure-history-table.d.ts +7 -0
- package/dist/server-postgresql/src/index.d.ts +13 -0
- package/dist/server-postgresql/src/interfaces.d.ts +18 -0
- package/dist/server-postgresql/src/schema/auth-schema.d.ts +868 -0
- 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 +2 -0
- package/dist/server-postgresql/src/schema/generate-drizzle-schema.d.ts +1 -0
- package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +82 -0
- package/dist/server-postgresql/src/schema/introspect-db.d.ts +1 -0
- package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
- package/dist/server-postgresql/src/services/BranchService.d.ts +47 -0
- package/dist/server-postgresql/src/services/EntityFetchService.d.ts +209 -0
- package/dist/server-postgresql/src/services/EntityPersistService.d.ts +41 -0
- package/dist/server-postgresql/src/services/RelationService.d.ts +98 -0
- package/dist/server-postgresql/src/services/entity-helpers.d.ts +38 -0
- package/dist/server-postgresql/src/services/entityService.d.ts +104 -0
- package/dist/server-postgresql/src/services/index.d.ts +4 -0
- package/dist/server-postgresql/src/services/realtimeService.d.ts +188 -0
- package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +116 -0
- package/dist/server-postgresql/src/websocket.d.ts +5 -0
- package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
- package/dist/types/src/controllers/auth.d.ts +119 -0
- package/dist/types/src/controllers/client.d.ts +170 -0
- package/dist/types/src/controllers/collection_registry.d.ts +45 -0
- package/dist/types/src/controllers/customization_controller.d.ts +60 -0
- package/dist/types/src/controllers/data.d.ts +168 -0
- package/dist/types/src/controllers/data_driver.d.ts +160 -0
- package/dist/types/src/controllers/database_admin.d.ts +11 -0
- package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
- package/dist/types/src/controllers/effective_role.d.ts +4 -0
- package/dist/types/src/controllers/email.d.ts +34 -0
- package/dist/types/src/controllers/index.d.ts +18 -0
- package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
- package/dist/types/src/controllers/navigation.d.ts +213 -0
- package/dist/types/src/controllers/registry.d.ts +54 -0
- package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
- package/dist/types/src/controllers/side_entity_controller.d.ts +90 -0
- package/dist/types/src/controllers/snackbar.d.ts +24 -0
- package/dist/types/src/controllers/storage.d.ts +171 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/rebase_context.d.ts +105 -0
- package/dist/types/src/types/backend.d.ts +536 -0
- package/dist/types/src/types/builders.d.ts +15 -0
- package/dist/types/src/types/chips.d.ts +5 -0
- package/dist/types/src/types/collections.d.ts +856 -0
- package/dist/types/src/types/cron.d.ts +102 -0
- package/dist/types/src/types/data_source.d.ts +64 -0
- package/dist/types/src/types/entities.d.ts +145 -0
- package/dist/types/src/types/entity_actions.d.ts +98 -0
- package/dist/types/src/types/entity_callbacks.d.ts +173 -0
- package/dist/types/src/types/entity_link_builder.d.ts +7 -0
- package/dist/types/src/types/entity_overrides.d.ts +10 -0
- package/dist/types/src/types/entity_views.d.ts +61 -0
- package/dist/types/src/types/export_import.d.ts +21 -0
- package/dist/types/src/types/index.d.ts +23 -0
- package/dist/types/src/types/locales.d.ts +4 -0
- package/dist/types/src/types/modify_collections.d.ts +5 -0
- package/dist/types/src/types/plugins.d.ts +279 -0
- package/dist/types/src/types/properties.d.ts +1176 -0
- package/dist/types/src/types/property_config.d.ts +70 -0
- package/dist/types/src/types/relations.d.ts +336 -0
- package/dist/types/src/types/slots.d.ts +252 -0
- package/dist/types/src/types/translations.d.ts +870 -0
- package/dist/types/src/types/user_management_delegate.d.ts +121 -0
- package/dist/types/src/types/websockets.d.ts +78 -0
- package/dist/types/src/users/index.d.ts +2 -0
- package/dist/types/src/users/roles.d.ts +22 -0
- package/dist/types/src/users/user.d.ts +46 -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/jest-all.log +3128 -0
- package/jest.log +49 -0
- package/package.json +92 -0
- package/scratch.ts +41 -0
- package/src/PostgresBackendDriver.ts +1008 -0
- package/src/PostgresBootstrapper.ts +231 -0
- package/src/auth/ensure-tables.ts +381 -0
- package/src/auth/services.ts +799 -0
- package/src/cli.ts +648 -0
- package/src/collections/PostgresCollectionRegistry.ts +96 -0
- package/src/connection.ts +84 -0
- package/src/data-transformer.ts +608 -0
- package/src/databasePoolManager.ts +85 -0
- package/src/history/HistoryService.ts +248 -0
- package/src/history/ensure-history-table.ts +45 -0
- package/src/index.ts +13 -0
- package/src/interfaces.ts +60 -0
- package/src/schema/auth-schema.ts +169 -0
- package/src/schema/doctor-cli.ts +47 -0
- package/src/schema/doctor.ts +595 -0
- package/src/schema/generate-drizzle-schema-logic.ts +765 -0
- package/src/schema/generate-drizzle-schema.ts +151 -0
- package/src/schema/introspect-db-logic.ts +542 -0
- package/src/schema/introspect-db.ts +211 -0
- package/src/schema/test-schema.ts +11 -0
- package/src/services/BranchService.ts +237 -0
- package/src/services/EntityFetchService.ts +1576 -0
- package/src/services/EntityPersistService.ts +349 -0
- package/src/services/RelationService.ts +1274 -0
- package/src/services/entity-helpers.ts +147 -0
- package/src/services/entityService.ts +211 -0
- package/src/services/index.ts +13 -0
- package/src/services/realtimeService.ts +1034 -0
- package/src/utils/drizzle-conditions.ts +1000 -0
- package/src/websocket.ts +518 -0
- package/test/auth-services.test.ts +661 -0
- package/test/batch-many-to-many-regression.test.ts +573 -0
- package/test/branchService.test.ts +367 -0
- 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/drizzle-conditions.test.ts +895 -0
- package/test/entityService.errors.test.ts +367 -0
- package/test/entityService.relations.test.ts +1008 -0
- package/test/entityService.subcollection-search.test.ts +566 -0
- package/test/entityService.test.ts +1035 -0
- package/test/generate-drizzle-schema.test.ts +988 -0
- package/test/historyService.test.ts +141 -0
- package/test/introspect-db-generation.test.ts +436 -0
- package/test/introspect-db-utils.test.ts +389 -0
- package/test/n-plus-one-regression.test.ts +314 -0
- package/test/postgresDataDriver.test.ts +648 -0
- package/test/realtimeService.test.ts +307 -0
- package/test/relation-pipeline-gaps.test.ts +637 -0
- package/test/relations.test.ts +1115 -0
- package/test/unmapped-tables-safety.test.ts +345 -0
- 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 +3 -0
- package/test_find_changed.mjs +32 -0
- package/test_hash.js +14 -0
- package/test_output.txt +3145 -0
- package/tsconfig.json +49 -0
- package/tsconfig.prod.json +20 -0
- package/vite.config.ts +82 -0
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
import { EntityCollection, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollection, DateProperty, ArrayProperty, MapProperty, ReferenceProperty } from "@rebasepro/types";
|
|
2
|
+
import { getPrimaryKeys } from "../services/entity-helpers";
|
|
3
|
+
import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
|
|
4
|
+
import { toSnakeCase } from "@rebasepro/utils";
|
|
5
|
+
import { createHash } from "crypto";
|
|
6
|
+
// --- Helper Functions ---
|
|
7
|
+
|
|
8
|
+
const getPrimaryKeyProp = (collection: EntityCollection): { name: string, type: "string" | "number", isUuid: boolean } => {
|
|
9
|
+
if (collection.properties) {
|
|
10
|
+
const idPropEntry = Object.entries(collection.properties).find(([_, prop]) => "isId" in (prop as object) && Boolean((prop as unknown as Record<string, unknown>).isId));
|
|
11
|
+
if (idPropEntry) {
|
|
12
|
+
const prop = idPropEntry[1] as Property;
|
|
13
|
+
const isUuid = prop.type === "string" && "isId" in prop && (prop as StringProperty).isId === "uuid";
|
|
14
|
+
return { name: idPropEntry[0],
|
|
15
|
+
type: prop.type === "number" ? "number" : "string",
|
|
16
|
+
isUuid };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// Fallback
|
|
20
|
+
const idProp = collection.properties?.["id"] as Property | undefined;
|
|
21
|
+
if (idProp?.type === "number") {
|
|
22
|
+
return { name: "id",
|
|
23
|
+
type: "number",
|
|
24
|
+
isUuid: false };
|
|
25
|
+
}
|
|
26
|
+
const isUuid = idProp?.type === "string" && "isId" in idProp && (idProp as StringProperty).isId === "uuid";
|
|
27
|
+
return { name: "id",
|
|
28
|
+
type: "string",
|
|
29
|
+
isUuid: isUuid ?? false };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const isNumericId = (collection: EntityCollection): boolean => {
|
|
33
|
+
return getPrimaryKeyProp(collection).type === "number";
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const getPrimaryKeyName = (collection: EntityCollection): string => {
|
|
37
|
+
return getPrimaryKeyProp(collection).name;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const isIdProperty = (propName: string, prop: Property, collection: EntityCollection): boolean => {
|
|
41
|
+
if ("isId" in prop && Boolean(prop.isId)) return true;
|
|
42
|
+
|
|
43
|
+
// We only fallback to "id" if NO property is explicitly marked with `isId: true` or a generator string
|
|
44
|
+
const hasExplicitId = Object.values(collection.properties ?? {}).some(p => "isId" in (p as object) && Boolean((p as unknown as Record<string, unknown>).isId));
|
|
45
|
+
return !hasExplicitId && propName === "id";
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCollection, collections: EntityCollection[]): string | null => {
|
|
49
|
+
const colName = toSnakeCase(propName);
|
|
50
|
+
let columnDefinition: string;
|
|
51
|
+
|
|
52
|
+
switch (prop.type) {
|
|
53
|
+
case "string": {
|
|
54
|
+
const stringProp = prop as StringProperty;
|
|
55
|
+
if (stringProp.enum) {
|
|
56
|
+
const enumName = getEnumVarName(getTableName(collection), propName);
|
|
57
|
+
columnDefinition = `${enumName}("${colName}")`;
|
|
58
|
+
} else if ("isId" in stringProp && stringProp.isId === "uuid") {
|
|
59
|
+
columnDefinition = `uuid("${colName}")`;
|
|
60
|
+
} else if (stringProp.columnType === "text") {
|
|
61
|
+
columnDefinition = `text("${colName}")`;
|
|
62
|
+
} else if (stringProp.columnType === "char") {
|
|
63
|
+
columnDefinition = `char("${colName}")`;
|
|
64
|
+
} else {
|
|
65
|
+
columnDefinition = `varchar("${colName}")`;
|
|
66
|
+
}
|
|
67
|
+
if (isIdProperty(propName, prop, collection)) {
|
|
68
|
+
columnDefinition += ".primaryKey()";
|
|
69
|
+
}
|
|
70
|
+
if ("isId" in stringProp && stringProp.isId !== "manual" && stringProp.isId !== true) {
|
|
71
|
+
if (stringProp.isId === "uuid") {
|
|
72
|
+
columnDefinition += ".defaultRandom()";
|
|
73
|
+
} else if (stringProp.isId === "cuid") {
|
|
74
|
+
columnDefinition += ".default(sql`cuid()`)";
|
|
75
|
+
} else if (typeof stringProp.isId === "string") {
|
|
76
|
+
const sqlContent = stringProp.isId.startsWith("sql`") && stringProp.isId.endsWith("`")
|
|
77
|
+
? stringProp.isId.substring(4, stringProp.isId.length - 1)
|
|
78
|
+
: stringProp.isId;
|
|
79
|
+
columnDefinition += `.default(sql\`${sqlContent}\`)`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (stringProp.validation?.unique) {
|
|
83
|
+
columnDefinition += ".unique()";
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
case "number": {
|
|
88
|
+
const numProp = prop as NumberProperty;
|
|
89
|
+
const isId = isIdProperty(propName, prop, collection);
|
|
90
|
+
|
|
91
|
+
let baseType = (numProp.validation?.integer || isId) ? `integer("${colName}")` : `numeric("${colName}")`;
|
|
92
|
+
if (numProp.columnType) {
|
|
93
|
+
if (numProp.columnType === "double precision") baseType = `doublePrecision("${colName}")`;
|
|
94
|
+
else baseType = `${numProp.columnType}("${colName}")`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if ("isId" in numProp && numProp.isId === "increment") {
|
|
98
|
+
columnDefinition = `${baseType}.generatedByDefaultAsIdentity()`;
|
|
99
|
+
} else if ("isId" in numProp && typeof numProp.isId === "string" && numProp.isId !== "manual") {
|
|
100
|
+
columnDefinition = baseType;
|
|
101
|
+
const sqlContent = numProp.isId.startsWith("sql`") && numProp.isId.endsWith("`")
|
|
102
|
+
? numProp.isId.substring(4, numProp.isId.length - 1)
|
|
103
|
+
: numProp.isId;
|
|
104
|
+
columnDefinition += `.default(sql\`${sqlContent}\`)`;
|
|
105
|
+
} else {
|
|
106
|
+
columnDefinition = baseType;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (isId) {
|
|
110
|
+
columnDefinition += ".primaryKey()";
|
|
111
|
+
}
|
|
112
|
+
if (numProp.validation?.unique) {
|
|
113
|
+
columnDefinition += ".unique()";
|
|
114
|
+
}
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case "boolean":
|
|
118
|
+
columnDefinition = `boolean("${colName}")`;
|
|
119
|
+
break;
|
|
120
|
+
case "date": {
|
|
121
|
+
const dateProp = prop as DateProperty;
|
|
122
|
+
if (dateProp.columnType === "date") {
|
|
123
|
+
columnDefinition = `date("${colName}", { mode: 'string' })`;
|
|
124
|
+
} else if (dateProp.columnType === "time") {
|
|
125
|
+
columnDefinition = `time("${colName}")`;
|
|
126
|
+
} else {
|
|
127
|
+
columnDefinition = `timestamp("${colName}", { withTimezone: true, mode: 'string' })`;
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
case "map":
|
|
132
|
+
case "array": {
|
|
133
|
+
const arrayOrMapProp = prop as ArrayProperty | MapProperty;
|
|
134
|
+
if (arrayOrMapProp.columnType === "json") {
|
|
135
|
+
columnDefinition = `json("${colName}")`;
|
|
136
|
+
} else {
|
|
137
|
+
columnDefinition = `jsonb("${colName}")`;
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case "relation": {
|
|
142
|
+
const refProp = prop as RelationProperty;
|
|
143
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
144
|
+
const relation = findRelation(resolvedRelations, refProp.relationName ?? propName);
|
|
145
|
+
|
|
146
|
+
// Only owning one-to-one/many-to-one relations create a column here.
|
|
147
|
+
if (!relation || relation.direction !== "owning" || relation.cardinality !== "one") {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The localKey property is the source of truth for the FK column name.
|
|
152
|
+
if (!relation.localKey) {
|
|
153
|
+
console.warn(`Could not generate column for owning relation '${relation.relationName}' on '${collection.name}': 'localKey' is not defined.`);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// If the localKey property is defined elsewhere in the properties, it will be handled there.
|
|
158
|
+
// This logic is for when the relation property itself defines the FK.
|
|
159
|
+
if (collection.properties[relation.localKey] && propName !== relation.localKey) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let targetCollection: EntityCollection;
|
|
164
|
+
try {
|
|
165
|
+
targetCollection = relation.target();
|
|
166
|
+
} catch {
|
|
167
|
+
return null; // Cannot resolve target
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const fkColumnName = toSnakeCase(relation.localKey);
|
|
171
|
+
const targetTableVar = getTableVarName(getTableName(targetCollection));
|
|
172
|
+
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
173
|
+
const targetIdField = pkProp.name;
|
|
174
|
+
const baseColumn = pkProp.type === "number" ? `integer("${fkColumnName}")` : (pkProp.isUuid ? `uuid("${fkColumnName}")` : `varchar("${fkColumnName}")`);
|
|
175
|
+
|
|
176
|
+
const onUpdate = relation.onUpdate ? `onUpdate: "${relation.onUpdate}"` : "";
|
|
177
|
+
const required = prop.validation?.required;
|
|
178
|
+
const onDeleteVal = relation.onDelete ?? (required ? "cascade" : "set null");
|
|
179
|
+
const onDelete = `onDelete: \"${onDeleteVal}\"`;
|
|
180
|
+
|
|
181
|
+
const refOptionsParts = [onUpdate, onDelete].filter(Boolean);
|
|
182
|
+
const refOptions = refOptionsParts.length > 0 ? `{ ${refOptionsParts.join(", ")} }` : "";
|
|
183
|
+
|
|
184
|
+
let columnDef = `${baseColumn}.references(() => ${targetTableVar}.${targetIdField}${refOptions ? `, ${refOptions}` : ""})`;
|
|
185
|
+
|
|
186
|
+
if (required) {
|
|
187
|
+
columnDef += ".notNull()";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return ` ${relation.localKey}: ${columnDef}`;
|
|
191
|
+
}
|
|
192
|
+
case "reference": {
|
|
193
|
+
const refProp = prop as ReferenceProperty;
|
|
194
|
+
const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
|
|
195
|
+
if (!targetCollection) {
|
|
196
|
+
columnDefinition = `varchar("${colName}")`;
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
201
|
+
const targetTableVar = getTableVarName(getTableName(targetCollection));
|
|
202
|
+
const targetIdField = pkProp.name;
|
|
203
|
+
const baseColumn = pkProp.type === "number" ? `integer("${colName}")` : (pkProp.isUuid ? `uuid("${colName}")` : `varchar("${colName}")`);
|
|
204
|
+
|
|
205
|
+
const required = prop.validation?.required;
|
|
206
|
+
const onDelete = required ? "cascade" : "set null";
|
|
207
|
+
const refOptions = `{ onDelete: "${onDelete}" }`;
|
|
208
|
+
|
|
209
|
+
columnDefinition = `${baseColumn}.references(() => ${targetTableVar}.${targetIdField}, ${refOptions})`;
|
|
210
|
+
if (required) {
|
|
211
|
+
columnDefinition += ".notNull()";
|
|
212
|
+
}
|
|
213
|
+
// Skip the standard notNull() handling below because we did it here with references
|
|
214
|
+
return ` ${propName}: ${columnDefinition}`;
|
|
215
|
+
}
|
|
216
|
+
default:
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (prop.validation?.required) {
|
|
221
|
+
columnDefinition += ".notNull()";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return ` ${propName}: ${columnDefinition}`;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Resolves a raw SQL string, replacing `{column_name}` with `${table.column_name}`.
|
|
229
|
+
* The result is wrapped in a Drizzle sql`` template literal.
|
|
230
|
+
*/
|
|
231
|
+
const resolveRawSql = (expression: string): string => {
|
|
232
|
+
// Replace {column_name} with ${table.column_name}
|
|
233
|
+
const resolved = expression.replace(/\{(\w+)\}/g, (_, col) => `\${table.${col}}`);
|
|
234
|
+
return `sql\`${resolved}\``;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Wraps a SQL clause with a role check using AND.
|
|
239
|
+
* Generates: `(<clause>) AND (string_to_array(auth.roles(), ',') && ARRAY['<role1>','<role2>'])`
|
|
240
|
+
*/
|
|
241
|
+
const wrapWithRoleCheck = (clause: string, roles: string[]): string => {
|
|
242
|
+
const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(",")}]`;
|
|
243
|
+
const roleCondition = `string_to_array(auth.roles(), ',') @> ${rolesArrayString}`;
|
|
244
|
+
return `sql\`(${unwrapSql(clause)}) AND (${roleCondition})\``;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Extracts the inner expression from a `sql\`...\`` wrapper.
|
|
249
|
+
*/
|
|
250
|
+
const unwrapSql = (sqlExpr: string): string => {
|
|
251
|
+
const match = sqlExpr.match(/^sql`(.*)`$/s);
|
|
252
|
+
return match ? match[1] : sqlExpr;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Builds the USING clause for a policy based on shortcuts or raw SQL.
|
|
257
|
+
*/
|
|
258
|
+
const buildUsingClause = (rule: SecurityRule): string | null => {
|
|
259
|
+
if (rule.using) {
|
|
260
|
+
return resolveRawSql(rule.using);
|
|
261
|
+
}
|
|
262
|
+
if (rule.access === "public") {
|
|
263
|
+
return "sql`true`";
|
|
264
|
+
}
|
|
265
|
+
if (rule.ownerField) {
|
|
266
|
+
return `sql\`\${table.${rule.ownerField}} = auth.uid()\``;
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Builds the WITH CHECK clause for a policy based on shortcuts or raw SQL.
|
|
273
|
+
* Falls back to the USING clause if not explicitly provided.
|
|
274
|
+
*/
|
|
275
|
+
const buildWithCheckClause = (rule: SecurityRule): string | null => {
|
|
276
|
+
if (rule.withCheck) {
|
|
277
|
+
return resolveRawSql(rule.withCheck);
|
|
278
|
+
}
|
|
279
|
+
// For insert/update/all, fall back to using clause if withCheck not specified
|
|
280
|
+
return buildUsingClause(rule);
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Generates a deterministic hash based on the rule configuration.
|
|
285
|
+
*/
|
|
286
|
+
const getPolicyNameHash = (rule: SecurityRule): string => {
|
|
287
|
+
const data = JSON.stringify({
|
|
288
|
+
a: rule.access,
|
|
289
|
+
m: rule.mode,
|
|
290
|
+
op: rule.operation,
|
|
291
|
+
ops: rule.operations?.slice().sort(),
|
|
292
|
+
own: rule.ownerField,
|
|
293
|
+
rol: rule.roles?.slice().sort(),
|
|
294
|
+
pg: rule.pgRoles?.slice().sort(),
|
|
295
|
+
u: rule.using,
|
|
296
|
+
w: rule.withCheck
|
|
297
|
+
});
|
|
298
|
+
return createHash("sha1").update(data).digest("hex").substring(0, 7);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Generates Drizzle pgPolicy() calls from a declarative SecurityRule definition.
|
|
303
|
+
*
|
|
304
|
+
* Supports the full spectrum:
|
|
305
|
+
* - Convenience shortcuts: ownerField, access, roles
|
|
306
|
+
* - Raw SQL: using, withCheck
|
|
307
|
+
* - Mode: permissive (default) or restrictive
|
|
308
|
+
* - operations[] array: generates one policy per operation
|
|
309
|
+
* - Combinations: roles + ownerField, roles + raw SQL, etc.
|
|
310
|
+
*/
|
|
311
|
+
const generatePolicyCode = (tableName: string, rule: SecurityRule, index: number): string => {
|
|
312
|
+
// Resolve operations: operations[] takes precedence over operation (singular)
|
|
313
|
+
const ops: SecurityOperation[] = rule.operations && rule.operations.length > 0
|
|
314
|
+
? rule.operations
|
|
315
|
+
: [rule.operation ?? "all"];
|
|
316
|
+
|
|
317
|
+
const ruleHash = getPolicyNameHash(rule);
|
|
318
|
+
|
|
319
|
+
// Generate one pgPolicy per operation
|
|
320
|
+
return ops.map((op, opIdx) => {
|
|
321
|
+
const policyName = rule.name
|
|
322
|
+
? (ops.length > 1 ? `${rule.name}_${op}` : rule.name)
|
|
323
|
+
: `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`;
|
|
324
|
+
|
|
325
|
+
return generateSinglePolicyCode(tableName, rule, op, policyName);
|
|
326
|
+
}).join("");
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Generates a single pgPolicy() call for one specific operation.
|
|
331
|
+
*/
|
|
332
|
+
const generateSinglePolicyCode = (tableName: string, rule: SecurityRule, operation: SecurityOperation, policyName: string): string => {
|
|
333
|
+
const mode = rule.mode ?? "permissive";
|
|
334
|
+
const roles = rule.roles ? [...rule.roles].sort() : undefined;
|
|
335
|
+
|
|
336
|
+
// Determine which clauses this operation needs:
|
|
337
|
+
// SELECT, DELETE → USING only
|
|
338
|
+
// INSERT → WITH CHECK only
|
|
339
|
+
// UPDATE, ALL → both USING and WITH CHECK
|
|
340
|
+
const needsUsing = operation !== "insert";
|
|
341
|
+
const needsWithCheck = operation !== "select" && operation !== "delete";
|
|
342
|
+
|
|
343
|
+
let usingClause = needsUsing ? buildUsingClause(rule) : null;
|
|
344
|
+
let withCheckClause = needsWithCheck ? buildWithCheckClause(rule) : null;
|
|
345
|
+
|
|
346
|
+
// If roles are specified, wrap existing clauses with role check,
|
|
347
|
+
// or generate a roles-only clause.
|
|
348
|
+
if (roles && roles.length > 0) {
|
|
349
|
+
if (usingClause) {
|
|
350
|
+
usingClause = wrapWithRoleCheck(usingClause, roles);
|
|
351
|
+
} else if (needsUsing) {
|
|
352
|
+
// Roles-only rule (e.g. { operation: "select", roles: ["admin"] })
|
|
353
|
+
const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(",")}]`;
|
|
354
|
+
usingClause = `sql\`string_to_array(auth.roles(), ',') @> ${rolesArrayString}\``;
|
|
355
|
+
}
|
|
356
|
+
if (withCheckClause) {
|
|
357
|
+
withCheckClause = wrapWithRoleCheck(withCheckClause, roles);
|
|
358
|
+
} else if (needsWithCheck) {
|
|
359
|
+
const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(",")}]`;
|
|
360
|
+
withCheckClause = `sql\`string_to_array(auth.roles(), ',') @> ${rolesArrayString}\``;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Fallback: if we still have no clauses, deny all (safety net)
|
|
365
|
+
if (!usingClause && needsUsing) {
|
|
366
|
+
usingClause = "sql`false`";
|
|
367
|
+
}
|
|
368
|
+
if (!withCheckClause && needsWithCheck) {
|
|
369
|
+
withCheckClause = "sql`false`";
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Build the policy options object
|
|
373
|
+
const parts: string[] = [];
|
|
374
|
+
parts.push(`as: "${mode}"`);
|
|
375
|
+
parts.push(`for: "${operation}"`);
|
|
376
|
+
const toRoles = rule.pgRoles ? [...rule.pgRoles].sort() : ["public"];
|
|
377
|
+
parts.push(`to: [${toRoles.map(r => `"${r}"`).join(", ")}]`);
|
|
378
|
+
if (usingClause) parts.push(`using: ${usingClause}`);
|
|
379
|
+
if (withCheckClause) parts.push(`withCheck: ${withCheckClause}`);
|
|
380
|
+
|
|
381
|
+
return ` pgPolicy("${policyName}", { ${parts.join(", ")} }),\n`;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Computes a deterministic shared relation name for Drizzle.
|
|
386
|
+
*
|
|
387
|
+
* Drizzle requires both sides of a relation (owning + inverse) to use the
|
|
388
|
+
* exact same `relationName` string so it can pair them. Each collection
|
|
389
|
+
* definition may use a different local `relationName`, so we need a canonical
|
|
390
|
+
* form that both sides can independently compute.
|
|
391
|
+
*
|
|
392
|
+
* Strategy: `{owningTable}_{foreignKey}`
|
|
393
|
+
* - owning side → `{thisTable}_{localKey}` e.g. "jobs_company_id"
|
|
394
|
+
* - inverse side → `{targetTable}_{foreignKeyOnTarget}` e.g. "jobs_company_id"
|
|
395
|
+
*
|
|
396
|
+
* For M2M with junction tables the owning relation name is already shared via
|
|
397
|
+
* the junction table wiring, so we keep it as-is.
|
|
398
|
+
*
|
|
399
|
+
* Falls back to the local relation name when the counterpart can't be resolved.
|
|
400
|
+
*/
|
|
401
|
+
const computeSharedRelationName = (
|
|
402
|
+
rel: Relation,
|
|
403
|
+
sourceCollection: EntityCollection,
|
|
404
|
+
_collections: EntityCollection[]
|
|
405
|
+
): string => {
|
|
406
|
+
const fallback = rel.relationName ?? toSnakeCase(rel.target().slug);
|
|
407
|
+
|
|
408
|
+
// --- owning one (belongs-to) ---
|
|
409
|
+
if (rel.direction === "owning" && rel.cardinality === "one" && rel.localKey) {
|
|
410
|
+
return `${getTableName(sourceCollection)}_${rel.localKey}`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// --- inverse many (one-to-many has-many) ---
|
|
414
|
+
if (rel.direction === "inverse" && rel.cardinality === "many" && rel.foreignKeyOnTarget) {
|
|
415
|
+
// The owning table is the *target*, the FK column is foreignKeyOnTarget
|
|
416
|
+
try {
|
|
417
|
+
const targetCollection = rel.target();
|
|
418
|
+
return `${getTableName(targetCollection)}_${rel.foreignKeyOnTarget}`;
|
|
419
|
+
} catch {
|
|
420
|
+
return fallback;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// --- inverse one (one-to-one inverse) ---
|
|
425
|
+
if (rel.direction === "inverse" && rel.cardinality === "one") {
|
|
426
|
+
if (rel.foreignKeyOnTarget) {
|
|
427
|
+
// FK lives on the target table
|
|
428
|
+
try {
|
|
429
|
+
const targetCollection = rel.target();
|
|
430
|
+
return `${getTableName(targetCollection)}_${rel.foreignKeyOnTarget}`;
|
|
431
|
+
} catch {
|
|
432
|
+
return fallback;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// No explicit foreignKeyOnTarget — try to find the corresponding owning relation
|
|
436
|
+
try {
|
|
437
|
+
const targetCollection = rel.target();
|
|
438
|
+
const targetResolvedRelations = resolveCollectionRelations(targetCollection);
|
|
439
|
+
const correspondingRelation = Object.values(targetResolvedRelations).find(targetRel =>
|
|
440
|
+
targetRel.direction === "owning" &&
|
|
441
|
+
targetRel.cardinality === "one" &&
|
|
442
|
+
targetRel.localKey &&
|
|
443
|
+
targetRel.target().slug === sourceCollection.slug
|
|
444
|
+
);
|
|
445
|
+
if (correspondingRelation && correspondingRelation.localKey) {
|
|
446
|
+
return `${getTableName(targetCollection)}_${correspondingRelation.localKey}`;
|
|
447
|
+
}
|
|
448
|
+
} catch {
|
|
449
|
+
// ignore
|
|
450
|
+
}
|
|
451
|
+
return fallback;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// --- M2M owning (through) — keep local name (already shared via junction wiring) ---
|
|
455
|
+
// --- M2M inverse — keep local name ---
|
|
456
|
+
// --- joinPath — not emitted as Drizzle relations ---
|
|
457
|
+
return fallback;
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
// --- Main Schema Generation Logic ---
|
|
461
|
+
export const generateSchema = async (collections: EntityCollection[], stripPolicies = false): Promise<string> => {
|
|
462
|
+
let schemaContent = "// This file is auto-generated by the Rebase Drizzle generator. Do not edit manually.\n\n";
|
|
463
|
+
|
|
464
|
+
const hasUuid = collections.some(c =>
|
|
465
|
+
c.properties && Object.values(c.properties).some(
|
|
466
|
+
(p: Property) => p.type === "string" && ((p as unknown as Record<string, unknown>).autoValue === "uuid" || (p as unknown as Record<string, unknown>).isId === "uuid")
|
|
467
|
+
)
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
const hasJson = collections.some(c =>
|
|
471
|
+
c.properties && Object.values(c.properties).some(
|
|
472
|
+
(p: Property) => (p.type === "map" || p.type === "array") && (p as unknown as Record<string, unknown>).columnType === "json"
|
|
473
|
+
)
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
// Always import pgPolicy and sql — RLS is enabled on every table (secure by default)
|
|
477
|
+
const pgCoreImports = ["primaryKey", "pgTable", "integer", "varchar", "text", "char", "boolean", "timestamp", "date", "time", "jsonb", "json", "pgEnum", "numeric", "real", "doublePrecision", "bigint", "serial", "bigserial", "pgPolicy"];
|
|
478
|
+
if (hasUuid) pgCoreImports.push("uuid");
|
|
479
|
+
schemaContent += `import { ${pgCoreImports.join(", ")} } from 'drizzle-orm/pg-core';\n`;
|
|
480
|
+
schemaContent += "import { relations as drizzleRelations, sql } from 'drizzle-orm';\n\n";
|
|
481
|
+
|
|
482
|
+
const exportedTableVars: string[] = [];
|
|
483
|
+
const exportedEnumVars: string[] = [];
|
|
484
|
+
const exportedRelationVars: string[] = [];
|
|
485
|
+
|
|
486
|
+
const allTablesToGenerate = new Map<string, {
|
|
487
|
+
collection: EntityCollection,
|
|
488
|
+
isJunction?: boolean,
|
|
489
|
+
relation?: Relation,
|
|
490
|
+
sourceCollection?: EntityCollection
|
|
491
|
+
}>();
|
|
492
|
+
|
|
493
|
+
// 1. Generate Enums
|
|
494
|
+
collections.forEach(collection => {
|
|
495
|
+
const collectionPath = getTableName(collection);
|
|
496
|
+
Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
|
|
497
|
+
if (("enum" in prop) && (prop.type === "string" || prop.type === "number") && prop.enum) {
|
|
498
|
+
const enumVarName = getEnumVarName(collectionPath, propName);
|
|
499
|
+
const enumDbName = `${collectionPath}_${toSnakeCase(propName)}`;
|
|
500
|
+
const values = Array.isArray(prop.enum)
|
|
501
|
+
? prop.enum.map(v => String(v.id ?? v))
|
|
502
|
+
: Object.keys(prop.enum);
|
|
503
|
+
if (values.length > 0) {
|
|
504
|
+
schemaContent += `export const ${enumVarName} = pgEnum(\"${enumDbName}\", [${values.map(v => `'${v}'`).join(", ")}]);\n`;
|
|
505
|
+
if (!exportedEnumVars.includes(enumVarName)) exportedEnumVars.push(enumVarName);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
schemaContent += "\n";
|
|
511
|
+
|
|
512
|
+
// 2. Identify all tables (collections and junction tables only)
|
|
513
|
+
for (const collection of collections) {
|
|
514
|
+
const tableName = getTableName(collection);
|
|
515
|
+
if (tableName) {
|
|
516
|
+
allTablesToGenerate.set(tableName, { collection });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
520
|
+
for (const relation of Object.values(resolvedRelations)) {
|
|
521
|
+
if (relation.through) { // Standard M2M junction table
|
|
522
|
+
const junctionTableName = relation.through.table;
|
|
523
|
+
if (!allTablesToGenerate.has(junctionTableName)) {
|
|
524
|
+
allTablesToGenerate.set(junctionTableName, {
|
|
525
|
+
collection: {
|
|
526
|
+
table: junctionTableName,
|
|
527
|
+
properties: {}
|
|
528
|
+
} as EntityCollection,
|
|
529
|
+
isJunction: true,
|
|
530
|
+
relation: relation,
|
|
531
|
+
sourceCollection: collection
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
// joinPath relations use existing user-controlled tables - no generation needed
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// 3. Generate pgTable definitions for all unique tables
|
|
540
|
+
for (const [tableName, {
|
|
541
|
+
collection,
|
|
542
|
+
isJunction,
|
|
543
|
+
relation,
|
|
544
|
+
sourceCollection
|
|
545
|
+
}] of allTablesToGenerate.entries()) {
|
|
546
|
+
const tableVarName = getTableVarName(tableName);
|
|
547
|
+
if (isJunction && relation && sourceCollection && relation.through) {
|
|
548
|
+
const targetCollection = relation.target();
|
|
549
|
+
const {
|
|
550
|
+
sourceColumn,
|
|
551
|
+
targetColumn
|
|
552
|
+
} = relation.through;
|
|
553
|
+
|
|
554
|
+
const onDelete = relation.onDelete ?? "cascade";
|
|
555
|
+
const refOptions = `{ onDelete: \"${onDelete}\" }`;
|
|
556
|
+
|
|
557
|
+
const sourceColType = isNumericId(sourceCollection) ? "integer" : (getPrimaryKeyProp(sourceCollection).isUuid ? "uuid" : "varchar");
|
|
558
|
+
const targetColType = isNumericId(targetCollection) ? "integer" : (getPrimaryKeyProp(targetCollection).isUuid ? "uuid" : "varchar");
|
|
559
|
+
const sourceId = getPrimaryKeyName(sourceCollection);
|
|
560
|
+
const targetId = getPrimaryKeyName(targetCollection);
|
|
561
|
+
|
|
562
|
+
schemaContent += `export const ${tableVarName} = pgTable(\"${tableName}\", {\n`;
|
|
563
|
+
schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${toSnakeCase(sourceColumn)}\").notNull().references(() => ${getTableVarName(getTableName(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
|
|
564
|
+
schemaContent += ` ${targetColumn}: ${targetColType}(\"${toSnakeCase(targetColumn)}\").notNull().references(() => ${getTableVarName(getTableName(targetCollection))}.${targetId}, ${refOptions}),\n`;
|
|
565
|
+
schemaContent += "}, (table) => ({\n";
|
|
566
|
+
schemaContent += ` pk: primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] })\n`;
|
|
567
|
+
schemaContent += "}));\n\n";
|
|
568
|
+
} else if (!isJunction) {
|
|
569
|
+
schemaContent += `export const ${tableVarName} = pgTable(\"${tableName}\", {\n`;
|
|
570
|
+
const columns = new Set<string>();
|
|
571
|
+
Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
|
|
572
|
+
const columnString = getDrizzleColumn(propName, prop as Property, collection, collections);
|
|
573
|
+
if (columnString) columns.add(columnString);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// Backwards compatibility: if no id/primary key column is found in properties, but `id` wasn't explicitly provided
|
|
577
|
+
// We should generate a basic id column if one was completely omitted.
|
|
578
|
+
const hasIdColumn = Array.from(columns).some(col => col.includes(".primaryKey()"));
|
|
579
|
+
if (!hasIdColumn) {
|
|
580
|
+
columns.add(" id: varchar(\"id\").primaryKey()");
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
schemaContent += `${Array.from(columns).join(",\n")}`;
|
|
584
|
+
|
|
585
|
+
const securityRules = isPostgresCollection(collection) ? collection.securityRules : undefined;
|
|
586
|
+
if (!stripPolicies && securityRules && securityRules.length > 0) {
|
|
587
|
+
schemaContent += "\n}, (table) => ([\n";
|
|
588
|
+
securityRules.forEach((rule: SecurityRule, idx: number) => {
|
|
589
|
+
schemaContent += generatePolicyCode(tableName, rule, idx);
|
|
590
|
+
});
|
|
591
|
+
schemaContent += "])).enableRLS();\n\n";
|
|
592
|
+
} else {
|
|
593
|
+
// No explicit policies — RLS enabled with deny-all default (Postgres denies
|
|
594
|
+
// everything when RLS is on and no permissive policies exist).
|
|
595
|
+
schemaContent += "\n}).enableRLS();\n\n";
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (!exportedTableVars.includes(tableVarName)) exportedTableVars.push(tableVarName);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// 4. Generate Drizzle Relations
|
|
602
|
+
for (const [tableName, {
|
|
603
|
+
collection,
|
|
604
|
+
isJunction
|
|
605
|
+
}] of allTablesToGenerate.entries()) {
|
|
606
|
+
const tableVarName = getTableVarName(tableName);
|
|
607
|
+
const tableRelations: string[] = [];
|
|
608
|
+
|
|
609
|
+
if (isJunction) {
|
|
610
|
+
const relationInfo = Array.from(allTablesToGenerate.values()).find(v => v.isJunction && getTableName(v.collection) === tableName);
|
|
611
|
+
if (relationInfo && relationInfo.relation && relationInfo.sourceCollection && relationInfo.relation.through) {
|
|
612
|
+
const {
|
|
613
|
+
relation,
|
|
614
|
+
sourceCollection
|
|
615
|
+
} = relationInfo;
|
|
616
|
+
const targetCollection = relation.target();
|
|
617
|
+
const sourceTableVar = getTableVarName(getTableName(sourceCollection));
|
|
618
|
+
const targetTableVar = getTableVarName(getTableName(targetCollection));
|
|
619
|
+
const sourceId = getPrimaryKeyName(sourceCollection);
|
|
620
|
+
const targetId = getPrimaryKeyName(targetCollection);
|
|
621
|
+
|
|
622
|
+
if (!relation?.through)
|
|
623
|
+
throw new Error("Internal, the relation should have a through property. Relations passed to this script should sanitized first with sanitizeRelation().");
|
|
624
|
+
|
|
625
|
+
// The owning relation's name — used on the source side of the junction
|
|
626
|
+
const owningRelationName = relation.relationName ?? toSnakeCase(getTableName(targetCollection));
|
|
627
|
+
|
|
628
|
+
// Find the inverse relation name on the target collection (if any)
|
|
629
|
+
// This is needed so the junction's target-side one() can pair with the
|
|
630
|
+
// inverse many() on the target table.
|
|
631
|
+
let inverseRelationName: string | null = null;
|
|
632
|
+
try {
|
|
633
|
+
const targetRelations = resolveCollectionRelations(targetCollection);
|
|
634
|
+
for (const [, targetRel] of Object.entries(targetRelations)) {
|
|
635
|
+
if (targetRel.direction === "inverse" &&
|
|
636
|
+
targetRel.cardinality === "many" &&
|
|
637
|
+
targetRel.inverseRelationName === owningRelationName) {
|
|
638
|
+
inverseRelationName = targetRel.relationName ?? null;
|
|
639
|
+
break;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
} catch {
|
|
643
|
+
// ignore — inverse side may not exist
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Source side one(): pairs with owning table's many(junctionTable, { relationName })
|
|
647
|
+
tableRelations.push(` "${relation.through.sourceColumn}": one(${sourceTableVar}, {\n fields: [${tableVarName}.${relation.through.sourceColumn}],\n references: [${sourceTableVar}.${sourceId}],\n relationName: \"${owningRelationName}\"\n })`);
|
|
648
|
+
|
|
649
|
+
// Target side one(): pairs with inverse table's many(junctionTable, { relationName })
|
|
650
|
+
const targetRelName = inverseRelationName ?? owningRelationName;
|
|
651
|
+
tableRelations.push(` "${relation.through.targetColumn}": one(${targetTableVar}, {\n fields: [${tableVarName}.${relation.through.targetColumn}],\n references: [${targetTableVar}.${targetId}],\n relationName: \"${targetRelName}\"\n })`);
|
|
652
|
+
}
|
|
653
|
+
} else {
|
|
654
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
655
|
+
// Defensive safety net: track emitted `drizzleRelationName` values
|
|
656
|
+
// to prevent duplicate one()/many() entries in the generated schema.
|
|
657
|
+
// The root deduplication happens inside resolveCollectionRelations,
|
|
658
|
+
// but this guards against any future regressions in that utility.
|
|
659
|
+
const emittedRelationNames = new Set<string>();
|
|
660
|
+
for (const [relationKey, rel] of Object.entries(resolvedRelations)) {
|
|
661
|
+
try {
|
|
662
|
+
const target = rel.target();
|
|
663
|
+
const targetTableVar = getTableVarName(getTableName(target));
|
|
664
|
+
|
|
665
|
+
// Compute a deterministic shared relationName for Drizzle.
|
|
666
|
+
// Both sides of an owning/inverse pair MUST share the same
|
|
667
|
+
// relationName, otherwise Drizzle cannot pair them.
|
|
668
|
+
//
|
|
669
|
+
// Strategy: use "{ownerTable}_{foreignKey}" which is
|
|
670
|
+
// computable from either side:
|
|
671
|
+
// - owning side: {thisTable}_{localKey}
|
|
672
|
+
// - inverse side: {targetTable}_{foreignKeyOnTarget}
|
|
673
|
+
const drizzleRelationName = computeSharedRelationName(rel, collection, collections);
|
|
674
|
+
|
|
675
|
+
// Skip if we've already emitted a relation with this drizzleRelationName
|
|
676
|
+
// for this table — prevents duplicate definitions when
|
|
677
|
+
// resolveCollectionRelations returns alias entries for the same FK.
|
|
678
|
+
const deduplicationKey = `${drizzleRelationName}::${rel.direction}`;
|
|
679
|
+
if (emittedRelationNames.has(deduplicationKey)) continue;
|
|
680
|
+
emittedRelationNames.add(deduplicationKey);
|
|
681
|
+
|
|
682
|
+
if (rel.cardinality === "one") {
|
|
683
|
+
if (rel.direction === "owning" && rel.localKey) {
|
|
684
|
+
tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${rel.localKey}],\n references: [${targetTableVar}.${getPrimaryKeyName(target)}],\n relationName: \"${drizzleRelationName}\"\n })`);
|
|
685
|
+
} else if (rel.direction === "inverse" && rel.foreignKeyOnTarget) {
|
|
686
|
+
const sourceIdField = getPrimaryKeyName(collection);
|
|
687
|
+
tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${sourceIdField}],\n references: [${targetTableVar}.${rel.foreignKeyOnTarget}],\n relationName: \"${drizzleRelationName}\"\n })`);
|
|
688
|
+
} else if (rel.direction === "inverse" && !rel.foreignKeyOnTarget) {
|
|
689
|
+
// Handle inverse one-to-one relations where the FK is on the target table
|
|
690
|
+
// but foreignKeyOnTarget is not explicitly specified
|
|
691
|
+
// In this case, we need to find the corresponding owning relation on the target
|
|
692
|
+
try {
|
|
693
|
+
const targetCollection = rel.target();
|
|
694
|
+
const targetResolvedRelations = resolveCollectionRelations(targetCollection);
|
|
695
|
+
|
|
696
|
+
// Find the owning relation on the target that points back to this collection
|
|
697
|
+
const correspondingRelation = Object.values(targetResolvedRelations).find(targetRel =>
|
|
698
|
+
targetRel.direction === "owning" &&
|
|
699
|
+
targetRel.cardinality === "one" &&
|
|
700
|
+
targetRel.target().slug === collection.slug
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
if (correspondingRelation && correspondingRelation.localKey) {
|
|
704
|
+
const sourceIdField = getPrimaryKeyName(collection);
|
|
705
|
+
tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${sourceIdField}],\n references: [${targetTableVar}.${correspondingRelation.localKey}],\n relationName: \"${drizzleRelationName}\"\n })`);
|
|
706
|
+
}
|
|
707
|
+
} catch (e) {
|
|
708
|
+
console.warn(`Could not resolve inverse one-to-one relation '${relationKey}':`, e);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
} else if (rel.cardinality === "many") {
|
|
712
|
+
if (rel.direction === "inverse" && rel.foreignKeyOnTarget) {
|
|
713
|
+
// One-to-many inverse relation
|
|
714
|
+
tableRelations.push(` "${relationKey}": many(${targetTableVar}, { relationName: \"${drizzleRelationName}\" })`);
|
|
715
|
+
} else if (rel.through) {
|
|
716
|
+
// Many-to-many owning relation with explicit junction table
|
|
717
|
+
const junctionTableVar = getTableVarName(rel.through.table);
|
|
718
|
+
tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
|
|
719
|
+
} else if (rel.direction === "inverse" && rel.inverseRelationName) {
|
|
720
|
+
// Many-to-many inverse relation - find the corresponding owning relation's junction table
|
|
721
|
+
try {
|
|
722
|
+
const targetCollection = rel.target();
|
|
723
|
+
const targetResolvedRelations = resolveCollectionRelations(targetCollection);
|
|
724
|
+
|
|
725
|
+
// Find the corresponding owning many-to-many relation on the target
|
|
726
|
+
const correspondingRelation = Object.values(targetResolvedRelations).find(targetRel =>
|
|
727
|
+
targetRel.direction === "owning" &&
|
|
728
|
+
targetRel.cardinality === "many" &&
|
|
729
|
+
targetRel.through &&
|
|
730
|
+
targetRel.relationName === rel.inverseRelationName
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
if (correspondingRelation && correspondingRelation.through) {
|
|
734
|
+
const junctionTableVar = getTableVarName(correspondingRelation.through.table);
|
|
735
|
+
tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
|
|
736
|
+
} else {
|
|
737
|
+
console.warn(`Could not find corresponding owning many-to-many relation for inverse relation '${relationKey}' on '${collection.name}'`);
|
|
738
|
+
}
|
|
739
|
+
} catch (e) {
|
|
740
|
+
console.warn(`Could not resolve inverse many-to-many relation '${relationKey}':`, e);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
// joinPath relations don't generate Drizzle relations - they use existing user tables
|
|
744
|
+
}
|
|
745
|
+
} catch (e) {
|
|
746
|
+
console.warn(`Could not generate relation ${relationKey} for ${collection.name}:`, e);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (tableRelations.length > 0) {
|
|
752
|
+
const relVarName = `${tableVarName}Relations`;
|
|
753
|
+
schemaContent += `export const ${relVarName} = drizzleRelations(${tableVarName}, ({ one, many }) => ({\n${tableRelations.join(",\n")}\n}));\n\n`;
|
|
754
|
+
if (!exportedRelationVars.includes(relVarName)) exportedRelationVars.push(relVarName);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// <<< ADDED: Final aggregated exports block
|
|
759
|
+
const tablesExport = `export const tables = { ${exportedTableVars.join(", ")} };\n`;
|
|
760
|
+
const enumsExport = `export const enums = { ${exportedEnumVars.join(", ")} };\n`;
|
|
761
|
+
const relationsExport = `export const relations = { ${exportedRelationVars.join(", ")} };\n\n`;
|
|
762
|
+
schemaContent += tablesExport + enumsExport + relationsExport;
|
|
763
|
+
|
|
764
|
+
return schemaContent;
|
|
765
|
+
};
|