@rebasepro/server-postgresql 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/package.json +24 -18
  2. package/src/PostgresBackendDriver.ts +65 -44
  3. package/src/PostgresBootstrapper.ts +49 -20
  4. package/src/auth/ensure-tables.ts +56 -1
  5. package/src/auth/services.ts +94 -1
  6. package/src/cli-errors.ts +162 -0
  7. package/src/cli-helpers.ts +183 -0
  8. package/src/cli.ts +198 -251
  9. package/src/data-transformer.ts +9 -1
  10. package/src/schema/auth-default-policies.ts +90 -0
  11. package/src/schema/auth-schema.ts +25 -2
  12. package/src/schema/doctor.ts +2 -4
  13. package/src/schema/generate-drizzle-schema-logic.ts +4 -3
  14. package/src/schema/generate-drizzle-schema.ts +3 -5
  15. package/src/schema/generate-postgres-ddl-logic.ts +524 -0
  16. package/src/schema/generate-postgres-ddl.ts +116 -0
  17. package/src/services/EntityPersistService.ts +18 -16
  18. package/src/services/entityService.ts +28 -3
  19. package/src/utils/pg-array-null-patch.ts +42 -0
  20. package/src/utils/pg-error-utils.ts +16 -0
  21. package/src/utils/table-classification.ts +16 -0
  22. package/src/websocket.ts +9 -0
  23. package/test/array-null-safety.test.ts +335 -0
  24. package/test/auth-default-policies.test.ts +89 -0
  25. package/test/cli-helpers-extended.test.ts +324 -0
  26. package/test/cli-helpers.test.ts +59 -0
  27. package/test/connection.test.ts +292 -0
  28. package/test/data-transformer.test.ts +53 -2
  29. package/test/databasePoolManager.test.ts +289 -0
  30. package/test/doctor-extended.test.ts +443 -0
  31. package/test/e2e/db-e2e.test.ts +293 -0
  32. package/test/e2e/pg-setup.ts +79 -0
  33. package/test/entity-persist-composite-keys.test.ts +451 -0
  34. package/test/generate-postgres-ddl-edge-cases.test.ts +716 -0
  35. package/test/generate-postgres-ddl.test.ts +300 -0
  36. package/test/mfa-service.test.ts +544 -0
  37. package/test/pg-array-null-patch.test.ts +65 -0
  38. package/test/pg-error-utils.test.ts +50 -1
  39. package/test/realtimeService-channels.test.ts +696 -0
  40. package/test/unmapped-tables-safety.test.ts +55 -342
  41. package/vite.config.ts +8 -6
  42. package/vitest.e2e.config.ts +10 -0
  43. package/build-errors.txt +0 -37
  44. package/dist/PostgresAdapter.d.ts +0 -6
  45. package/dist/PostgresBackendDriver.d.ts +0 -110
  46. package/dist/PostgresBootstrapper.d.ts +0 -46
  47. package/dist/auth/ensure-tables.d.ts +0 -10
  48. package/dist/auth/services.d.ts +0 -231
  49. package/dist/cli.d.ts +0 -1
  50. package/dist/collections/PostgresCollectionRegistry.d.ts +0 -47
  51. package/dist/connection.d.ts +0 -65
  52. package/dist/data-transformer.d.ts +0 -55
  53. package/dist/databasePoolManager.d.ts +0 -20
  54. package/dist/history/HistoryService.d.ts +0 -71
  55. package/dist/history/ensure-history-table.d.ts +0 -7
  56. package/dist/index.d.ts +0 -14
  57. package/dist/index.es.js +0 -10764
  58. package/dist/index.es.js.map +0 -1
  59. package/dist/index.umd.js +0 -11055
  60. package/dist/index.umd.js.map +0 -1
  61. package/dist/interfaces.d.ts +0 -18
  62. package/dist/schema/auth-schema.d.ts +0 -2149
  63. package/dist/schema/doctor-cli.d.ts +0 -2
  64. package/dist/schema/doctor.d.ts +0 -52
  65. package/dist/schema/generate-drizzle-schema-logic.d.ts +0 -2
  66. package/dist/schema/generate-drizzle-schema.d.ts +0 -1
  67. package/dist/schema/introspect-db-inference.d.ts +0 -5
  68. package/dist/schema/introspect-db-logic.d.ts +0 -118
  69. package/dist/schema/introspect-db.d.ts +0 -1
  70. package/dist/schema/test-schema.d.ts +0 -24
  71. package/dist/services/BranchService.d.ts +0 -47
  72. package/dist/services/EntityFetchService.d.ts +0 -214
  73. package/dist/services/EntityPersistService.d.ts +0 -40
  74. package/dist/services/RelationService.d.ts +0 -98
  75. package/dist/services/entity-helpers.d.ts +0 -38
  76. package/dist/services/entityService.d.ts +0 -110
  77. package/dist/services/index.d.ts +0 -4
  78. package/dist/services/realtimeService.d.ts +0 -220
  79. package/dist/types.d.ts +0 -3
  80. package/dist/utils/drizzle-conditions.d.ts +0 -138
  81. package/dist/utils/pg-error-utils.d.ts +0 -55
  82. package/dist/websocket.d.ts +0 -11
@@ -0,0 +1,90 @@
1
+ import { EntityCollection, SecurityRule, SecurityOperation, AuthCollectionConfig, isPostgresCollection } from "@rebasepro/types";
2
+ import { getTableName } from "@rebasepro/common";
3
+
4
+ /**
5
+ * Default RLS for authentication collections.
6
+ *
7
+ * Users are persisted through the same write path as any other collection —
8
+ * the only thing that distinguishes them is the `auth` flag. That means
9
+ * privileged columns (most importantly `roles`) would be editable by any
10
+ * authenticated user unless the collection author remembers to write a correct
11
+ * admin-only write policy.
12
+ *
13
+ * To make this safe by default, the schema generator injects two policies for
14
+ * every write operation on an auth collection. Enforcement lives in the
15
+ * database (RLS), so it applies to every write path — REST, WebSocket, and any
16
+ * internal caller — not just one entry point:
17
+ *
18
+ * 1. A **restrictive** admin gate. Restrictive policies are AND'd with every
19
+ * other policy, so this guarantees a write is rejected unless the caller is
20
+ * an admin (or the trusted server context) — even if the author also wrote
21
+ * a permissive rule such as "a user may edit their own row". Without this,
22
+ * a permissive owner rule would let a user change their own `roles`.
23
+ *
24
+ * 2. A **permissive** admin grant. Restrictive policies only constrain; at
25
+ * least one permissive policy must also pass for the write to be allowed.
26
+ * This grants the baseline write so admins (and the server context) can
27
+ * manage users even when the collection has no other permissive write rule.
28
+ *
29
+ * Both mirror the framework's trusted-server convention: the write is allowed
30
+ * when there is no authenticated user context (`auth.uid() IS NULL`, used by
31
+ * the built-in auth flows that run as the service role) OR the caller holds the
32
+ * `admin` role. Net effect: only admins (or the server) can write the row, and
33
+ * therefore only admins can change privileged columns like `roles`.
34
+ *
35
+ * Author-provided write rules are preserved but cannot loosen this guarantee —
36
+ * a non-admin write is always blocked by the restrictive gate. To take full
37
+ * control of an auth collection's write authorization, set
38
+ * `disableDefaultAuthPolicies: true` on the collection.
39
+ */
40
+ const ADMIN_WRITE_EXPR = "auth.uid() IS NULL OR string_to_array(auth.roles(), ',') && ARRAY['admin']";
41
+
42
+ /** Write operations that must be admin-gated by default on auth collections. */
43
+ const DEFAULT_GUARDED_OPS: SecurityOperation[] = ["insert", "update", "delete"];
44
+
45
+ /** Whether a collection is flagged as an authentication collection. */
46
+ function isAuthCollection(collection: EntityCollection): boolean {
47
+ const auth = collection.auth;
48
+ return auth === true || (typeof auth === "object" && (auth as AuthCollectionConfig)?.enabled === true);
49
+ }
50
+
51
+ /**
52
+ * Returns the security rules that should be applied to a collection: the
53
+ * author's explicit `securityRules`, plus, for auth collections, an
54
+ * auto-injected restrictive admin gate and permissive admin grant on every
55
+ * write operation. Together these guarantee only admins (or the trusted server
56
+ * context) can write the row.
57
+ *
58
+ * Non-auth collections, and auth collections that opt out via
59
+ * `disableDefaultAuthPolicies`, are returned unchanged.
60
+ */
61
+ export function getEffectiveSecurityRules(collection: EntityCollection): SecurityRule[] {
62
+ const explicit = (isPostgresCollection(collection) ? collection.securityRules : undefined) ?? [];
63
+
64
+ if (!isAuthCollection(collection) || collection.disableDefaultAuthPolicies) {
65
+ return explicit;
66
+ }
67
+
68
+ const tableName = getTableName(collection);
69
+
70
+ // Restrictive gate: AND'd with all other policies, so no permissive rule
71
+ // (e.g. an owner "edit your own row" rule) can let a non-admin through.
72
+ const requireAdminGate: SecurityRule = {
73
+ name: `${tableName}_require_admin_write`,
74
+ mode: "restrictive",
75
+ operations: [...DEFAULT_GUARDED_OPS],
76
+ using: ADMIN_WRITE_EXPR,
77
+ withCheck: ADMIN_WRITE_EXPR
78
+ };
79
+
80
+ // Permissive grant: a restrictive policy alone denies everything, so this
81
+ // grants the baseline write to admins / the server context.
82
+ const allowAdminWrite: SecurityRule = {
83
+ name: `${tableName}_default_admin_write`,
84
+ operations: [...DEFAULT_GUARDED_OPS],
85
+ using: ADMIN_WRITE_EXPR,
86
+ withCheck: ADMIN_WRITE_EXPR
87
+ };
88
+
89
+ return [...explicit, allowAdminWrite, requireAdminGate];
90
+ }
@@ -118,6 +118,18 @@ export function createAuthSchema(usersSchemaName = "rebase") {
118
118
  createdAt: timestamp("created_at").defaultNow().notNull()
119
119
  });
120
120
 
121
+ /**
122
+ * Magic link tokens for passwordless email login
123
+ */
124
+ const magicLinkTokens = tableCreator("magic_link_tokens", {
125
+ id: uuid("id").defaultRandom().primaryKey(),
126
+ userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
127
+ tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
128
+ expiresAt: timestamp("expires_at").notNull(),
129
+ usedAt: timestamp("used_at"),
130
+ createdAt: timestamp("created_at").defaultNow().notNull()
131
+ });
132
+
121
133
  return {
122
134
  usersSchema,
123
135
  users,
@@ -127,7 +139,8 @@ export function createAuthSchema(usersSchemaName = "rebase") {
127
139
  userIdentities,
128
140
  mfaFactors,
129
141
  mfaChallenges,
130
- recoveryCodes
142
+ recoveryCodes,
143
+ magicLinkTokens
131
144
  };
132
145
  }
133
146
 
@@ -144,6 +157,7 @@ export const userIdentities = defaultAuthSchema.userIdentities;
144
157
  export const mfaFactors = defaultAuthSchema.mfaFactors;
145
158
  export const mfaChallenges = defaultAuthSchema.mfaChallenges;
146
159
  export const recoveryCodes = defaultAuthSchema.recoveryCodes;
160
+ export const magicLinkTokens = defaultAuthSchema.magicLinkTokens;
147
161
 
148
162
  // Relations
149
163
  export const usersRelations = relations(users, ({ many }) => ({
@@ -151,7 +165,8 @@ export const usersRelations = relations(users, ({ many }) => ({
151
165
  passwordResetTokens: many(passwordResetTokens),
152
166
  userIdentities: many(userIdentities),
153
167
  mfaFactors: many(mfaFactors),
154
- recoveryCodes: many(recoveryCodes)
168
+ recoveryCodes: many(recoveryCodes),
169
+ magicLinkTokens: many(magicLinkTokens)
155
170
  }));
156
171
 
157
172
  export const refreshTokensRelations = relations(refreshTokens, ({ one }) => ({
@@ -197,6 +212,13 @@ export const recoveryCodesRelations = relations(recoveryCodes, ({ one }) => ({
197
212
  })
198
213
  }));
199
214
 
215
+ export const magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({
216
+ user: one(users, {
217
+ fields: [magicLinkTokens.userId],
218
+ references: [users.id]
219
+ })
220
+ }));
221
+
200
222
  // Type exports
201
223
  export type User = typeof users.$inferSelect;
202
224
  export type NewUser = typeof users.$inferInsert;
@@ -208,3 +230,4 @@ export type NewUserIdentity = typeof userIdentities.$inferInsert;
208
230
  export type MfaFactorRow = typeof mfaFactors.$inferSelect;
209
231
  export type MfaChallengeRow = typeof mfaChallenges.$inferSelect;
210
232
  export type RecoveryCodeRow = typeof recoveryCodes.$inferSelect;
233
+ export type MagicLinkToken = typeof magicLinkTokens.$inferSelect;
@@ -143,8 +143,7 @@ export async function loadCollections(collectionsPath: string): Promise<EntityCo
143
143
  const filePath = path.join(resolvedPath, file);
144
144
  try {
145
145
  const fileUrl = pathToFileURL(filePath).href;
146
- const dynamicImport = new Function("url", "return import(url)");
147
- const mod = await dynamicImport(fileUrl);
146
+ const mod = await import(fileUrl);
148
147
  if (mod?.default) {
149
148
  collections.push(mod.default);
150
149
  }
@@ -156,8 +155,7 @@ export async function loadCollections(collectionsPath: string): Promise<EntityCo
156
155
  }
157
156
  } else {
158
157
  const fileUrl = pathToFileURL(resolvedPath).href + `?t=${Date.now()}`;
159
- const dynamicImport = new Function("url", "return import(url)");
160
- const imported = await dynamicImport(fileUrl);
158
+ const imported = await import(fileUrl);
161
159
  const loaded = imported.backendCollections || imported.collections;
162
160
  if (Array.isArray(loaded)) {
163
161
  collections.push(...loaded);
@@ -4,6 +4,7 @@ import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelatio
4
4
  import { toSnakeCase } from "@rebasepro/utils";
5
5
  import { createHash } from "crypto";
6
6
  import { logger } from "@rebasepro/server-core";
7
+ import { getEffectiveSecurityRules } from "./auth-default-policies";
7
8
  // --- Helper Functions ---
8
9
 
9
10
  /**
@@ -98,7 +99,7 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCo
98
99
  columnDefinition = `uuid("${colName}")`;
99
100
  } else if (stringProp.columnType === "uuid") {
100
101
  columnDefinition = `uuid("${colName}")`;
101
- } else if (stringProp.columnType === "text") {
102
+ } else if (stringProp.columnType === "text" || stringProp.ui?.markdown || stringProp.ui?.multiline) {
102
103
  columnDefinition = `text("${colName}")`;
103
104
  } else if (stringProp.columnType === "char") {
104
105
  columnDefinition = `char("${colName}")`;
@@ -707,8 +708,8 @@ export const generateSchema = async (collections: EntityCollection[], stripPolic
707
708
 
708
709
  schemaContent += `${Array.from(columns).join(",\n")}`;
709
710
 
710
- const securityRules = isPostgresCollection(collection) ? collection.securityRules : undefined;
711
- if (!stripPolicies && securityRules && securityRules.length > 0) {
711
+ const securityRules = getEffectiveSecurityRules(collection);
712
+ if (!stripPolicies && securityRules.length > 0) {
712
713
  schemaContent += "\n}, (table) => ([\n";
713
714
  securityRules.forEach((rule: SecurityRule, idx: number) => {
714
715
  schemaContent += generatePolicyCode(collection, rule, idx);
@@ -68,8 +68,7 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
68
68
  const filePath = path.join(resolvedPath, file);
69
69
  try {
70
70
  const fileUrl = pathToFileURL(filePath).href;
71
- const dynamicImport = new Function("url", "return import(url)");
72
- const module = await dynamicImport(fileUrl);
71
+ const module = await import(fileUrl);
73
72
  if (module && module.default) {
74
73
  collections.push(module.default);
75
74
  }
@@ -81,8 +80,7 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
81
80
  }
82
81
  } else {
83
82
  const fileUrl = pathToFileURL(resolvedPath).href + `?t=${Date.now()}`;
84
- const dynamicImport = new Function("url", "return import(url)");
85
- const imported = await dynamicImport(fileUrl);
83
+ const imported = await import(fileUrl);
86
84
  collections = imported.backendCollections || imported.collections;
87
85
  }
88
86
 
@@ -101,7 +99,7 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
101
99
  const outputDir = path.dirname(outputPath);
102
100
  await fsPromises.mkdir(outputDir, { recursive: true });
103
101
  await fsPromises.writeFile(outputPath, schemaContent);
104
- logger.info("✅ Drizzle schema generated successfully at", { detail: outputPath });
102
+ logger.info(`✅ Drizzle schema generated successfully at ${outputPath}`);
105
103
  } else {
106
104
  logger.info("✅ Drizzle schema generated successfully.");
107
105
  logger.info(String(schemaContent));