@rebasepro/common 0.17.3 → 0.18.1

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 (58) hide show
  1. package/README.md +4 -0
  2. package/dist/collections/CollectionRegistry.d.ts +1 -1
  3. package/dist/collections/default-collections.d.ts +15 -84
  4. package/dist/data/buildRebaseData.d.ts +1 -1
  5. package/dist/data/filter-dialect.d.ts +11 -0
  6. package/dist/data/sort-dialect.d.ts +15 -3
  7. package/dist/index.es.js +375 -63
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/util/builders.d.ts +69 -24
  10. package/dist/util/callback-errors.d.ts +77 -0
  11. package/dist/util/callback-errors.test.d.ts +1 -0
  12. package/dist/util/index.d.ts +1 -0
  13. package/dist/util/policy/evaluatePolicy.d.ts +6 -0
  14. package/dist/util/relations.d.ts +41 -0
  15. package/dist/util/table-name.test.d.ts +1 -0
  16. package/package.json +26 -22
  17. package/src/collections/CollectionRegistry.ts +0 -485
  18. package/src/collections/default-collections.ts +0 -109
  19. package/src/collections/index.ts +0 -2
  20. package/src/data/buildRebaseData.ts +0 -816
  21. package/src/data/buildRoutedRebaseData.ts +0 -103
  22. package/src/data/filter-conditions.ts +0 -46
  23. package/src/data/filter-dialect.ts +0 -737
  24. package/src/data/paginate.ts +0 -334
  25. package/src/data/query_builder.ts +0 -176
  26. package/src/data/resolveDataSource.ts +0 -135
  27. package/src/data/sort-dialect.ts +0 -237
  28. package/src/index.ts +0 -11
  29. package/src/table-classification.ts +0 -109
  30. package/src/types/json-logic-js.d.ts +0 -8
  31. package/src/util/auth-default-policies.ts +0 -215
  32. package/src/util/builders.ts +0 -82
  33. package/src/util/callbacks.ts +0 -122
  34. package/src/util/collections.ts +0 -117
  35. package/src/util/common.ts +0 -2
  36. package/src/util/conditions.ts +0 -168
  37. package/src/util/email.ts +0 -32
  38. package/src/util/entities.ts +0 -282
  39. package/src/util/enums.ts +0 -26
  40. package/src/util/identity.ts +0 -202
  41. package/src/util/index.ts +0 -21
  42. package/src/util/internal-tables.test.ts +0 -188
  43. package/src/util/internal-tables.ts +0 -197
  44. package/src/util/junction-policies.ts +0 -355
  45. package/src/util/paths.ts +0 -27
  46. package/src/util/permissions.test.ts +0 -866
  47. package/src/util/permissions.ts +0 -206
  48. package/src/util/pg-column-to-property.ts +0 -377
  49. package/src/util/policy/evaluatePolicy.ts +0 -194
  50. package/src/util/policy/index.ts +0 -4
  51. package/src/util/policy/policyToPostgres.ts +0 -263
  52. package/src/util/policy/securityRuleToConditions.ts +0 -67
  53. package/src/util/policy/sqlToPolicy.ts +0 -422
  54. package/src/util/relations.ts +0 -236
  55. package/src/util/resolutions.ts +0 -534
  56. package/src/util/resolve-relation.ts +0 -243
  57. package/src/util/storage.ts +0 -177
  58. package/src/util/string-column-length.ts +0 -31
@@ -1,206 +0,0 @@
1
- import { AuthState, Entity, CollectionConfig, SecurityOperation, SecurityRule, User } from "@rebasepro/types";
2
- import { securityRuleToConditions } from "./policy/securityRuleToConditions";
3
- import { evaluatePolicy, PolicyEvalContext, TriState } from "./policy/evaluatePolicy";
4
-
5
- /**
6
- * Minimal auth context for permission checking.
7
- * Only requires the user object — avoids forcing callers to construct
8
- * a full AuthController just to check permissions.
9
- *
10
- * An alias, not a second definition: {@link AuthState} in `@rebasepro/types` is
11
- * the same shape and is what `dynamicProps` and the JSON-Logic condition context
12
- * now take, so declaring it twice would be the `WhereFilterOp` mistake again —
13
- * two copies that agree only by luck.
14
- */
15
- export type AuthContext<USER extends User = User> = AuthState<USER>;
16
-
17
- /**
18
- * How to resolve a policy result that cannot be decided client-side (a raw-SQL
19
- * escape-hatch rule, or a row-column reference with no row in hand).
20
- *
21
- * - `"allow"` (default): optimistic — used for admin-UI gating, where Postgres
22
- * remains the authoritative gate and hiding a working action is worse than
23
- * showing one the server may reject.
24
- * - `"deny"`: fail-closed — used by real enforcement callers (e.g. a driver
25
- * applying policies in-process), so an undecidable rule never silently allows.
26
- */
27
- export type UnknownResolution = "allow" | "deny";
28
-
29
- /**
30
- * Which half of a rule to evaluate.
31
- *
32
- * Postgres evaluates `USING` against the row as it is *now* and `WITH CHECK`
33
- * against the row as it *will be*, both inside the transaction. A driver
34
- * enforcing an update in-process has two different rows in hand and therefore
35
- * needs to ask the two questions separately — asking one question about one row
36
- * either checks the new values against the old row's ownership or the reverse.
37
- *
38
- * - `"both"` (default): what a single-row decision means (`USING ∧ WITH CHECK`).
39
- * - `"using"`: the read/target clause only — ask it about the stored row.
40
- * - `"withCheck"`: the write clause only — ask it about the row being written.
41
- *
42
- * Rule *selection* is unaffected: the target operation still decides which rules
43
- * apply, so `"using"` on an `update` evaluates the update rules' USING clause,
44
- * not the delete rules'.
45
- */
46
- export type PolicyClauses = "both" | "using" | "withCheck";
47
-
48
- export interface CheckOperationOptions {
49
- onUnknown?: UnknownResolution;
50
- clauses?: PolicyClauses;
51
- }
52
-
53
- /** Combine clause results with AND under three-valued (Kleene) logic. */
54
- function kleeneAnd(values: TriState[]): TriState {
55
- if (values.some(v => v === false)) return false;
56
- if (values.some(v => v === "unknown")) return "unknown";
57
- return true;
58
- }
59
-
60
- /** The operations a rule covers, mirroring the Postgres generator's resolution. */
61
- function ruleOperations(rule: SecurityRule): readonly SecurityOperation[] {
62
- return rule.operations && rule.operations.length > 0
63
- ? rule.operations
64
- : [rule.operation ?? "all"];
65
- }
66
-
67
- function ruleApplies(rule: SecurityRule, targetOperation: SecurityOperation): boolean {
68
- const ops = ruleOperations(rule);
69
- return ops.includes(targetOperation) || ops.includes("all");
70
- }
71
-
72
- /**
73
- * Evaluate a single rule for one operation, returning a tri-state.
74
- *
75
- * A `null` clause (the rule contributes no condition for a required clause)
76
- * denies — matching Postgres, which emits `USING (false)` / `WITH CHECK (false)`
77
- * in that case. USING applies to SELECT/UPDATE/DELETE; WITH CHECK to
78
- * INSERT/UPDATE; both must pass for UPDATE.
79
- */
80
- function evaluateRuleForOperation(
81
- rule: SecurityRule,
82
- ctx: PolicyEvalContext,
83
- targetOperation: SecurityOperation,
84
- clauses: PolicyClauses = "both"
85
- ): TriState {
86
- const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
87
- const clause = (expr: typeof usingExpr): TriState => expr === null ? false : evaluatePolicy(expr, ctx);
88
-
89
- const needsUsing = targetOperation !== "insert" && clauses !== "withCheck";
90
- const needsWithCheck = (targetOperation === "insert" || targetOperation === "update") && clauses !== "using";
91
-
92
- const results: TriState[] = [];
93
- if (needsUsing) results.push(clause(usingExpr));
94
- if (needsWithCheck) results.push(clause(withCheckExpr));
95
- return kleeneAnd(results);
96
- }
97
-
98
- function resolveTriState(value: TriState, onUnknown: UnknownResolution): boolean {
99
- if (value === "unknown") return onUnknown === "allow";
100
- return value;
101
- }
102
-
103
- /**
104
- * Decide whether an operation is permitted for a user on a (possibly null) row,
105
- * by evaluating the collection's security rules with the shared policy model —
106
- * the same model compiled to Postgres RLS DDL, so the decision matches database
107
- * enforcement for every non-raw rule.
108
- *
109
- * Engine-independent by design. `securityRules` are a declaration about the
110
- * data, not about Postgres: the engine decides *who* enforces them (Postgres
111
- * compiles them to RLS DDL, a document driver applies them in-process), never
112
- * *whether* they hold. Gating this function on the engine's `supportsRLS`
113
- * capability is what made every `{ onUnknown: "deny" }` call site in the Mongo
114
- * driver return `true` before it evaluated anything — and it did so only for
115
- * collections that spelled their engine out, so declaring `engine: "mongodb"`
116
- * was what switched authorization off.
117
- *
118
- * @param options.onUnknown how to treat rules that cannot be decided
119
- * client-side (raw SQL, or row predicates with no row). Defaults to `"allow"`
120
- * for optimistic UI gating; enforcement callers should pass `"deny"`.
121
- * @param options.clauses which half of each rule to evaluate. See
122
- * {@link PolicyClauses}; defaults to `"both"`.
123
- */
124
- export function checkOperation<M extends Record<string, unknown>, USER extends User>(
125
- collection: CollectionConfig<M>,
126
- authContext: AuthContext<USER>,
127
- entity: Entity<M> | null,
128
- targetOperation: SecurityOperation,
129
- options?: CheckOperationOptions
130
- ): boolean {
131
- const onUnknown = options?.onUnknown ?? "allow";
132
- const clauses = options?.clauses ?? "both";
133
- const securityRules = collection.securityRules;
134
- if (!securityRules || securityRules.length === 0) {
135
- return true;
136
- }
137
-
138
- const applicableRules = securityRules.filter((r: SecurityRule) => ruleApplies(r, targetOperation));
139
- if (applicableRules.length === 0) return false;
140
-
141
- const ctx: PolicyEvalContext = {
142
- uid: authContext.user?.uid,
143
- roles: authContext.user?.roles ?? [],
144
- entity
145
- };
146
-
147
- let grantedByPermissive = false;
148
- let deniedByRestrictive = false;
149
- let hasPermissive = false;
150
-
151
- for (const rule of applicableRules) {
152
- const mode = rule.mode || "permissive";
153
- const passed = resolveTriState(evaluateRuleForOperation(rule, ctx, targetOperation, clauses), onUnknown);
154
-
155
- if (mode === "restrictive") {
156
- if (!passed) {
157
- deniedByRestrictive = true;
158
- break;
159
- }
160
- } else {
161
- hasPermissive = true;
162
- if (passed) grantedByPermissive = true;
163
- }
164
- }
165
-
166
- if (deniedByRestrictive) return false;
167
- return hasPermissive ? grantedByPermissive : false;
168
- }
169
-
170
- export function canReadCollection<M extends Record<string, unknown>, USER extends User>
171
- (
172
- collection: CollectionConfig<M>,
173
- authContext: AuthContext<USER>
174
- ): boolean {
175
- return checkOperation(collection, authContext, null, "select");
176
- }
177
-
178
- export function canEditEntity<M extends Record<string, unknown>, USER extends User>
179
- (
180
- collection: CollectionConfig<M>,
181
- authContext: AuthContext<USER>,
182
- path: string,
183
- entity: Entity<M> | null
184
- ): boolean {
185
- return checkOperation(collection, authContext, entity, "update");
186
- }
187
-
188
- export function canCreateEntity<M extends Record<string, unknown>, USER extends User>
189
- (
190
- collection: CollectionConfig<M>,
191
- authContext: AuthContext<USER>,
192
- path: string,
193
- entity: Entity<M> | null
194
- ): boolean {
195
- return checkOperation(collection, authContext, entity, "insert");
196
- }
197
-
198
- export function canDeleteEntity<M extends Record<string, unknown>, USER extends User>
199
- (
200
- collection: CollectionConfig<M>,
201
- authContext: AuthContext<USER>,
202
- path: string,
203
- entity: Entity<M> | null
204
- ): boolean {
205
- return checkOperation(collection, authContext, entity, "delete");
206
- }
@@ -1,377 +0,0 @@
1
- import { rewriteLegacyRlsFunctions } from "@rebasepro/types";
2
- import type {
3
- ArrayProperty,
4
- NumberProperty,
5
- PostgresProperties,
6
- Property,
7
- Relation,
8
- SecurityOperation,
9
- SecurityRule,
10
- StringProperty,
11
- TableColumnInfo,
12
- TableMetadata
13
- } from "@rebasepro/types";
14
- import { firstFreeKey, prettifyIdentifier, toWireKey } from "@rebasepro/utils";
15
-
16
- /**
17
- * A collection as introspection can describe it: the table, its columns, the
18
- * relations its foreign keys imply, and the RLS policies already on it.
19
- *
20
- * Deliberately not `Partial<AdminCollection>`, which is what this returned
21
- * while it lived in `@rebasepro/studio`. `propertiesOrder` is the only admin
22
- * key it produces, and naming the admin view model for one field would put
23
- * `@rebasepro/cms-types` on the dependency path of a package the backend
24
- * loads.
25
- */
26
- export interface IntrospectedCollection {
27
- name: string;
28
- slug: string;
29
- table: string;
30
- properties: PostgresProperties;
31
- propertiesOrder: string[];
32
- relations?: Relation[];
33
- securityRules?: SecurityRule[];
34
- }
35
-
36
- /**
37
- * Maps a PostgreSQL column data type to a Rebase property type.
38
- */
39
- function pgTypeToRebaseProperty(column: TableColumnInfo): Property | null {
40
- const {
41
- column_name,
42
- data_type,
43
- udt_name,
44
- is_nullable,
45
- column_default,
46
- character_maximum_length,
47
- enum_values
48
- } = column;
49
-
50
- const required = is_nullable === "NO";
51
- const prettifiedName = prettifyIdentifier(column_name);
52
-
53
- // Detect if this column is a primary key (auto-generated id)
54
- const isAutoId = column_default != null && (
55
- column_default.includes("nextval") ||
56
- column_default.includes("gen_random_uuid") ||
57
- column_default.includes("uuid_generate") ||
58
- column_default.includes("identity")
59
- );
60
-
61
- // USER-DEFINED = PostgreSQL enums
62
- if (data_type === "USER-DEFINED" && enum_values && enum_values.length > 0) {
63
- return {
64
- type: "string",
65
- name: prettifiedName,
66
- enum: enum_values.map((v: string) => ({ id: v,
67
- label: prettifyIdentifier(v) })),
68
- validation: required ? { required: true } : undefined
69
- } as StringProperty;
70
- }
71
-
72
- const dt = data_type.toLowerCase();
73
- switch (dt) {
74
- case "character varying":
75
- case "varchar":
76
- case "text":
77
- case "char":
78
- case "character":
79
- case "citext": {
80
- let colType: "varchar" | "text" | "char" = "varchar";
81
- if (dt === "text" || dt === "citext") colType = "text";
82
- if (dt === "char" || dt === "character") colType = "char";
83
- // Carry the declared width across. Dropping it made introspection
84
- // lossy in the one direction that costs data: a `character
85
- // varying(500)` column read back as a bare `varchar` regenerates as
86
- // `VARCHAR(255)`, narrowing a column that already holds longer
87
- // values. TEXT has no width, and reporting one would invent a limit
88
- // the database does not have.
89
- const declaredLength = colType === "text" ? null : character_maximum_length;
90
- const prop: StringProperty = {
91
- type: "string",
92
- name: prettifiedName,
93
- columnType: colType,
94
- validation: required || declaredLength
95
- ? {
96
- ...(required ? { required: true } : {}),
97
- ...(declaredLength ? { max: declaredLength } : {})
98
- }
99
- : undefined
100
- };
101
- if (isAutoId) {
102
- prop.isId = "manual";
103
- }
104
- return prop;
105
- }
106
-
107
- case "uuid": {
108
- const prop: StringProperty = {
109
- type: "string",
110
- name: prettifiedName,
111
- validation: required ? { required: true } : undefined
112
- };
113
- if (isAutoId) {
114
- prop.isId = "uuid";
115
- }
116
- return prop;
117
- }
118
-
119
- case "integer":
120
- case "bigint":
121
- case "smallint": {
122
- const colType = dt === "bigint" ? "bigint" : "integer";
123
- const prop: NumberProperty = {
124
- type: "number",
125
- name: prettifiedName,
126
- columnType: colType,
127
- validation: {
128
- ...(required ? { required: true } : {}),
129
- integer: true
130
- }
131
- };
132
- if (isAutoId) {
133
- prop.isId = "increment";
134
- }
135
- return prop;
136
- }
137
-
138
- case "serial":
139
- case "bigserial":
140
- case "smallserial": {
141
- const colType = dt === "bigserial" ? "bigserial" : "serial";
142
- return {
143
- type: "number",
144
- name: prettifiedName,
145
- columnType: colType,
146
- isId: "increment",
147
- validation: {
148
- ...(required ? { required: true } : {}),
149
- integer: true
150
- }
151
- } as NumberProperty;
152
- }
153
-
154
- case "numeric":
155
- case "decimal":
156
- case "real":
157
- case "double precision": {
158
- let colType: "numeric" | "real" | "double precision" = "numeric";
159
- if (dt === "real") colType = "real";
160
- if (dt === "double precision") colType = "double precision";
161
- return {
162
- type: "number",
163
- name: prettifiedName,
164
- columnType: colType,
165
- validation: required ? { required: true } : undefined
166
- };
167
- }
168
-
169
- case "boolean":
170
- return {
171
- type: "boolean",
172
- name: prettifiedName,
173
- validation: required ? { required: true } : undefined
174
- };
175
-
176
- case "timestamp with time zone":
177
- case "timestamp without time zone":
178
- case "timestamp":
179
- case "timestamptz":
180
- case "date":
181
- case "time with time zone":
182
- case "time without time zone":
183
- case "time": {
184
- let colType: "timestamp" | "date" | "time" = "timestamp";
185
- if (dt.startsWith("date")) colType = "date";
186
- if (dt.startsWith("time ") || dt === "time") colType = "time";
187
- return {
188
- type: "date",
189
- name: prettifiedName,
190
- columnType: colType,
191
- validation: required ? { required: true } : undefined
192
- };
193
- }
194
-
195
- case "jsonb":
196
- case "json":
197
- return {
198
- type: "map",
199
- name: prettifiedName,
200
- columnType: dt === "jsonb" ? "jsonb" : "json",
201
- keyValue: true,
202
- properties: {}
203
- };
204
-
205
- case "array":
206
- case "ARRAY": {
207
- let innerType = "string";
208
- let colType: ArrayProperty["columnType"] = undefined;
209
- if (udt_name === "_text" || udt_name === "_varchar") {
210
- innerType = "string";
211
- colType = "text[]";
212
- } else if (udt_name === "_int4" || udt_name === "_int2" || udt_name === "_int8") {
213
- innerType = "number";
214
- colType = "integer[]";
215
- } else if (udt_name === "_bool") {
216
- innerType = "boolean";
217
- colType = "boolean[]";
218
- } else if (udt_name === "_numeric") {
219
- innerType = "number";
220
- colType = "numeric[]";
221
- }
222
- return {
223
- type: "array",
224
- name: prettifiedName,
225
- columnType: colType,
226
- of: { type: innerType }
227
- } as ArrayProperty;
228
- }
229
-
230
- default:
231
- // Fallback: treat unknown types as string
232
- return {
233
- type: "string",
234
- name: prettifiedName,
235
- validation: required ? { required: true } : undefined
236
- };
237
- }
238
- }
239
-
240
- /**
241
- * Builds a collection description from PostgreSQL table metadata.
242
- * This is used when creating a new collection from an existing database table.
243
- */
244
- export function buildCollectionFromTableMetadata(
245
- tableName: string,
246
- metadata: TableMetadata
247
- ): IntrospectedCollection {
248
- const properties: Record<string, Property> = {};
249
- const propertiesOrder: string[] = [];
250
- // Introspection can only ever produce two shapes: a foreign key on this
251
- // table, or a junction between two. Both are named by their kind.
252
- const relations: Array<{
253
- id: string;
254
- relationName: string;
255
- target: string;
256
- kind: "belongsTo" | "manyToMany";
257
- localKey?: string;
258
- through?: { table: string; sourceColumn: string; targetColumn: string };
259
- }> = [];
260
- const securityRules: SecurityRule[] = [];
261
-
262
- // Parse columns
263
- for (const column of metadata.columns) {
264
- const property = pgTypeToRebaseProperty(column);
265
- if (property) {
266
- const propRecord = property as unknown as Record<string, unknown>;
267
- Object.keys(propRecord).forEach(key => propRecord[key] === undefined && delete propRecord[key]);
268
-
269
- // The key is the wire name; `columnName` carries the column. This
270
- // used to key by the column and rely on the two being the same
271
- // string, which is what put `user_id` on the API of an imported
272
- // collection and `displayName` on the API of an authored one.
273
- //
274
- // `columnName` is stamped unconditionally rather than left to the
275
- // snake_case default, because the default is not the inverse of
276
- // camel-casing for every name — the mapping has to be recorded, not
277
- // recomputed.
278
- //
279
- // First free candidate: `user_id` and `userId` as two real columns
280
- // camel-case to one key, and one of them would otherwise overwrite
281
- // the other and be silently dropped.
282
- const key = firstFreeKey(
283
- [toWireKey(column.column_name), column.column_name],
284
- { has: (candidate: string) => candidate in properties }
285
- );
286
- if (key !== column.column_name) propRecord.columnName = column.column_name;
287
- properties[key] = property;
288
- propertiesOrder.push(key);
289
- }
290
- }
291
-
292
- // Parse Outgoing Foreign Keys -> Many-to-One / One-to-One
293
- if (metadata.foreignKeys) {
294
- for (const fk of metadata.foreignKeys) {
295
- const relName = toWireKey(
296
- fk.column_name.endsWith("_id")
297
- ? fk.column_name.substring(0, fk.column_name.length - 3)
298
- : fk.column_name
299
- );
300
- relations.push({
301
- id: fk.column_name,
302
- relationName: relName,
303
- target: fk.foreign_table_name, // Will be hydrated later
304
- kind: "belongsTo",
305
- localKey: fk.column_name
306
- });
307
- }
308
- }
309
-
310
- // Parse Incoming Junctions -> Many-to-Many
311
- if (metadata.junctions) {
312
- for (const junction of metadata.junctions) {
313
- const relName = junction.target_table_name; // E.g., 'roles'
314
- relations.push({
315
- id: junction.target_table_name + "_relation",
316
- relationName: relName,
317
- target: junction.target_table_name, // Will be hydrated later
318
- kind: "manyToMany",
319
- through: {
320
- table: junction.junction_table_name,
321
- sourceColumn: junction.source_column_name,
322
- targetColumn: junction.target_column_name
323
- }
324
- });
325
- }
326
- }
327
-
328
- // Parse RLS Policies
329
- if (metadata.policies) {
330
- for (const policy of metadata.policies) {
331
- // Attempt to map typical cmds to operations.
332
- // Postgres cmd: SELECT, INSERT, UPDATE, DELETE, ALL
333
- let operations: SecurityOperation[] = [];
334
- switch (policy.cmd) {
335
- case "ALL": operations = ["all"]; break;
336
- case "SELECT": operations = ["select"]; break;
337
- case "INSERT": operations = ["insert"]; break;
338
- case "UPDATE": operations = ["update"]; break;
339
- case "DELETE": operations = ["delete"]; break;
340
- }
341
- // Normalised on the way in, the same way `sqlToPolicy` normalises
342
- // what the admin UI reads back. Without it, importing a table from a
343
- // database provisioned before 1.0 copies `auth.uid()` straight into
344
- // the project's config — a call to a function the framework no
345
- // longer creates, which then boots with a legacy-helper warning
346
- // forever and holds the `auth` schema open.
347
- const qual = policy.qual ? rewriteLegacyRlsFunctions(policy.qual) : undefined;
348
- const withCheck = policy.with_check ? rewriteLegacyRlsFunctions(policy.with_check) : undefined;
349
- if (qual) {
350
- securityRules.push({
351
- name: policy.policy_name,
352
- operations,
353
- roles: policy.roles ?? [],
354
- using: qual,
355
- ...(withCheck ? { withCheck } : {})
356
- });
357
- } else {
358
- securityRules.push({
359
- name: policy.policy_name,
360
- operations,
361
- roles: policy.roles ?? []
362
- });
363
- }
364
- }
365
- }
366
-
367
- return {
368
- name: prettifyIdentifier(tableName),
369
- slug: tableName,
370
- table: tableName,
371
- properties: properties as PostgresProperties,
372
- propertiesOrder,
373
- // `target` is still a slug here — the caller hydrates it into a thunk.
374
- ...(relations.length > 0 ? { relations: relations as unknown as Relation[] } : {}),
375
- ...(securityRules.length > 0 ? { securityRules } : {})
376
- };
377
- }