@rebasepro/server-postgresql 0.6.1 → 0.8.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.
- package/dist/PostgresBackendDriver.d.ts +8 -0
- package/dist/auth/services.d.ts +21 -1
- package/dist/cli-errors.d.ts +29 -0
- package/dist/cli-helpers.d.ts +7 -0
- package/dist/collections/PostgresCollectionRegistry.d.ts +2 -2
- package/dist/index.es.js +2987 -230
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-default-policies.d.ts +12 -0
- package/dist/schema/auth-schema.d.ts +227 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
- package/dist/schema/generate-postgres-ddl.d.ts +1 -0
- package/dist/services/entityService.d.ts +1 -1
- package/dist/utils/pg-error-utils.d.ts +10 -0
- package/dist/utils/table-classification.d.ts +8 -0
- package/package.json +15 -9
- package/src/PostgresBackendDriver.ts +200 -68
- package/src/PostgresBootstrapper.ts +34 -2
- package/src/auth/ensure-tables.ts +56 -1
- package/src/auth/services.ts +94 -1
- package/src/cli-errors.ts +162 -0
- package/src/cli-helpers.ts +183 -0
- package/src/cli.ts +264 -245
- package/src/collections/PostgresCollectionRegistry.ts +6 -6
- package/src/data-transformer.ts +2 -2
- package/src/schema/auth-default-policies.ts +97 -0
- package/src/schema/auth-schema.ts +25 -2
- package/src/schema/doctor.ts +2 -4
- package/src/schema/generate-drizzle-schema-logic.ts +20 -82
- package/src/schema/generate-drizzle-schema.ts +3 -5
- package/src/schema/generate-postgres-ddl-logic.ts +487 -0
- package/src/schema/generate-postgres-ddl.ts +116 -0
- package/src/services/EntityPersistService.ts +10 -8
- package/src/services/entityService.ts +28 -3
- package/src/services/realtimeService.ts +26 -2
- package/src/utils/pg-error-utils.ts +16 -0
- package/src/utils/table-classification.ts +16 -0
- package/src/websocket.ts +9 -0
- package/test/auth-default-policies.test.ts +89 -0
- package/test/cli-helpers-extended.test.ts +324 -0
- package/test/cli-helpers.test.ts +59 -0
- package/test/connection.test.ts +292 -0
- package/test/databasePoolManager.test.ts +289 -0
- package/test/doctor-extended.test.ts +443 -0
- package/test/e2e/db-e2e.test.ts +293 -0
- package/test/e2e/pg-setup.ts +79 -0
- package/test/entity-callbacks-redaction.test.ts +86 -0
- package/test/entity-persist-composite-keys.test.ts +451 -0
- package/test/generate-postgres-ddl-edge-cases.test.ts +716 -0
- package/test/generate-postgres-ddl.test.ts +300 -0
- package/test/mfa-service.test.ts +544 -0
- package/test/pg-error-utils.test.ts +50 -1
- package/test/postgresDataDriver.test.ts +2 -1
- package/test/realtimeService-channels.test.ts +696 -0
- package/test/unmapped-tables-safety.test.ts +55 -342
- package/vitest.e2e.config.ts +10 -0
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
import { EntityCollection, PostgresCollection } from "@rebasepro/types";
|
|
2
|
+
import { generatePostgresDdl, generatePostgresPoliciesDdl } from "../src/schema/generate-postgres-ddl-logic";
|
|
3
|
+
|
|
4
|
+
describe("generatePostgresDdl edge cases", () => {
|
|
5
|
+
const cleanDdl = (ddl: string) => {
|
|
6
|
+
return ddl
|
|
7
|
+
.replace(/--.*$/gm, "") // Remove single-line SQL comments
|
|
8
|
+
.replace(/\n{2,}/g, "\n") // Collapse multiple newlines
|
|
9
|
+
.replace(/\s+/g, " ") // Collapse whitespace
|
|
10
|
+
.trim();
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// ── 1. Empty collections array ────────────────────────────────────
|
|
14
|
+
describe("empty collections array", () => {
|
|
15
|
+
it("should produce valid DDL with only the header comment", async () => {
|
|
16
|
+
const result = await generatePostgresDdl([]);
|
|
17
|
+
expect(result).toContain("auto-generated");
|
|
18
|
+
// No CREATE TABLE or ALTER TABLE statements
|
|
19
|
+
expect(result).not.toContain("CREATE TABLE");
|
|
20
|
+
expect(result).not.toContain("ALTER TABLE");
|
|
21
|
+
expect(result).not.toContain("CREATE TYPE");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should produce valid DDL regardless of includePolicies option", async () => {
|
|
25
|
+
const withPolicies = await generatePostgresDdl([], { includePolicies: true });
|
|
26
|
+
const withoutPolicies = await generatePostgresDdl([], { includePolicies: false });
|
|
27
|
+
expect(withPolicies).not.toContain("CREATE TABLE");
|
|
28
|
+
expect(withoutPolicies).not.toContain("CREATE TABLE");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// ── 2. Enum values with single quotes ─────────────────────────────
|
|
33
|
+
describe("enum values with single quotes", () => {
|
|
34
|
+
it("should embed single quotes in enum values without escaping (documents current behavior)", async () => {
|
|
35
|
+
const collections: EntityCollection[] = [
|
|
36
|
+
{
|
|
37
|
+
slug: "phrases",
|
|
38
|
+
table: "phrases",
|
|
39
|
+
name: "Phrases",
|
|
40
|
+
properties: {
|
|
41
|
+
mood: {
|
|
42
|
+
type: "string",
|
|
43
|
+
enum: ["it's", "won't"]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const result = await generatePostgresDdl(collections);
|
|
50
|
+
// Current behavior: single quotes inside values are NOT escaped,
|
|
51
|
+
// producing invalid SQL. Document this as a known limitation.
|
|
52
|
+
expect(result).toContain("CREATE TYPE");
|
|
53
|
+
expect(result).toContain("phrases_mood");
|
|
54
|
+
// The raw output will contain the unescaped quotes
|
|
55
|
+
expect(result).toContain("it's");
|
|
56
|
+
expect(result).toContain("won't");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ── 3. Table name with special characters ─────────────────────────
|
|
61
|
+
describe("table name with special characters", () => {
|
|
62
|
+
it("should quote table names containing hyphens", async () => {
|
|
63
|
+
const collections: EntityCollection[] = [
|
|
64
|
+
{
|
|
65
|
+
slug: "my-items",
|
|
66
|
+
table: "my-items",
|
|
67
|
+
name: "My Items",
|
|
68
|
+
properties: {
|
|
69
|
+
name: { type: "string" }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const result = await generatePostgresDdl(collections);
|
|
75
|
+
const cleanResult = cleanDdl(result);
|
|
76
|
+
// Table name should be quoted, preserving hyphens
|
|
77
|
+
expect(cleanResult).toContain("CREATE TABLE \"public\".\"my-items\" (");
|
|
78
|
+
expect(cleanResult).toContain("ALTER TABLE \"public\".\"my-items\" ENABLE ROW LEVEL SECURITY");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should quote table names containing spaces", async () => {
|
|
82
|
+
const collections: EntityCollection[] = [
|
|
83
|
+
{
|
|
84
|
+
slug: "my items",
|
|
85
|
+
table: "my items",
|
|
86
|
+
name: "My Items",
|
|
87
|
+
properties: {
|
|
88
|
+
name: { type: "string" }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const result = await generatePostgresDdl(collections);
|
|
94
|
+
expect(result).toContain("\"my items\"");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ── 4. Column with reserved SQL keyword names ─────────────────────
|
|
99
|
+
describe("columns with reserved SQL keyword names", () => {
|
|
100
|
+
it("should quote column names that are reserved SQL keywords", async () => {
|
|
101
|
+
const collections: EntityCollection[] = [
|
|
102
|
+
{
|
|
103
|
+
slug: "records",
|
|
104
|
+
table: "records",
|
|
105
|
+
name: "Records",
|
|
106
|
+
properties: {
|
|
107
|
+
order: { type: "number" },
|
|
108
|
+
select: { type: "string" },
|
|
109
|
+
table: { type: "string" },
|
|
110
|
+
user: { type: "string" }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
const result = await generatePostgresDdl(collections);
|
|
116
|
+
const cleanResult = cleanDdl(result);
|
|
117
|
+
|
|
118
|
+
// All column names should be double-quoted, which protects them
|
|
119
|
+
// from being interpreted as SQL keywords
|
|
120
|
+
expect(cleanResult).toContain("\"order\" NUMERIC");
|
|
121
|
+
expect(cleanResult).toContain("\"select\" VARCHAR(255)");
|
|
122
|
+
expect(cleanResult).toContain("\"table\" VARCHAR(255)");
|
|
123
|
+
expect(cleanResult).toContain("\"user\" VARCHAR(255)");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("should also generate implicit id column when properties use reserved keywords", async () => {
|
|
127
|
+
const collections: EntityCollection[] = [
|
|
128
|
+
{
|
|
129
|
+
slug: "records",
|
|
130
|
+
table: "records",
|
|
131
|
+
name: "Records",
|
|
132
|
+
properties: {
|
|
133
|
+
order: { type: "number" },
|
|
134
|
+
group: { type: "string" }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
const result = await generatePostgresDdl(collections);
|
|
140
|
+
const cleanResult = cleanDdl(result);
|
|
141
|
+
// Implicit id should still be present
|
|
142
|
+
expect(cleanResult).toContain("\"id\" VARCHAR(255) PRIMARY KEY");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ── 5. Number with isId: 'increment' ──────────────────────────────
|
|
147
|
+
describe("number with isId increment", () => {
|
|
148
|
+
it("should generate GENERATED BY DEFAULT AS IDENTITY for numeric auto-increment id", async () => {
|
|
149
|
+
const collections: EntityCollection[] = [
|
|
150
|
+
{
|
|
151
|
+
slug: "counters",
|
|
152
|
+
table: "counters",
|
|
153
|
+
name: "Counters",
|
|
154
|
+
properties: {
|
|
155
|
+
id: { type: "number", isId: "increment" },
|
|
156
|
+
label: { type: "string" }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
const result = await generatePostgresDdl(collections);
|
|
162
|
+
const cleanResult = cleanDdl(result);
|
|
163
|
+
|
|
164
|
+
expect(cleanResult).toContain("\"id\" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY");
|
|
165
|
+
// Ensure no implicit VARCHAR id is added
|
|
166
|
+
expect(cleanResult).not.toContain("VARCHAR(255) PRIMARY KEY");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("should not add duplicate id column when isId increment is used", async () => {
|
|
170
|
+
const collections: EntityCollection[] = [
|
|
171
|
+
{
|
|
172
|
+
slug: "items",
|
|
173
|
+
table: "items",
|
|
174
|
+
name: "Items",
|
|
175
|
+
properties: {
|
|
176
|
+
id: { type: "number", isId: "increment" },
|
|
177
|
+
name: { type: "string" }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
const result = await generatePostgresDdl(collections);
|
|
183
|
+
// Count occurrences of PRIMARY KEY — should be exactly 1
|
|
184
|
+
const pkCount = (result.match(/PRIMARY KEY/g) ?? []).length;
|
|
185
|
+
expect(pkCount).toBe(1);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ── 6. Collection with only relation properties ───────────────────
|
|
190
|
+
describe("collection with only relation properties", () => {
|
|
191
|
+
it("should add implicit id column when collection has only relation properties", async () => {
|
|
192
|
+
const usersCollection: EntityCollection = {
|
|
193
|
+
slug: "users",
|
|
194
|
+
table: "users",
|
|
195
|
+
name: "Users",
|
|
196
|
+
properties: {
|
|
197
|
+
name: { type: "string" }
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
const profileCollection: EntityCollection = {
|
|
201
|
+
slug: "profiles",
|
|
202
|
+
table: "profiles",
|
|
203
|
+
name: "Profiles",
|
|
204
|
+
properties: {
|
|
205
|
+
owner: { type: "relation", relationName: "owner" }
|
|
206
|
+
},
|
|
207
|
+
relations: [
|
|
208
|
+
{
|
|
209
|
+
relationName: "owner",
|
|
210
|
+
target: () => usersCollection,
|
|
211
|
+
cardinality: "one",
|
|
212
|
+
localKey: "owner_id",
|
|
213
|
+
onDelete: "cascade"
|
|
214
|
+
}
|
|
215
|
+
]
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const result = await generatePostgresDdl([usersCollection, profileCollection]);
|
|
219
|
+
const cleanResult = cleanDdl(result);
|
|
220
|
+
|
|
221
|
+
// Implicit id should be added since no explicit PK exists
|
|
222
|
+
expect(cleanResult).toContain("\"id\" VARCHAR(255) PRIMARY KEY");
|
|
223
|
+
// FK column should also be present
|
|
224
|
+
expect(cleanResult).toContain("\"owner_id\" VARCHAR(255)");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("should add implicit id even when collection has zero non-relation properties", async () => {
|
|
228
|
+
const collections: EntityCollection[] = [
|
|
229
|
+
{
|
|
230
|
+
slug: "empty_ish",
|
|
231
|
+
table: "empty_ish",
|
|
232
|
+
name: "Empty-ish",
|
|
233
|
+
properties: {}
|
|
234
|
+
}
|
|
235
|
+
];
|
|
236
|
+
|
|
237
|
+
const result = await generatePostgresDdl(collections);
|
|
238
|
+
const cleanResult = cleanDdl(result);
|
|
239
|
+
|
|
240
|
+
// Even with no properties, implicit id is added
|
|
241
|
+
expect(cleanResult).toContain("CREATE TABLE \"public\".\"empty_ish\" (");
|
|
242
|
+
expect(cleanResult).toContain("\"id\" VARCHAR(255) PRIMARY KEY");
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ── 7. generatePostgresPoliciesDdl standalone ─────────────────────
|
|
247
|
+
describe("generatePostgresPoliciesDdl", () => {
|
|
248
|
+
it("should generate RLS enable statement for each collection", () => {
|
|
249
|
+
const collections: EntityCollection[] = [
|
|
250
|
+
{
|
|
251
|
+
slug: "posts",
|
|
252
|
+
table: "posts",
|
|
253
|
+
name: "Posts",
|
|
254
|
+
properties: {
|
|
255
|
+
title: { type: "string" }
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
slug: "comments",
|
|
260
|
+
table: "comments",
|
|
261
|
+
name: "Comments",
|
|
262
|
+
properties: {
|
|
263
|
+
body: { type: "string" }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
];
|
|
267
|
+
|
|
268
|
+
const result = generatePostgresPoliciesDdl(collections);
|
|
269
|
+
|
|
270
|
+
expect(result).toContain("ALTER TABLE \"public\".\"posts\" ENABLE ROW LEVEL SECURITY");
|
|
271
|
+
expect(result).toContain("ALTER TABLE \"public\".\"comments\" ENABLE ROW LEVEL SECURITY");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("should generate policies for collections with security rules", () => {
|
|
275
|
+
const collections: PostgresCollection[] = [
|
|
276
|
+
{
|
|
277
|
+
slug: "posts",
|
|
278
|
+
table: "posts",
|
|
279
|
+
name: "Posts",
|
|
280
|
+
properties: {
|
|
281
|
+
title: { type: "string" },
|
|
282
|
+
author_id: { type: "string" }
|
|
283
|
+
},
|
|
284
|
+
securityRules: [
|
|
285
|
+
{
|
|
286
|
+
operation: "select",
|
|
287
|
+
access: "public"
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
operation: "delete",
|
|
291
|
+
ownerField: "author_id"
|
|
292
|
+
}
|
|
293
|
+
]
|
|
294
|
+
}
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
const result = generatePostgresPoliciesDdl(collections);
|
|
298
|
+
const cleanResult = cleanDdl(result);
|
|
299
|
+
|
|
300
|
+
expect(cleanResult).toContain("CREATE POLICY");
|
|
301
|
+
expect(cleanResult).toContain("USING (true)");
|
|
302
|
+
expect(cleanResult).toContain("FOR SELECT");
|
|
303
|
+
expect(cleanResult).toContain("FOR DELETE");
|
|
304
|
+
expect(cleanResult).toContain("author_id = auth.uid()");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("should not generate any policies for collections without security rules", () => {
|
|
308
|
+
const collections: EntityCollection[] = [
|
|
309
|
+
{
|
|
310
|
+
slug: "items",
|
|
311
|
+
table: "items",
|
|
312
|
+
name: "Items",
|
|
313
|
+
properties: {
|
|
314
|
+
name: { type: "string" }
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
];
|
|
318
|
+
|
|
319
|
+
const result = generatePostgresPoliciesDdl(collections);
|
|
320
|
+
|
|
321
|
+
expect(result).toContain("ALTER TABLE");
|
|
322
|
+
expect(result).toContain("ENABLE ROW LEVEL SECURITY");
|
|
323
|
+
expect(result).not.toContain("CREATE POLICY");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("should return only the header comment for an empty collections array", () => {
|
|
327
|
+
const result = generatePostgresPoliciesDdl([]);
|
|
328
|
+
expect(result).toContain("RLS policies");
|
|
329
|
+
expect(result).not.toContain("ALTER TABLE");
|
|
330
|
+
expect(result).not.toContain("CREATE POLICY");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("should use custom schema in policy DDL", () => {
|
|
334
|
+
const collections: PostgresCollection[] = [
|
|
335
|
+
{
|
|
336
|
+
slug: "docs",
|
|
337
|
+
table: "docs",
|
|
338
|
+
name: "Docs",
|
|
339
|
+
schema: "app",
|
|
340
|
+
properties: {
|
|
341
|
+
title: { type: "string" }
|
|
342
|
+
},
|
|
343
|
+
securityRules: [
|
|
344
|
+
{
|
|
345
|
+
operation: "select",
|
|
346
|
+
access: "public"
|
|
347
|
+
}
|
|
348
|
+
]
|
|
349
|
+
}
|
|
350
|
+
];
|
|
351
|
+
|
|
352
|
+
const result = generatePostgresPoliciesDdl(collections);
|
|
353
|
+
|
|
354
|
+
expect(result).toContain("ALTER TABLE \"app\".\"docs\" ENABLE ROW LEVEL SECURITY");
|
|
355
|
+
expect(result).toContain("ON \"app\".\"docs\"");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("should handle multiple operations in a single rule", () => {
|
|
359
|
+
const collections: PostgresCollection[] = [
|
|
360
|
+
{
|
|
361
|
+
slug: "entries",
|
|
362
|
+
table: "entries",
|
|
363
|
+
name: "Entries",
|
|
364
|
+
properties: {
|
|
365
|
+
data: { type: "string" }
|
|
366
|
+
},
|
|
367
|
+
securityRules: [
|
|
368
|
+
{
|
|
369
|
+
name: "public_read_write",
|
|
370
|
+
operations: ["select", "insert"],
|
|
371
|
+
access: "public"
|
|
372
|
+
}
|
|
373
|
+
]
|
|
374
|
+
}
|
|
375
|
+
];
|
|
376
|
+
|
|
377
|
+
const result = generatePostgresPoliciesDdl(collections);
|
|
378
|
+
const cleanResult = cleanDdl(result);
|
|
379
|
+
|
|
380
|
+
expect(cleanResult).toContain("CREATE POLICY \"public_read_write_select\"");
|
|
381
|
+
expect(cleanResult).toContain("CREATE POLICY \"public_read_write_insert\"");
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("should generate restrictive policies with pgRoles", () => {
|
|
385
|
+
const collections: PostgresCollection[] = [
|
|
386
|
+
{
|
|
387
|
+
slug: "secrets",
|
|
388
|
+
table: "secrets",
|
|
389
|
+
name: "Secrets",
|
|
390
|
+
properties: {
|
|
391
|
+
value: { type: "string" }
|
|
392
|
+
},
|
|
393
|
+
securityRules: [
|
|
394
|
+
{
|
|
395
|
+
name: "admin_only",
|
|
396
|
+
operation: "select",
|
|
397
|
+
mode: "restrictive",
|
|
398
|
+
using: "true",
|
|
399
|
+
pgRoles: ["admin_role"]
|
|
400
|
+
}
|
|
401
|
+
]
|
|
402
|
+
}
|
|
403
|
+
];
|
|
404
|
+
|
|
405
|
+
const result = generatePostgresPoliciesDdl(collections);
|
|
406
|
+
const cleanResult = cleanDdl(result);
|
|
407
|
+
|
|
408
|
+
expect(cleanResult).toContain("AS RESTRICTIVE");
|
|
409
|
+
expect(cleanResult).toContain("TO \"admin_role\"");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("should fall back to USING (false) when no clause can be built for select", () => {
|
|
413
|
+
const collections: PostgresCollection[] = [
|
|
414
|
+
{
|
|
415
|
+
slug: "locked",
|
|
416
|
+
table: "locked",
|
|
417
|
+
name: "Locked",
|
|
418
|
+
properties: {
|
|
419
|
+
data: { type: "string" }
|
|
420
|
+
},
|
|
421
|
+
securityRules: [
|
|
422
|
+
{
|
|
423
|
+
operation: "select"
|
|
424
|
+
// No access, ownerField, or using — should produce USING (false)
|
|
425
|
+
}
|
|
426
|
+
]
|
|
427
|
+
}
|
|
428
|
+
];
|
|
429
|
+
|
|
430
|
+
const result = generatePostgresPoliciesDdl(collections);
|
|
431
|
+
const cleanResult = cleanDdl(result);
|
|
432
|
+
|
|
433
|
+
expect(cleanResult).toContain("USING (false)");
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("should generate WITH CHECK (false) for insert without any clause", () => {
|
|
437
|
+
const collections: PostgresCollection[] = [
|
|
438
|
+
{
|
|
439
|
+
slug: "locked",
|
|
440
|
+
table: "locked",
|
|
441
|
+
name: "Locked",
|
|
442
|
+
properties: {
|
|
443
|
+
data: { type: "string" }
|
|
444
|
+
},
|
|
445
|
+
securityRules: [
|
|
446
|
+
{
|
|
447
|
+
operation: "insert"
|
|
448
|
+
// No clause — should produce WITH CHECK (false)
|
|
449
|
+
}
|
|
450
|
+
]
|
|
451
|
+
}
|
|
452
|
+
];
|
|
453
|
+
|
|
454
|
+
const result = generatePostgresPoliciesDdl(collections);
|
|
455
|
+
const cleanResult = cleanDdl(result);
|
|
456
|
+
|
|
457
|
+
expect(cleanResult).toContain("WITH CHECK (false)");
|
|
458
|
+
// INSERT should not have USING clause
|
|
459
|
+
expect(cleanResult).not.toContain("USING");
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("should generate both USING and WITH CHECK for update", () => {
|
|
463
|
+
const collections: PostgresCollection[] = [
|
|
464
|
+
{
|
|
465
|
+
slug: "items",
|
|
466
|
+
table: "items",
|
|
467
|
+
name: "Items",
|
|
468
|
+
properties: {
|
|
469
|
+
owner_id: { type: "string" }
|
|
470
|
+
},
|
|
471
|
+
securityRules: [
|
|
472
|
+
{
|
|
473
|
+
operation: "update",
|
|
474
|
+
ownerField: "owner_id"
|
|
475
|
+
}
|
|
476
|
+
]
|
|
477
|
+
}
|
|
478
|
+
];
|
|
479
|
+
|
|
480
|
+
const result = generatePostgresPoliciesDdl(collections);
|
|
481
|
+
const cleanResult = cleanDdl(result);
|
|
482
|
+
|
|
483
|
+
expect(cleanResult).toContain("FOR UPDATE");
|
|
484
|
+
expect(cleanResult).toContain("USING (owner_id = auth.uid())");
|
|
485
|
+
expect(cleanResult).toContain("WITH CHECK (owner_id = auth.uid())");
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("should include DROP POLICY IF EXISTS before CREATE POLICY", () => {
|
|
489
|
+
const collections: PostgresCollection[] = [
|
|
490
|
+
{
|
|
491
|
+
slug: "items",
|
|
492
|
+
table: "items",
|
|
493
|
+
name: "Items",
|
|
494
|
+
properties: {
|
|
495
|
+
data: { type: "string" }
|
|
496
|
+
},
|
|
497
|
+
securityRules: [
|
|
498
|
+
{
|
|
499
|
+
operation: "select",
|
|
500
|
+
access: "public"
|
|
501
|
+
}
|
|
502
|
+
]
|
|
503
|
+
}
|
|
504
|
+
];
|
|
505
|
+
|
|
506
|
+
const result = generatePostgresPoliciesDdl(collections);
|
|
507
|
+
|
|
508
|
+
expect(result).toContain("DROP POLICY IF EXISTS");
|
|
509
|
+
// DROP should appear before CREATE
|
|
510
|
+
const dropIdx = result.indexOf("DROP POLICY IF EXISTS");
|
|
511
|
+
const createIdx = result.indexOf("CREATE POLICY");
|
|
512
|
+
expect(dropIdx).toBeLessThan(createIdx);
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// ── 8. Multiple collections referencing the same enum ─────────────
|
|
517
|
+
describe("multiple collections referencing same enum type", () => {
|
|
518
|
+
it("should create separate enum types per collection (enum names are scoped to table)", async () => {
|
|
519
|
+
const collections: EntityCollection[] = [
|
|
520
|
+
{
|
|
521
|
+
slug: "orders",
|
|
522
|
+
table: "orders",
|
|
523
|
+
name: "Orders",
|
|
524
|
+
properties: {
|
|
525
|
+
status: {
|
|
526
|
+
type: "string",
|
|
527
|
+
enum: ["pending", "active", "closed"]
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
slug: "invoices",
|
|
533
|
+
table: "invoices",
|
|
534
|
+
name: "Invoices",
|
|
535
|
+
properties: {
|
|
536
|
+
status: {
|
|
537
|
+
type: "string",
|
|
538
|
+
enum: ["pending", "active", "closed"]
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
];
|
|
543
|
+
|
|
544
|
+
const result = await generatePostgresDdl(collections);
|
|
545
|
+
|
|
546
|
+
// Each collection gets its own scoped enum type
|
|
547
|
+
expect(result).toContain("\"orders_status\"");
|
|
548
|
+
expect(result).toContain("\"invoices_status\"");
|
|
549
|
+
// Both should appear exactly once
|
|
550
|
+
const ordersEnumCount = (result.match(/CREATE TYPE.*orders_status/g) ?? []).length;
|
|
551
|
+
const invoicesEnumCount = (result.match(/CREATE TYPE.*invoices_status/g) ?? []).length;
|
|
552
|
+
expect(ordersEnumCount).toBe(1);
|
|
553
|
+
expect(invoicesEnumCount).toBe(1);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it("should not create duplicate enum when same collection has same-named enum", async () => {
|
|
557
|
+
const collections: EntityCollection[] = [
|
|
558
|
+
{
|
|
559
|
+
slug: "products",
|
|
560
|
+
table: "products",
|
|
561
|
+
name: "Products",
|
|
562
|
+
properties: {
|
|
563
|
+
category: {
|
|
564
|
+
type: "string",
|
|
565
|
+
enum: ["electronics", "clothing"]
|
|
566
|
+
},
|
|
567
|
+
size: {
|
|
568
|
+
type: "string",
|
|
569
|
+
enum: ["S", "M", "L"]
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
];
|
|
574
|
+
|
|
575
|
+
const result = await generatePostgresDdl(collections);
|
|
576
|
+
|
|
577
|
+
// Two different enums should be created
|
|
578
|
+
expect(result).toContain("\"products_category\"");
|
|
579
|
+
expect(result).toContain("\"products_size\"");
|
|
580
|
+
const categoryCount = (result.match(/CREATE TYPE.*products_category/g) ?? []).length;
|
|
581
|
+
const sizeCount = (result.match(/CREATE TYPE.*products_size/g) ?? []).length;
|
|
582
|
+
expect(categoryCount).toBe(1);
|
|
583
|
+
expect(sizeCount).toBe(1);
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// ── 9. Binary type ────────────────────────────────────────────────
|
|
588
|
+
describe("binary type", () => {
|
|
589
|
+
it("should generate BYTEA column for binary type", async () => {
|
|
590
|
+
const collections: EntityCollection[] = [
|
|
591
|
+
{
|
|
592
|
+
slug: "files",
|
|
593
|
+
table: "files",
|
|
594
|
+
name: "Files",
|
|
595
|
+
properties: {
|
|
596
|
+
data: { type: "binary" },
|
|
597
|
+
checksum: { type: "binary" }
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
];
|
|
601
|
+
|
|
602
|
+
const result = await generatePostgresDdl(collections);
|
|
603
|
+
const cleanResult = cleanDdl(result);
|
|
604
|
+
|
|
605
|
+
expect(cleanResult).toContain("\"data\" BYTEA");
|
|
606
|
+
expect(cleanResult).toContain("\"checksum\" BYTEA");
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("should support required binary columns", async () => {
|
|
610
|
+
const collections: EntityCollection[] = [
|
|
611
|
+
{
|
|
612
|
+
slug: "blobs",
|
|
613
|
+
table: "blobs",
|
|
614
|
+
name: "Blobs",
|
|
615
|
+
properties: {
|
|
616
|
+
content: { type: "binary", validation: { required: true } }
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
];
|
|
620
|
+
|
|
621
|
+
const result = await generatePostgresDdl(collections);
|
|
622
|
+
const cleanResult = cleanDdl(result);
|
|
623
|
+
|
|
624
|
+
expect(cleanResult).toContain("\"content\" BYTEA NOT NULL");
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// ── 10. Vector type ───────────────────────────────────────────────
|
|
629
|
+
describe("vector type", () => {
|
|
630
|
+
it("should generate VECTOR(N) column with specified dimensions", async () => {
|
|
631
|
+
const collections: EntityCollection[] = [
|
|
632
|
+
{
|
|
633
|
+
slug: "embeddings",
|
|
634
|
+
table: "embeddings",
|
|
635
|
+
name: "Embeddings",
|
|
636
|
+
properties: {
|
|
637
|
+
embedding: { type: "vector", dimensions: 1536 }
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
];
|
|
641
|
+
|
|
642
|
+
const result = await generatePostgresDdl(collections);
|
|
643
|
+
const cleanResult = cleanDdl(result);
|
|
644
|
+
|
|
645
|
+
expect(cleanResult).toContain("\"embedding\" VECTOR(1536)");
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("should support different dimension values", async () => {
|
|
649
|
+
const collections: EntityCollection[] = [
|
|
650
|
+
{
|
|
651
|
+
slug: "vectors",
|
|
652
|
+
table: "vectors",
|
|
653
|
+
name: "Vectors",
|
|
654
|
+
properties: {
|
|
655
|
+
small_vec: { type: "vector", dimensions: 3 },
|
|
656
|
+
medium_vec: { type: "vector", dimensions: 384 },
|
|
657
|
+
large_vec: { type: "vector", dimensions: 4096 }
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
];
|
|
661
|
+
|
|
662
|
+
const result = await generatePostgresDdl(collections);
|
|
663
|
+
const cleanResult = cleanDdl(result);
|
|
664
|
+
|
|
665
|
+
expect(cleanResult).toContain("\"small_vec\" VECTOR(3)");
|
|
666
|
+
expect(cleanResult).toContain("\"medium_vec\" VECTOR(384)");
|
|
667
|
+
expect(cleanResult).toContain("\"large_vec\" VECTOR(4096)");
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it("should support required vector columns", async () => {
|
|
671
|
+
const collections: EntityCollection[] = [
|
|
672
|
+
{
|
|
673
|
+
slug: "required_vecs",
|
|
674
|
+
table: "required_vecs",
|
|
675
|
+
name: "Required Vecs",
|
|
676
|
+
properties: {
|
|
677
|
+
vec: { type: "vector", dimensions: 768, validation: { required: true } }
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
];
|
|
681
|
+
|
|
682
|
+
const result = await generatePostgresDdl(collections);
|
|
683
|
+
const cleanResult = cleanDdl(result);
|
|
684
|
+
|
|
685
|
+
expect(cleanResult).toContain("\"vec\" VECTOR(768) NOT NULL");
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
// ── Bonus: includePolicies: false ─────────────────────────────────
|
|
690
|
+
describe("includePolicies option", () => {
|
|
691
|
+
it("should not include RLS or policies when includePolicies is false", async () => {
|
|
692
|
+
const collections: PostgresCollection[] = [
|
|
693
|
+
{
|
|
694
|
+
slug: "items",
|
|
695
|
+
table: "items",
|
|
696
|
+
name: "Items",
|
|
697
|
+
properties: {
|
|
698
|
+
name: { type: "string" }
|
|
699
|
+
},
|
|
700
|
+
securityRules: [
|
|
701
|
+
{
|
|
702
|
+
operation: "select",
|
|
703
|
+
access: "public"
|
|
704
|
+
}
|
|
705
|
+
]
|
|
706
|
+
}
|
|
707
|
+
];
|
|
708
|
+
|
|
709
|
+
const result = await generatePostgresDdl(collections, { includePolicies: false });
|
|
710
|
+
|
|
711
|
+
expect(result).toContain("CREATE TABLE");
|
|
712
|
+
expect(result).not.toContain("ENABLE ROW LEVEL SECURITY");
|
|
713
|
+
expect(result).not.toContain("CREATE POLICY");
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
});
|