@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.
- package/package.json +24 -18
- package/src/PostgresBackendDriver.ts +65 -44
- package/src/PostgresBootstrapper.ts +49 -20
- 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 +198 -251
- package/src/data-transformer.ts +9 -1
- package/src/schema/auth-default-policies.ts +90 -0
- package/src/schema/auth-schema.ts +25 -2
- package/src/schema/doctor.ts +2 -4
- package/src/schema/generate-drizzle-schema-logic.ts +4 -3
- package/src/schema/generate-drizzle-schema.ts +3 -5
- package/src/schema/generate-postgres-ddl-logic.ts +524 -0
- package/src/schema/generate-postgres-ddl.ts +116 -0
- package/src/services/EntityPersistService.ts +18 -16
- package/src/services/entityService.ts +28 -3
- package/src/utils/pg-array-null-patch.ts +42 -0
- 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/array-null-safety.test.ts +335 -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/data-transformer.test.ts +53 -2
- 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-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-array-null-patch.test.ts +65 -0
- package/test/pg-error-utils.test.ts +50 -1
- package/test/realtimeService-channels.test.ts +696 -0
- package/test/unmapped-tables-safety.test.ts +55 -342
- package/vite.config.ts +8 -6
- package/vitest.e2e.config.ts +10 -0
- package/build-errors.txt +0 -37
- package/dist/PostgresAdapter.d.ts +0 -6
- package/dist/PostgresBackendDriver.d.ts +0 -110
- package/dist/PostgresBootstrapper.d.ts +0 -46
- package/dist/auth/ensure-tables.d.ts +0 -10
- package/dist/auth/services.d.ts +0 -231
- package/dist/cli.d.ts +0 -1
- package/dist/collections/PostgresCollectionRegistry.d.ts +0 -47
- package/dist/connection.d.ts +0 -65
- package/dist/data-transformer.d.ts +0 -55
- package/dist/databasePoolManager.d.ts +0 -20
- package/dist/history/HistoryService.d.ts +0 -71
- package/dist/history/ensure-history-table.d.ts +0 -7
- package/dist/index.d.ts +0 -14
- package/dist/index.es.js +0 -10764
- package/dist/index.es.js.map +0 -1
- package/dist/index.umd.js +0 -11055
- package/dist/index.umd.js.map +0 -1
- package/dist/interfaces.d.ts +0 -18
- package/dist/schema/auth-schema.d.ts +0 -2149
- package/dist/schema/doctor-cli.d.ts +0 -2
- package/dist/schema/doctor.d.ts +0 -52
- package/dist/schema/generate-drizzle-schema-logic.d.ts +0 -2
- package/dist/schema/generate-drizzle-schema.d.ts +0 -1
- package/dist/schema/introspect-db-inference.d.ts +0 -5
- package/dist/schema/introspect-db-logic.d.ts +0 -118
- package/dist/schema/introspect-db.d.ts +0 -1
- package/dist/schema/test-schema.d.ts +0 -24
- package/dist/services/BranchService.d.ts +0 -47
- package/dist/services/EntityFetchService.d.ts +0 -214
- package/dist/services/EntityPersistService.d.ts +0 -40
- package/dist/services/RelationService.d.ts +0 -98
- package/dist/services/entity-helpers.d.ts +0 -38
- package/dist/services/entityService.d.ts +0 -110
- package/dist/services/index.d.ts +0 -4
- package/dist/services/realtimeService.d.ts +0 -220
- package/dist/types.d.ts +0 -3
- package/dist/utils/drizzle-conditions.d.ts +0 -138
- package/dist/utils/pg-error-utils.d.ts +0 -55
- package/dist/websocket.d.ts +0 -11
|
@@ -40,19 +40,21 @@ export class EntityPersistService {
|
|
|
40
40
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
41
41
|
const table = getTableForCollection(collection, this.registry);
|
|
42
42
|
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
43
|
-
const idInfo = idInfoArray[0];
|
|
44
|
-
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
45
|
-
|
|
46
|
-
if (!idField) {
|
|
47
|
-
throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
48
|
-
}
|
|
49
43
|
|
|
50
44
|
const parsedIdObj = parseIdValues(entityId, idInfoArray);
|
|
51
|
-
|
|
45
|
+
|
|
46
|
+
const conditions = [];
|
|
47
|
+
for (const info of idInfoArray) {
|
|
48
|
+
const field = table[info.fieldName as keyof typeof table] as AnyPgColumn;
|
|
49
|
+
if (!field) {
|
|
50
|
+
throw new Error(`ID field '${info.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
51
|
+
}
|
|
52
|
+
conditions.push(eq(field, parsedIdObj[info.fieldName]));
|
|
53
|
+
}
|
|
52
54
|
|
|
53
55
|
await this.db
|
|
54
56
|
.delete(table)
|
|
55
|
-
.where(
|
|
57
|
+
.where(and(...conditions));
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
/**
|
|
@@ -192,17 +194,17 @@ export class EntityPersistService {
|
|
|
192
194
|
}
|
|
193
195
|
}
|
|
194
196
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
+
let savedId: string | number;
|
|
198
|
+
try {
|
|
199
|
+
// Transform relations to IDs, then sanitize
|
|
200
|
+
const serializedResult = serializeDataToServer(otherValues as M, collection.properties as Properties, collection, this.registry);
|
|
197
201
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
202
|
+
// Extract relation updates from the typed result
|
|
203
|
+
const inverseRelationUpdates = serializedResult.inverseRelationUpdates;
|
|
204
|
+
const joinPathRelationUpdates = serializedResult.joinPathRelationUpdates;
|
|
201
205
|
|
|
202
|
-
|
|
206
|
+
const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
|
|
203
207
|
|
|
204
|
-
let savedId: string | number;
|
|
205
|
-
try {
|
|
206
208
|
savedId = await this.db.transaction(async (tx) => {
|
|
207
209
|
let currentId: string | number;
|
|
208
210
|
|
|
@@ -180,12 +180,37 @@ export class EntityService implements EntityRepository {
|
|
|
180
180
|
/**
|
|
181
181
|
* Execute raw SQL
|
|
182
182
|
*/
|
|
183
|
-
async executeSql(sqlText: string): Promise<Record<string, unknown>[]> {
|
|
183
|
+
async executeSql(sqlText: string, params?: unknown[]): Promise<Record<string, unknown>[]> {
|
|
184
184
|
if (process.env.NODE_ENV !== "production") {
|
|
185
|
-
console.debug("Executing raw SQL:", sqlText);
|
|
185
|
+
console.debug("Executing raw SQL:", sqlText, params?.length ? `with ${params.length} params` : "");
|
|
186
186
|
}
|
|
187
187
|
const { sql } = await import("drizzle-orm");
|
|
188
|
-
|
|
188
|
+
|
|
189
|
+
let result;
|
|
190
|
+
if (params && params.length > 0) {
|
|
191
|
+
// Build a parameterized query using Drizzle's sql tagged template.
|
|
192
|
+
// Split the SQL text on $1, $2, … placeholders and interleave
|
|
193
|
+
// with sql.param() calls so the underlying pg driver binds them safely.
|
|
194
|
+
const parts = sqlText.split(/\$(\d+)/);
|
|
195
|
+
const chunks: ReturnType<typeof sql.raw | typeof sql.param>[] = [];
|
|
196
|
+
for (let i = 0; i < parts.length; i++) {
|
|
197
|
+
if (i % 2 === 0) {
|
|
198
|
+
// Literal SQL text fragment
|
|
199
|
+
if (parts[i].length > 0) {
|
|
200
|
+
chunks.push(sql.raw(parts[i]));
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
// Parameter reference — $N (1-indexed)
|
|
204
|
+
const paramIndex = Number(parts[i]) - 1;
|
|
205
|
+
chunks.push(sql.param(params[paramIndex]));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const query = sql.join(chunks, sql.raw(""));
|
|
209
|
+
result = await this.db.execute(query);
|
|
210
|
+
} else {
|
|
211
|
+
result = await this.db.execute(sql.raw(sqlText));
|
|
212
|
+
}
|
|
213
|
+
|
|
189
214
|
const rows = result.rows;
|
|
190
215
|
if (process.env.NODE_ENV !== "production") {
|
|
191
216
|
console.debug(`SQL executed successfully. Returned ${Array.isArray(rows) ? rows.length : "non-array"} rows.`);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { getTableColumns } from "drizzle-orm";
|
|
2
|
+
import { PgArray, PgTable } from "drizzle-orm/pg-core";
|
|
3
|
+
import { logger } from "@rebasepro/server-core";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Patches all PgArray columns on the given tables to handle NULL values safely.
|
|
7
|
+
*
|
|
8
|
+
* Drizzle ORM's `PgArray.mapFromDriverValue` calls `value.map(...)` without
|
|
9
|
+
* guarding against `null`. When a PostgreSQL native array column (`text[]`,
|
|
10
|
+
* `integer[]`, etc.) contains NULL, the pg driver returns `null` in JavaScript,
|
|
11
|
+
* and `null.map(...)` throws `TypeError: value.map is not a function`.
|
|
12
|
+
*
|
|
13
|
+
* This function walks every column of every registered table and, for any
|
|
14
|
+
* `PgArray` column, wraps its `mapFromDriverValue` to return `null` when the
|
|
15
|
+
* database value is nullish.
|
|
16
|
+
*
|
|
17
|
+
* This is a workaround for a known Drizzle ORM issue. Should be removed once
|
|
18
|
+
* Drizzle handles nullable arrays natively.
|
|
19
|
+
*/
|
|
20
|
+
export function patchPgArrayNullSafety(tables: Record<string, unknown>): void {
|
|
21
|
+
let patchedCount = 0;
|
|
22
|
+
|
|
23
|
+
for (const tableOrRelation of Object.values(tables)) {
|
|
24
|
+
if (!(tableOrRelation instanceof PgTable)) continue;
|
|
25
|
+
|
|
26
|
+
const columns = getTableColumns(tableOrRelation);
|
|
27
|
+
for (const column of Object.values(columns)) {
|
|
28
|
+
if (column instanceof PgArray) {
|
|
29
|
+
const original = column.mapFromDriverValue.bind(column);
|
|
30
|
+
column.mapFromDriverValue = function (value: unknown) {
|
|
31
|
+
if (value == null) return null;
|
|
32
|
+
return original(value as string | unknown[]);
|
|
33
|
+
};
|
|
34
|
+
patchedCount++;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (patchedCount > 0) {
|
|
40
|
+
logger.debug(`[PgArray] Patched ${patchedCount} array column(s) for null-safety`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -68,6 +68,22 @@ export function extractCauseMessage(error: unknown): string | null {
|
|
|
68
68
|
return null;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Detect whether an error is specifically a role-switching permission failure
|
|
73
|
+
* (e.g. "permission denied to set role" or "must be member of role"),
|
|
74
|
+
* as opposed to a table-level permission denial.
|
|
75
|
+
*
|
|
76
|
+
* This is used by the backend driver to auto-disable role switching when the
|
|
77
|
+
* connection user lacks SET ROLE privileges, rather than surfacing a confusing
|
|
78
|
+
* error to the Studio SQL Editor user.
|
|
79
|
+
*/
|
|
80
|
+
export function isRoleSwitchingPermissionError(error: unknown): boolean {
|
|
81
|
+
const pgError = extractPgError(error);
|
|
82
|
+
if (!pgError || pgError.code !== "42501") return false;
|
|
83
|
+
const msg = pgError.message.toLowerCase();
|
|
84
|
+
return msg.includes("set role") || msg.includes("member of role");
|
|
85
|
+
}
|
|
86
|
+
|
|
71
87
|
/**
|
|
72
88
|
* Translate a raw PostgreSQL error into a user-friendly message.
|
|
73
89
|
*
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table Classification Utility
|
|
3
|
+
*
|
|
4
|
+
* Re-exports shared classification logic from @rebasepro/common.
|
|
5
|
+
* This module exists for backward compatibility — prefer importing directly
|
|
6
|
+
* from @rebasepro/common in new code.
|
|
7
|
+
*/
|
|
8
|
+
export {
|
|
9
|
+
type TableCategory,
|
|
10
|
+
REBASE_INTERNAL_SCHEMAS,
|
|
11
|
+
REBASE_INTERNAL_PREFIXES,
|
|
12
|
+
classifyTable,
|
|
13
|
+
isRebaseInternalTable,
|
|
14
|
+
detectJunctionTables,
|
|
15
|
+
JUNCTION_TABLES_SQL,
|
|
16
|
+
} from "@rebasepro/common";
|
package/src/websocket.ts
CHANGED
|
@@ -403,6 +403,15 @@ colors: true }));
|
|
|
403
403
|
if (process.env.NODE_ENV !== "production") {
|
|
404
404
|
wsDebug(`⚡ [WebSocket Server] SQL executed. Returned ${Array.isArray(result) ? result.length : "non-array"} rows.`);
|
|
405
405
|
}
|
|
406
|
+
const auditSession = clientSessions.get(clientId);
|
|
407
|
+
console.log("[SQL Audit] WebSocket SQL execution", JSON.stringify({
|
|
408
|
+
sql: typeof sql === "string" ? sql.substring(0, 500) : sql,
|
|
409
|
+
options,
|
|
410
|
+
resultRows: Array.isArray(result) ? result.length : "unknown",
|
|
411
|
+
userId: auditSession?.user?.userId ?? "unknown",
|
|
412
|
+
roles: auditSession?.user?.roles ?? [],
|
|
413
|
+
isAdmin: auditSession?.user?.isAdmin ?? false,
|
|
414
|
+
}));
|
|
406
415
|
const response = {
|
|
407
416
|
type: "EXECUTE_SQL_SUCCESS",
|
|
408
417
|
payload: { result },
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { describe, it, expect } from "@jest/globals";
|
|
2
|
+
import {
|
|
3
|
+
serializePropertyToServer,
|
|
4
|
+
parsePropertyFromServer,
|
|
5
|
+
serializeDataToServer
|
|
6
|
+
} from "../src/data-transformer";
|
|
7
|
+
import type { Property, Properties, EntityCollection } from "@rebasepro/types";
|
|
8
|
+
|
|
9
|
+
// ─────────────────────────────────────────────────────────────
|
|
10
|
+
// Fixture helpers
|
|
11
|
+
// ─────────────────────────────────────────────────────────────
|
|
12
|
+
function makeCollection(slug: string, properties: Properties): EntityCollection {
|
|
13
|
+
return {
|
|
14
|
+
name: slug,
|
|
15
|
+
slug,
|
|
16
|
+
path: slug,
|
|
17
|
+
collectionType: "postgres",
|
|
18
|
+
tableName: slug,
|
|
19
|
+
properties
|
|
20
|
+
} as unknown as EntityCollection;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ─────────────────────────────────────────────────────────────
|
|
24
|
+
// Property fixtures
|
|
25
|
+
// ─────────────────────────────────────────────────────────────
|
|
26
|
+
const arrayOfStringsProp: Property = {
|
|
27
|
+
type: "array",
|
|
28
|
+
name: "Tags",
|
|
29
|
+
of: { type: "string", name: "Tag" }
|
|
30
|
+
} as Property;
|
|
31
|
+
|
|
32
|
+
const arrayOfNumbersProp: Property = {
|
|
33
|
+
type: "array",
|
|
34
|
+
name: "Scores",
|
|
35
|
+
of: { type: "number", name: "Score" }
|
|
36
|
+
} as Property;
|
|
37
|
+
|
|
38
|
+
const arrayOfMapsProp: Property = {
|
|
39
|
+
type: "array",
|
|
40
|
+
name: "Addresses",
|
|
41
|
+
of: {
|
|
42
|
+
type: "map",
|
|
43
|
+
name: "Address",
|
|
44
|
+
properties: {
|
|
45
|
+
street: { type: "string", name: "Street" },
|
|
46
|
+
city: { type: "string", name: "City" }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} as Property;
|
|
50
|
+
|
|
51
|
+
const arrayOneOfProp: Property = {
|
|
52
|
+
type: "array",
|
|
53
|
+
name: "Blocks",
|
|
54
|
+
oneOf: {
|
|
55
|
+
typeField: "type",
|
|
56
|
+
valueField: "value",
|
|
57
|
+
properties: {
|
|
58
|
+
text: { type: "string", name: "Text" } as Property,
|
|
59
|
+
number: { type: "number", name: "Number" } as Property
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} as Property;
|
|
63
|
+
|
|
64
|
+
const arrayOfRelationsProp: Property = {
|
|
65
|
+
type: "array",
|
|
66
|
+
name: "Authors",
|
|
67
|
+
of: { type: "relation", name: "Author" }
|
|
68
|
+
} as Property;
|
|
69
|
+
|
|
70
|
+
// A bare array property without `of` or `oneOf`
|
|
71
|
+
const bareArrayProp: Property = {
|
|
72
|
+
type: "array",
|
|
73
|
+
name: "RawArray"
|
|
74
|
+
} as Property;
|
|
75
|
+
|
|
76
|
+
// ─────────────────────────────────────────────────────────────
|
|
77
|
+
// 1. serializePropertyToServer — array null safety
|
|
78
|
+
// ─────────────────────────────────────────────────────────────
|
|
79
|
+
describe("serializePropertyToServer — array null safety", () => {
|
|
80
|
+
|
|
81
|
+
// ── Null / undefined early return ──
|
|
82
|
+
it("returns null when value is null with array-of-strings property", () => {
|
|
83
|
+
expect(serializePropertyToServer(null, arrayOfStringsProp)).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns undefined when value is undefined with array-of-strings property", () => {
|
|
87
|
+
expect(serializePropertyToServer(undefined, arrayOfStringsProp)).toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── Empty array ──
|
|
91
|
+
it("returns [] when value is an empty array", () => {
|
|
92
|
+
expect(serializePropertyToServer([], arrayOfStringsProp)).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ── Null with different sub-types ──
|
|
96
|
+
it("returns null when value is null with array-of-numbers property", () => {
|
|
97
|
+
expect(serializePropertyToServer(null, arrayOfNumbersProp)).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns null when value is null with array-of-maps property", () => {
|
|
101
|
+
expect(serializePropertyToServer(null, arrayOfMapsProp)).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns null when value is null with array oneOf property", () => {
|
|
105
|
+
expect(serializePropertyToServer(null, arrayOneOfProp)).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ── Non-array values (coerced to empty array) ──
|
|
109
|
+
it("coerces a non-array string to empty array for array property", () => {
|
|
110
|
+
expect(serializePropertyToServer("not-an-array", arrayOfStringsProp)).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("coerces a non-array number to empty array for array property", () => {
|
|
114
|
+
expect(serializePropertyToServer(42, arrayOfStringsProp)).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("coerces a non-array object to empty array for bare array property", () => {
|
|
118
|
+
const obj = { a: 1 };
|
|
119
|
+
expect(serializePropertyToServer(obj, bareArrayProp)).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("coerces a boolean to empty array for array property", () => {
|
|
123
|
+
expect(serializePropertyToServer(true, arrayOfStringsProp)).toEqual([]);
|
|
124
|
+
expect(serializePropertyToServer(false, arrayOfStringsProp)).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── Arrays with null / undefined elements ──
|
|
128
|
+
it("handles array with null elements by passing each through sub-serialization", () => {
|
|
129
|
+
const result = serializePropertyToServer([null, "a", null], arrayOfStringsProp);
|
|
130
|
+
expect(result).toEqual([null, "a", null]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("handles array with undefined elements by passing each through sub-serialization", () => {
|
|
134
|
+
const result = serializePropertyToServer([undefined, "a"], arrayOfStringsProp);
|
|
135
|
+
expect(result).toEqual([undefined, "a"]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ── Array of relation objects with null entries ──
|
|
139
|
+
it("serializes array of relation objects with null entries correctly", () => {
|
|
140
|
+
const value = [{ id: "1" }, null, { id: "2" }];
|
|
141
|
+
const result = serializePropertyToServer(value, arrayOfRelationsProp);
|
|
142
|
+
// null entries pass through serializePropertyToServer for the sub-property
|
|
143
|
+
// which returns null for null values
|
|
144
|
+
expect(result).toEqual(["1", null, "2"]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── Array of maps where some entries are null ──
|
|
148
|
+
it("handles array of maps where some entries are null gracefully", () => {
|
|
149
|
+
const value = [
|
|
150
|
+
{ street: "123 Main", city: "NYC" },
|
|
151
|
+
null,
|
|
152
|
+
{ street: "456 Oak", city: "LA" }
|
|
153
|
+
];
|
|
154
|
+
const result = serializePropertyToServer(value, arrayOfMapsProp);
|
|
155
|
+
expect(result).toEqual([
|
|
156
|
+
{ street: "123 Main", city: "NYC" },
|
|
157
|
+
null,
|
|
158
|
+
{ street: "456 Oak", city: "LA" }
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ── Empty array with oneOf property ──
|
|
163
|
+
it("returns [] when value is an empty array with oneOf property", () => {
|
|
164
|
+
expect(serializePropertyToServer([], arrayOneOfProp)).toEqual([]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ── Nested array of arrays (unusual) ──
|
|
168
|
+
it("returns nested array of arrays as-is for bare array property", () => {
|
|
169
|
+
const nested = [["a", "b"], ["c"]];
|
|
170
|
+
// Without `of` or `oneOf`, the array case falls through to `return value`
|
|
171
|
+
expect(serializePropertyToServer(nested, bareArrayProp)).toEqual([["a", "b"], ["c"]]);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ─────────────────────────────────────────────────────────────
|
|
176
|
+
// 2. parsePropertyFromServer — array null safety
|
|
177
|
+
// ─────────────────────────────────────────────────────────────
|
|
178
|
+
describe("parsePropertyFromServer — array null safety", () => {
|
|
179
|
+
const dummyCollection = makeCollection("items", {
|
|
180
|
+
tags: arrayOfStringsProp
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ── Null / undefined early return ──
|
|
184
|
+
it("returns null when value is null with array property", () => {
|
|
185
|
+
expect(parsePropertyFromServer(null, arrayOfStringsProp, dummyCollection)).toBeNull();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("returns undefined when value is undefined with array property", () => {
|
|
189
|
+
expect(parsePropertyFromServer(undefined, arrayOfStringsProp, dummyCollection)).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ── Empty array ──
|
|
193
|
+
it("returns [] when value is an empty array with array-of-strings", () => {
|
|
194
|
+
expect(parsePropertyFromServer([], arrayOfStringsProp, dummyCollection)).toEqual([]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ── Non-array values (coerced to array) ──
|
|
198
|
+
it("coerces a non-array string to single-element array for array property", () => {
|
|
199
|
+
expect(parsePropertyFromServer("not-an-array", arrayOfStringsProp, dummyCollection)).toEqual(["not-an-array"]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("coerces a non-array number to empty array for array property", () => {
|
|
203
|
+
expect(parsePropertyFromServer(99, arrayOfStringsProp, dummyCollection)).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ── Null with oneOf ──
|
|
207
|
+
it("returns null when value is null with array oneOf property", () => {
|
|
208
|
+
expect(parsePropertyFromServer(null, arrayOneOfProp, dummyCollection)).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ── Array with null elements ──
|
|
212
|
+
it("handles array with null elements for parsing gracefully", () => {
|
|
213
|
+
const result = parsePropertyFromServer([null, "a", null], arrayOfStringsProp, dummyCollection);
|
|
214
|
+
expect(result).toEqual([null, "a", null]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ── Falsy non-null values (coerced to array) ──
|
|
218
|
+
it("coerces falsy number 0 to empty array for array property", () => {
|
|
219
|
+
expect(parsePropertyFromServer(0, arrayOfStringsProp, dummyCollection)).toEqual([]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("coerces false to empty array for array property", () => {
|
|
223
|
+
expect(parsePropertyFromServer(false, arrayOfStringsProp, dummyCollection)).toEqual([]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("coerces empty string to single-element array for array property", () => {
|
|
227
|
+
expect(parsePropertyFromServer("", arrayOfStringsProp, dummyCollection)).toEqual([""]);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ─────────────────────────────────────────────────────────────
|
|
232
|
+
// 3. serializeDataToServer — entity with null array fields
|
|
233
|
+
// ─────────────────────────────────────────────────────────────
|
|
234
|
+
describe("serializeDataToServer — entity with null array fields", () => {
|
|
235
|
+
const tagsProperty: Property = {
|
|
236
|
+
type: "array",
|
|
237
|
+
name: "Tags",
|
|
238
|
+
of: { type: "string", name: "Tag" }
|
|
239
|
+
} as Property;
|
|
240
|
+
|
|
241
|
+
const categoriesProperty: Property = {
|
|
242
|
+
type: "array",
|
|
243
|
+
name: "Categories",
|
|
244
|
+
of: { type: "string", name: "Category" }
|
|
245
|
+
} as Property;
|
|
246
|
+
|
|
247
|
+
const metadataProperty: Property = {
|
|
248
|
+
type: "map",
|
|
249
|
+
name: "Metadata",
|
|
250
|
+
properties: {
|
|
251
|
+
labels: {
|
|
252
|
+
type: "array",
|
|
253
|
+
name: "Labels",
|
|
254
|
+
of: { type: "string", name: "Label" }
|
|
255
|
+
} as Property
|
|
256
|
+
}
|
|
257
|
+
} as Property;
|
|
258
|
+
|
|
259
|
+
const properties: Properties = {
|
|
260
|
+
tags: tagsProperty
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// ── Null array field ──
|
|
264
|
+
it("serializes entity with tags: null — scalarData.tags should be null", () => {
|
|
265
|
+
const result = serializeDataToServer(
|
|
266
|
+
{ tags: null },
|
|
267
|
+
properties
|
|
268
|
+
);
|
|
269
|
+
expect(result.scalarData.tags).toBeNull();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ── Undefined array field ──
|
|
273
|
+
it("serializes entity with tags: undefined — tags value should be undefined in scalarData", () => {
|
|
274
|
+
const result = serializeDataToServer(
|
|
275
|
+
{ tags: undefined },
|
|
276
|
+
properties
|
|
277
|
+
);
|
|
278
|
+
// Object.entries iterates keys even when value is undefined,
|
|
279
|
+
// and serializePropertyToServer returns undefined for undefined input
|
|
280
|
+
expect(result.scalarData.tags).toBeUndefined();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ── Empty array field ──
|
|
284
|
+
it("serializes entity with tags: [] — scalarData.tags should be []", () => {
|
|
285
|
+
const result = serializeDataToServer(
|
|
286
|
+
{ tags: [] },
|
|
287
|
+
properties
|
|
288
|
+
);
|
|
289
|
+
expect(result.scalarData.tags).toEqual([]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// ── Multiple array fields, mixed null / populated ──
|
|
293
|
+
it("handles entity with multiple array fields, some null, some populated", () => {
|
|
294
|
+
const multiProperties: Properties = {
|
|
295
|
+
tags: tagsProperty,
|
|
296
|
+
categories: categoriesProperty
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const result = serializeDataToServer(
|
|
300
|
+
{ tags: null, categories: ["cat-a", "cat-b"] },
|
|
301
|
+
multiProperties
|
|
302
|
+
);
|
|
303
|
+
expect(result.scalarData.tags).toBeNull();
|
|
304
|
+
expect(result.scalarData.categories).toEqual(["cat-a", "cat-b"]);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// ── Nested map containing null array ──
|
|
308
|
+
it("handles entity with nested map containing array field that is null", () => {
|
|
309
|
+
const nestedProperties: Properties = {
|
|
310
|
+
metadata: metadataProperty
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const result = serializeDataToServer(
|
|
314
|
+
{ metadata: { labels: null } },
|
|
315
|
+
nestedProperties
|
|
316
|
+
);
|
|
317
|
+
// The map serializer recurses into sub-properties; the inner array gets null-checked
|
|
318
|
+
expect(result.scalarData.metadata).toEqual({ labels: null });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ── All array fields null ──
|
|
322
|
+
it("handles entity with only array fields, all null — scalarData should have all null values", () => {
|
|
323
|
+
const allArrayProperties: Properties = {
|
|
324
|
+
tags: tagsProperty,
|
|
325
|
+
categories: categoriesProperty
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const result = serializeDataToServer(
|
|
329
|
+
{ tags: null, categories: null },
|
|
330
|
+
allArrayProperties
|
|
331
|
+
);
|
|
332
|
+
expect(result.scalarData.tags).toBeNull();
|
|
333
|
+
expect(result.scalarData.categories).toBeNull();
|
|
334
|
+
});
|
|
335
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { EntityCollection, PostgresCollection } from "@rebasepro/types";
|
|
2
|
+
import { generatePostgresPoliciesDdl } from "../src/schema/generate-postgres-ddl-logic";
|
|
3
|
+
import { getEffectiveSecurityRules } from "../src/schema/auth-default-policies";
|
|
4
|
+
|
|
5
|
+
describe("auth collection default RLS policies", () => {
|
|
6
|
+
const adminWrite = "string_to_array(auth.roles(), ',') && ARRAY['admin']";
|
|
7
|
+
|
|
8
|
+
it("injects admin write policies (permissive grant + restrictive gate) when an auth collection has no security rules", () => {
|
|
9
|
+
const collection: PostgresCollection = {
|
|
10
|
+
slug: "users",
|
|
11
|
+
table: "users",
|
|
12
|
+
name: "Users",
|
|
13
|
+
auth: true,
|
|
14
|
+
properties: {
|
|
15
|
+
id: { type: "string", isId: "uuid" },
|
|
16
|
+
roles: { type: "array", columnType: "text[]", of: { type: "string" } }
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const ddl = generatePostgresPoliciesDdl([collection]);
|
|
21
|
+
|
|
22
|
+
expect(ddl).toContain("FORCE ROW LEVEL SECURITY");
|
|
23
|
+
// Restrictive gate exists for every write op.
|
|
24
|
+
expect(ddl).toContain("AS RESTRICTIVE FOR INSERT");
|
|
25
|
+
expect(ddl).toContain("AS RESTRICTIVE FOR UPDATE");
|
|
26
|
+
expect(ddl).toContain("AS RESTRICTIVE FOR DELETE");
|
|
27
|
+
expect(ddl).toContain(adminWrite);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("keeps an author's permissive owner write rule but the restrictive gate still blocks non-admins", () => {
|
|
31
|
+
const collection: PostgresCollection = {
|
|
32
|
+
slug: "users",
|
|
33
|
+
table: "users",
|
|
34
|
+
name: "Users",
|
|
35
|
+
auth: true,
|
|
36
|
+
properties: {
|
|
37
|
+
id: { type: "string", isId: "uuid" },
|
|
38
|
+
roles: { type: "array", columnType: "text[]", of: { type: "string" } }
|
|
39
|
+
},
|
|
40
|
+
// The dangerous case: author lets users edit their own row.
|
|
41
|
+
securityRules: [
|
|
42
|
+
{ name: "edit_own", operation: "update", ownerField: "id" }
|
|
43
|
+
]
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const rules = getEffectiveSecurityRules(collection);
|
|
47
|
+
const gate = rules.find(r => r.name === "users_require_admin_write");
|
|
48
|
+
const grant = rules.find(r => r.name === "users_default_admin_write");
|
|
49
|
+
|
|
50
|
+
// Author rule preserved.
|
|
51
|
+
expect(rules.find(r => r.name === "edit_own")).toBeDefined();
|
|
52
|
+
// Restrictive gate added covering all write ops.
|
|
53
|
+
expect(gate).toBeDefined();
|
|
54
|
+
expect(gate?.mode).toBe("restrictive");
|
|
55
|
+
expect(gate?.operations).toEqual(["insert", "update", "delete"]);
|
|
56
|
+
// Permissive grant added.
|
|
57
|
+
expect(grant).toBeDefined();
|
|
58
|
+
expect(grant?.mode).toBeUndefined();
|
|
59
|
+
|
|
60
|
+
// The generated SQL AND's the restrictive gate, so a self-update by a
|
|
61
|
+
// non-admin cannot pass — only admins/server can write.
|
|
62
|
+
const ddl = generatePostgresPoliciesDdl([collection]);
|
|
63
|
+
expect(ddl).toContain("AS RESTRICTIVE FOR UPDATE");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("can be opted out with disableDefaultAuthPolicies", () => {
|
|
67
|
+
const collection: PostgresCollection = {
|
|
68
|
+
slug: "users",
|
|
69
|
+
table: "users",
|
|
70
|
+
name: "Users",
|
|
71
|
+
auth: true,
|
|
72
|
+
disableDefaultAuthPolicies: true,
|
|
73
|
+
properties: { id: { type: "string", isId: "uuid" } }
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
expect(getEffectiveSecurityRules(collection)).toHaveLength(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("leaves non-auth collections untouched", () => {
|
|
80
|
+
const collection: EntityCollection = {
|
|
81
|
+
slug: "products",
|
|
82
|
+
table: "products",
|
|
83
|
+
name: "Products",
|
|
84
|
+
properties: { name: { type: "string" } }
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
expect(getEffectiveSecurityRules(collection)).toHaveLength(0);
|
|
88
|
+
});
|
|
89
|
+
});
|